diff --git a/.gitignore b/.gitignore index e6c4d47..51bd3f5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ .classpath .project .cache +.settings # IntelliJ specific .idea/ diff --git a/README.md b/README.md index c6311fd..fa6718d 100644 --- a/README.md +++ b/README.md @@ -37,23 +37,64 @@ 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. +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. - --port=[NUMBER] - --prefix=[CONTEXTPATH] - --host=[HOSTNAME] - --https=true +- --gitbucket.home=[DATA_DIR] -To upgrade GitBucket, only replace gitbucket.war. All GitBucket data is stored in HOME/gitbucket. So if you want to back up GitBucket data, copy this directory to the other disk. +To upgrade GitBucket, only replace gitbucket.war. All GitBucket data is stored in HOME/.gitbucket. So if you want to back up GitBucket data, copy this directory to the other disk. For Installation on Windows Server with IIS see [this wiki page](https://github.com/takezoe/gitbucket/wiki/Installation-on-IIS-and-Helicontech-Zoo) +### Mac OS X +On OS X, copy the [gitbucket.plist](https://raw.github.com/takezoe/gitbucket/master/contrib/macosx/gitbucket.plist) file to `~/Library/LaunchAgents/` + +Run the following commands in `Terminal` to + +- start gitbucket: `launchctl load ~/Library/LaunchAgents/gitbucket.plist` +- stop gitbucket: `launchctl unload ~/Library/LaunchAgents/gitbucket.plist` + Release Notes -------- +### 1.11 - End of Feb 2014 +- Base URL for redirect, notification and repository URL box is configurable +- Headline anchor is available for Markdown contents such as Wiki page +- Improve H2 connectivity +- Label is available for pull requests not only issues +- Delete branch button is added +- Repository icons are updated +- Select lines of source code by URL hash like `#L10` or `#L10-L15` in repository viewer +- Display reference to issue from others in comment list +- Fix some bugs + +### 1.10 - 01 Feb 2014 +- Rename repository +- Transfer repository owner +- Change default data directory to `HOME/.gitbucket` from `HOME/gitbucket` to avoid problem like #243, but if data directory already exist at HOME/gitbucket, it continues being used. +- Add LDAP display name attribute +- Response performance improvement +- Fix some bugs + +### 1.9 - 28 Dec 2013 +- Display GITBUCKET_HOME on the system settings page +- Fix some bugs + +### 1.8 - 30 Nov 2013 +- Add user and group deletion +- Improve pull request performance +- Pull request synchronization (when source repository is updated after pull request, it's applied to the pull request) +- LDAP StartTLS support +- Enable hard wrapping in Markdown +- Add new some options to specify the data directory. See details in [Wiki](https://github.com/takezoe/gitbucket/wiki/DirectoryStructure). +- Fix some bugs + ### 1.7 - 26 Oct 2013 - Support working on Java6 in embedded Jetty mode -- Add ```--host``` option to bind specified host name in embedded Jetty mode -- Add ```--https=true``` option to use https in embedded Jetty mode +- Add `--host` option to bind specified host name in embedded Jetty mode +- Add `--https=true` option to force https scheme when using embedded Jetty mode at the back of https proxy - Add full name as user property - Change link color for absent Wiki pages - Add ZIP download button to the repository viewer tab diff --git a/contrib/macosx/gitbucket.plist b/contrib/macosx/gitbucket.plist new file mode 100644 index 0000000..827c15a --- /dev/null +++ b/contrib/macosx/gitbucket.plist @@ -0,0 +1,20 @@ + + + + + Label + gitbucket + ProgramArguments + + /usr/bin/java + -Dmail.smtp.starttls.enable=true + -jar + gitbucket.war + --host=127.0.0.1 + --port=8080 + --https=true + + RunAtLoad + + + diff --git a/etc/icons.svg b/etc/icons.svg index 6943304..c2efe0e 100644 --- a/etc/icons.svg +++ b/etc/icons.svg @@ -25,17 +25,17 @@ inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="1.4" - inkscape:cx="629.30023" - inkscape:cy="281.44758" + inkscape:cx="450.21999" + inkscape:cy="97.51519" inkscape:document-units="px" inkscape:current-layer="layer1-9" showgrid="false" inkscape:window-width="1366" - inkscape:window-height="705" - inkscape:window-x="-8" + inkscape:window-height="706" + inkscape:window-x="1912" inkscape:window-y="-8" inkscape:window-maximized="1" - inkscape:snap-global="true" + inkscape:snap-global="false" inkscape:snap-grids="false" inkscape:snap-page="false" inkscape:snap-bbox="true" @@ -746,6 +746,238 @@ d="m 937.41093,1044.4944 0,30.6797 -28.50183,0 0,41.2377 28.50183,0 0,27.1288 41.19033,0 0,-27.1288 29.35404,0 0,-41.2377 -29.35404,0 0,-30.6797 -41.19033,0 z" id="rect2995-0-2-7-7" inkscape:connector-curvature="0" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/project/build.scala b/project/build.scala index df7b368..ce6d4d9 100644 --- a/project/build.scala +++ b/project/build.scala @@ -1,8 +1,6 @@ import sbt._ import Keys._ import org.scalatra.sbt._ -import org.scalatra.sbt.PluginKeys._ -import sbt.ScalaVersion import twirl.sbt.TwirlPlugin._ import com.typesafe.sbteclipse.plugin.EclipsePlugin.EclipseKeys @@ -25,14 +23,14 @@ Classpaths.typesafeReleases, "amateras-repo" at "http://amateras.sourceforge.jp/mvn/" ), - scalacOptions := Seq("-deprecation"), + scalacOptions := Seq("-deprecation", "-language:postfixOps"), libraryDependencies ++= Seq( "org.eclipse.jgit" % "org.eclipse.jgit.http.server" % "3.0.0.201306101825-r", "org.scalatra" %% "scalatra" % ScalatraVersion, "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.8", + "jp.sf.amateras" %% "scalatra-forms" % "0.0.11", "commons-io" % "commons-io" % "2.4", "org.pegdown" % "pegdown" % "1.4.1", "org.apache.commons" % "commons-compress" % "1.5", @@ -43,7 +41,7 @@ "com.h2database" % "h2" % "1.3.173", "ch.qos.logback" % "logback-classic" % "1.0.13" % "runtime", "org.eclipse.jetty" % "jetty-webapp" % "8.1.8.v20121106" % "container;provided", - "org.eclipse.jetty.orbit" % "javax.servlet" % "3.0.0.v201112011016" % "container;provided;test" artifacts (Artifact("javax.servlet", "jar", "jar")), + "org.eclipse.jetty.orbit" % "javax.servlet" % "3.0.0.v201112011016" % "container;provided;test" artifacts Artifact("javax.servlet", "jar", "jar"), "junit" % "junit" % "4.11" % "test" ), EclipseKeys.withSource := true, diff --git a/src/main/java/JettyLauncher.java b/src/main/java/JettyLauncher.java index a644f1f..d7b137d 100644 --- a/src/main/java/JettyLauncher.java +++ b/src/main/java/JettyLauncher.java @@ -53,6 +53,9 @@ context.setDescriptor(location.toExternalForm() + "/WEB-INF/web.xml"); context.setServer(server); context.setWar(location.toExternalForm()); + if (forceHttps) { + context.setInitParameter("org.scalatra.ForceHttps", "true"); + } server.setHandler(context); server.start(); diff --git a/src/main/scala/ScalatraBootstrap.scala b/src/main/scala/ScalatraBootstrap.scala index 4ef4c88..de3c40d 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -17,7 +17,6 @@ context.mount(new IndexController, "/") context.mount(new SearchController, "/") context.mount(new FileUploadController, "/upload") - context.mount(new SignInController, "/*") context.mount(new DashboardController, "/*") context.mount(new UserManagementController, "/*") context.mount(new SystemSettingsController, "/*") diff --git a/src/main/scala/app/AccountController.scala b/src/main/scala/app/AccountController.scala index b32d5df..5bea0b4 100644 --- a/src/main/scala/app/AccountController.scala +++ b/src/main/scala/app/AccountController.scala @@ -9,12 +9,10 @@ import org.apache.commons.io.FileUtils class AccountController extends AccountControllerBase - with SystemSettingsService with AccountService with RepositoryService with ActivityService - with OneselfAuthenticator + with AccountService with RepositoryService with ActivityService with OneselfAuthenticator trait AccountControllerBase extends AccountManagementControllerBase with FlashMapSupport { - self: SystemSettingsService with AccountService with RepositoryService with ActivityService - with OneselfAuthenticator => + self: AccountService with RepositoryService with ActivityService with OneselfAuthenticator => case class AccountNewForm(userName: String, password: String, fullName: String, mailAddress: String, url: Option[String], fileId: Option[String]) diff --git a/src/main/scala/app/ControllerBase.scala b/src/main/scala/app/ControllerBase.scala index a026a18..bdea042 100644 --- a/src/main/scala/app/ControllerBase.scala +++ b/src/main/scala/app/ControllerBase.scala @@ -10,8 +10,7 @@ import jp.sf.amateras.scalatra.forms._ import org.apache.commons.io.FileUtils import model.Account -import scala.Some -import service.AccountService +import service.{SystemSettingsService, AccountService} import javax.servlet.http.{HttpServletResponse, HttpSession, HttpServletRequest} import java.text.SimpleDateFormat import javax.servlet.{FilterChain, ServletResponse, ServletRequest} @@ -21,7 +20,7 @@ * Provides generic features for controller implementations. */ abstract class ControllerBase extends ScalatraFilter - with ClientSideValidationFormSupport with JacksonJsonSupport with I18nSupport with Validations { + with ClientSideValidationFormSupport with JacksonJsonSupport with I18nSupport with Validations with SystemSettingsService { implicit val jsonFormats = DefaultFormats @@ -58,11 +57,7 @@ /** * Returns the context object for the request. */ - implicit def context: Context = Context(servletContext.getContextPath, LoginAccount, currentURL, request) - - private def currentURL: String = defining(request.getQueryString){ queryString => - request.getRequestURI + (if(queryString != null) "?" + queryString else "") - } + implicit def context: Context = Context(servletContext.getContextPath, LoginAccount, request) private def LoginAccount: Option[Account] = session.getAs[Account](Keys.Session.LoginAccount) @@ -107,27 +102,27 @@ if(request.getMethod.toUpperCase == "POST"){ org.scalatra.Unauthorized(redirect("/signin")) } else { - org.scalatra.Unauthorized(redirect("/signin?redirect=" + StringUtil.urlEncode(currentURL))) + val currentUrl = baseUrl + defining(request.getQueryString){ queryString => + request.getRequestURI.substring(request.getContextPath.length) + (if(queryString != null) "?" + queryString else "") + } + session.setAttribute(Keys.Session.Redirect, currentUrl) + org.scalatra.Unauthorized(redirect("/signin")) } } } - protected def baseUrl = defining(request.getRequestURL.toString){ url => - url.substring(0, url.length - (request.getRequestURI.length - request.getContextPath.length)) - } + protected def baseUrl = loadSystemSettings().baseUrl.getOrElse { + defining(request.getRequestURL.toString){ url => + url.substring(0, url.length - (request.getRequestURI.length - request.getContextPath.length)) + } + }.replaceFirst("/$", "") } /** * Context object for the current request. */ -case class Context(path: String, loginAccount: Option[Account], currentUrl: String, request: HttpServletRequest){ - - def redirectUrl = if(request.getParameter("redirect") != null){ - request.getParameter("redirect") - } else { - currentUrl - } +case class Context(path: String, loginAccount: Option[Account], request: HttpServletRequest){ /** * Get object from cache. diff --git a/src/main/scala/app/CreateRepositoryController.scala b/src/main/scala/app/CreateRepositoryController.scala index 67083aa..16cd05c 100644 --- a/src/main/scala/app/CreateRepositoryController.scala +++ b/src/main/scala/app/CreateRepositoryController.scala @@ -113,54 +113,62 @@ val loginUserName = loginAccount.userName LockUtil.lock(s"${loginUserName}/${repository.name}/create"){ - if(getRepository(loginUserName, repository.name, baseUrl).isEmpty){ - // Insert to the database at first - val originUserName = repository.repository.originUserName.getOrElse(repository.owner) - val originRepositoryName = repository.repository.originRepositoryName.getOrElse(repository.name) + 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) - ) + 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) + // Insert default labels + insertDefaultLabels(loginUserName, repository.name) - // clone repository actually - JGitUtil.cloneRepository( - getRepositoryDir(repository.owner, repository.name), - getRepositoryDir(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)) + // 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) + // 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(_) => ??? } - case Left(_) => ??? } } - } - // Record activity - recordForkActivity(repository.owner, repository.name, loginUserName) + // Record activity + recordForkActivity(repository.owner, repository.name, loginUserName) + // redirect to the repository + redirect(s"/${loginUserName}/${repository.name}") + } } - // redirect to the repository - redirect("/%s/%s".format(loginUserName, repository.name)) } }) diff --git a/src/main/scala/app/IndexController.scala b/src/main/scala/app/IndexController.scala index d18b3d3..2ee96bd 100644 --- a/src/main/scala/app/IndexController.scala +++ b/src/main/scala/app/IndexController.scala @@ -1,16 +1,22 @@ package app import util._ +import util.Implicits._ import service._ import jp.sf.amateras.scalatra.forms._ class IndexController extends IndexControllerBase - with RepositoryService with SystemSettingsService with ActivityService with AccountService -with UsersAuthenticator + with RepositoryService with ActivityService with AccountService with UsersAuthenticator trait IndexControllerBase extends ControllerBase { - self: RepositoryService with SystemSettingsService with ActivityService with AccountService - with UsersAuthenticator => + self: RepositoryService with ActivityService with AccountService with UsersAuthenticator => + + case class SignInForm(userName: String, password: String) + + val form = mapping( + "userName" -> trim(label("Username", text(required))), + "password" -> trim(label("Password", text(required))) + )(SignInForm.apply) get("/"){ val loginAccount = context.loginAccount @@ -22,6 +28,44 @@ ) } + get("/signin"){ + val redirect = params.get("redirect") + if(redirect.isDefined && redirect.get.startsWith("/")){ + session.setAttribute(Keys.Session.Redirect, redirect.get) + } + html.signin(loadSystemSettings()) + } + + post("/signin", form){ form => + authenticate(loadSystemSettings(), form.userName, form.password) match { + case Some(account) => signin(account) + case None => redirect("/signin") + } + } + + get("/signout"){ + session.invalidate + redirect("/") + } + + /** + * Set account information into HttpSession and redirect. + */ + private def signin(account: model.Account) = { + session.setAttribute(Keys.Session.LoginAccount, account) + updateLastLoginDate(account.userName) + + session.getAndRemove[String](Keys.Session.Redirect).map { redirectUrl => + if(redirectUrl.replaceFirst("/$", "") == request.getContextPath){ + redirect("/") + } else { + redirect(redirectUrl) + } + }.getOrElse { + redirect("/") + } + } + /** * JSON API for collaborator completion. * diff --git a/src/main/scala/app/IssuesController.scala b/src/main/scala/app/IssuesController.scala index 22544e7..be564ee 100644 --- a/src/main/scala/app/IssuesController.scala +++ b/src/main/scala/app/IssuesController.scala @@ -4,10 +4,11 @@ import service._ import IssuesService._ -import util.{CollaboratorsAuthenticator, ReferrerAuthenticator, ReadableUsersAuthenticator, Notifier, Keys} +import util._ import util.Implicits._ import util.ControlUtil._ import org.scalatra.Ok +import model.Issue class IssuesController extends IssuesControllerBase with IssuesService with RepositoryService with AccountService with LabelsService with MilestonesService with ActivityService @@ -110,6 +111,11 @@ // record activity recordCreateIssueActivity(owner, name, userName, issueId, form.title) + // extract references and create refer comment + getIssue(owner, name, issueId.toString).foreach { issue => + createReferComment(owner, name, issue, form.title + " " + form.content.getOrElse("")) + } + // notifications Notifier().toNotify(repository, issueId, form.content.getOrElse("")){ Notifier.msgIssue(s"${baseUrl}/${owner}/${name}/issues/${issueId}") @@ -123,7 +129,11 @@ defining(repository.owner, repository.name){ case (owner, name) => getIssue(owner, name, params("id")).map { issue => if(isEditable(owner, name, issue.openedUserName)){ + // update issue updateIssue(owner, name, issue.issueId, form.title, form.content) + // extract references and create refer comment + createReferComment(owner, name, issue, form.title + " " + form.content.getOrElse("")) + redirect(s"/${owner}/${name}/issues/_data/${issue.issueId}") } else Unauthorized } getOrElse NotFound @@ -274,6 +284,15 @@ redirect(s"/${repository.owner}/${repository.name}/issues") } + private def createReferComment(owner: String, repository: String, fromIssue: Issue, message: String) = { + StringUtil.extractIssueId(message).foreach { issueId => + if(getIssue(owner, repository, issueId).isDefined){ + createComment(owner, repository, context.loginAccount.get.userName, issueId.toInt, + fromIssue.issueId + ":" + fromIssue.title, "refer") + } + } + } + /** * @see [[https://github.com/takezoe/gitbucket/wiki/CommentAction]] */ @@ -313,6 +332,11 @@ } recordActivity foreach ( _ (owner, name, userName, issueId, issue.title) ) + // extract references and create refer comment + content.map { content => + createReferComment(owner, name, issue, content) + } + // notifications Notifier() match { case f => diff --git a/src/main/scala/app/PullRequestsController.scala b/src/main/scala/app/PullRequestsController.scala index e91ff78..5a3317d 100644 --- a/src/main/scala/app/PullRequestsController.scala +++ b/src/main/scala/app/PullRequestsController.scala @@ -18,26 +18,28 @@ import org.slf4j.LoggerFactory import org.eclipse.jgit.merge.MergeStrategy import org.eclipse.jgit.errors.NoMergeBaseException +import service.WebHookService.WebHookPayload class PullRequestsController extends PullRequestsControllerBase - with RepositoryService with AccountService with IssuesService with PullRequestService with MilestonesService with ActivityService - with ReferrerAuthenticator with CollaboratorsAuthenticator + with RepositoryService with AccountService with IssuesService with PullRequestService with MilestonesService with LabelsService + with ActivityService with WebHookService with ReferrerAuthenticator with CollaboratorsAuthenticator trait PullRequestsControllerBase extends ControllerBase { - self: RepositoryService with AccountService with IssuesService with MilestonesService with ActivityService with PullRequestService - with ReferrerAuthenticator with CollaboratorsAuthenticator => + self: RepositoryService with AccountService with IssuesService with MilestonesService with LabelsService + with ActivityService with PullRequestService with WebHookService with ReferrerAuthenticator with CollaboratorsAuthenticator => private val logger = LoggerFactory.getLogger(classOf[PullRequestsControllerBase]) val pullRequestForm = mapping( - "title" -> trim(label("Title" , text(required, maxlength(100)))), - "content" -> trim(label("Content", optional(text()))), - "targetUserName" -> trim(text(required, maxlength(100))), - "targetBranch" -> trim(text(required, maxlength(100))), - "requestUserName" -> trim(text(required, maxlength(100))), - "requestBranch" -> trim(text(required, maxlength(100))), - "commitIdFrom" -> trim(text(required, maxlength(40))), - "commitIdTo" -> trim(text(required, maxlength(40))) + "title" -> trim(label("Title" , text(required, maxlength(100)))), + "content" -> trim(label("Content", optional(text()))), + "targetUserName" -> trim(text(required, maxlength(100))), + "targetBranch" -> trim(text(required, maxlength(100))), + "requestUserName" -> trim(text(required, maxlength(100))), + "requestRepositoryName" -> trim(text(required, maxlength(100))), + "requestBranch" -> trim(text(required, maxlength(100))), + "commitIdFrom" -> trim(text(required, maxlength(40))), + "commitIdTo" -> trim(text(required, maxlength(40))) )(PullRequestForm.apply) val mergeForm = mapping( @@ -50,6 +52,7 @@ targetUserName: String, targetBranch: String, requestUserName: String, + requestRepositoryName: String, requestBranch: String, commitIdFrom: String, commitIdTo: String) @@ -76,8 +79,10 @@ pulls.html.pullreq( issue, pullreq, getComments(owner, name, issueId), + getIssueLabels(owner, name, issueId.toInt), (getCollaborators(owner, name) ::: (if(getAccountByUserName(owner).get.isGroupAccount) Nil else List(owner))).sorted, getMilestonesWithIssueCount(owner, name), + getLabels(owner, name), commits, diffs, hasWritePermission(owner, name, context.loginAccount), @@ -90,7 +95,7 @@ ajaxGet("/:owner/:repository/pull/:id/mergeguide")(collaboratorsOnly { repository => params("id").toIntOpt.flatMap{ issueId => val owner = repository.owner - val name = repository.name + val name = repository.name getPullRequest(owner, name, issueId) map { case(issue, pullreq) => pulls.html.mergeguide( checkConflictInPullRequest(owner, name, pullreq.branch, pullreq.requestUserName, name, pullreq.requestBranch, issueId), @@ -100,10 +105,25 @@ } getOrElse NotFound }) + get("/:owner/:repository/pull/:id/delete/:branchName")(collaboratorsOnly { repository => + params("id").toIntOpt.map { issueId => + val branchName = params("branchName") + val userName = context.loginAccount.get.userName + if(repository.repository.defaultBranch != branchName){ + using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => + git.branchDelete().setBranchNames(branchName).call() + recordDeleteBranchActivity(repository.owner, repository.name, userName, branchName) + } + } + createComment(repository.owner, repository.name, userName, issueId, branchName, "delete_branch") + redirect(s"/${repository.owner}/${repository.name}/pull/${issueId}") + } getOrElse NotFound + }) + post("/:owner/:repository/pull/:id/merge", mergeForm)(collaboratorsOnly { (form, repository) => params("id").toIntOpt.flatMap { issueId => val owner = repository.owner - val name = repository.name + val name = repository.name LockUtil.lock(s"${owner}/${name}/merge"){ getPullRequest(owner, name, issueId).map { case (issue, pullreq) => using(Git.open(getRepositoryDir(owner, name))) { git => @@ -175,6 +195,15 @@ } closeIssuesFromMessage(form.message, loginAccount.userName, owner, name) } + // call web hook + getWebHookURLs(owner, name) match { + case webHookURLs if(webHookURLs.nonEmpty) => + for(ownerAccount <- getAccountByUserName(owner)){ + callWebHook(owner, name, webHookURLs, + WebHookPayload(git, loginAccount, mergeBaseRefName, repository, commits.flatten.toList, ownerAccount)) + } + case _ => + } // notifications Notifier().toNotify(repository, issueId, "merge"){ @@ -215,73 +244,85 @@ } }) - get("/:owner/:repository/compare/*...*")(referrersOnly { repository => + get("/:owner/:repository/compare/*...*")(referrersOnly { forkedRepository => val Seq(origin, forked) = multiParams("splat") - val (originOwner, tmpOriginBranch) = parseCompareIdentifie(origin, repository.owner) - val (forkedOwner, tmpForkedBranch) = parseCompareIdentifie(forked, repository.owner) + val (originOwner, tmpOriginBranch) = parseCompareIdentifie(origin, forkedRepository.owner) + val (forkedOwner, tmpForkedBranch) = parseCompareIdentifie(forked, forkedRepository.owner) - (getRepository(originOwner, repository.name, baseUrl), - getRepository(forkedOwner, repository.name, baseUrl)) match { - case (Some(originRepository), Some(forkedRepository)) => { - using( - Git.open(getRepositoryDir(originOwner, repository.name)), - Git.open(getRepositoryDir(forkedOwner, repository.name)) - ){ case (oldGit, newGit) => - val originBranch = JGitUtil.getDefaultBranch(oldGit, originRepository, tmpOriginBranch).get._2 - val forkedBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository, tmpForkedBranch).get._2 - - val forkedId = getForkedCommitId(oldGit, newGit, - originOwner, repository.name, originBranch, - forkedOwner, repository.name, forkedBranch) - - val oldId = oldGit.getRepository.resolve(forkedId) - val newId = newGit.getRepository.resolve(forkedBranch) - - val (commits, diffs) = getRequestCompareInfo( - originOwner, repository.name, oldId.getName, - forkedOwner, repository.name, newId.getName) - - pulls.html.compare( - commits, - diffs, - repository.repository.originUserName.map { userName => - userName :: getForkedRepositories(userName, repository.name) - } getOrElse List(repository.owner), - originBranch, - forkedBranch, - oldId.getName, - newId.getName, - repository, - originRepository, - forkedRepository, - hasWritePermission(repository.owner, repository.name, context.loginAccount)) + (for( + originRepositoryName <- if(originOwner == forkedOwner){ + Some(forkedRepository.name) + } else { + forkedRepository.repository.originRepositoryName.orElse { + getForkedRepositories(forkedRepository.owner, forkedRepository.name).find(_._1 == originOwner).map(_._2) } + }; + originRepository <- getRepository(originOwner, originRepositoryName, baseUrl) + ) yield { + using( + Git.open(getRepositoryDir(originRepository.owner, originRepository.name)), + Git.open(getRepositoryDir(forkedRepository.owner, forkedRepository.name)) + ){ case (oldGit, newGit) => + val originBranch = JGitUtil.getDefaultBranch(oldGit, originRepository, tmpOriginBranch).get._2 + val forkedBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository, tmpForkedBranch).get._2 + + val forkedId = getForkedCommitId(oldGit, newGit, + originRepository.owner, originRepository.name, originBranch, + forkedRepository.owner, forkedRepository.name, forkedBranch) + + val oldId = oldGit.getRepository.resolve(forkedId) + val newId = newGit.getRepository.resolve(forkedBranch) + + val (commits, diffs) = getRequestCompareInfo( + originRepository.owner, originRepository.name, oldId.getName, + forkedRepository.owner, forkedRepository.name, newId.getName) + + pulls.html.compare( + commits, + diffs, + (forkedRepository.repository.originUserName, forkedRepository.repository.originRepositoryName) match { + case (Some(userName), Some(repositoryName)) => (userName, repositoryName) :: getForkedRepositories(userName, repositoryName) + case _ => (forkedRepository.owner, forkedRepository.name) :: getForkedRepositories(forkedRepository.owner, forkedRepository.name) + }, + originBranch, + forkedBranch, + oldId.getName, + newId.getName, + forkedRepository, + originRepository, + forkedRepository, + hasWritePermission(forkedRepository.owner, forkedRepository.name, context.loginAccount)) } - case _ => NotFound - } + }) getOrElse NotFound }) - ajaxGet("/:owner/:repository/compare/*...*/mergecheck")(collaboratorsOnly { repository => + ajaxGet("/:owner/:repository/compare/*...*/mergecheck")(collaboratorsOnly { forkedRepository => val Seq(origin, forked) = multiParams("splat") - val (originOwner, tmpOriginBranch) = parseCompareIdentifie(origin, repository.owner) - val (forkedOwner, tmpForkedBranch) = parseCompareIdentifie(forked, repository.owner) + val (originOwner, tmpOriginBranch) = parseCompareIdentifie(origin, forkedRepository.owner) + val (forkedOwner, tmpForkedBranch) = parseCompareIdentifie(forked, forkedRepository.owner) - (getRepository(originOwner, repository.name, baseUrl), - getRepository(forkedOwner, repository.name, baseUrl)) match { - case (Some(originRepository), Some(forkedRepository)) => { - using( - Git.open(getRepositoryDir(originOwner, repository.name)), - Git.open(getRepositoryDir(forkedOwner, repository.name)) - ){ case (oldGit, newGit) => - val originBranch = JGitUtil.getDefaultBranch(oldGit, originRepository, tmpOriginBranch).get._2 - val forkedBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository, tmpForkedBranch).get._2 - - pulls.html.mergecheck( - checkConflict(originOwner, repository.name, originBranch, forkedOwner, repository.name, forkedBranch)) + (for( + originRepositoryName <- if(originOwner == forkedOwner){ + Some(forkedRepository.name) + } else { + forkedRepository.repository.originRepositoryName.orElse { + getForkedRepositories(forkedRepository.owner, forkedRepository.name).find(_._1 == originOwner).map(_._2) } + }; + originRepository <- getRepository(originOwner, originRepositoryName, baseUrl) + ) yield { + using( + Git.open(getRepositoryDir(originRepository.owner, originRepository.name)), + Git.open(getRepositoryDir(forkedRepository.owner, forkedRepository.name)) + ){ case (oldGit, newGit) => + val originBranch = JGitUtil.getDefaultBranch(oldGit, originRepository, tmpOriginBranch).get._2 + val forkedBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository, tmpForkedBranch).get._2 + + pulls.html.mergecheck( + checkConflict(originRepository.owner, originRepository.name, originBranch, + forkedRepository.owner, forkedRepository.name, forkedBranch)) } - case _ => NotFound() - } + }) getOrElse NotFound }) post("/:owner/:repository/pulls/new", pullRequestForm)(referrersOnly { (form, repository) => @@ -303,7 +344,7 @@ issueId = issueId, originBranch = form.targetBranch, requestUserName = form.requestUserName, - requestRepositoryName = repository.name, + requestRepositoryName = form.requestRepositoryName, requestBranch = form.requestBranch, commitIdFrom = form.commitIdFrom, commitIdTo = form.commitIdTo) @@ -311,7 +352,7 @@ // fetch requested branch using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => git.fetch - .setRemote(getRepositoryDir(form.requestUserName, repository.name).toURI.toString) + .setRemote(getRepositoryDir(form.requestUserName, form.requestRepositoryName).toURI.toString) .setRefSpecs(new RefSpec(s"refs/heads/${form.requestBranch}:refs/pull/${issueId}/head")) .call } @@ -336,12 +377,12 @@ using(Git.open(getRepositoryDir(requestUserName, requestRepositoryName))) { git => val remoteRefName = s"refs/heads/${branch}" val tmpRefName = s"refs/merge-check/${userName}/${branch}" - - withTmpRefSpec(new RefSpec(s"${remoteRefName}:${tmpRefName}").setForceUpdate(true), git) { ref => + val refSpec = new RefSpec(s"${remoteRefName}:${tmpRefName}").setForceUpdate(true) + try { // fetch objects from origin repository branch git.fetch .setRemote(getRepositoryDir(userName, repositoryName).toURI.toString) - .setRefSpecs(ref) + .setRefSpecs(refSpec) .call // merge conflict check @@ -353,6 +394,10 @@ } catch { case e: NoMergeBaseException => true } + } finally { + val refUpdate = git.getRepository.updateRef(refSpec.getDestination) + refUpdate.setForceUpdate(true) + refUpdate.delete() } } } @@ -405,8 +450,7 @@ private def getForkedCommitId(oldGit: Git, newGit: Git, userName: String, repositoryName: String, branch: String, requestUserName: String, requestRepositoryName: String, requestBranch: String): String = JGitUtil.getCommitLogs(newGit, requestBranch, true){ commit => - existsCommitId(userName, repositoryName, commit.getName) && - JGitUtil.getBranchesOfCommit(oldGit, commit.getName).contains(branch) + existsCommitId(userName, repositoryName, commit.getName) && JGitUtil.getBranchesOfCommit(oldGit, commit.getName).contains(branch) }.head.id private def getRequestCompareInfo(userName: String, repositoryName: String, branch: String, @@ -421,7 +465,7 @@ val commits = newGit.log.addRange(oldId, newId).call.iterator.asScala.map { revCommit => new CommitInfo(revCommit) - }.toList.splitWith{ (commit1, commit2) => + }.toList.splitWith { (commit1, commit2) => view.helpers.date(commit1.time) == view.helpers.date(commit2.time) } diff --git a/src/main/scala/app/RepositorySettingsController.scala b/src/main/scala/app/RepositorySettingsController.scala index 05987d5..d745623 100644 --- a/src/main/scala/app/RepositorySettingsController.scala +++ b/src/main/scala/app/RepositorySettingsController.scala @@ -21,12 +21,13 @@ with OwnerAuthenticator with UsersAuthenticator => // for repository options - case class OptionsForm(description: Option[String], defaultBranch: String, isPrivate: Boolean) + case class OptionsForm(repositoryName: String, description: Option[String], defaultBranch: String, isPrivate: Boolean) val optionsForm = mapping( - "description" -> trim(label("Description" , optional(text()))), - "defaultBranch" -> trim(label("Default Branch" , text(required, maxlength(100)))), - "isPrivate" -> trim(label("Repository Type", boolean())) + "repositoryName" -> trim(label("Description" , text(required, maxlength(40), identifier, renameRepositoryName))), + "description" -> trim(label("Description" , optional(text()))), + "defaultBranch" -> trim(label("Default Branch" , text(required, maxlength(100)))), + "isPrivate" -> trim(label("Repository Type", boolean())) )(OptionsForm.apply) // for collaborator addition @@ -43,6 +44,13 @@ "url" -> trim(label("url", text(required, webHook))) )(WebHookForm.apply) + // for transfer ownership + case class TransferOwnerShipForm(newOwner: String) + + val transferForm = mapping( + "newOwner" -> trim(label("New owner", text(required, transferUser))) + )(TransferOwnerShipForm.apply) + /** * Redirect to the Options page. */ @@ -70,8 +78,21 @@ repository.repository.isPrivate } getOrElse form.isPrivate ) + // Change repository name + if(repository.name != form.repositoryName){ + // Update database + renameRepository(repository.owner, repository.name, repository.owner, form.repositoryName) + // Move git repository + defining(getRepositoryDir(repository.owner, repository.name)){ dir => + FileUtils.moveDirectory(dir, getRepositoryDir(repository.owner, form.repositoryName)) + } + // Move wiki repository + defining(getWikiRepositoryDir(repository.owner, repository.name)){ dir => + FileUtils.moveDirectory(dir, getWikiRepositoryDir(repository.owner, form.repositoryName)) + } + } flash += "info" -> "Repository settings has been updated." - redirect(s"/${repository.owner}/${repository.name}/settings/options") + redirect(s"/${repository.owner}/${form.repositoryName}/settings/options") }) /** @@ -138,17 +159,13 @@ .setMaxCount(3) .call.iterator.asScala.map(new CommitInfo(_)) - val webHookURLs = getWebHookURLs(repository.owner, repository.name) - if(webHookURLs.nonEmpty){ - val owner = getAccountByUserName(repository.owner).get - callWebHook(repository.owner, repository.name, webHookURLs, - WebHookPayload( - git, - owner, - "refs/heads/" + repository.repository.defaultBranch, - repository, - commits.toList, - owner)) + getWebHookURLs(repository.owner, repository.name) match { + case webHookURLs if(webHookURLs.nonEmpty) => + for(ownerAccount <- getAccountByUserName(repository.owner)){ + callWebHook(repository.owner, repository.name, webHookURLs, + WebHookPayload(git, ownerAccount, "refs/heads/" + repository.repository.defaultBranch, repository, commits.toList, ownerAccount)) + } + case _ => } flash += "info" -> "Test payload deployed!" @@ -157,10 +174,30 @@ }) /** - * Display the delete repository page. + * Display the danger zone. */ - get("/:owner/:repository/settings/delete")(ownerOnly { - settings.html.delete(_) + get("/:owner/:repository/settings/danger")(ownerOnly { + settings.html.danger(_) + }) + + /** + * Transfer repository ownership. + */ + post("/:owner/:repository/settings/transfer", transferForm)(ownerOnly { (form, repository) => + // Change repository owner + if(repository.owner != form.newOwner){ + // Update database + renameRepository(repository.owner, repository.name, form.newOwner, repository.name) + // Move git repository + defining(getRepositoryDir(repository.owner, repository.name)){ dir => + FileUtils.moveDirectory(dir, getRepositoryDir(form.newOwner, repository.name)) + } + // Move wiki repository + defining(getWikiRepositoryDir(repository.owner, repository.name)){ dir => + FileUtils.moveDirectory(dir, getWikiRepositoryDir(form.newOwner, repository.name)) + } + } + redirect(s"/${form.newOwner}/${repository.name}") }) /** @@ -199,4 +236,32 @@ } } + /** + * Duplicate check for the rename repository name. + */ + private def renameRepositoryName: Constraint = new Constraint(){ + override def validate(name: String, value: String, params: Map[String, String], messages: Messages): Option[String] = + params.get("repository").filter(_ != value).flatMap { _ => + params.get("owner").flatMap { userName => + getRepositoryNamesOfUser(userName).find(_ == value).map(_ => "Repository already exists.") + } + } + } + + /** + * Provides Constraint to validate the repository transfer user. + */ + private def transferUser: Constraint = new Constraint(){ + override def validate(name: String, value: String, messages: Messages): Option[String] = + getAccountByUserName(value) match { + case None => Some("User does not exist.") + case Some(x) => if(x.userName == params("owner")){ + Some("This is current repository owner.") + } else { + params.get("repository").flatMap { repositoryName => + getRepositoryNamesOfUser(x.userName).find(_ == repositoryName).map{ _ => "User already has same repository." } + } + } + } + } } \ No newline at end of file diff --git a/src/main/scala/app/RepositoryViewerController.scala b/src/main/scala/app/RepositoryViewerController.scala index 5fbfa75..2726136 100644 --- a/src/main/scala/app/RepositoryViewerController.scala +++ b/src/main/scala/app/RepositoryViewerController.scala @@ -3,7 +3,7 @@ import util.Directory._ import util.Implicits._ import util.ControlUtil._ -import _root_.util.{ReferrerAuthenticator, JGitUtil, FileUtil, StringUtil} +import _root_.util._ import service._ import org.scalatra._ import java.io.File @@ -12,15 +12,16 @@ import org.apache.commons.io.FileUtils import org.eclipse.jgit.treewalk._ import java.util.zip.{ZipEntry, ZipOutputStream} +import scala.Some class RepositoryViewerController extends RepositoryViewerControllerBase - with RepositoryService with AccountService with ReferrerAuthenticator + with RepositoryService with AccountService with ActivityService with ReferrerAuthenticator with CollaboratorsAuthenticator /** * The repository viewer. */ trait RepositoryViewerControllerBase extends ControllerBase { - self: RepositoryService with AccountService with ReferrerAuthenticator => + self: RepositoryService with AccountService with ActivityService with ReferrerAuthenticator with CollaboratorsAuthenticator => /** * Returns converted HTML from Markdown for preview. @@ -150,11 +151,26 @@ val revCommit = git.log.add(git.getRepository.resolve(branchName)).setMaxCount(1).call.iterator.next (branchName, revCommit.getCommitterIdent.getWhen) } - repo.html.branches(branchInfo, repository) + repo.html.branches(branchInfo, hasWritePermission(repository.owner, repository.name, context.loginAccount), repository) } }) /** + * Deletes branch. + */ + get("/:owner/:repository/delete/:branchName")(collaboratorsOnly { repository => + val branchName = params("branchName") + val userName = context.loginAccount.get.userName + if(repository.repository.defaultBranch != branchName){ + using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => + git.branchDelete().setBranchNames(branchName).call() + recordDeleteBranchActivity(repository.owner, repository.name, userName, branchName) + } + } + redirect(s"/${repository.owner}/${repository.name}/branches") + }) + + /** * Displays tags. */ get("/:owner/:repository/tags")(referrersOnly { @@ -175,7 +191,8 @@ } workDir.mkdirs - val zipFile = new File(workDir, (if(revision.length == 40) revision.substring(0, 10) else revision) + ".zip") + val zipFile = new File(workDir, repository.name + "-" + + (if(revision.length == 40) revision.substring(0, 10) else revision) + ".zip") using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => val revCommit = JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(revision)) @@ -204,6 +221,7 @@ } contentType = "application/octet-stream" + response.setHeader("Content-Disposition", s"attachment; filename=${zipFile.getName}") zipFile } else { BadRequest diff --git a/src/main/scala/app/SearchController.scala b/src/main/scala/app/SearchController.scala index 99ea743..ab091cf 100644 --- a/src/main/scala/app/SearchController.scala +++ b/src/main/scala/app/SearchController.scala @@ -6,13 +6,10 @@ import jp.sf.amateras.scalatra.forms._ class SearchController extends SearchControllerBase -with RepositoryService with AccountService with SystemSettingsService with ActivityService -with RepositorySearchService with IssuesService -with ReferrerAuthenticator + with RepositoryService with AccountService with ActivityService with RepositorySearchService with IssuesService with ReferrerAuthenticator trait SearchControllerBase extends ControllerBase { self: RepositoryService - with SystemSettingsService with ActivityService with RepositorySearchService - with ReferrerAuthenticator => + with ActivityService with RepositorySearchService with ReferrerAuthenticator => val searchForm = mapping( "query" -> trim(text(required)), diff --git a/src/main/scala/app/SignInController.scala b/src/main/scala/app/SignInController.scala deleted file mode 100644 index b6a43f0..0000000 --- a/src/main/scala/app/SignInController.scala +++ /dev/null @@ -1,58 +0,0 @@ -package app - -import service._ -import jp.sf.amateras.scalatra.forms._ -import util.Implicits._ -import util.StringUtil._ -import util.Keys - -class SignInController extends SignInControllerBase with SystemSettingsService with AccountService - -trait SignInControllerBase extends ControllerBase { self: SystemSettingsService with AccountService => - - case class SignInForm(userName: String, password: String) - - val form = mapping( - "userName" -> trim(label("Username", text(required))), - "password" -> trim(label("Password", text(required))) - )(SignInForm.apply) - - get("/signin"){ - val redirect = params.get("redirect") - if(redirect.isDefined && redirect.get.startsWith("/")){ - session.setAttribute(Keys.Session.Redirect, redirect.get) - } - html.signin(loadSystemSettings()) - } - - post("/signin", form){ form => - authenticate(loadSystemSettings(), form.userName, form.password) match { - case Some(account) => signin(account) - case None => redirect("/signin") - } - } - - get("/signout"){ - session.invalidate - redirect("/") - } - - /** - * Set account information into HttpSession and redirect. - */ - private def signin(account: model.Account) = { - session.setAttribute(Keys.Session.LoginAccount, account) - updateLastLoginDate(account.userName) - - session.getAndRemove[String](Keys.Session.Redirect).map { redirectUrl => - if(redirectUrl.replaceFirst("/$", "") == request.getContextPath){ - redirect("/") - } else { - redirect(redirectUrl) - } - }.getOrElse { - redirect("/") - } - } - -} \ No newline at end of file diff --git a/src/main/scala/app/SystemSettingsController.scala b/src/main/scala/app/SystemSettingsController.scala index 68efda5..08231b6 100644 --- a/src/main/scala/app/SystemSettingsController.scala +++ b/src/main/scala/app/SystemSettingsController.scala @@ -13,6 +13,7 @@ self: SystemSettingsService with 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())), @@ -33,6 +34,7 @@ "bindPassword" -> trim(label("Bind Password", optional(text()))), "baseDN" -> trim(label("Base DN", text(required))), "userNameAttribute" -> trim(label("User name attribute", text(required))), + "fullNameAttribute" -> trim(label("Full name attribute", optional(text()))), "mailAttribute" -> trim(label("Mail address attribute", text(required))), "tls" -> trim(label("Enable TLS", optional(boolean()))), "keystore" -> trim(label("Keystore", optional(text()))) diff --git a/src/main/scala/app/WikiController.scala b/src/main/scala/app/WikiController.scala index aaa88a8..704ce20 100644 --- a/src/main/scala/app/WikiController.scala +++ b/src/main/scala/app/WikiController.scala @@ -188,16 +188,16 @@ private def conflictForNew: Constraint = new Constraint(){ override def validate(name: String, value: String, messages: Messages): Option[String] = { - optionIf(targetWikiPage.nonEmpty){ - Some("Someone has created the wiki since you started. Please reload this page and re-apply your changes.") + targetWikiPage.map { _ => + "Someone has created the wiki since you started. Please reload this page and re-apply your changes." } } } private def conflictForEdit: Constraint = new Constraint(){ override def validate(name: String, value: String, messages: Messages): Option[String] = { - optionIf(targetWikiPage.map(_.id != params("id")).getOrElse(false)){ - Some("Someone has edited the wiki since you started. Please reload this page and re-apply your changes.") + targetWikiPage.filter(_.id != params("id")).map{ _ => + "Someone has edited the wiki since you started. Please reload this page and re-apply your changes." } } } diff --git a/src/main/scala/service/AccountService.scala b/src/main/scala/service/AccountService.scala index 905736b..84e78a1 100644 --- a/src/main/scala/service/AccountService.scala +++ b/src/main/scala/service/AccountService.scala @@ -36,11 +36,15 @@ */ private def ldapAuthentication(settings: SystemSettings, userName: String, password: String) = { LDAPUtil.authenticate(settings.ldap.get, userName, password) match { - case Right(mailAddress) => { + case Right(ldapUserInfo) => { // Create or update account by LDAP information - getAccountByUserName(userName) match { - case Some(x) => updateAccount(x.copy(mailAddress = mailAddress)) - case None => createAccount(userName, "", userName, mailAddress, false, None) + getAccountByUserName(userName, true) match { + case Some(x) if(!x.isRemoved) => updateAccount(x.copy(mailAddress = ldapUserInfo.mailAddress, fullName = ldapUserInfo.fullName)) + case Some(x) if(x.isRemoved) => { + logger.info(s"LDAP Authentication Failed: Account is already registered but disabled..") + defaultAuthentication(userName, password) + } + case None => createAccount(userName, "", ldapUserInfo.fullName, ldapUserInfo.mailAddress, false, None) } getAccountByUserName(userName) } diff --git a/src/main/scala/service/IssuesService.scala b/src/main/scala/service/IssuesService.scala index ec6f936..81a9bf8 100644 --- a/src/main/scala/service/IssuesService.scala +++ b/src/main/scala/service/IssuesService.scala @@ -343,7 +343,7 @@ def toURL: String = "?" + List( - if(labels.isEmpty) None else Some("labels=" + urlEncode(labels.mkString(" "))), + if(labels.isEmpty) None else Some("labels=" + urlEncode(labels.mkString(","))), milestoneId.map { id => "milestone=" + (id match { case Some(x) => x.toString case None => "none" @@ -364,7 +364,7 @@ def apply(request: HttpServletRequest): IssueSearchCondition = IssueSearchCondition( - param(request, "labels").map(_.split(" ").toSet).getOrElse(Set.empty), + param(request, "labels").map(_.split(",").toSet).getOrElse(Set.empty), param(request, "milestone").map{ case "none" => None case x => x.toIntOpt diff --git a/src/main/scala/service/RepositoryService.scala b/src/main/scala/service/RepositoryService.scala index 14172c1..74bd139 100644 --- a/src/main/scala/service/RepositoryService.scala +++ b/src/main/scala/service/RepositoryService.scala @@ -39,6 +39,67 @@ IssueId insert (userName, repositoryName, 0) } + def renameRepository(oldUserName: String, oldRepositoryName: String, newUserName: String, newRepositoryName: String): Unit = { + (Query(Repositories) filter { t => t.byRepository(oldUserName, oldRepositoryName) } firstOption).map { repository => + Repositories insert repository.copy(userName = newUserName, repositoryName = newRepositoryName) + + val webHooks = Query(WebHooks ).filter(_.byRepository(oldUserName, oldRepositoryName)).list + val milestones = Query(Milestones ).filter(_.byRepository(oldUserName, oldRepositoryName)).list + val issueId = Query(IssueId ).filter(_.byRepository(oldUserName, oldRepositoryName)).list + val issues = Query(Issues ).filter(_.byRepository(oldUserName, oldRepositoryName)).list + val pullRequests = Query(PullRequests ).filter(_.byRepository(oldUserName, oldRepositoryName)).list + val labels = Query(Labels ).filter(_.byRepository(oldUserName, oldRepositoryName)).list + val issueComments = Query(IssueComments).filter(_.byRepository(oldUserName, oldRepositoryName)).list + val issueLabels = Query(IssueLabels ).filter(_.byRepository(oldUserName, oldRepositoryName)).list + val collaborators = Query(Collaborators).filter(_.byRepository(oldUserName, oldRepositoryName)).list + val commitLog = Query(CommitLog ).filter(_.byRepository(oldUserName, oldRepositoryName)).list + val activities = Query(Activities ).filter(_.byRepository(oldUserName, oldRepositoryName)).list + + Repositories.filter { t => + (t.originUserName is oldUserName.bind) && (t.originRepositoryName is oldRepositoryName.bind) + }.map { t => t.originUserName ~ t.originRepositoryName }.update(newUserName, newRepositoryName) + + Repositories.filter { t => + (t.parentUserName is oldUserName.bind) && (t.parentRepositoryName is oldRepositoryName.bind) + }.map { t => t.originUserName ~ t.originRepositoryName }.update(newUserName, newRepositoryName) + + PullRequests.filter { t => + t.requestRepositoryName is oldRepositoryName.bind + }.map { t => t.requestUserName ~ t.requestRepositoryName }.update(newUserName, newRepositoryName) + + deleteRepository(oldUserName, oldRepositoryName) + + WebHooks .insertAll(webHooks .map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) + Milestones .insertAll(milestones .map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) + IssueId .insertAll(issueId .map(_.copy(_1 = newUserName, _2 = newRepositoryName)) :_*) + Issues .insertAll(issues .map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) + PullRequests .insertAll(pullRequests .map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) + IssueComments .insertAll(issueComments .map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) + Labels .insertAll(labels .map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) + IssueLabels .insertAll(issueLabels .map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) + Collaborators .insertAll(collaborators .map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) + CommitLog .insertAll(commitLog .map(_.copy(_1 = newUserName, _2 = newRepositoryName)) :_*) + Activities .insertAll(activities .map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) + + // Update activity messages + val updateActivities = Activities.filter { t => + (t.message like s"%:${oldUserName}/${oldRepositoryName}]%") || + (t.message like s"%:${oldUserName}/${oldRepositoryName}#%") + }.map { t => t.activityId ~ t.message }.list + + updateActivities.foreach { case (activityId, message) => + Activities.filter(_.activityId is activityId.bind).map(_.message).update( + message + .replace(s"[repo:${oldUserName}/${oldRepositoryName}]" ,s"[repo:${newUserName}/${newRepositoryName}]") + .replace(s"[branch:${oldUserName}/${oldRepositoryName}#" ,s"[branch:${newUserName}/${newRepositoryName}#") + .replace(s"[tag:${oldUserName}/${oldRepositoryName}#" ,s"[tag:${newUserName}/${newRepositoryName}#") + .replace(s"[pullreq:${oldUserName}/${oldRepositoryName}#",s"[pullreq:${newUserName}/${newRepositoryName}#") + .replace(s"[issue:${oldUserName}/${oldRepositoryName}#" ,s"[issue:${newUserName}/${newRepositoryName}#") + ) + } + } + } + def deleteRepository(userName: String, repositoryName: String): Unit = { Activities .filter(_.byRepository(userName, repositoryName)).delete CommitLog .filter(_.byRepository(userName, repositoryName)).delete @@ -207,11 +268,11 @@ }.length).first - def getForkedRepositories(userName: String, repositoryName: String): List[String] = + def getForkedRepositories(userName: String, repositoryName: String): List[(String, String)] = Query(Repositories).filter { t => (t.originUserName is userName.bind) && (t.originRepositoryName is repositoryName.bind) } - .sortBy(_.userName asc).map(_.userName).list + .sortBy(_.userName asc).map(t => t.userName ~ t.repositoryName).list } diff --git a/src/main/scala/service/SystemSettingsService.scala b/src/main/scala/service/SystemSettingsService.scala index 85e94f7..de7a966 100644 --- a/src/main/scala/service/SystemSettingsService.scala +++ b/src/main/scala/service/SystemSettingsService.scala @@ -8,6 +8,7 @@ def saveSystemSettings(settings: SystemSettings): Unit = { defining(new java.util.Properties()){ props => + settings.baseUrl.foreach(props.setProperty(BaseURL, _)) props.setProperty(AllowAccountRegistration, settings.allowAccountRegistration.toString) props.setProperty(Gravatar, settings.gravatar.toString) props.setProperty(Notification, settings.notification.toString) @@ -31,6 +32,7 @@ ldap.bindPassword.foreach(x => props.setProperty(LdapBindPassword, x)) props.setProperty(LdapBaseDN, ldap.baseDN) props.setProperty(LdapUserNameAttribute, ldap.userNameAttribute) + ldap.fullNameAttribute.foreach(x => props.setProperty(LdapFullNameAttribute, x)) props.setProperty(LdapMailAddressAttribute, ldap.mailAttribute) ldap.tls.foreach(x => props.setProperty(LdapTls, x.toString)) ldap.keystore.foreach(x => props.setProperty(LdapKeystore, x)) @@ -47,6 +49,7 @@ props.load(new java.io.FileInputStream(GitBucketConf)) } SystemSettings( + getOptionValue(props, BaseURL, None), getValue(props, AllowAccountRegistration, false), getValue(props, Gravatar, true), getValue(props, Notification, false), @@ -71,6 +74,7 @@ getOptionValue(props, LdapBindPassword, None), getValue(props, LdapBaseDN, ""), getValue(props, LdapUserNameAttribute, ""), + getOptionValue(props, LdapFullNameAttribute, None), getValue(props, LdapMailAddressAttribute, ""), getOptionValue[Boolean](props, LdapTls, None), getOptionValue(props, LdapKeystore, None))) @@ -87,6 +91,7 @@ import scala.reflect.ClassTag case class SystemSettings( + baseUrl: Option[String], allowAccountRegistration: Boolean, gravatar: Boolean, notification: Boolean, @@ -101,6 +106,7 @@ bindPassword: Option[String], baseDN: String, userNameAttribute: String, + fullNameAttribute: Option[String], mailAttribute: String, tls: Option[Boolean], keystore: Option[String]) @@ -117,6 +123,7 @@ val DefaultSmtpPort = 25 val DefaultLdapPort = 389 + private val BaseURL = "base_url" private val AllowAccountRegistration = "allow_account_registration" private val Gravatar = "gravatar" private val Notification = "notification" @@ -134,6 +141,7 @@ private val LdapBindPassword = "ldap.bind_password" private val LdapBaseDN = "ldap.baseDN" private val LdapUserNameAttribute = "ldap.username_attribute" + private val LdapFullNameAttribute = "ldap.fullname_attribute" private val LdapMailAddressAttribute = "ldap.mail_attribute" private val LdapTls = "ldap.tls" private val LdapKeystore = "ldap.keystore" diff --git a/src/main/scala/service/WikiService.scala b/src/main/scala/service/WikiService.scala index 4b8a05c..00fbabd 100644 --- a/src/main/scala/service/WikiService.scala +++ b/src/main/scala/service/WikiService.scala @@ -3,7 +3,7 @@ import java.util.Date import org.eclipse.jgit.api.Git import org.apache.commons.io.FileUtils -import util.{PatchUtil, Directory, JGitUtil, LockUtil} +import util._ import _root_.util.ControlUtil._ import org.eclipse.jgit.treewalk.{TreeWalk, CanonicalTreeParser} import org.eclipse.jgit.lib._ @@ -14,6 +14,7 @@ import org.eclipse.jgit.patch._ import org.eclipse.jgit.api.errors.PatchFormatException import scala.collection.JavaConverters._ +import scala.Some object WikiService { @@ -59,11 +60,12 @@ */ def getWikiPage(owner: String, repository: String, pageName: String): Option[WikiPageInfo] = { using(Git.open(Directory.getWikiRepositoryDir(owner, repository))){ git => - optionIf(!JGitUtil.isEmpty(git)){ + if(!JGitUtil.isEmpty(git)){ JGitUtil.getFileList(git, "master", ".").find(_.name == pageName + ".md").map { file => - WikiPageInfo(file.name, new String(git.getRepository.open(file.id).getBytes, "UTF-8"), file.committer, file.time, file.commitId) + WikiPageInfo(file.name, StringUtil.convertFromByteArray(git.getRepository.open(file.id).getBytes), + file.committer, file.time, file.commitId) } - } + } else None } } @@ -72,7 +74,7 @@ */ def getFileContent(owner: String, repository: String, path: String): Option[Array[Byte]] = using(Git.open(Directory.getWikiRepositoryDir(owner, repository))){ git => - optionIf(!JGitUtil.isEmpty(git)){ + if(!JGitUtil.isEmpty(git)){ val index = path.lastIndexOf('/') val parentPath = if(index < 0) "." else path.substring(0, index) val fileName = if(index < 0) path else path.substring(index + 1) @@ -80,7 +82,7 @@ JGitUtil.getFileList(git, "master", parentPath).find(_.name == fileName).map { file => git.getRepository.open(file.id).getBytes } - } + } else None } /** @@ -239,7 +241,7 @@ } } - optionIf(created || updated || removed){ + if(created || updated || removed){ builder.add(JGitUtil.createDirCacheEntry(newPageName + ".md", FileMode.REGULAR_FILE, inserter.insert(Constants.OBJ_BLOB, content.getBytes("UTF-8")))) builder.finish() val newHeadId = JGitUtil.createNewCommit(git, inserter, headId, builder.getDirCache.writeTree(inserter), committer.fullName, committer.mailAddress, @@ -256,7 +258,7 @@ }) Some(newHeadId) - } + } else None } } } diff --git a/src/main/scala/servlet/AutoUpdateListener.scala b/src/main/scala/servlet/AutoUpdateListener.scala index e38966d..bfa6992 100644 --- a/src/main/scala/servlet/AutoUpdateListener.scala +++ b/src/main/scala/servlet/AutoUpdateListener.scala @@ -50,6 +50,9 @@ * The history of versions. A head of this sequence is the current BitBucket version. */ val versions = Seq( + Version(1, 11), + Version(1, 10), + Version(1, 9), Version(1, 8), Version(1, 7), Version(1, 6), @@ -120,7 +123,7 @@ System.setProperty("gitbucket.home", datadir) } org.h2.Driver.load() - event.getServletContext.setInitParameter("db.url", s"jdbc:h2:${DatabaseHome}") + event.getServletContext.setInitParameter("db.url", s"jdbc:h2:${DatabaseHome};MVCC=true") logger.debug("Start schema update") defining(getConnection(event.getServletContext)){ conn => diff --git a/src/main/scala/servlet/GitRepositoryServlet.scala b/src/main/scala/servlet/GitRepositoryServlet.scala index 4c02070..8c9a3b6 100644 --- a/src/main/scala/servlet/GitRepositoryServlet.scala +++ b/src/main/scala/servlet/GitRepositoryServlet.scala @@ -9,7 +9,7 @@ import javax.servlet.ServletConfig import javax.servlet.ServletContext import javax.servlet.http.HttpServletRequest -import util.{Keys, JGitUtil, Directory} +import util.{StringUtil, Keys, JGitUtil, Directory} import util.ControlUtil._ import util.Implicits._ import service._ @@ -56,10 +56,10 @@ override def create(request: HttpServletRequest, db: Repository): ReceivePack = { val receivePack = new ReceivePack(db) - val userName = request.getAttribute(Keys.Request.UserName).asInstanceOf[String] + val pusher = request.getAttribute(Keys.Request.UserName).asInstanceOf[String] logger.debug("requestURI: " + request.getRequestURI) - logger.debug("userName:" + userName) + logger.debug("pusher:" + pusher) defining(request.paths){ paths => val owner = paths(1) @@ -69,7 +69,9 @@ logger.debug("repository:" + owner + "/" + repository) logger.debug("baseURL:" + baseURL) - receivePack.setPostReceiveHook(new CommitLogHook(owner, repository, userName, baseURL)) + if(!repository.endsWith(".wiki")){ + receivePack.setPostReceiveHook(new CommitLogHook(owner, repository, pusher, baseURL)) + } receivePack } } @@ -77,101 +79,107 @@ import scala.collection.JavaConverters._ -class CommitLogHook(owner: String, repository: String, userName: String, baseURL: String) extends PostReceiveHook +class CommitLogHook(owner: String, repository: String, pusher: String, baseURL: String) extends PostReceiveHook with RepositoryService with AccountService with IssuesService with ActivityService with PullRequestService with WebHookService { private val logger = LoggerFactory.getLogger(classOf[CommitLogHook]) def onPostReceive(receivePack: ReceivePack, commands: java.util.Collection[ReceiveCommand]): Unit = { - 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("/") + try { + 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("/") - // Extract new commit and apply issue comment - val newCommits = if(commits.size > 1000){ - val existIds = getAllCommitIds(owner, repository) - commits.flatMap { commit => - optionIf(!existIds.contains(commit.id)){ - createIssueComment(commit) - Some(commit) + // Extract new commit and apply issue comment + val newCommits = if(commits.size > 1000){ + val existIds = getAllCommitIds(owner, repository) + commits.flatMap { commit => + if(!existIds.contains(commit.id)){ + createIssueComment(commit) + Some(commit) + } else None + } + } else { + commits.flatMap { commit => + if(!existsCommitId(owner, repository, commit.id)){ + createIssueComment(commit) + Some(commit) + } else None } } - } else { - commits.flatMap { commit => - optionIf(!existsCommitId(owner, repository, commit.id)){ - createIssueComment(commit) - Some(commit) + + // batch insert all new commit id + insertAllCommitIds(owner, repository, newCommits.map(_.id)) + + // record activity + if(refName(1) == "heads"){ + command.getType match { + case ReceiveCommand.Type.CREATE => recordCreateBranchActivity(owner, repository, pusher, branchName) + case ReceiveCommand.Type.UPDATE => recordPushActivity(owner, repository, pusher, branchName, newCommits) + case ReceiveCommand.Type.DELETE => recordDeleteBranchActivity(owner, repository, pusher, branchName) + case _ => + } + } else if(refName(1) == "tags"){ + command.getType match { + case ReceiveCommand.Type.CREATE => recordCreateTagActivity(owner, repository, pusher, branchName, newCommits) + case ReceiveCommand.Type.DELETE => recordDeleteTagActivity(owner, repository, pusher, branchName, newCommits) + case _ => } } - } - // batch insert all new commit id - insertAllCommitIds(owner, repository, newCommits.map(_.id)) + if(refName(1) == "heads"){ + command.getType match { + case ReceiveCommand.Type.CREATE | + ReceiveCommand.Type.UPDATE | + ReceiveCommand.Type.UPDATE_NONFASTFORWARD => + updatePullRequests(branchName) + case _ => + } + } - // record activity - if(refName(1) == "heads"){ - command.getType match { - case ReceiveCommand.Type.CREATE => recordCreateBranchActivity(owner, repository, userName, branchName) - case ReceiveCommand.Type.UPDATE => recordPushActivity(owner, repository, userName, branchName, newCommits) - case ReceiveCommand.Type.DELETE => recordDeleteBranchActivity(owner, repository, userName, branchName) + // close issues + val defaultBranch = getRepository(owner, repository, baseURL).get.repository.defaultBranch + if(refName(1) == "heads" && branchName == defaultBranch && command.getType == ReceiveCommand.Type.UPDATE){ + git.log.addRange(command.getOldId, command.getNewId).call.asScala.foreach { commit => + closeIssuesFromMessage(commit.getFullMessage, pusher, owner, repository) + } + } + + // call web hook + getWebHookURLs(owner, repository) match { + case webHookURLs if(webHookURLs.nonEmpty) => + for(pusherAccount <- getAccountByUserName(pusher); + ownerAccount <- getAccountByUserName(owner); + repositoryInfo <- getRepository(owner, repository, baseURL)){ + callWebHook(owner, repository, webHookURLs, + WebHookPayload(git, pusherAccount, command.getRefName, repositoryInfo, newCommits, ownerAccount)) + } case _ => } - } else if(refName(1) == "tags"){ - command.getType match { - case ReceiveCommand.Type.CREATE => recordCreateTagActivity(owner, repository, userName, branchName, newCommits) - case ReceiveCommand.Type.DELETE => recordDeleteTagActivity(owner, repository, userName, branchName, newCommits) - case _ => - } - } - - if(refName(1) == "heads"){ - command.getType match { - case ReceiveCommand.Type.CREATE | - ReceiveCommand.Type.UPDATE | - ReceiveCommand.Type.UPDATE_NONFASTFORWARD => - updatePullRequests(branchName) - case _ => - } - } - - // close issues - val defaultBranch = getRepository(owner, repository, baseURL).get.repository.defaultBranch - if(refName(1) == "heads" && branchName == defaultBranch && command.getType == ReceiveCommand.Type.UPDATE){ - git.log.addRange(command.getOldId, command.getNewId).call.asScala.foreach { commit => - closeIssuesFromMessage(commit.getFullMessage, userName, owner, repository) - } - } - - // call web hook - val webHookURLs = getWebHookURLs(owner, repository) - if(webHookURLs.nonEmpty){ - val payload = WebHookPayload( - git, - getAccountByUserName(userName).get, - command.getRefName, - getRepository(owner, repository, baseURL).get, - newCommits, - getAccountByUserName(owner).get) - - callWebHook(owner, repository, webHookURLs, payload) } } + // update repository last modified time. + updateLastActivityDate(owner, repository) + } catch { + case ex: Exception => { + logger.error(ex.toString, ex) + throw ex + } } - // update repository last modified time. - updateLastActivityDate(owner, repository) } private def createIssueComment(commit: CommitInfo) = { - "(^|\\W)#(\\d+)(\\W|$)".r.findAllIn(commit.fullMessage).matchData.foreach { matchData => - val issueId = matchData.group(2) - if(getAccountByUserName(commit.committer).isDefined && getIssue(owner, repository, issueId).isDefined){ - createComment(owner, repository, commit.committer, issueId.toInt, commit.fullMessage + " " + commit.id, "commit") + StringUtil.extractIssueId(commit.fullMessage).foreach { issueId => + if(getIssue(owner, repository, issueId).isDefined){ + getAccountByMailAddress(commit.mailAddress).foreach { account => + createComment(owner, repository, account.userName, issueId.toInt, commit.fullMessage + " " + commit.id, "commit") + } } } } diff --git a/src/main/scala/util/ControlUtil.scala b/src/main/scala/util/ControlUtil.scala index 3e3fd5e..02d7fd1 100644 --- a/src/main/scala/util/ControlUtil.scala +++ b/src/main/scala/util/ControlUtil.scala @@ -4,6 +4,7 @@ import org.eclipse.jgit.revwalk.RevWalk import org.eclipse.jgit.treewalk.TreeWalk import org.eclipse.jgit.transport.RefSpec +import scala.language.reflectiveCalls /** * Provides control facilities. @@ -24,12 +25,12 @@ } def using[T](git: Git)(f: Git => T): T = - try f(git) finally git.getRepository.close + try f(git) finally git.getRepository.close() def using[T](git1: Git, git2: Git)(f: (Git, Git) => T): T = try f(git1, git2) finally { - git1.getRepository.close - git2.getRepository.close + git1.getRepository.close() + git2.getRepository.close() } def using[T](revWalk: RevWalk)(f: RevWalk => T): T = @@ -39,22 +40,14 @@ try f(treeWalk) finally treeWalk.release() - def withTmpRefSpec[T](ref: RefSpec, git: Git)(f: RefSpec => T): T = { - try { - f(ref) - } finally { - val refUpdate = git.getRepository.updateRef(ref.getDestination) - refUpdate.setForceUpdate(true) - refUpdate.delete() - } - } +// def withTmpRefSpec[T](ref: RefSpec, git: Git)(f: RefSpec => T): T = { +// try { +// f(ref) +// } finally { +// val refUpdate = git.getRepository.updateRef(ref.getDestination) +// refUpdate.setForceUpdate(true) +// refUpdate.delete() +// } +// } - def executeIf(condition: => Boolean)(action: => Unit): Boolean = - if(condition){ - action - true - } else false - - def optionIf[T](condition: => Boolean)(action: => Option[T]): Option[T] = - if(condition) action else None } diff --git a/src/main/scala/util/Directory.scala b/src/main/scala/util/Directory.scala index 8ea3325..d37577a 100644 --- a/src/main/scala/util/Directory.scala +++ b/src/main/scala/util/Directory.scala @@ -2,6 +2,7 @@ import java.io.File import util.ControlUtil._ +import org.apache.commons.io.FileUtils /** * Provides directories used by GitBucket. @@ -14,8 +15,16 @@ case _ => scala.util.Properties.envOrNone("GITBUCKET_HOME") match { // environment variable GITBUCKET_HOME case Some(env) => new File(env) - // default is HOME/gitbucket - case None => new File(System.getProperty("user.home"), "gitbucket") + // default is HOME/.gitbucket + case None => { + val oldHome = new File(System.getProperty("user.home"), "gitbucket") + if(oldHome.exists && oldHome.isDirectory && new File(oldHome, "version").exists){ + //FileUtils.moveDirectory(oldHome, newHome) + oldHome + } else { + new File(System.getProperty("user.home"), ".gitbucket") + } + } } }).getAbsolutePath diff --git a/src/main/scala/util/FileUtil.scala b/src/main/scala/util/FileUtil.scala index 0b1a98b..e4c052f 100644 --- a/src/main/scala/util/FileUtil.scala +++ b/src/main/scala/util/FileUtil.scala @@ -63,9 +63,9 @@ if(dir.exists()){ FileUtils.deleteDirectory(dir) } - try{ + try { action(dir) - }finally{ + } finally { FileUtils.deleteDirectory(dir) } } diff --git a/src/main/scala/util/JGitUtil.scala b/src/main/scala/util/JGitUtil.scala index 9286362..742641e 100644 --- a/src/main/scala/util/JGitUtil.scala +++ b/src/main/scala/util/JGitUtil.scala @@ -79,9 +79,9 @@ } val description = defining(fullMessage.trim.indexOf("\n")){ i => - optionIf(i >= 0){ + if(i >= 0){ Some(fullMessage.trim.substring(i).trim) - } + } else None } } diff --git a/src/main/scala/util/LDAPUtil.scala b/src/main/scala/util/LDAPUtil.scala index 3f19b28..05a7171 100644 --- a/src/main/scala/util/LDAPUtil.scala +++ b/src/main/scala/util/LDAPUtil.scala @@ -18,51 +18,49 @@ /** * Try authentication by LDAP using given configuration. - * Returns Right(mailAddress) if authentication is successful, otherwise Left(errorMessage). + * Returns Right(LDAPUserInfo) if authentication is successful, otherwise Left(errorMessage). */ - def authenticate(ldapSettings: Ldap, userName: String, password: String): Either[String, String] = { + def authenticate(ldapSettings: Ldap, userName: String, password: String): Either[String, LDAPUserInfo] = { bind( - ldapSettings.host, - ldapSettings.port.getOrElse(SystemSettingsService.DefaultLdapPort), - ldapSettings.bindDN.getOrElse(""), - ldapSettings.bindPassword.getOrElse(""), - ldapSettings.tls.getOrElse(false), - ldapSettings.keystore.getOrElse("") - ) match { - case Some(conn) => { - withConnection(conn) { conn => - findUser(conn, userName, ldapSettings.baseDN, ldapSettings.userNameAttribute) match { - case Some(userDN) => userAuthentication(ldapSettings, userDN, password) - case None => Left("User does not exist.") - } - } + host = ldapSettings.host, + port = ldapSettings.port.getOrElse(SystemSettingsService.DefaultLdapPort), + dn = ldapSettings.bindDN.getOrElse(""), + password = ldapSettings.bindPassword.getOrElse(""), + tls = ldapSettings.tls.getOrElse(false), + keystore = ldapSettings.keystore.getOrElse(""), + error = "System LDAP authentication failed." + ){ conn => + findUser(conn, userName, ldapSettings.baseDN, ldapSettings.userNameAttribute) match { + case Some(userDN) => userAuthentication(ldapSettings, userDN, userName, password) + case None => Left("User does not exist.") } - case None => Left("System LDAP authentication failed.") } } - private def userAuthentication(ldapSettings: Ldap, userDN: String, password: String): Either[String, String] = { + private def userAuthentication(ldapSettings: Ldap, userDN: String, userName: String, password: String): Either[String, LDAPUserInfo] = { bind( - ldapSettings.host, - ldapSettings.port.getOrElse(SystemSettingsService.DefaultLdapPort), - userDN, - password, - ldapSettings.tls.getOrElse(false), - ldapSettings.keystore.getOrElse("") - ) match { - case Some(conn) => { - withConnection(conn) { conn => - findMailAddress(conn, userDN, ldapSettings.mailAttribute) match { - case Some(mailAddress) => Right(mailAddress) - case None => Left("Can't find mail address.") - } - } + host = ldapSettings.host, + port = ldapSettings.port.getOrElse(SystemSettingsService.DefaultLdapPort), + dn = userDN, + password = password, + tls = ldapSettings.tls.getOrElse(false), + keystore = ldapSettings.keystore.getOrElse(""), + error = "User LDAP Authentication Failed." + ){ conn => + findMailAddress(conn, userDN, ldapSettings.mailAttribute) match { + case Some(mailAddress) => Right(LDAPUserInfo( + userName = userName, + fullName = ldapSettings.fullNameAttribute.flatMap { fullNameAttribute => + findFullName(conn, userDN, fullNameAttribute) + }.getOrElse(userName), + mailAddress = mailAddress)) + case None => Left("Can't find mail address.") } - case None => Left("User LDAP Authentication Failed.") } } - private def bind(host: String, port: Int, dn: String, password: String, tls: Boolean, keystore: String): Option[LDAPConnection] = { + private def bind[A](host: String, port: Int, dn: String, password: String, tls: Boolean, keystore: String, error: String) + (f: LDAPConnection => Either[String, A]): Either[String, A] = { if (tls) { // Dynamically set Sun as the security provider Security.addProvider(new com.sun.net.ssl.internal.ssl.Provider()) @@ -87,7 +85,9 @@ // Bind to the server conn.bind(LDAP_VERSION, dn, password.getBytes) - Some(conn) + // Execute a given function and returns a its result + f(conn) + } catch { case e: Exception => { // Provide more information if something goes wrong @@ -96,20 +96,15 @@ if (conn.isConnected) { conn.disconnect() } - - None + // Returns an error message + Left(error) } } } - private def withConnection[T](conn: LDAPConnection)(f: LDAPConnection => T): T = { - try { - f(conn) - } finally { - conn.disconnect() - } - } - + /** + * Search a specified user and returns userDN if exists. + */ private def findUser(conn: LDAPConnection, userName: String, baseDN: String, userNameAttribute: String): Option[String] = { @tailrec def getEntries(results: LDAPSearchResults, entries: List[Option[LDAPEntry]] = Nil): List[LDAPEntry] = { @@ -130,8 +125,18 @@ private def findMailAddress(conn: LDAPConnection, userDN: String, mailAttribute: String): Option[String] = defining(conn.search(userDN, LDAPConnection.SCOPE_BASE, null, Array[String](mailAttribute), false)){ results => - optionIf (results.hasMore) { + if(results.hasMore) { Option(results.next.getAttribute(mailAttribute)).map(_.getStringValue) - } + } else None } + + private def findFullName(conn: LDAPConnection, userDN: String, nameAttribute: String): Option[String] = + defining(conn.search(userDN, LDAPConnection.SCOPE_BASE, null, Array[String](nameAttribute), false)){ results => + if(results.hasMore) { + Option(results.next.getAttribute(nameAttribute)).map(_.getStringValue) + } else None + } + + case class LDAPUserInfo(userName: String, fullName: String, mailAddress: String) + } diff --git a/src/main/scala/util/StringUtil.scala b/src/main/scala/util/StringUtil.scala index 61c5c18..7de2a62 100644 --- a/src/main/scala/util/StringUtil.scala +++ b/src/main/scala/util/StringUtil.scala @@ -3,6 +3,8 @@ import java.net.{URLDecoder, URLEncoder} import org.mozilla.universalchardet.UniversalDetector import util.ControlUtil._ +import org.apache.commons.io.input.BOMInputStream +import org.apache.commons.io.IOUtils object StringUtil { @@ -27,7 +29,12 @@ def escapeHtml(value: String): String = value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) - def convertFromByteArray(content: Array[Byte]): String = new String(content, detectEncoding(content)) + /** + * Make string from byte array. Character encoding is detected automatically by [[util.StringUtil.detectEncoding]]. + * And if given bytes contains UTF-8 BOM, it's removed from returned string.. + */ + def convertFromByteArray(content: Array[Byte]): String = + IOUtils.toString(new BOMInputStream(new java.io.ByteArrayInputStream(content)), detectEncoding(content)) def detectEncoding(content: Array[Byte]): String = defining(new UniversalDetector(null)){ detector => @@ -38,4 +45,14 @@ case e => e } } + + /** + * Extract issue id like ````#issueId``` from the given message. + * + *@param message the message which may contains issue id + * @return the iterator of issue id + */ + def extractIssueId(message: String): Iterator[String] = + "(^|\\W)#(\\d+)(\\W|$)".r.findAllIn(message).matchData.map { matchData => matchData.group(2) } + } diff --git a/src/main/scala/view/Markdown.scala b/src/main/scala/view/Markdown.scala index 6def4df..6b7ab2d 100644 --- a/src/main/scala/view/Markdown.scala +++ b/src/main/scala/view/Markdown.scala @@ -7,6 +7,8 @@ import org.pegdown._ import org.pegdown.ast._ import org.pegdown.LinkRenderer.Rendering +import java.text.Normalizer +import java.util.Locale import scala.collection.JavaConverters._ import service.{RequestCache, WikiService} @@ -110,6 +112,21 @@ printer.print(' ').print(name).print('=').print('"').print(value).print('"') } + private def printHeaderTag(node: HeaderNode): Unit = { + val tag = s"h${node.getLevel}" + val headerTextString = printChildrenToString(node) + val anchorName = GitBucketHtmlSerializer.generateAnchorName(headerTextString) + printer.print(s"""<$tag class="markdown-head">""") + printer.print(s"""""") + printer.print(s"""""") + visitChildren(node) + printer.print(s"") + } + + override def visit(node: HeaderNode): Unit = { + printHeaderTag(node) + } + override def visit(node: TextNode): Unit = { // convert commit id and username to link. val text = if(enableRefsLink) convertRefsLinks(node.getText, repository, "issue:") else node.getText @@ -120,5 +137,16 @@ printWithAbbreviations(text) } } +} +object GitBucketHtmlSerializer { + + private val Whitespace = "[\\s]".r + + def generateAnchorName(text: String): String = { + val noWhitespace = Whitespace.replaceAllIn(text, "-") + val normalized = Normalizer.normalize(noWhitespace, Normalizer.Form.NFD) + val noSpecialChars = StringUtil.urlEncode(normalized) + noSpecialChars.toLowerCase(Locale.ENGLISH) + } } diff --git a/src/main/twirl/admin/system.scala.html b/src/main/twirl/admin/system.scala.html index 9a02cb5..6594e29 100644 --- a/src/main/twirl/admin/system.scala.html +++ b/src/main/twirl/admin/system.scala.html @@ -1,5 +1,6 @@ @(settings: service.SystemSettingsService.SystemSettings, info: Option[Any])(implicit context: app.Context) @import context._ +@import util.Directory._ @import view.helpers._ @html.main("System Settings"){ @menu("system"){ @@ -9,8 +10,29 @@
System Settings
+ + + + @GitBucketHome + + + +
+ +
+
+ +
+
+

