Merge pull request #1471 from gitbucket/feature/deploykeys

Deploy key support
This commit is contained in:
Naoki Takezoe
2017-03-03 11:54:41 +09:00
committed by GitHub
13 changed files with 324 additions and 108 deletions

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<changeSet>
<createTable tableName="DEPLOY_KEY">
<column name="USER_NAME" type="varchar(100)" nullable="false"/>
<column name="REPOSITORY_NAME" type="varchar(100)" nullable="false"/>
<column name="DEPLOY_KEY_ID" type="int" nullable="false" autoIncrement="true" unique="true"/>
<column name="TITLE" type="varchar(100)" nullable="false"/>
<column name="PUBLIC_KEY" type="text" nullable="false"/>
<column name="ALLOW_WRITE" type="boolean" nullable="false" defaultValueBoolean="false"/>
</createTable>
<addPrimaryKey constraintName="IDX_DEPLOY_KEY_PK" tableName="DEPLOY_KEY" columnNames="USER_NAME, REPOSITORY_NAME, DEPLOY_KEY_ID"/>
<addForeignKeyConstraint constraintName="IDX_DEPLOY_KEY_FK0" baseTableName="DEPLOY_KEY" baseColumnNames="USER_NAME, REPOSITORY_NAME" referencedTableName="REPOSITORY" referencedColumnNames="USER_NAME, REPOSITORY_NAME"/>
</changeSet>

View File

@@ -28,5 +28,8 @@ object GitBucketCoreModule extends Module("gitbucket-core",
new Version("4.9.0",
new LiquibaseMigration("update/gitbucket-core_4.9.xml")
),
new Version("4.10.0")
new Version("4.10.0"),
new Version("4.11.0",
new LiquibaseMigration("update/gitbucket-core_4.11.xml")
)
)

View File

