diff --git a/project/build.scala b/project/build.scala index ce6d4d9..82b4fbe 100644 --- a/project/build.scala +++ b/project/build.scala @@ -36,6 +36,7 @@ "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 0030169..f8658a2 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 d35379b..71197b6 100644 --- a/src/main/scala/app/AccountController.scala +++ b/src/main/scala/app/AccountController.scala @@ -14,11 +14,11 @@ 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 @@ 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 @@ "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 @@ 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 @@ 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 d68dc53..5454f94 100644 --- a/src/main/scala/app/RepositoryViewerController.scala +++ b/src/main/scala/app/RepositoryViewerController.scala @@ -65,7 +65,7 @@ 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 @@ 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 @@ 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 @@ 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 @@ * Displays tags. */ get("/:owner/:repository/tags")(referrersOnly { - repo.html.tags(_) + repo.html.tags(_, loadSystemSettings()) }) /** @@ -284,7 +285,7 @@ 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 a2e410e..4b28e4c 100644 --- a/src/main/scala/app/SystemSettingsController.scala +++ b/src/main/scala/app/SystemSettingsController.scala @@ -4,6 +4,7 @@ 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 @@ "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 @@ "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 @@ 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 07208a5..1f9d239 100644 --- a/src/main/scala/app/WikiController.scala +++ b/src/main/scala/app/WikiController.scala @@ -36,7 +36,7 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ }) 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 @@ 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 0000000..39d89d4 --- /dev/null +++ b/src/main/scala/model/SshKey.scala @@ -0,0 +1,22 @@ +package model + +import scala.slick.driver.H2Driver.simple._ + +object SshKeys extends Table[SshKey]("SSH_KEY") { + def userName = column[String]("USER_NAME") + def sshKeyId = column[Int]("SSH_KEY_ID", O AutoInc) + def title = column[String]("TITLE") + def publicKey = column[String]("PUBLIC_KEY") + + def ins = userName ~ title ~ publicKey returning sshKeyId + def * = userName ~ sshKeyId ~ title ~ publicKey <> (SshKey, SshKey.unapply _) + + def byPrimaryKey(userName: String, sshKeyId: Int) = (this.userName is userName.bind) && (this.sshKeyId is sshKeyId.bind) +} + +case class SshKey( + userName: String, + sshKeyId: Int, + title: String, + publicKey: String +) diff --git a/src/main/scala/service/RepositoryService.scala b/src/main/scala/service/RepositoryService.scala index 9038ea5..4c6abd1 100644 --- a/src/main/scala/service/RepositoryService.scala +++ b/src/main/scala/service/RepositoryService.scala @@ -288,10 +288,14 @@ 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 0000000..c249a3a --- /dev/null +++ b/src/main/scala/service/SshKeyService.scala @@ -0,0 +1,19 @@ +package service + +import model._ +import scala.slick.driver.H2Driver.simple._ +import Database.threadLocalSession + +trait SshKeyService { + + def addPublicKey(userName: String, title: String, publicKey: String): Unit = + SshKeys.ins insert (userName, title, publicKey) + + def getPublicKeys(userName: String): List[SshKey] = + Query(SshKeys).filter(_.userName is userName.bind).sortBy(_.sshKeyId).list + + def deletePublicKey(userName: String, sshKeyId: Int): Unit = + SshKeys filter (_.byPrimaryKey(userName, sshKeyId)) delete + + +} diff --git a/src/main/scala/service/SystemSettingsService.scala b/src/main/scala/service/SystemSettingsService.scala index df27bb7..dd83fda 100644 --- a/src/main/scala/service/SystemSettingsService.scala +++ b/src/main/scala/service/SystemSettingsService.scala @@ -15,10 +15,12 @@ def saveSystemSettings(settings: SystemSettings): Unit = { defining(new java.util.Properties()){ props => - settings.baseUrl.foreach(props.setProperty(BaseURL, _)) + settings.baseUrl.foreach(x => props.setProperty(BaseURL, x.replaceFirst("/\\Z", ""))) props.setProperty(AllowAccountRegistration, settings.allowAccountRegistration.toString) props.setProperty(Gravatar, settings.gravatar.toString) props.setProperty(Notification, settings.notification.toString) + props.setProperty(Ssh, settings.ssh.toString) + settings.sshPort.foreach(x => props.setProperty(SshPort, x.toString)) if(settings.notification) { settings.smtp.foreach { smtp => props.setProperty(SmtpHost, smtp.host) @@ -57,10 +59,12 @@ props.load(new java.io.FileInputStream(GitBucketConf)) } SystemSettings( - getOptionValue(props, BaseURL, None), + getOptionValue[String](props, BaseURL, None).map(x => x.replaceFirst("/\\Z", "")), getValue(props, AllowAccountRegistration, false), getValue(props, Gravatar, true), getValue(props, Notification, false), + getValue(props, Ssh, false), + getOptionValue(props, SshPort, Some(DefaultSshPort)), if(getValue(props, Notification, false)){ Some(Smtp( getValue(props, SmtpHost, ""), @@ -104,6 +108,8 @@ allowAccountRegistration: Boolean, gravatar: Boolean, notification: Boolean, + ssh: Boolean, + sshPort: Option[Int], smtp: Option[Smtp], ldapAuthentication: Boolean, ldap: Option[Ldap]) @@ -130,6 +136,7 @@ fromAddress: Option[String], fromName: Option[String]) + val DefaultSshPort = 29418 val DefaultSmtpPort = 25 val DefaultLdapPort = 389 @@ -137,6 +144,8 @@ private val AllowAccountRegistration = "allow_account_registration" private val Gravatar = "gravatar" private val Notification = "notification" + private val Ssh = "ssh" + private val SshPort = "ssh.port" private val SmtpHost = "smtp.host" private val SmtpPort = "smtp.port" private val SmtpUser = "smtp.user" diff --git a/src/main/scala/service/WebHookService.scala b/src/main/scala/service/WebHookService.scala index 496f784..5662c44 100644 --- a/src/main/scala/service/WebHookService.scala +++ b/src/main/scala/service/WebHookService.scala @@ -87,7 +87,7 @@ refName, commits.map { commit => val diffs = JGitUtil.getDiffs(git, commit.id, false) - val commitUrl = repositoryInfo.url.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/commit/" + commit.id + val commitUrl = repositoryInfo.httpUrl.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/commit/" + commit.id WebHookCommit( id = commit.id, @@ -106,7 +106,7 @@ }.toList, WebHookRepository( name = repositoryInfo.name, - url = repositoryInfo.url, + url = repositoryInfo.httpUrl, description = repositoryInfo.repository.description.getOrElse(""), watchers = 0, forks = repositoryInfo.forkedCount, diff --git a/src/main/scala/service/WikiService.scala b/src/main/scala/service/WikiService.scala index f4e3ac5..fb19399 100644 --- a/src/main/scala/service/WikiService.scala +++ b/src/main/scala/service/WikiService.scala @@ -15,6 +15,7 @@ import org.eclipse.jgit.api.errors.PatchFormatException import scala.collection.JavaConverters._ import scala.Some +import service.RepositoryService.RepositoryInfo object WikiService { @@ -40,6 +41,10 @@ */ case class WikiPageHistoryInfo(name: String, committer: String, message: String, date: Date) + def httpUrl(repository: RepositoryInfo) = repository.httpUrl.replaceFirst("\\.git\\Z", ".wiki.git") + + def sshUrl(repository: RepositoryInfo, settings: SystemSettingsService.SystemSettings) = + 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 0000000..9f014f0 --- /dev/null +++ b/src/main/scala/ssh/GitCommand.scala @@ -0,0 +1,130 @@ +package ssh + +import org.apache.sshd.server.{CommandFactory, Environment, ExitCallback, Command} +import org.slf4j.LoggerFactory +import java.io.{InputStream, OutputStream} +import util.ControlUtil._ +import org.eclipse.jgit.api.Git +import util.Directory._ +import org.eclipse.jgit.transport.{ReceivePack, UploadPack} +import org.apache.sshd.server.command.UnknownCommand +import servlet.{Database, CommitLogHook} +import service.{AccountService, RepositoryService, SystemSettingsService} +import org.eclipse.jgit.errors.RepositoryNotFoundException +import javax.servlet.ServletContext + + +object GitCommand { + val CommandRegex = """\Agit-(upload|receive)-pack '/([a-zA-Z0-9\-_.]+)/([a-zA-Z0-9\-_.]+).git'\Z""".r +} + +abstract class GitCommand(val context: ServletContext, val owner: String, val repoName: String) extends Command { + self: RepositoryService with AccountService => + + private val logger = LoggerFactory.getLogger(classOf[GitCommand]) + protected var err: OutputStream = null + protected var in: InputStream = null + protected var out: OutputStream = null + protected var callback: ExitCallback = null + + protected def runTask(user: String): Unit + + private def newTask(user: String): Runnable = new Runnable { + override def run(): Unit = { + Database(context) withTransaction { + try { + runTask(user) + callback.onExit(0) + } catch { + case e: RepositoryNotFoundException => + logger.info(e.getMessage) + callback.onExit(1, "Repository Not Found") + case e: Throwable => + logger.error(e.getMessage, e) + callback.onExit(1) + } + } + } + } + + override def start(env: Environment): Unit = { + val user = env.getEnv.get("USER") + val thread = new Thread(newTask(user)) + thread.start() + } + + override def destroy(): Unit = {} + + override def setExitCallback(callback: ExitCallback): Unit = { + this.callback = callback + } + + override def setErrorStream(err: OutputStream): Unit = { + this.err = err + } + + override def setOutputStream(out: OutputStream): Unit = { + this.out = out + } + + override def setInputStream(in: InputStream): Unit = { + this.in = in + } + + protected def isWritableUser(username: String, repositoryInfo: RepositoryService.RepositoryInfo): Boolean = + getAccountByUserName(username) match { + case Some(account) => hasWritePermission(repositoryInfo.owner, repositoryInfo.name, Some(account)) + case None => false + } + +} + +class GitUploadPack(context: ServletContext, owner: String, repoName: String, baseUrl: String) extends GitCommand(context, owner, repoName) + with RepositoryService with AccountService { + + override protected def runTask(user: String): Unit = { + getRepository(owner, repoName.replaceFirst("\\.wiki\\Z", ""), baseUrl).foreach { repositoryInfo => + if(!repositoryInfo.repository.isPrivate || isWritableUser(user, repositoryInfo)){ + using(Git.open(getRepositoryDir(owner, repoName))) { git => + val repository = git.getRepository + val upload = new UploadPack(repository) + upload.upload(in, out, err) + } + } + } + } + +} + +class GitReceivePack(context: ServletContext, owner: String, repoName: String, baseUrl: String) extends GitCommand(context, owner, repoName) + with SystemSettingsService with RepositoryService with AccountService { + + override protected def runTask(user: String): Unit = { + getRepository(owner, repoName.replaceFirst("\\.wiki\\Z", ""), baseUrl).foreach { repositoryInfo => + if(isWritableUser(user, repositoryInfo)){ + using(Git.open(getRepositoryDir(owner, repoName))) { git => + val repository = git.getRepository + val receive = new ReceivePack(repository) + if(!repoName.endsWith(".wiki")){ + receive.setPostReceiveHook(new CommitLogHook(owner, repoName, user, baseUrl)) + } + receive.receive(in, out, err) + } + } + } + } + +} + +class GitCommandFactory(context: ServletContext, baseUrl: String) extends CommandFactory { + private val logger = LoggerFactory.getLogger(classOf[GitCommandFactory]) + + override def createCommand(command: String): Command = { + logger.debug(s"command: $command") + command match { + case GitCommand.CommandRegex("upload", owner, repoName) => new GitUploadPack(context, owner, repoName, baseUrl) + case GitCommand.CommandRegex("receive", owner, repoName) => new GitReceivePack(context, owner, repoName, baseUrl) + case _ => new UnknownCommand(command) + } + } +} \ No newline at end of file diff --git a/src/main/scala/ssh/NoShell.scala b/src/main/scala/ssh/NoShell.scala new file mode 100644 index 0000000..c107be6 --- /dev/null +++ b/src/main/scala/ssh/NoShell.scala @@ -0,0 +1,62 @@ +package ssh + +import org.apache.sshd.common.Factory +import org.apache.sshd.server.{Environment, ExitCallback, Command} +import java.io.{OutputStream, InputStream} +import org.eclipse.jgit.lib.Constants +import service.SystemSettingsService + +class NoShell extends Factory[Command] with SystemSettingsService { + override def create(): Command = new Command() { + private var in: InputStream = null + private var out: OutputStream = null + private var err: OutputStream = null + private var callback: ExitCallback = null + + override def start(env: Environment): Unit = { + val user = env.getEnv.get("USER") + val port = loadSystemSettings().sshPort.getOrElse(SystemSettingsService.DefaultSshPort) + val message = + """ + | Welcome to + | _____ _ _ ____ _ _ + | / ____| (_) | | | _ \ | | | | + | | | __ _ | |_ | |_) | _ _ ___ | | __ ___ | |_ + | | | |_ | | | | __| | _ < | | | | / __| | |/ / / _ \ | __| + | | |__| | | | | |_ | |_) | | |_| | | (__ | < | __/ | |_ + | \_____| |_| \__| |____/ \__,_| \___| |_|\_\ \___| \__| + | + | Successfully SSH Access. + | But interactive shell is disabled. + | + | Please use: + | + | git clone ssh://%s@GITBUCKET_HOST:%d/OWNER/REPOSITORY_NAME.git + """.stripMargin.format(user, port).replace("\n", "\r\n") + "\r\n" + err.write(Constants.encode(message)) + err.flush() + in.close() + out.close() + err.close() + callback.onExit(127) + } + + override def destroy(): Unit = {} + + override def setInputStream(in: InputStream): Unit = { + this.in = in + } + + override def setOutputStream(out: OutputStream): Unit = { + this.out = out + } + + override def setErrorStream(err: OutputStream): Unit = { + this.err = err + } + + override def setExitCallback(callback: ExitCallback): Unit = { + this.callback = callback + } + } +} diff --git a/src/main/scala/ssh/PublicKeyAuthenticator.scala b/src/main/scala/ssh/PublicKeyAuthenticator.scala new file mode 100644 index 0000000..a9ea634 --- /dev/null +++ b/src/main/scala/ssh/PublicKeyAuthenticator.scala @@ -0,0 +1,23 @@ +package ssh + +import org.apache.sshd.server.PublickeyAuthenticator +import org.apache.sshd.server.session.ServerSession +import java.security.PublicKey +import service.SshKeyService +import servlet.Database +import javax.servlet.ServletContext + +class PublicKeyAuthenticator(context: ServletContext) extends PublickeyAuthenticator with SshKeyService { + + override def authenticate(username: String, key: PublicKey, session: ServerSession): Boolean = { + Database(context) withTransaction { + getPublicKeys(username).exists { sshKey => + SshUtil.str2PublicKey(sshKey.publicKey) match { + case Some(publicKey) => key.equals(publicKey) + case _ => false + } + } + } + } + +} diff --git a/src/main/scala/ssh/SshServerListener.scala b/src/main/scala/ssh/SshServerListener.scala new file mode 100644 index 0000000..f8e08fd --- /dev/null +++ b/src/main/scala/ssh/SshServerListener.scala @@ -0,0 +1,68 @@ +package ssh + +import javax.servlet.{ServletContext, ServletContextEvent, ServletContextListener} +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider +import org.slf4j.LoggerFactory +import util.Directory +import service.SystemSettingsService +import java.util.concurrent.atomic.AtomicBoolean + +object SshServer { + private val logger = LoggerFactory.getLogger(SshServer.getClass) + private val server = org.apache.sshd.SshServer.setUpDefaultServer() + private val active = new AtomicBoolean(false) + + private def configure(context: ServletContext, port: Int, baseUrl: String) = { + server.setPort(port) + server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider(s"${Directory.GitBucketHome}/gitbucket.ser")) + server.setPublickeyAuthenticator(new PublicKeyAuthenticator(context)) + server.setCommandFactory(new GitCommandFactory(context, baseUrl)) + server.setShellFactory(new NoShell) + } + + def start(context: ServletContext, port: Int, baseUrl: String) = { + if(active.compareAndSet(false, true)){ + configure(context, port, baseUrl) + server.start() + logger.info(s"Start SSH Server Listen on ${server.getPort}") + } + } + + def stop() = { + if(active.compareAndSet(true, false)){ + server.stop(true) + } + } + + def isActive = active.get +} + +/* + * Start a SSH Server Daemon + * + * How to use: + * git clone ssh://username@host_or_ip:29418/owner/repository_name.git + */ +class SshServerListener extends ServletContextListener with SystemSettingsService { + + override def contextInitialized(sce: ServletContextEvent): Unit = { + val settings = loadSystemSettings() + if(settings.ssh){ + if(settings.baseUrl.isEmpty){ + // TODO use logger? + println("Could not start SshServer because the baseUrl is not configured.") + } else { + SshServer.start(sce.getServletContext, + settings.sshPort.getOrElse(SystemSettingsService.DefaultSshPort), + settings.baseUrl.get) + } + } + } + + override def contextDestroyed(sce: ServletContextEvent): Unit = { + if(loadSystemSettings().ssh){ + SshServer.stop() + } + } + +} diff --git a/src/main/scala/ssh/SshUtil.scala b/src/main/scala/ssh/SshUtil.scala new file mode 100644 index 0000000..db578de --- /dev/null +++ b/src/main/scala/ssh/SshUtil.scala @@ -0,0 +1,33 @@ +package ssh + +import java.security.PublicKey +import org.slf4j.LoggerFactory +import org.apache.commons.codec.binary.Base64 +import org.eclipse.jgit.lib.Constants +import org.apache.sshd.common.util.{KeyUtils, Buffer} + +object SshUtil { + + private val logger = LoggerFactory.getLogger(SshUtil.getClass) + + def str2PublicKey(key: String): Option[PublicKey] = { + // TODO RFC 4716 Public Key is not supported... + val parts = key.split(" ") + if (parts.size < 2) { + logger.debug(s"Invalid PublicKey Format: key") + return None + } + try { + val encodedKey = parts(1) + val decode = Base64.decodeBase64(Constants.encodeASCII(encodedKey)) + Some(new Buffer(decode).getRawPublicKey) + } catch { + case e: Throwable => + logger.debug(e.getMessage, e) + None + } + } + + def fingerPrint(key: String): String = KeyUtils.getFingerPrint(str2PublicKey(key).get) + +} diff --git a/src/main/scala/view/Markdown.scala b/src/main/scala/view/Markdown.scala index 6b7ab2d..4d998bd 100644 --- a/src/main/scala/view/Markdown.scala +++ b/src/main/scala/view/Markdown.scala @@ -45,7 +45,7 @@ (text, text) } - val url = repository.url.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/" + StringUtil.urlEncode(page) + val url = repository.httpUrl.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/" + StringUtil.urlEncode(page) if(getWikiPage(repository.owner, repository.name, page).isDefined){ new Rendering(url, label) @@ -104,7 +104,7 @@ if(!enableWikiLink || url.startsWith("http://") || url.startsWith("https://")){ url } else { - repository.url.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/_blob/" + url + repository.httpUrl.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/_blob/" + url } } diff --git a/src/main/twirl/account/edit.scala.html b/src/main/twirl/account/edit.scala.html index 670411d..f2c5c10 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 0000000..a5d9139 --- /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 0000000..7bce2db --- /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 0000000..5d1817e --- /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 8e52c8d..81fff5d 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 2574f3e..97df400 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 2e52027..eee9cea 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)