+ 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. +

+ +
+ +
+ + +
+
+
diff --git a/src/main/twirl/header.scala.html b/src/main/twirl/header.scala.html index b132711..bb2be97 100644 --- a/src/main/twirl/header.scala.html +++ b/src/main/twirl/header.scala.html @@ -12,10 +12,13 @@ }
@if(repository.repository.isPrivate){ - - } - @if(!repository.repository.isPrivate){ - + + } else { + @if(repository.repository.originUserName.isDefined){ + + } else { + + } } @repository.owner / @repository.name diff --git a/src/main/twirl/helper/dropdown.scala.html b/src/main/twirl/helper/dropdown.scala.html index f7479a8..55bb6d6 100644 --- a/src/main/twirl/helper/dropdown.scala.html +++ b/src/main/twirl/helper/dropdown.scala.html @@ -1,4 +1,4 @@ -@(value: String = "", prefix: String = "", mini: Boolean = true, style: String = "")(body: Html) +@(value: String = "", prefix: String = "", mini: Boolean = true, style: String = "", right: Boolean = false)(body: Html)
-
diff --git a/src/main/twirl/index.scala.html b/src/main/twirl/index.scala.html index ef8bd67..c6e3de6 100644 --- a/src/main/twirl/index.scala.html +++ b/src/main/twirl/index.scala.html @@ -32,10 +32,13 @@ @if(repository.repository.isPrivate){ - - } - @if(!repository.repository.isPrivate){ - + + } else { + @if(repository.repository.originUserName.isDefined){ + + } else { + + } } @if(repository.owner == loginAccount.get.userName){ @repository.name @@ -64,10 +67,13 @@ @if(repository.repository.isPrivate){ - - } - @if(!repository.repository.isPrivate){ - + + } else { + @if(repository.repository.originUserName.isDefined){ + + } else { + + } } @repository.owner/@repository.name diff --git a/src/main/twirl/issues/commentlist.scala.html b/src/main/twirl/issues/commentlist.scala.html index 3460585..92a82f7 100644 --- a/src/main/twirl/issues/commentlist.scala.html +++ b/src/main/twirl/issues/commentlist.scala.html @@ -6,15 +6,21 @@ @import context._ @import view.helpers._ @comments.map { comment => - @if(comment.action != "close" && comment.action != "reopen"){ + @if(comment.action != "close" && comment.action != "reopen" && comment.action != "delete_branch"){
@avatar(comment.commentedUserName, 48)
- @user(comment.commentedUserName, styleClass="username strong") commented + @user(comment.commentedUserName, styleClass="username strong") + @if(comment.action == "comment"){ + commented + } else { + @if(pullreq.isEmpty){ referenced the issue } else { referenced the pull request } + } @datetime(comment.registeredDate) - @if(comment.action != "commit" && comment.action != "merge" && (hasWritePermission || loginAccount.map(_.userName == comment.commentedUserName).getOrElse(false))){ + @if(comment.action != "commit" && comment.action != "merge" && comment.action != "refer" && + (hasWritePermission || loginAccount.map(_.userName == comment.commentedUserName).getOrElse(false))){   } @@ -27,7 +33,13 @@ @markdown(comment.content.substring(0, comment.content.length - 41), repository, false, true) } } else { - @markdown(comment.content, repository, false, true) + @if(comment.action == "refer"){ + @defining(comment.content.split(":")){ case Array(issueId, rest @ _*) => + Issue #@issueId: @rest.mkString(":") + } + } else { + @markdown(comment.content, repository, false, true) + } }
@@ -36,11 +48,11 @@
Merged @avatar(comment.commentedUserName, 20) - @user(comment.commentedUserName, styleClass="username strong") merged commit @pullreq.map(_.commitIdTo.substring(0, 7)) + @user(comment.commentedUserName, styleClass="username strong") merged commit @pullreq.map(_.commitIdTo.substring(0, 7)) into @if(pullreq.get.requestUserName == repository.owner){ - @pullreq.map(_.requestBranch) to @pullreq.map(_.branch) + @pullreq.map(_.branch) from @pullreq.map(_.requestBranch) } else { - @pullreq.map(_.requestUserName):@pullreq.map(_.requestBranch) to @pullreq.map(_.userName):@pullreq.map(_.branch) + @pullreq.map(_.userName):@pullreq.map(_.branch) to @pullreq.map(_.requestUserName):@pullreq.map(_.requestBranch) } @datetime(comment.registeredDate)
@@ -63,6 +75,13 @@ @user(comment.commentedUserName, styleClass="username strong") reopened the issue @datetime(comment.registeredDate)
} + @if(comment.action == "delete_branch"){ +
+ Deleted + @avatar(comment.commentedUserName, 20) + @user(comment.commentedUserName, styleClass="username strong") deleted the @pullreq.map(_.requestBranch) branch @datetime(comment.registeredDate) +
+ } } \ No newline at end of file diff --git a/src/main/twirl/issues/labels.scala.html b/src/main/twirl/issues/labels.scala.html new file mode 100644 index 0000000..a8abfbf --- /dev/null +++ b/src/main/twirl/issues/labels.scala.html @@ -0,0 +1,51 @@ +@(issue: model.Issue, + issueLabels: List[model.Label], + labels: List[model.Label], + hasWritePermission: Boolean, + repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) +@import view.helpers._ +
+ Labels + @if(hasWritePermission){ +
+ @helper.html.dropdown(right = true) { + @labels.map { label => +
  • + + @helper.html.checkicon(issueLabels.exists(_.labelId == label.labelId)) +   + @label.labelName + +
  • + } + } +
    + } +
    + + diff --git a/src/main/twirl/main.scala.html b/src/main/twirl/main.scala.html index 592238a..7ec9842 100644 --- a/src/main/twirl/main.scala.html +++ b/src/main/twirl/main.scala.html @@ -61,7 +61,7 @@ } } else { - Sign in + Sign in }
    @@ -76,6 +76,7 @@ $('#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/newrepo.scala.html b/src/main/twirl/newrepo.scala.html index 6d24a9c..05fefbc 100644 --- a/src/main/twirl/newrepo.scala.html +++ b/src/main/twirl/newrepo.scala.html @@ -30,7 +30,7 @@