@@ -2,7 +2,7 @@ package gitbucket.core.controller
import gitbucket.core.settings.html
import gitbucket.core.model.WebHook
import gitbucket.core.service.{RepositoryService, AccountService, WebHookService, ProtectedBranchService, CommitStatusService}
import gitbucket.core.service._
import gitbucket.core.service.WebHookService._
import gitbucket.core.util._
import gitbucket.core.util.JGitUtil._
@@ -19,11 +19,11 @@ import gitbucket.core.model.WebHookContentType
class RepositorySettingsController extends RepositorySettingsControllerBase
with RepositoryService with AccountService with WebHookService with ProtectedBranchService with CommitStatusService
with RepositoryService with AccountService with WebHookService with ProtectedBranchService with CommitStatusService with DeployKeyService
with OwnerAuthenticator with UsersAuthenticator
trait RepositorySettingsControllerBase extends ControllerBase {
self: RepositoryService with AccountService with WebHookService with ProtectedBranchService with CommitStatusService
self: RepositoryService with AccountService with WebHookService with ProtectedBranchService with CommitStatusService with DeployKeyService
with OwnerAuthenticator with UsersAuthenticator =>
// for repository options
@@ -37,7 +37,7 @@ trait RepositorySettingsControllerBase extends ControllerBase {
externalWikiUrl: Option[String],
allowFork: Boolean
)
val optionsForm = mapping(
"repositoryName" -> trim(label("Repository Name" , text(required, maxlength(100), identifier, renameRepositoryName))),
"description" -> trim(label("Description" , optional(text()))),
@@ -56,12 +56,15 @@ trait RepositorySettingsControllerBase extends ControllerBase {
"defaultBranch" -> trim(label("Default Branch" , text(required, maxlength(100))))
)(DefaultBranchForm.apply)
// // for collaborator addition
// case class CollaboratorForm(userName: String)
//
// val collaboratorForm = mapping(
// "userName" -> trim(label("Username", text(required, collaborator)))
// )(CollaboratorForm.apply)
// for deploy key
case class DeployKeyForm(title: String, publicKey: String, allowWrite: Boolean)
val deployKeyForm = mapping(
"title" -> trim(label("Title", text(required, maxlength(100)))),
"publicKey" -> trim(label("Key" , text(required))), // TODO duplication check in the repository?
"allowWrite" -> trim(label("Key" , boolean()))
)(DeployKeyForm.apply)
// for web hook url addition
case class WebHookForm(url: String, events: Set[WebHook.Event], ctype: WebHookContentType, token: Option[String])
@@ -382,6 +385,24 @@ trait RepositorySettingsControllerBase extends ControllerBase {
redirect(s"/${repository.owner}/${repository.name}/settings/danger")
})
/** List deploy keys */
get("/:owner/:repository/settings/deploykey")(ownerOnly { repository =>
html.deploykey(repository, getDeployKeys(repository.owner, repository.name))
})
/** Register a deploy key */
post("/:owner/:repository/settings/deploykey", deployKeyForm)(ownerOnly { (form, repository) =>
addDeployKey(repository.owner, repository.name, form.title, form.publicKey, form.allowWrite)
redirect(s"/${repository.owner}/${repository.name}/settings/deploykey")
})
/** Delete a deploy key */
get("/:owner/:repository/settings/deploykey/delete/:id")(ownerOnly { repository =>
val deployKeyId = params("id").toInt
deleteDeployKey(repository.owner, repository.name, deployKeyId)
redirect(s"/${repository.owner}/${repository.name}/settings/deploykey")
})
/**
* Provides duplication check for web hook url.
*/

View File

@@ -0,0 +1,29 @@
package gitbucket.core.model
trait DeployKeyComponent { self: Profile =>
import profile.api._
lazy val DeployKeys = TableQuery[DeployKeys]
class DeployKeys(tag: Tag) extends Table[DeployKey](tag, "DEPLOY_KEY") {
val userName = column[String]("USER_NAME")
val repositoryName = column[String]("REPOSITORY_NAME")
val deployKeyId = column[Int]("DEPLOY_KEY_ID", O AutoInc)
val title = column[String]("TITLE")
val publicKey = column[String]("PUBLIC_KEY")
val allowWrite = column[Boolean]("ALLOW_WRITE")
def * = (userName, repositoryName, deployKeyId, title, publicKey, allowWrite) <> (DeployKey.tupled, DeployKey.unapply)
def byPrimaryKey(userName: String, repositoryName: String, deployKeyId: Int) =
(this.userName === userName.bind) && (this.repositoryName === repositoryName.bind) && (this.deployKeyId === deployKeyId.bind)
}
}
case class DeployKey(
userName: String,
repositoryName: String,
deployKeyId: Int = 0,
title: String,
publicKey: String,
allowWrite: Boolean
)

View File

@@ -54,5 +54,6 @@ trait CoreProfile extends ProfileProvider with Profile
with WebHookComponent
with WebHookEventComponent
with ProtectedBranchComponent
with DeployKeyComponent
object Profile extends CoreProfile

View File

@@ -0,0 +1,31 @@
package gitbucket.core.service
import gitbucket.core.model.DeployKey
import gitbucket.core.model.Profile._
import gitbucket.core.model.Profile.profile.blockingApi._
trait DeployKeyService {
def addDeployKey(userName: String, repositoryName: String, title: String, publicKey: String, allowWrite: Boolean)
(implicit s: Session): Unit =
DeployKeys.insert(DeployKey(
userName = userName,
repositoryName = repositoryName,
title = title,
publicKey = publicKey,
allowWrite = allowWrite
))
def getDeployKeys(userName: String, repositoryName: String)(implicit s: Session): List[DeployKey] =
DeployKeys
.filter(x => (x.userName === userName.bind) && (x.repositoryName === repositoryName.bind))
.sortBy(_.deployKeyId).list
def getAllDeployKeys()(implicit s: Session): List[DeployKey] =
DeployKeys.filter(_.publicKey.trim =!= "").list
def deleteDeployKey(userName: String, repositoryName: String, deployKeyId: Int)(implicit s: Session): Unit =
DeployKeys.filter(_.byPrimaryKey(userName, repositoryName, deployKeyId)).delete
}

View File

@@ -7,7 +7,7 @@ import gitbucket.core.model.Profile.profile.blockingApi._
trait SshKeyService {
def addPublicKey(userName: String, title: String, publicKey: String)(implicit s: Session): Unit =
SshKeys insert SshKey(userName = userName, title = title, publicKey = publicKey)
SshKeys.insert(SshKey(userName = userName, title = title, publicKey = publicKey))
def getPublicKeys(userName: String)(implicit s: Session): List[SshKey] =
SshKeys.filter(_.userName === userName.bind).sortBy(_.sshKeyId).list
@@ -16,6 +16,6 @@ trait SshKeyService {
SshKeys.filter(_.publicKey.trim =!= "").list
def deletePublicKey(userName: String, sshKeyId: Int)(implicit s: Session): Unit =
SshKeys filter (_.byPrimaryKey(userName, sshKeyId)) delete
SshKeys.filter(_.byPrimaryKey(userName, sshKeyId)).delete
}

View File

@@ -175,9 +175,9 @@ object SystemSettingsService {
fromName: Option[String])
case class SshAddress(
host:String,
port:Int,
genericUser:String)
host: String,
port: Int,
genericUser: String)
case class Lfs(
serverUrl: Option[String])

View File

@@ -2,7 +2,7 @@ package gitbucket.core.ssh
import gitbucket.core.model.Profile.profile.blockingApi._
import gitbucket.core.plugin.{GitRepositoryRouting, PluginRegistry}
import gitbucket.core.service.{AccountService, RepositoryService, SystemSettingsService}
import gitbucket.core.service.{AccountService, DeployKeyService, RepositoryService, SystemSettingsService}
import gitbucket.core.servlet.{CommitLogHook, Database}
import gitbucket.core.util.{ControlUtil, Directory}
import org.apache.sshd.server.{Command, CommandFactory, Environment, ExitCallback, SessionAware}
@@ -13,6 +13,7 @@ import java.io.{File, InputStream, OutputStream}
import ControlUtil._
import org.eclipse.jgit.api.Git
import Directory._
import gitbucket.core.ssh.PublicKeyAuthenticator.AuthType
import org.eclipse.jgit.transport.{ReceivePack, UploadPack}
import org.apache.sshd.server.scp.UnknownCommand
import org.eclipse.jgit.errors.RepositoryNotFoundException
@@ -25,34 +26,33 @@ object GitCommand {
abstract class GitCommand extends Command with SessionAware {
private val logger = LoggerFactory.getLogger(classOf[GitCommand])
@volatile protected var err: OutputStream = null
@volatile protected var in: InputStream = null
@volatile protected var out: OutputStream = null
@volatile protected var callback: ExitCallback = null
@volatile private var authUser:Option[String] = None
@volatile private var authType: Option[AuthType] = None
protected def runTask(authUser: String): Unit
protected def runTask(authType: AuthType): Unit
private def newTask(): Runnable = new Runnable {
override def run(): Unit = {
authUser match {
case Some(authUser) =>
try {
runTask(authUser)
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)
}
case None =>
val message = "User not authenticated"
logger.error(message)
callback.onExit(1, message)
}
private def newTask(): Runnable = () => {
authType match {
case Some(authType) =>
try {
runTask(authType)
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)
}
case None =>
val message = "User not authenticated"
logger.error(message)
callback.onExit(1, message)
}
}
@@ -79,32 +79,68 @@ abstract class GitCommand extends Command with SessionAware {
this.in = in
}
override def setSession(serverSession:ServerSession) {
this.authUser = PublicKeyAuthenticator.getUserName(serverSession)
override def setSession(serverSession: ServerSession) {
this.authType = PublicKeyAuthenticator.getAuthType(serverSession)
}
}
abstract class DefaultGitCommand(val owner: String, val repoName: String) extends GitCommand {
self: RepositoryService with AccountService =>
self: RepositoryService with AccountService with DeployKeyService =>
protected def isWritableUser(username: String, repositoryInfo: RepositoryService.RepositoryInfo)
(implicit session: Session): Boolean =
getAccountByUserName(username) match {
case Some(account) => hasDeveloperRole(repositoryInfo.owner, repositoryInfo.name, Some(account))
case None => false
protected def userName(authType: AuthType): String = {
authType match {
case AuthType.UserAuthType(userName) => userName
case AuthType.DeployKeyType(_) => owner
}
}
protected def isReadableUser(authType: AuthType, repositoryInfo: RepositoryService.RepositoryInfo)
(implicit session: Session): Boolean = {
authType match {
case AuthType.UserAuthType(username) => {
getAccountByUserName(username) match {
case Some(account) => hasGuestRole(owner, repoName, Some(account))
case None => false
}
}
case AuthType.DeployKeyType(key) => {
getDeployKeys(owner, repoName).filter(sshKey => SshUtil.str2PublicKey(sshKey.publicKey).exists(_ == key)) match {
case List(_) => true
case _ => false
}
}
}
}
protected def isWritableUser(authType: AuthType, repositoryInfo: RepositoryService.RepositoryInfo)
(implicit session: Session): Boolean = {
authType match {
case AuthType.UserAuthType(username) => {
getAccountByUserName(username) match {
case Some(account) => hasDeveloperRole(owner, repoName, Some(account))
case None => false
}
}
case AuthType.DeployKeyType(key) => {
getDeployKeys(owner, repoName).filter(sshKey => SshUtil.str2PublicKey(sshKey.publicKey).exists(_ == key)) match {
case List(x) if x.allowWrite => true
case _ => false
}
}
}
}
}
class DefaultGitUploadPack(owner: String, repoName: String) extends DefaultGitCommand(owner, repoName)
with RepositoryService with AccountService {
with RepositoryService with AccountService with DeployKeyService {
override protected def runTask(user: String): Unit = {
override protected def runTask(authType: AuthType): Unit = {
val execute = Database() withSession { implicit session =>
getRepository(owner, repoName.replaceFirst("\\.wiki\\Z", "")).map { repositoryInfo =>
!repositoryInfo.repository.isPrivate || isWritableUser(user, repositoryInfo)
!repositoryInfo.repository.isPrivate || isReadableUser(authType, repositoryInfo)
}.getOrElse(false)
}
@@ -119,12 +155,12 @@ class DefaultGitUploadPack(owner: String, repoName: String) extends DefaultGitCo
}
class DefaultGitReceivePack(owner: String, repoName: String, baseUrl: String) extends DefaultGitCommand(owner, repoName)
with RepositoryService with AccountService {
with RepositoryService with AccountService with DeployKeyService {
override protected def runTask(user: String): Unit = {
override protected def runTask(authType: AuthType): Unit = {
val execute = Database() withSession { implicit session =>
getRepository(owner, repoName.replaceFirst("\\.wiki\\Z", "")).map { repositoryInfo =>
isWritableUser(user, repositoryInfo)
isWritableUser(authType, repositoryInfo)
}.getOrElse(false)
}
@@ -133,7 +169,7 @@ class DefaultGitReceivePack(owner: String, repoName: String, baseUrl: String) ex
val repository = git.getRepository
val receive = new ReceivePack(repository)
if (!repoName.endsWith(".wiki")) {
val hook = new CommitLogHook(owner, repoName, user, baseUrl)
val hook = new CommitLogHook(owner, repoName, userName(authType), baseUrl)
receive.setPreReceiveHook(hook)
receive.setPostReceiveHook(hook)
}
@@ -143,12 +179,11 @@ class DefaultGitReceivePack(owner: String, repoName: String, baseUrl: String) ex
}
}
class PluginGitUploadPack(repoName: String, routing: GitRepositoryRouting) extends GitCommand
with SystemSettingsService {
class PluginGitUploadPack(repoName: String, routing: GitRepositoryRouting) extends GitCommand with SystemSettingsService {
override protected def runTask(user: String): Unit = {
override protected def runTask(authType: AuthType): Unit = {
val execute = Database() withSession { implicit session =>
routing.filter.filter("/" + repoName, Some(user), loadSystemSettings(), false)
routing.filter.filter("/" + repoName, AuthType.userName(authType), loadSystemSettings(), false)
}
if(execute){
@@ -162,13 +197,13 @@ class PluginGitUploadPack(repoName: String, routing: GitRepositoryRouting) exten
}
}
class PluginGitReceivePack(repoName: String, routing: GitRepositoryRouting) extends GitCommand
with SystemSettingsService {
class PluginGitReceivePack(repoName: String, routing: GitRepositoryRouting) extends GitCommand with SystemSettingsService {
override protected def runTask(user: String): Unit = {
override protected def runTask(authType: AuthType): Unit = {
val execute = Database() withSession { implicit session =>
routing.filter.filter("/" + repoName, Some(user), loadSystemSettings(), true)
routing.filter.filter("/" + repoName, AuthType.userName(authType), loadSystemSettings(), true)
}
if(execute){
val path = routing.urlPattern.r.replaceFirstIn(repoName, routing.localPath)
using(Git.open(new File(Directory.GitBucketHome, path))){ git =>

View File

@@ -2,73 +2,102 @@ package gitbucket.core.ssh
import java.security.PublicKey
import gitbucket.core.service.SshKeyService
import gitbucket.core.service.{DeployKeyService, SshKeyService}
import gitbucket.core.servlet.Database
import gitbucket.core.model.Profile.profile.blockingApi._
import gitbucket.core.ssh.PublicKeyAuthenticator.AuthType
import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator
import org.apache.sshd.server.session.ServerSession
import org.apache.sshd.common.AttributeStore
import org.slf4j.LoggerFactory
object PublicKeyAuthenticator {
// put in the ServerSession here to be read by GitCommand later
private val userNameSessionKey = new AttributeStore.AttributeKey[String]
private val authTypeSessionKey = new AttributeStore.AttributeKey[AuthType]
def putUserName(serverSession:ServerSession, userName:String):Unit =
serverSession.setAttribute(userNameSessionKey, userName)
def putAuthType(serverSession: ServerSession, authType: AuthType):Unit =
serverSession.setAttribute(authTypeSessionKey, authType)
def getUserName(serverSession:ServerSession):Option[String] =
Option(serverSession.getAttribute(userNameSessionKey))
def getAuthType(serverSession: ServerSession): Option[AuthType] =
Option(serverSession.getAttribute(authTypeSessionKey))
sealed trait AuthType
object AuthType {
case class UserAuthType(userName: String) extends AuthType
case class DeployKeyType(publicKey: PublicKey) extends AuthType
/**
* Retrieves username if authType is UserAuthType, otherwise None.
*/
def userName(authType: AuthType): Option[String] = {
authType match {
case UserAuthType(userName) => Some(userName)
case _ => None
}
}
}
}
class PublicKeyAuthenticator(genericUser:String) extends PublickeyAuthenticator with SshKeyService {
class PublicKeyAuthenticator(genericUser: String) extends PublickeyAuthenticator with SshKeyService with DeployKeyService {
private val logger = LoggerFactory.getLogger(classOf[PublicKeyAuthenticator])
override def authenticate(username: String, key: PublicKey, session: ServerSession): Boolean =
if (username == genericUser) authenticateGenericUser(username, key, session, genericUser)
else authenticateLoginUser(username, key, session)
private def authenticateLoginUser(username: String, key: PublicKey, session: ServerSession): Boolean = {
val authenticated =
Database()
.withSession { implicit dbSession => getPublicKeys(username) }
.map(_.publicKey)
.flatMap(SshUtil.str2PublicKey)
.contains(key)
if (authenticated) {
logger.info(s"authentication as ssh user ${username} succeeded")
PublicKeyAuthenticator.putUserName(session, username)
override def authenticate(username: String, key: PublicKey, session: ServerSession): Boolean = {
Database().withSession { implicit s =>
if (username == genericUser) {
authenticateGenericUser(username, key, session, genericUser)
} else {
authenticateLoginUser(username, key, session)
}
}
else {
logger.info(s"authentication as ssh user ${username} failed")
}
private def authenticateLoginUser(userName: String, key: PublicKey, session: ServerSession)(implicit s: Session): Boolean = {
val authenticated = getPublicKeys(userName).map(_.publicKey).flatMap(SshUtil.str2PublicKey).contains(key)
if (authenticated) {
logger.info(s"authentication as ssh user ${userName} succeeded")
PublicKeyAuthenticator.putAuthType(session, AuthType.UserAuthType(userName))
} else {
logger.info(s"authentication as ssh user ${userName} failed")
}
authenticated
}
private def authenticateGenericUser(username: String, key: PublicKey, session: ServerSession, genericUser:String): Boolean = {
private def authenticateGenericUser(userName: String, key: PublicKey, session: ServerSession, genericUser: String)(implicit s: Session): Boolean = {
// find all users having the key we got from ssh
val possibleUserNames =
Database()
.withSession { implicit dbSession => getAllKeys() }
.filter { sshKey =>
val possibleUserNames = getAllKeys().filter { sshKey =>
SshUtil.str2PublicKey(sshKey.publicKey).exists(_ == key)
}.map(_.userName).distinct
// determine the user - if different accounts share the same key, tough luck
val uniqueUserName = possibleUserNames match {
case List(name) => Some(name)
case _ => None
}
uniqueUserName.map { userName =>
// found public key for user
logger.info(s"authentication as generic user ${genericUser} succeeded, identified ${userName}")
PublicKeyAuthenticator.putAuthType(session, AuthType.UserAuthType(userName))
true
}.getOrElse {
// search deploy keys
val existsDeployKey = getAllDeployKeys().exists { sshKey =>
SshUtil.str2PublicKey(sshKey.publicKey).exists(_ == key)
}
.map(_.userName)
.distinct
// determine the user - if different accounts share the same key, tough luck
val uniqueUserName =
possibleUserNames match {
case List() =>
logger.info(s"authentication as generic user ${genericUser} failed, public key not found")
None
case List(name) =>
logger.info(s"authentication as generic user ${genericUser} succeeded, identified ${name}")
Some(name)
case _ =>
logger.info(s"authentication as generic user ${genericUser} failed, public key is ambiguous")
None
if(existsDeployKey){
// found deploy key for repository
PublicKeyAuthenticator.putAuthType(session, AuthType.DeployKeyType(key))
logger.info(s"authentication as generic user ${genericUser} succeeded, deploy key was found")
true
} else {
// public key not found
logger.info(s"authentication by generic user ${genericUser} failed")
false
}
uniqueUserName.foreach(PublicKeyAuthenticator.putUserName(session, _))
uniqueUserName.isDefined
}
}
}

View File

@@ -20,7 +20,7 @@
</div>
<form method="POST" action="@context.path/@account.userName/_ssh" validate="true">
<div class="panel panel-default">
<div class="panel-heading strong">Add an SSH Key</div>
<div class="panel-heading strong">Add a SSH Key</div>
<div class="panel-body">
<fieldset class="form-group">
<label for="title" class="strong">Title</label>
@@ -30,7 +30,7 @@
<fieldset class="form-group">
<label for="publicKey" class="strong">Key</label>
<div><span id="error-publicKey" class="error"></span></div>
<textarea name="publicKey" id="publicKey" class="form-control" style="height: 250px;"></textarea>
<textarea name="publicKey" id="publicKey" class="form-control" style="height: 200px;"></textarea>
</fieldset>
<input type="submit" class="btn btn-success" value="Add"/>
</div>

View File

@@ -0,0 +1,50 @@
@(repository: gitbucket.core.service.RepositoryService.RepositoryInfo, deployKeys: List[gitbucket.core.model.DeployKey])(implicit context: gitbucket.core.controller.Context)
@import gitbucket.core.ssh.SshUtil
@import gitbucket.core.view.helpers
@gitbucket.core.html.main("Deploy keys", Some(repository)){
@gitbucket.core.html.menu("settings", repository){
@gitbucket.core.settings.html.menu("deploykeys", repository){
<div class="panel panel-default">
<div class="panel-heading strong">Deploy keys</div>
<div class="panel-body">
@if(deployKeys.isEmpty){
No keys
}
@deployKeys.zipWithIndex.map { case (key, i) =>
@if(i != 0){
<hr>
}
<strong style="line-height: 30px;">@key.title</strong> (@SshUtil.fingerPrint(key.publicKey).getOrElse("Key is invalid."))
@if(key.allowWrite){
<label class="label label-warning">Write access</label>
}
<a href="@helpers.url(repository)/settings/deploykey/delete/@key.deployKeyId" class="btn btn-sm btn-danger pull-right">Delete</a>
}
</div>
</div>
<form method="POST" action="@helpers.url(repository)/settings/deploykey" validate="true">
<div class="panel panel-default">
<div class="panel-heading strong">Add a deploy key</div>
<div class="panel-body">
<fieldset class="form-group">
<label for="title" class="strong">Title</label>
<div><span id="error-title" class="error"></span></div>
<input type="text" name="title" id="title" class="form-control"/>
</fieldset>
<fieldset class="form-group">
<label for="publicKey" class="strong">Key</label>
<div><span id="error-publicKey" class="error"></span></div>
<textarea name="publicKey" id="publicKey" class="form-control" style="height: 200px;"></textarea>
</fieldset>
<fieldset class="form-group">
<label>
<input type="checkbox" name="allowWrite"> Allow write access
</label>
</fieldset>
<input type="submit" class="btn btn-success" value="Add"/>
</div>
</div>
</form>
}
}
}

View File

@@ -15,6 +15,9 @@
<li@if(active=="hooks"){ class="active"}>
<a href="@helpers.url(repository)/settings/hooks">Service Hooks</a>
</li>
<li@if(active=="deploykeys"){ class="active"}>
<a href="@helpers.url(repository)/settings/deploykey">Deploy Keys</a>
</li>
<li@if(active=="danger"){ class="active"}>
<a href="@helpers.url(repository)/settings/danger">Danger Zone</a>
</li>