diff --git a/project/build.scala b/project/build.scala index ce6d4d9f9..82b4fbe99 100644 --- a/project/build.scala +++ b/project/build.scala @@ -36,6 +36,7 @@ object MyBuild extends Build { "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/src/main/resources/update/1_12.sql b/src/main/resources/update/1_12.sql index 00301693c..f8658a24c 100644 --- a/src/main/resources/update/1_12.sql +++ b/src/main/resources/update/1_12.sql @@ -1 +1,11 @@ -ALTER TABLE GROUP_MEMBER ADD COLUMN MANAGER BOOLEAN DEFAULT FALSE; \ No newline at end of file +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/app/AccountController.scala b/src/main/scala/app/AccountController.scala index d35379b88..71197b653 100644 --- a/src/main/scala/app/AccountController.scala +++ b/src/main/scala/app/AccountController.scala @@ -14,11 +14,11 @@ import org.eclipse.jgit.dircache.DirCache import model.GroupMember class AccountController extends AccountControllerBase - with AccountService with RepositoryService with ActivityService with WikiService with LabelsService + 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 WikiService with LabelsService + 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, @@ -27,6 +27,8 @@ trait AccountControllerBase extends AccountManagementControllerBase { case class AccountEditForm(password: Option[String], fullName: String, mailAddress: String, url: Option[String], fileId: Option[String], clearImage: Boolean) + case class SshKeyForm(title: String, publicKey: String) + val newForm = mapping( "userName" -> trim(label("User name" , text(required, maxlength(100), identifier, uniqueUserName))), "password" -> trim(label("Password" , text(required, maxlength(20)))), @@ -45,6 +47,11 @@ trait AccountControllerBase extends AccountManagementControllerBase { "clearImage" -> trim(label("Clear image" , boolean())) )(AccountEditForm.apply) + val sshKeyForm = mapping( + "title" -> trim(label("Title", text(required, maxlength(100)))), + "publicKey" -> trim(label("Key" , text(required))) + )(SshKeyForm.apply) + case class NewGroupForm(groupName: String, url: Option[String], fileId: Option[String], members: String) case class EditGroupForm(groupName: String, url: Option[String], fileId: Option[String], members: String, clearImage: Boolean) @@ -124,7 +131,9 @@ trait AccountControllerBase extends AccountManagementControllerBase { get("/:userName/_edit")(oneselfOnly { val userName = params("userName") - getAccountByUserName(userName).map(x => account.html.edit(Some(x), flash.get("info"))) getOrElse NotFound + getAccountByUserName(userName).map { x => + account.html.edit(x, loadSystemSettings(), flash.get("info")) + } getOrElse NotFound }) post("/:userName/_edit", editForm)(oneselfOnly { form => @@ -164,12 +173,32 @@ trait AccountControllerBase extends AccountManagementControllerBase { redirect("/") }) + get("/:userName/_ssh")(oneselfOnly { + val userName = params("userName") + getAccountByUserName(userName).map { x => + account.html.ssh(x, loadSystemSettings(), 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.loginAccount.isDefined){ redirect("/") } else { - account.html.edit(None, None) + account.html.register() } } else NotFound } diff --git a/src/main/scala/app/RepositoryViewerController.scala b/src/main/scala/app/RepositoryViewerController.scala index d68dc53d4..5454f9411 100644 --- a/src/main/scala/app/RepositoryViewerController.scala +++ b/src/main/scala/app/RepositoryViewerController.scala @@ -65,7 +65,7 @@ trait RepositoryViewerControllerBase extends ControllerBase { repo.html.commits(if(path.isEmpty) Nil else path.split("/").toList, branchName, repository, logs.splitWith{ (commit1, commit2) => view.helpers.date(commit1.time) == view.helpers.date(commit2.time) - }, page, hasNext) + }, page, hasNext, loadSystemSettings()) case Left(_) => NotFound } } @@ -118,7 +118,7 @@ trait RepositoryViewerControllerBase extends ControllerBase { JGitUtil.ContentInfo(viewer, None) } - repo.html.blob(id, repository, path.split("/").toList, content, new JGitUtil.CommitInfo(revCommit)) + repo.html.blob(id, repository, path.split("/").toList, content, new JGitUtil.CommitInfo(revCommit), loadSystemSettings()) } } getOrElse NotFound } @@ -136,7 +136,7 @@ trait RepositoryViewerControllerBase extends ControllerBase { repo.html.commit(id, new JGitUtil.CommitInfo(revCommit), JGitUtil.getBranchesOfCommit(git, revCommit.getName), JGitUtil.getTagsOfCommit(git, revCommit.getName), - repository, diffs, oldCommitId) + repository, diffs, oldCommitId, loadSystemSettings()) } } } @@ -152,7 +152,8 @@ trait RepositoryViewerControllerBase extends ControllerBase { val revCommit = git.log.add(git.getRepository.resolve(branchName)).setMaxCount(1).call.iterator.next (branchName, revCommit.getCommitterIdent.getWhen) } - repo.html.branches(branchInfo, hasWritePermission(repository.owner, repository.name, context.loginAccount), repository) + repo.html.branches(branchInfo, hasWritePermission(repository.owner, repository.name, context.loginAccount), + repository, loadSystemSettings()) } }) @@ -175,7 +176,7 @@ trait RepositoryViewerControllerBase extends ControllerBase { * Displays tags. */ get("/:owner/:repository/tags")(referrersOnly { - repo.html.tags(_) + repo.html.tags(_, loadSystemSettings()) }) /** @@ -284,7 +285,7 @@ trait RepositoryViewerControllerBase extends ControllerBase { repo.html.files(revision, repository, if(path == ".") Nil else path.split("/").toList, // current path new JGitUtil.CommitInfo(revCommit), // latest commit - files, readme) + files, readme, loadSystemSettings()) } } getOrElse NotFound } diff --git a/src/main/scala/app/SystemSettingsController.scala b/src/main/scala/app/SystemSettingsController.scala index a2e410e90..4b28e4ccd 100644 --- a/src/main/scala/app/SystemSettingsController.scala +++ b/src/main/scala/app/SystemSettingsController.scala @@ -4,6 +4,7 @@ import service.{AccountService, SystemSettingsService} import SystemSettingsService._ import util.AdminAuthenticator import jp.sf.amateras.scalatra.forms._ +import ssh.SshServer class SystemSettingsController extends SystemSettingsControllerBase with SystemSettingsService with AccountService with AdminAuthenticator @@ -16,6 +17,8 @@ trait SystemSettingsControllerBase extends ControllerBase { "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()))), @@ -39,7 +42,11 @@ trait SystemSettingsControllerBase extends ControllerBase { "tls" -> trim(label("Enable TLS", optional(boolean()))), "keystore" -> trim(label("Keystore", optional(text()))) )(Ldap.apply)) - )(SystemSettings.apply) + )(SystemSettings.apply).verifying { settings => + if(settings.ssh && settings.baseUrl.isEmpty){ + Seq("baseUrl" -> "Base URL is required if SSH access is enabled.") + } else Nil + } get("/admin/system")(adminOnly { @@ -48,6 +55,15 @@ trait SystemSettingsControllerBase extends ControllerBase { 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/WikiController.scala b/src/main/scala/app/WikiController.scala index 07208a5dd..1f9d239cf 100644 --- a/src/main/scala/app/WikiController.scala +++ b/src/main/scala/app/WikiController.scala @@ -36,7 +36,7 @@ trait WikiControllerBase extends ControllerBase { get("/:owner/:repository/wiki")(referrersOnly { repository => getWikiPage(repository.owner, repository.name, "Home").map { page => - wiki.html.page("Home", page, repository, hasWritePermission(repository.owner, repository.name, context.loginAccount)) + wiki.html.page("Home", page, repository, hasWritePermission(repository.owner, repository.name, context.loginAccount), loadSystemSettings()) } getOrElse redirect(s"/${repository.owner}/${repository.name}/wiki/Home/_edit") }) @@ -44,7 +44,7 @@ trait WikiControllerBase extends ControllerBase { val pageName = StringUtil.urlDecode(params("page")) getWikiPage(repository.owner, repository.name, pageName).map { page => - wiki.html.page(pageName, page, repository, hasWritePermission(repository.owner, repository.name, context.loginAccount)) + wiki.html.page(pageName, page, repository, hasWritePermission(repository.owner, repository.name, context.loginAccount), loadSystemSettings()) } getOrElse redirect(s"/${repository.owner}/${repository.name}/wiki/${StringUtil.urlEncode(pageName)}/_edit") }) @@ -53,7 +53,7 @@ trait WikiControllerBase extends ControllerBase { using(Git.open(getWikiRepositoryDir(repository.owner, repository.name))){ git => JGitUtil.getCommitLog(git, "master", path = pageName + ".md") match { - case Right((logs, hasNext)) => wiki.html.history(Some(pageName), logs, repository) + case Right((logs, hasNext)) => wiki.html.history(Some(pageName), logs, repository, loadSystemSettings()) case Left(_) => NotFound } } @@ -65,7 +65,7 @@ trait WikiControllerBase extends ControllerBase { using(Git.open(getWikiRepositoryDir(repository.owner, repository.name))){ git => wiki.html.compare(Some(pageName), from, to, JGitUtil.getDiffs(git, from, to, true).filter(_.newPath == pageName + ".md"), repository, - hasWritePermission(repository.owner, repository.name, context.loginAccount), flash.get("info")) + hasWritePermission(repository.owner, repository.name, context.loginAccount), loadSystemSettings(), flash.get("info")) } }) @@ -74,7 +74,7 @@ trait WikiControllerBase extends ControllerBase { using(Git.open(getWikiRepositoryDir(repository.owner, repository.name))){ git => wiki.html.compare(None, from, to, JGitUtil.getDiffs(git, from, to, true), repository, - hasWritePermission(repository.owner, repository.name, context.loginAccount), flash.get("info")) + hasWritePermission(repository.owner, repository.name, context.loginAccount), loadSystemSettings(), flash.get("info")) } }) @@ -103,7 +103,7 @@ trait WikiControllerBase extends ControllerBase { get("/:owner/:repository/wiki/:page/_edit")(collaboratorsOnly { repository => val pageName = StringUtil.urlDecode(params("page")) - wiki.html.edit(pageName, getWikiPage(repository.owner, repository.name, pageName), repository) + wiki.html.edit(pageName, getWikiPage(repository.owner, repository.name, pageName), repository, loadSystemSettings()) }) post("/:owner/:repository/wiki/_edit", editForm)(collaboratorsOnly { (form, repository) => @@ -118,7 +118,7 @@ trait WikiControllerBase extends ControllerBase { }) get("/:owner/:repository/wiki/_new")(collaboratorsOnly { - wiki.html.edit("", None, _) + wiki.html.edit("", None, _, loadSystemSettings()) }) post("/:owner/:repository/wiki/_new", newForm)(collaboratorsOnly { (form, repository) => @@ -146,13 +146,13 @@ trait WikiControllerBase extends ControllerBase { get("/:owner/:repository/wiki/_pages")(referrersOnly { repository => wiki.html.pages(getWikiPageList(repository.owner, repository.name), repository, - hasWritePermission(repository.owner, repository.name, context.loginAccount)) + hasWritePermission(repository.owner, repository.name, context.loginAccount), loadSystemSettings()) }) get("/:owner/:repository/wiki/_history")(referrersOnly { repository => using(Git.open(getWikiRepositoryDir(repository.owner, repository.name))){ git => JGitUtil.getCommitLog(git, "master") match { - case Right((logs, hasNext)) => wiki.html.history(None, logs, repository) + case Right((logs, hasNext)) => wiki.html.history(None, logs, repository, loadSystemSettings()) case Left(_) => NotFound } } diff --git a/src/main/scala/model/SshKey.scala b/src/main/scala/model/SshKey.scala new file mode 100644 index 000000000..39d89d481 --- /dev/null +++ b/src/main/scala/model/SshKey.scala @@ -0,0 +1,22 @@ +package model + +import scala.slick.driver.H2Driver.simple._ + +object SshKeys extends Table[SshKey]("SSH_KEY") { + def userName = column[String]("USER_NAME") + def sshKeyId = column[Int]("SSH_KEY_ID", O AutoInc) + def title = column[String]("TITLE") + def publicKey = column[String]("PUBLIC_KEY") + + def ins = userName ~ title ~ publicKey returning sshKeyId + def * = userName ~ sshKeyId ~ title ~ publicKey <> (SshKey, SshKey.unapply _) + + def byPrimaryKey(userName: String, sshKeyId: Int) = (this.userName is userName.bind) && (this.sshKeyId is sshKeyId.bind) +} + +case class SshKey( + userName: String, + sshKeyId: Int, + title: String, + publicKey: String +) diff --git a/src/main/scala/service/RepositoryService.scala b/src/main/scala/service/RepositoryService.scala index 9038ea54d..4c6abd1dc 100644 --- a/src/main/scala/service/RepositoryService.scala +++ b/src/main/scala/service/RepositoryService.scala @@ -288,10 +288,14 @@ trait RepositoryService { self: AccountService => object RepositoryService { - case class RepositoryInfo(owner: String, name: String, url: String, repository: Repository, + case class RepositoryInfo(owner: String, name: String, httpUrl: String, repository: Repository, issueCount: Int, pullCount: Int, commitCount: Int, forkedCount: Int, branchList: 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) = s"ssh://${host}:${port}/${owner}/${name}.git" + /** * Creates instance with issue count and pull request count. */ diff --git a/src/main/scala/service/SshKeyService.scala b/src/main/scala/service/SshKeyService.scala new file mode 100644 index 000000000..c249a3a6e --- /dev/null +++ b/src/main/scala/service/SshKeyService.scala @@ -0,0 +1,19 @@ +package service + +import model._ +import scala.slick.driver.H2Driver.simple._ +import Database.threadLocalSession + +trait SshKeyService { + + def addPublicKey(userName: String, title: String, publicKey: String): Unit = + SshKeys.ins insert (userName, title, publicKey) + + def getPublicKeys(userName: String): List[SshKey] = + Query(SshKeys).filter(_.userName is userName.bind).sortBy(_.sshKeyId).list + + def deletePublicKey(userName: String, sshKeyId: Int): Unit = + SshKeys filter (_.byPrimaryKey(userName, sshKeyId)) delete + + +} diff --git a/src/main/scala/service/SystemSettingsService.scala b/src/main/scala/service/SystemSettingsService.scala index df27bb75a..dd83fda13 100644 --- a/src/main/scala/service/SystemSettingsService.scala +++ b/src/main/scala/service/SystemSettingsService.scala @@ -15,10 +15,12 @@ trait SystemSettingsService { def saveSystemSettings(settings: SystemSettings): Unit = { defining(new java.util.Properties()){ props => - settings.baseUrl.foreach(props.setProperty(BaseURL, _)) + settings.baseUrl.foreach(x => props.setProperty(BaseURL, x.replaceFirst("/\\Z", ""))) props.setProperty(AllowAccountRegistration, settings.allowAccountRegistration.toString) props.setProperty(Gravatar, settings.gravatar.toString) props.setProperty(Notification, settings.notification.toString) + props.setProperty(Ssh, settings.ssh.toString) + settings.sshPort.foreach(x => props.setProperty(SshPort, x.toString)) if(settings.notification) { settings.smtp.foreach { smtp => props.setProperty(SmtpHost, smtp.host) @@ -57,10 +59,12 @@ trait SystemSettingsService { props.load(new java.io.FileInputStream(GitBucketConf)) } SystemSettings( - getOptionValue(props, BaseURL, None), + getOptionValue[String](props, BaseURL, None).map(x => x.replaceFirst("/\\Z", "")), getValue(props, AllowAccountRegistration, false), getValue(props, Gravatar, true), getValue(props, Notification, false), + getValue(props, Ssh, false), + getOptionValue(props, SshPort, Some(DefaultSshPort)), if(getValue(props, Notification, false)){ Some(Smtp( getValue(props, SmtpHost, ""), @@ -104,6 +108,8 @@ object SystemSettingsService { allowAccountRegistration: Boolean, gravatar: Boolean, notification: Boolean, + ssh: Boolean, + sshPort: Option[Int], smtp: Option[Smtp], ldapAuthentication: Boolean, ldap: Option[Ldap]) @@ -130,6 +136,7 @@ object SystemSettingsService { fromAddress: Option[String], fromName: Option[String]) + val DefaultSshPort = 29418 val DefaultSmtpPort = 25 val DefaultLdapPort = 389 @@ -137,6 +144,8 @@ object SystemSettingsService { private val AllowAccountRegistration = "allow_account_registration" private val Gravatar = "gravatar" private val Notification = "notification" + private val Ssh = "ssh" + private val SshPort = "ssh.port" private val SmtpHost = "smtp.host" private val SmtpPort = "smtp.port" private val SmtpUser = "smtp.user" diff --git a/src/main/scala/service/WebHookService.scala b/src/main/scala/service/WebHookService.scala index 496f78486..5662c4483 100644 --- a/src/main/scala/service/WebHookService.scala +++ b/src/main/scala/service/WebHookService.scala @@ -87,7 +87,7 @@ object WebHookService { refName, commits.map { commit => val diffs = JGitUtil.getDiffs(git, commit.id, false) - val commitUrl = repositoryInfo.url.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/commit/" + commit.id + val commitUrl = repositoryInfo.httpUrl.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/commit/" + commit.id WebHookCommit( id = commit.id, @@ -106,7 +106,7 @@ object WebHookService { }.toList, WebHookRepository( name = repositoryInfo.name, - url = repositoryInfo.url, + url = repositoryInfo.httpUrl, description = repositoryInfo.repository.description.getOrElse(""), watchers = 0, forks = repositoryInfo.forkedCount, diff --git a/src/main/scala/service/WikiService.scala b/src/main/scala/service/WikiService.scala index f4e3ac588..fb1939956 100644 --- a/src/main/scala/service/WikiService.scala +++ b/src/main/scala/service/WikiService.scala @@ -15,6 +15,7 @@ import org.eclipse.jgit.patch._ import org.eclipse.jgit.api.errors.PatchFormatException import scala.collection.JavaConverters._ import scala.Some +import service.RepositoryService.RepositoryInfo object WikiService { @@ -40,6 +41,10 @@ object WikiService { */ case class WikiPageHistoryInfo(name: String, committer: String, message: String, date: Date) + def httpUrl(repository: RepositoryInfo) = repository.httpUrl.replaceFirst("\\.git\\Z", ".wiki.git") + + def sshUrl(repository: RepositoryInfo, settings: SystemSettingsService.SystemSettings) = + repository.sshUrl(settings.sshPort.getOrElse(SystemSettingsService.DefaultSshPort)).replaceFirst("\\.git\\Z", ".wiki.git") } trait WikiService { diff --git a/src/main/scala/ssh/GitCommand.scala b/src/main/scala/ssh/GitCommand.scala new file mode 100644 index 000000000..9f014f0e7 --- /dev/null +++ b/src/main/scala/ssh/GitCommand.scala @@ -0,0 +1,130 @@ +package ssh + +import org.apache.sshd.server.{CommandFactory, Environment, ExitCallback, Command} +import org.slf4j.LoggerFactory +import java.io.{InputStream, OutputStream} +import util.ControlUtil._ +import org.eclipse.jgit.api.Git +import util.Directory._ +import org.eclipse.jgit.transport.{ReceivePack, UploadPack} +import org.apache.sshd.server.command.UnknownCommand +import servlet.{Database, CommitLogHook} +import service.{AccountService, RepositoryService, SystemSettingsService} +import org.eclipse.jgit.errors.RepositoryNotFoundException +import javax.servlet.ServletContext + + +object GitCommand { + val CommandRegex = """\Agit-(upload|receive)-pack '/([a-zA-Z0-9\-_.]+)/([a-zA-Z0-9\-_.]+).git'\Z""".r +} + +abstract class GitCommand(val context: ServletContext, val owner: String, val repoName: String) extends Command { + self: RepositoryService with AccountService => + + private val logger = LoggerFactory.getLogger(classOf[GitCommand]) + protected var err: OutputStream = null + protected var in: InputStream = null + protected var out: OutputStream = null + protected var callback: ExitCallback = null + + protected def runTask(user: String): Unit + + private def newTask(user: String): Runnable = new Runnable { + override def run(): Unit = { + Database(context) withTransaction { + try { + runTask(user) + callback.onExit(0) + } catch { + case e: RepositoryNotFoundException => + logger.info(e.getMessage) + callback.onExit(1, "Repository Not Found") + case e: Throwable => + logger.error(e.getMessage, e) + callback.onExit(1) + } + } + } + } + + override def start(env: Environment): Unit = { + val user = env.getEnv.get("USER") + val thread = new Thread(newTask(user)) + thread.start() + } + + override def destroy(): Unit = {} + + override def setExitCallback(callback: ExitCallback): Unit = { + this.callback = callback + } + + override def setErrorStream(err: OutputStream): Unit = { + this.err = err + } + + override def setOutputStream(out: OutputStream): Unit = { + this.out = out + } + + override def setInputStream(in: InputStream): Unit = { + this.in = in + } + + protected def isWritableUser(username: String, repositoryInfo: RepositoryService.RepositoryInfo): Boolean = + getAccountByUserName(username) match { + case Some(account) => hasWritePermission(repositoryInfo.owner, repositoryInfo.name, Some(account)) + case None => false + } + +} + +class GitUploadPack(context: ServletContext, owner: String, repoName: String, baseUrl: String) extends GitCommand(context, owner, repoName) + with RepositoryService with AccountService { + + override protected def runTask(user: String): Unit = { + getRepository(owner, repoName.replaceFirst("\\.wiki\\Z", ""), baseUrl).foreach { repositoryInfo => + if(!repositoryInfo.repository.isPrivate || isWritableUser(user, repositoryInfo)){ + using(Git.open(getRepositoryDir(owner, repoName))) { git => + val repository = git.getRepository + val upload = new UploadPack(repository) + upload.upload(in, out, err) + } + } + } + } + +} + +class GitReceivePack(context: ServletContext, owner: String, repoName: String, baseUrl: String) extends GitCommand(context, owner, repoName) + with SystemSettingsService with RepositoryService with AccountService { + + override protected def runTask(user: String): Unit = { + getRepository(owner, repoName.replaceFirst("\\.wiki\\Z", ""), baseUrl).foreach { repositoryInfo => + if(isWritableUser(user, repositoryInfo)){ + using(Git.open(getRepositoryDir(owner, repoName))) { git => + val repository = git.getRepository + val receive = new ReceivePack(repository) + if(!repoName.endsWith(".wiki")){ + receive.setPostReceiveHook(new CommitLogHook(owner, repoName, user, baseUrl)) + } + receive.receive(in, out, err) + } + } + } + } + +} + +class GitCommandFactory(context: ServletContext, baseUrl: String) extends CommandFactory { + private val logger = LoggerFactory.getLogger(classOf[GitCommandFactory]) + + override def createCommand(command: String): Command = { + logger.debug(s"command: $command") + command match { + case GitCommand.CommandRegex("upload", owner, repoName) => new GitUploadPack(context, owner, repoName, baseUrl) + case GitCommand.CommandRegex("receive", owner, repoName) => new GitReceivePack(context, owner, repoName, baseUrl) + case _ => new UnknownCommand(command) + } + } +} \ No newline at end of file diff --git a/src/main/scala/ssh/NoShell.scala b/src/main/scala/ssh/NoShell.scala new file mode 100644 index 000000000..c107be695 --- /dev/null +++ b/src/main/scala/ssh/NoShell.scala @@ -0,0 +1,62 @@ +package ssh + +import org.apache.sshd.common.Factory +import org.apache.sshd.server.{Environment, ExitCallback, Command} +import java.io.{OutputStream, InputStream} +import org.eclipse.jgit.lib.Constants +import service.SystemSettingsService + +class NoShell extends Factory[Command] with SystemSettingsService { + override def create(): Command = new Command() { + private var in: InputStream = null + private var out: OutputStream = null + private var err: OutputStream = null + private var callback: ExitCallback = null + + override def start(env: Environment): Unit = { + val user = env.getEnv.get("USER") + val port = loadSystemSettings().sshPort.getOrElse(SystemSettingsService.DefaultSshPort) + val message = + """ + | Welcome to + | _____ _ _ ____ _ _ + | / ____| (_) | | | _ \ | | | | + | | | __ _ | |_ | |_) | _ _ ___ | | __ ___ | |_ + | | | |_ | | | | __| | _ < | | | | / __| | |/ / / _ \ | __| + | | |__| | | | | |_ | |_) | | |_| | | (__ | < | __/ | |_ + | \_____| |_| \__| |____/ \__,_| \___| |_|\_\ \___| \__| + | + | Successfully SSH Access. + | But interactive shell is disabled. + | + | Please use: + | + | git clone ssh://%s@GITBUCKET_HOST:%d/OWNER/REPOSITORY_NAME.git + """.stripMargin.format(user, port).replace("\n", "\r\n") + "\r\n" + err.write(Constants.encode(message)) + err.flush() + in.close() + out.close() + err.close() + callback.onExit(127) + } + + override def destroy(): Unit = {} + + override def setInputStream(in: InputStream): Unit = { + this.in = in + } + + override def setOutputStream(out: OutputStream): Unit = { + this.out = out + } + + override def setErrorStream(err: OutputStream): Unit = { + this.err = err + } + + override def setExitCallback(callback: ExitCallback): Unit = { + this.callback = callback + } + } +} diff --git a/src/main/scala/ssh/PublicKeyAuthenticator.scala b/src/main/scala/ssh/PublicKeyAuthenticator.scala new file mode 100644 index 000000000..a9ea634fa --- /dev/null +++ b/src/main/scala/ssh/PublicKeyAuthenticator.scala @@ -0,0 +1,23 @@ +package ssh + +import org.apache.sshd.server.PublickeyAuthenticator +import org.apache.sshd.server.session.ServerSession +import java.security.PublicKey +import service.SshKeyService +import servlet.Database +import javax.servlet.ServletContext + +class PublicKeyAuthenticator(context: ServletContext) extends PublickeyAuthenticator with SshKeyService { + + override def authenticate(username: String, key: PublicKey, session: ServerSession): Boolean = { + Database(context) withTransaction { + getPublicKeys(username).exists { sshKey => + SshUtil.str2PublicKey(sshKey.publicKey) match { + case Some(publicKey) => key.equals(publicKey) + case _ => false + } + } + } + } + +} diff --git a/src/main/scala/ssh/SshServerListener.scala b/src/main/scala/ssh/SshServerListener.scala new file mode 100644 index 000000000..f8e08fdb0 --- /dev/null +++ b/src/main/scala/ssh/SshServerListener.scala @@ -0,0 +1,68 @@ +package ssh + +import javax.servlet.{ServletContext, ServletContextEvent, ServletContextListener} +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider +import org.slf4j.LoggerFactory +import util.Directory +import service.SystemSettingsService +import java.util.concurrent.atomic.AtomicBoolean + +object SshServer { + private val logger = LoggerFactory.getLogger(SshServer.getClass) + private val server = org.apache.sshd.SshServer.setUpDefaultServer() + private val active = new AtomicBoolean(false) + + private def configure(context: ServletContext, port: Int, baseUrl: String) = { + server.setPort(port) + server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider(s"${Directory.GitBucketHome}/gitbucket.ser")) + server.setPublickeyAuthenticator(new PublicKeyAuthenticator(context)) + server.setCommandFactory(new GitCommandFactory(context, baseUrl)) + server.setShellFactory(new NoShell) + } + + def start(context: ServletContext, port: Int, baseUrl: String) = { + if(active.compareAndSet(false, true)){ + configure(context, port, baseUrl) + server.start() + logger.info(s"Start SSH Server Listen on ${server.getPort}") + } + } + + def stop() = { + if(active.compareAndSet(true, false)){ + server.stop(true) + } + } + + def isActive = active.get +} + +/* + * Start a SSH Server Daemon + * + * How to use: + * git clone ssh://username@host_or_ip:29418/owner/repository_name.git + */ +class SshServerListener extends ServletContextListener with SystemSettingsService { + + override def contextInitialized(sce: ServletContextEvent): Unit = { + val settings = loadSystemSettings() + if(settings.ssh){ + if(settings.baseUrl.isEmpty){ + // TODO use logger? + println("Could not start SshServer because the baseUrl is not configured.") + } else { + SshServer.start(sce.getServletContext, + settings.sshPort.getOrElse(SystemSettingsService.DefaultSshPort), + settings.baseUrl.get) + } + } + } + + override def contextDestroyed(sce: ServletContextEvent): Unit = { + if(loadSystemSettings().ssh){ + SshServer.stop() + } + } + +} diff --git a/src/main/scala/ssh/SshUtil.scala b/src/main/scala/ssh/SshUtil.scala new file mode 100644 index 000000000..db578ded2 --- /dev/null +++ b/src/main/scala/ssh/SshUtil.scala @@ -0,0 +1,33 @@ +package ssh + +import java.security.PublicKey +import org.slf4j.LoggerFactory +import org.apache.commons.codec.binary.Base64 +import org.eclipse.jgit.lib.Constants +import org.apache.sshd.common.util.{KeyUtils, Buffer} + +object SshUtil { + + private val logger = LoggerFactory.getLogger(SshUtil.getClass) + + def str2PublicKey(key: String): Option[PublicKey] = { + // TODO RFC 4716 Public Key is not supported... + val parts = key.split(" ") + if (parts.size < 2) { + logger.debug(s"Invalid PublicKey Format: key") + return None + } + try { + val encodedKey = parts(1) + val decode = Base64.decodeBase64(Constants.encodeASCII(encodedKey)) + Some(new Buffer(decode).getRawPublicKey) + } catch { + case e: Throwable => + logger.debug(e.getMessage, e) + None + } + } + + def fingerPrint(key: String): String = KeyUtils.getFingerPrint(str2PublicKey(key).get) + +} diff --git a/src/main/scala/view/Markdown.scala b/src/main/scala/view/Markdown.scala index 6b7ab2dc7..4d998bd27 100644 --- a/src/main/scala/view/Markdown.scala +++ b/src/main/scala/view/Markdown.scala @@ -45,7 +45,7 @@ class GitBucketLinkRender(context: app.Context, repository: service.RepositorySe (text, text) } - val url = repository.url.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/" + StringUtil.urlEncode(page) + val url = repository.httpUrl.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/" + StringUtil.urlEncode(page) if(getWikiPage(repository.owner, repository.name, page).isDefined){ new Rendering(url, label) @@ -104,7 +104,7 @@ class GitBucketHtmlSerializer( if(!enableWikiLink || url.startsWith("http://") || url.startsWith("https://")){ url } else { - repository.url.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/_blob/" + url + repository.httpUrl.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/_blob/" + url } } diff --git a/src/main/twirl/account/edit.scala.html b/src/main/twirl/account/edit.scala.html index 670411dc3..f2c5c10c2 100644 --- a/src/main/twirl/account/edit.scala.html +++ b/src/main/twirl/account/edit.scala.html @@ -1,77 +1,66 @@ -@(account: Option[model.Account], info: Option[Any])(implicit context: app.Context) +@(account: model.Account, settings: service.SystemSettingsService.SystemSettings, info: Option[Any])(implicit context: app.Context) @import context._ @import view.helpers._ @import util.AccountUtil -@html.main((if(account.isDefined) "Edit your profile" else "Create your account")){ -@helper.html.information(info) - @if(account.isDefined && AccountUtil.hasLdapDummyMailAddress(account.get)) { +@html.main("Edit your profile"){ +
+
+ @menu("profile", settings.ssh) +
+
+ @helper.html.information(info) + @if(AccountUtil.hasLdapDummyMailAddress(account)) {
Please register your mail address.
- } - @if(account.isDefined){ -

Edit your profile

- } else { -

Create your account

- } -
-
-
- @if(account.isEmpty){ -
- - - -
- } - @if(account.map(_.password.nonEmpty).getOrElse(true)){ -
- - - -
- } -
- - - -
-
- - - -
-
- - - -
-
-
-
- - @helper.html.uploadavatar(account) -
+ } + +
+
Profile
+
+
+
+ @if(account.password.nonEmpty){ +
+ + + +
+ } +
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ + @helper.html.uploadavatar(Some(account)) +
+
+
+
+ + + @if(!AccountUtil.hasLdapDummyMailAddress(account)){Cancel} +
-
- @if(account.isDefined){ - - - @if(!AccountUtil.hasLdapDummyMailAddress(account.get)){ - Cancel - } - } else { - - } -
+
} \ No newline at end of file + diff --git a/src/main/twirl/account/menu.scala.html b/src/main/twirl/account/menu.scala.html new file mode 100644 index 000000000..a5d913948 --- /dev/null +++ b/src/main/twirl/account/menu.scala.html @@ -0,0 +1,14 @@ +@(active: String, ssh: Boolean)(implicit context: app.Context) +@import context._ +
+ +
diff --git a/src/main/twirl/account/register.scala.html b/src/main/twirl/account/register.scala.html new file mode 100644 index 000000000..7bce2dba2 --- /dev/null +++ b/src/main/twirl/account/register.scala.html @@ -0,0 +1,48 @@ +@()(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("Create your account"){ +

Create your account

+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ + @helper.html.uploadavatar(None) +
+
+
+
+ +
+
+} diff --git a/src/main/twirl/account/ssh.scala.html b/src/main/twirl/account/ssh.scala.html new file mode 100644 index 000000000..5d1817ec3 --- /dev/null +++ b/src/main/twirl/account/ssh.scala.html @@ -0,0 +1,45 @@ +@(account: model.Account, settings: service.SystemSettingsService.SystemSettings, sshKeys: List[model.SshKey])(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("SSH Keys"){ +
+
+ @menu("ssh", settings.ssh) +
+
+
+
SSH Keys
+
+ @if(sshKeys.isEmpty){ + No keys + } + @sshKeys.zipWithIndex.map { case (key, i) => + @if(i != 0){ +
+ } + @key.title (@_root_.ssh.SshUtil.fingerPrint(key.publicKey)) + Delete + } +
+
+
+
+
Add an SSH Key
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+
+} \ No newline at end of file diff --git a/src/main/twirl/admin/system.scala.html b/src/main/twirl/admin/system.scala.html index 8e52c8d46..81fff5dcb 100644 --- a/src/main/twirl/admin/system.scala.html +++ b/src/main/twirl/admin/system.scala.html @@ -22,9 +22,10 @@
+
-

+

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


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

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

+
@@ -213,6 +237,10 @@ } +} \ No newline at end of file diff --git a/src/main/twirl/repo/tags.scala.html b/src/main/twirl/repo/tags.scala.html index 2574f3e95..97df40009 100644 --- a/src/main/twirl/repo/tags.scala.html +++ b/src/main/twirl/repo/tags.scala.html @@ -1,9 +1,10 @@ -@(repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) +@(repository: service.RepositoryService.RepositoryInfo, + settings: service.SystemSettingsService.SystemSettings)(implicit context: app.Context) @import context._ @import view.helpers._ @html.main(s"${repository.owner}/${repository.name}", Some(repository)) { @html.header("code", repository) - @tab(repository.repository.defaultBranch, repository, "tags", true) + @tab(repository.repository.defaultBranch, repository, "tags", settings, true)

Tags

diff --git a/src/main/twirl/wiki/compare.scala.html b/src/main/twirl/wiki/compare.scala.html index 2e52027ee..eee9ceaf5 100644 --- a/src/main/twirl/wiki/compare.scala.html +++ b/src/main/twirl/wiki/compare.scala.html @@ -4,6 +4,7 @@ diffs: Seq[util.JGitUtil.DiffInfo], repository: service.RepositoryService.RepositoryInfo, hasWritePermission: Boolean, + settings: service.SystemSettingsService.SystemSettings, info: Option[Any])(implicit context: app.Context) @import context._ @import view.helpers._ @@ -11,7 +12,7 @@ @html.main(s"Compare Revisions - ${repository.owner}/${repository.name}", Some(repository)){ @helper.html.information(info) @html.header("wiki", repository) - @tab("history", repository) + @tab("history", repository, settings)