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"){ +