All Downloads are FREE. Search and download functionalities are using the official Maven repository.

gitbucket.core.controller.RepositorySettingsController.scala Maven / Gradle / Ivy

The newest version!
package gitbucket.core.controller

import java.time.{LocalDateTime, ZoneOffset}
import java.util.Date
import gitbucket.core.settings.html
import gitbucket.core.model.{RepositoryWebHook, WebHook}
import gitbucket.core.service._
import gitbucket.core.service.WebHookService._
import gitbucket.core.util._
import gitbucket.core.util.JGitUtil._
import gitbucket.core.util.SyntaxSugars._
import gitbucket.core.util.Implicits._
import gitbucket.core.util.Directory._
import gitbucket.core.model.WebHookContentType
import gitbucket.core.model.activity.RenameRepositoryInfo
import org.scalatra.forms._
import org.scalatra.i18n.Messages
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.lib.Constants
import org.eclipse.jgit.lib.ObjectId

import scala.util.Using
import org.scalatra.{Forbidden, Ok}

class RepositorySettingsController
    extends RepositorySettingsControllerBase
    with RepositoryService
    with AccountService
    with WebHookService
    with ProtectedBranchService
    with CommitStatusService
    with DeployKeyService
    with CustomFieldsService
    with ActivityService
    with OwnerAuthenticator
    with UsersAuthenticator
    with RequestCache

trait RepositorySettingsControllerBase extends ControllerBase {
  self: RepositoryService
    with AccountService
    with WebHookService
    with ProtectedBranchService
    with CommitStatusService
    with DeployKeyService
    with CustomFieldsService
    with ActivityService
    with OwnerAuthenticator
    with UsersAuthenticator =>

  // for repository options
  case class OptionsForm(
    description: Option[String],
    isPrivate: Boolean,
    issuesOption: String,
    externalIssuesUrl: Option[String],
    wikiOption: String,
    externalWikiUrl: Option[String],
    allowFork: Boolean,
    mergeOptions: Seq[String],
    defaultMergeOption: String,
    safeMode: Boolean
  )

  val optionsForm = mapping(
    "description" -> trim(label("Description", optional(text()))),
    "isPrivate" -> trim(label("Repository Type", boolean())),
    "issuesOption" -> trim(label("Issues Option", text(required, featureOption))),
    "externalIssuesUrl" -> trim(label("External Issues URL", optional(text(maxlength(200))))),
    "wikiOption" -> trim(label("Wiki Option", text(required, featureOption))),
    "externalWikiUrl" -> trim(label("External Wiki URL", optional(text(maxlength(200))))),
    "allowFork" -> trim(label("Allow Forking", boolean())),
    "mergeOptions" -> mergeOptions,
    "defaultMergeOption" -> trim(label("Default merge strategy", text(required))),
    "safeMode" -> trim(label("XSS protection", boolean()))
  )(OptionsForm.apply).verifying { form =>
    if (!form.mergeOptions.contains(form.defaultMergeOption)) {
      Seq("defaultMergeOption" -> s"This merge strategy isn't enabled.")
    } else Nil
  }

  // for default branch
  case class DefaultBranchForm(defaultBranch: String)

  val defaultBranchForm = mapping(
    "defaultBranch" -> trim(label("Default Branch", text(required, maxlength(100))))
  )(DefaultBranchForm.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" -> trim2(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])

  def webHookForm(update: Boolean) =
    mapping(
      "url" -> trim(label("url", text(required, webHook(update)))),
      "events" -> webhookEvents,
      "ctype" -> label("ctype", text()),
      "token" -> optional(trim(label("token", text(maxlength(100)))))
    )((url, events, ctype, token) => WebHookForm(url, events, WebHookContentType.valueOf(ctype), token))

  // for rename repository
  case class RenameRepositoryForm(repositoryName: String)

  val renameForm = mapping(
    "repositoryName" -> trim(
      label("New repository name", text(required, maxlength(100), repository, renameRepositoryName))
    )
  )(RenameRepositoryForm.apply)

  // for transfer ownership
  case class TransferOwnerShipForm(newOwner: String)

  val transferForm = mapping(
    "newOwner" -> trim(label("New owner", text(required, transferUser)))
  )(TransferOwnerShipForm.apply)

  // for custom field
  case class CustomFieldForm(
    fieldName: String,
    fieldType: String,
    constraints: Option[String],
    enableForIssues: Boolean,
    enableForPullRequests: Boolean
  )

  val customFieldForm = mapping(
    "fieldName" -> trim(label("Field name", text(required, maxlength(100)))),
    "fieldType" -> trim(label("Field type", text(required))),
    "constraints" -> trim(label("Constraints", optional(text()))),
    "enableForIssues" -> trim(label("Enable for issues", boolean(required))),
    "enableForPullRequests" -> trim(label("Enable for pull requests", boolean(required))),
  )(CustomFieldForm.apply)

  /**
   * Redirect to the Options page.
   */
  get("/:owner/:repository/settings")(ownerOnly { repository =>
    redirect(s"/${repository.owner}/${repository.name}/settings/options")
  })

  /**
   * Display the Options page.
   */
  get("/:owner/:repository/settings/options")(ownerOnly {
    html.options(_, flash.get("info"))
  })

  /**
   * Save the repository options.
   */
  post("/:owner/:repository/settings/options", optionsForm)(ownerOnly { (form, repository) =>
    saveRepositoryOptions(
      repository.owner,
      repository.name,
      form.description,
      repository.repository.parentUserName.map { _ =>
        repository.repository.isPrivate
      } getOrElse form.isPrivate,
      form.issuesOption,
      form.externalIssuesUrl,
      form.wikiOption,
      form.externalWikiUrl,
      form.allowFork,
      form.mergeOptions,
      form.defaultMergeOption,
      form.safeMode
    )
    flash.update("info", "Repository settings has been updated.")
    redirect(s"/${repository.owner}/${repository.name}/settings/options")
  })

  /** branch settings */
  get("/:owner/:repository/settings/branches")(ownerOnly { repository =>
    val protecteions = getProtectedBranchList(repository.owner, repository.name)
    html.branches(repository, protecteions, flash.get("info"))
  })

  /** Update default branch */
  post("/:owner/:repository/settings/update_default_branch", defaultBranchForm)(ownerOnly { (form, repository) =>
    if (!repository.branchList.contains(form.defaultBranch)) {
      redirect(s"/${repository.owner}/${repository.name}/settings/options")
    } else {
      saveRepositoryDefaultBranch(repository.owner, repository.name, form.defaultBranch)
      // Change repository HEAD
      Using.resource(Git.open(getRepositoryDir(repository.owner, repository.name))) { git =>
        git.getRepository.updateRef(Constants.HEAD, true).link(Constants.R_HEADS + form.defaultBranch)
      }
      flash.update("info", "Repository default branch has been updated.")
      redirect(s"/${repository.owner}/${repository.name}/settings/branches")
    }
  })

  /** Branch protection for branch */
  get("/:owner/:repository/settings/branches/*")(ownerOnly { repository =>
    import gitbucket.core.api._
    val branch = params("splat")

    if (!repository.branchList.contains(branch)) {
      redirect(s"/${repository.owner}/${repository.name}/settings/branches")
    } else {
      val protection = ApiBranchProtection(getProtectedBranchInfo(repository.owner, repository.name, branch))
      val lastWeeks = getRecentStatusContexts(
        repository.owner,
        repository.name,
        Date.from(LocalDateTime.now.minusWeeks(1).toInstant(ZoneOffset.UTC))
      ).toSet
      val knownContexts = (lastWeeks ++ protection.status.contexts).toSeq.sortBy(identity)
      html.branchprotection(repository, branch, protection, knownContexts, flash.get("info"))
    }
  })

  /**
   * Display the Collaborators page.
   */
  get("/:owner/:repository/settings/collaborators")(ownerOnly { repository =>
    html.collaborators(
      getCollaborators(repository.owner, repository.name),
      getAccountByUserName(repository.owner).get.isGroupAccount,
      repository
    )
  })

  post("/:owner/:repository/settings/collaborators")(ownerOnly { repository =>
    val collaborators = params("collaborators")
    removeCollaborators(repository.owner, repository.name)
    collaborators.split(",").withFilter(_.nonEmpty).foreach { collaborator =>
      val userName :: role :: Nil = collaborator.split(":").toList
      addCollaborator(repository.owner, repository.name, userName, role)
    }
    redirect(s"/${repository.owner}/${repository.name}/settings/collaborators")
  })

  /**
   * Display the web hook page.
   */
  get("/:owner/:repository/settings/hooks")(ownerOnly { repository =>
    html.hooks(getWebHooks(repository.owner, repository.name), repository, flash.get("info"))
  })

  /**
   * Display the web hook edit page.
   */
  get("/:owner/:repository/settings/hooks/new")(ownerOnly { repository =>
    val webhook = RepositoryWebHook(
      userName = repository.owner,
      repositoryName = repository.name,
      url = "",
      ctype = WebHookContentType.FORM,
      token = None
    )
    html.edithook(webhook, Set(WebHook.Push), repository, true)
  })

  /**
   * Add the web hook URL.
   */
  post("/:owner/:repository/settings/hooks/new", webHookForm(false))(ownerOnly { (form, repository) =>
    addWebHook(repository.owner, repository.name, form.url, form.events, form.ctype, form.token)
    flash.update("info", s"Webhook ${form.url} created")
    redirect(s"/${repository.owner}/${repository.name}/settings/hooks")
  })

  /**
   * Delete the web hook URL.
   */
  get("/:owner/:repository/settings/hooks/delete")(ownerOnly { repository =>
    deleteWebHook(repository.owner, repository.name, params("url"))
    flash.update("info", s"Webhook ${params("url")} deleted")
    redirect(s"/${repository.owner}/${repository.name}/settings/hooks")
  })

  /**
   * Send the test request to registered web hook URLs.
   */
  ajaxPost("/:owner/:repository/settings/hooks/test")(ownerOnly { repository =>
    def _headers(h: Array[org.apache.http.Header]): Array[Array[String]] =
      h.map { h =>
        Array(h.getName, h.getValue)
      }

    Using.resource(Git.open(getRepositoryDir(repository.owner, repository.name))) { git =>
      import scala.concurrent.duration._
      import scala.concurrent._
      import scala.jdk.CollectionConverters._
      import scala.util.control.NonFatal
      import org.apache.http.util.EntityUtils
      import scala.concurrent.ExecutionContext.Implicits.global

      val url = params("url")
      val token = Some(params("token"))
      val ctype = WebHookContentType.valueOf(params("ctype"))
      val dummyWebHookInfo = RepositoryWebHook(
        userName = repository.owner,
        repositoryName = repository.name,
        url = url,
        ctype = ctype,
        token = token
      )
      val dummyPayload = {
        val ownerAccount = getAccountByUserName(repository.owner).get
        val commits =
          if (JGitUtil.isEmpty(git)) List.empty
          else
            git.log
              .add(git.getRepository.resolve(repository.repository.defaultBranch))
              .setMaxCount(4)
              .call
              .iterator
              .asScala
              .map(new CommitInfo(_))
              .toList
        val pushedCommit = commits.drop(1)

        WebHookPushPayload(
          git = git,
          sender = ownerAccount,
          refName = "refs/heads/" + repository.repository.defaultBranch,
          repositoryInfo = repository,
          commits = pushedCommit,
          repositoryOwner = ownerAccount,
          oldId = commits.lastOption.map(_.id).map(ObjectId.fromString).getOrElse(ObjectId.zeroId()),
          newId = commits.headOption.map(_.id).map(ObjectId.fromString).getOrElse(ObjectId.zeroId())
        )
      }

      val (webHook, json, reqFuture, resFuture) =
        callWebHook(WebHook.Push, List(dummyWebHookInfo), dummyPayload, context.settings).head

      val toErrorMap: PartialFunction[Throwable, Map[String, String]] = {
        case e: java.net.UnknownHostException                  => Map("error" -> ("Unknown host " + e.getMessage))
        case e: java.lang.IllegalArgumentException             => Map("error" -> ("invalid url"))
        case e: org.apache.http.client.ClientProtocolException => Map("error" -> ("invalid url"))
        case NonFatal(e)                                       => Map("error" -> (s"${e.getClass} ${e.getMessage}"))
      }

      contentType = formats("json")
      org.json4s.jackson.Serialization.write(
        Map(
          "url" -> url,
          "request" -> Await.result(
            reqFuture
              .map(req =>
                Map(
                  "headers" -> _headers(req.getAllHeaders),
                  "payload" -> json
                )
              )
              .recover(toErrorMap),
            20 seconds
          ),
          "response" -> Await.result(
            resFuture
              .map(res =>
                Map(
                  "status" -> res.getStatusLine.getStatusCode,
                  "body" -> EntityUtils.toString(res.getEntity()),
                  "headers" -> _headers(res.getAllHeaders())
                )
              )
              .recover(toErrorMap),
            20 seconds
          )
        )
      )
    }
  })

  /**
   * Display the web hook edit page.
   */
  get("/:owner/:repository/settings/hooks/edit")(ownerOnly { repository =>
    getWebHook(repository.owner, repository.name, params("url")).map { case (webhook, events) =>
      html.edithook(webhook, events, repository, false)
    } getOrElse NotFound()
  })

  /**
   * Update web hook settings.
   */
  post("/:owner/:repository/settings/hooks/edit", webHookForm(true))(ownerOnly { (form, repository) =>
    updateWebHook(repository.owner, repository.name, form.url, form.events, form.ctype, form.token)
    flash.update("info", s"webhook ${form.url} updated")
    redirect(s"/${repository.owner}/${repository.name}/settings/hooks")
  })

  /**
   * Display the danger zone.
   */
  get("/:owner/:repository/settings/danger")(ownerOnly {
    html.danger(_, flash.get("info"))
  })

  /**
   * Rename repository.
   */
  post("/:owner/:repository/settings/rename", renameForm)(ownerOnly { (form, repository) =>
    context.withLoginAccount { loginAccount =>
      if (context.settings.basicBehavior.repositoryOperation.rename || loginAccount.isAdmin) {
        if (repository.name != form.repositoryName) {
          // Update database and move git repository
          renameRepository(repository.owner, repository.name, repository.owner, form.repositoryName)
          // Record activity log
          val renameInfo = RenameRepositoryInfo(
            repository.owner,
            form.repositoryName,
            loginAccount.userName,
            repository.name
          )
          recordActivity(renameInfo)
        }
        redirect(s"/${repository.owner}/${form.repositoryName}")
      } else Forbidden()
    }
  })

  /**
   * Transfer repository ownership.
   */
  post("/:owner/:repository/settings/transfer", transferForm)(ownerOnly { (form, repository) =>
    context.withLoginAccount { loginAccount =>
      if (context.settings.basicBehavior.repositoryOperation.transfer || loginAccount.isAdmin) {
        // Change repository owner
        if (repository.owner != form.newOwner) {
          // Update database and move git repository
          renameRepository(repository.owner, repository.name, form.newOwner, repository.name)
          // Record activity log
          val renameInfo = RenameRepositoryInfo(
            form.newOwner,
            repository.name,
            loginAccount.userName,
            repository.owner
          )
          recordActivity(renameInfo)
        }
        redirect(s"/${form.newOwner}/${repository.name}")
      } else Forbidden()
    }
  })

  /**
   * Delete the repository.
   */
  post("/:owner/:repository/settings/delete")(ownerOnly { repository =>
    context.withLoginAccount { loginAccount =>
      if (context.settings.basicBehavior.repositoryOperation.delete || loginAccount.isAdmin) {
        // Delete the repository and related files
        deleteRepository(repository.repository)
        redirect(s"/${repository.owner}")
      } else Forbidden()
    }
  })

  /**
   * Run GC
   */
  post("/:owner/:repository/settings/gc")(ownerOnly { repository =>
    LockUtil.lock(s"${repository.owner}/${repository.name}") {
      Using.resource(Git.open(getRepositoryDir(repository.owner, repository.name))) { git =>
        git.gc().call()
      }
    }
    flash.update("info", "Garbage collection has been executed.")
    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")
  })

  /** Custom fields for issues and pull requests */
  get("/:owner/:repository/settings/issues")(ownerOnly { repository =>
    val customFields = getCustomFields(repository.owner, repository.name)
    html.issues(customFields, repository)
  })

  /** New custom field form */
  get("/:owner/:repository/settings/issues/fields/new")(ownerOnly { repository =>
    html.issuesfieldform(None, repository)
  })

  /** Add custom field */
  ajaxPost("/:owner/:repository/settings/issues/fields/new", customFieldForm)(ownerOnly { (form, repository) =>
    val fieldId = createCustomField(
      repository.owner,
      repository.name,
      form.fieldName,
      form.fieldType,
      if (form.fieldType == "enum") form.constraints else None,
      form.enableForIssues,
      form.enableForPullRequests
    )
    html.issuesfield(getCustomField(repository.owner, repository.name, fieldId).get)
  })

  /** Edit custom field form */
  ajaxGet("/:owner/:repository/settings/issues/fields/:fieldId/edit")(ownerOnly { repository =>
    getCustomField(repository.owner, repository.name, params("fieldId").toInt).map { customField =>
      html.issuesfieldform(Some(customField), repository)
    } getOrElse NotFound()
  })

  /** Update custom field */
  ajaxPost("/:owner/:repository/settings/issues/fields/:fieldId/edit", customFieldForm)(ownerOnly {
    (form, repository) =>
      updateCustomField(
        repository.owner,
        repository.name,
        params("fieldId").toInt,
        form.fieldName,
        form.fieldType,
        if (form.fieldType == "enum") form.constraints else None,
        form.enableForIssues,
        form.enableForPullRequests
      )
      html.issuesfield(getCustomField(repository.owner, repository.name, params("fieldId").toInt).get)
  })

  /** Delete custom field */
  ajaxPost("/:owner/:repository/settings/issues/fields/:fieldId/delete")(ownerOnly { repository =>
    deleteCustomField(repository.owner, repository.name, params("fieldId").toInt)
    Ok()
  })

  /**
   * Provides duplication check for web hook url.
   */
  private def webHook(needExists: Boolean): Constraint =
    new Constraint() {
      override def validate(name: String, value: String, messages: Messages): Option[String] =
        if (getWebHook(params("owner"), params("repository"), value).isDefined != needExists) {
          Some(if (needExists) {
            "URL had not been registered yet."
          } else {
            "URL had been registered already."
          })
        } else {
          None
        }
    }

  private def webhookEvents =
    new ValueType[Set[WebHook.Event]] {
      def convert(name: String, params: Map[String, Seq[String]], messages: Messages): Set[WebHook.Event] = {
        WebHook.Event.values.flatMap { t =>
          params.get(name + "." + t.name).map(_ => t)
        }.toSet
      }
      def validate(name: String, params: Map[String, Seq[String]], messages: Messages): Seq[(String, String)] =
        if (convert(name, params, messages).isEmpty) {
          Seq(name -> messages("error.required").format(name))
        } else {
          Nil
        }
    }

//  /**
//   * Provides Constraint to validate the collaborator name.
//   */
//  private def collaborator: Constraint = new Constraint(){
//    override def validate(name: String, value: String, messages: Messages): Option[String] =
//      getAccountByUserName(value) match {
//        case None => Some("User does not exist.")
////        case Some(x) if(x.isGroupAccount)
////                  => Some("User does not exist.")
//        case Some(x) if(x.userName == params("owner") || getCollaborators(params("owner"), params("repository")).contains(x.userName))
//                  => Some(value + " is repository owner.") // TODO also group members?
//        case _    => None
//      }
//  }

  /**
   * Duplicate check for the rename repository name.
   */
  private def renameRepositoryName: Constraint =
    new Constraint() {
      override def validate(
        name: String,
        value: String,
        params: Map[String, Seq[String]],
        messages: Messages
      ): Option[String] = {
        for {
          repoName <- params.optionValue("repository") if repoName != value
          userName <- params.optionValue("owner")
          _ <- getRepositoryNamesOfUser(userName).find(_ == value)
        } yield {
          "Repository already exists."
        }
      }
    }

  /**
   */
  private def featureOption: Constraint =
    new Constraint() {
      override def validate(
        name: String,
        value: String,
        params: Map[String, Seq[String]],
        messages: Messages
      ): Option[String] =
        if (Seq("DISABLE", "PRIVATE", "PUBLIC", "ALL").contains(value)) None else Some("Option is invalid.")
    }

  /**
   * Provides Constraint to validate the repository transfer user.
   */
  private def transferUser: Constraint =
    new Constraint() {
      override def validate(name: String, value: String, messages: Messages): Option[String] =
        getAccountByUserName(value) match {
          case None => Some("User does not exist.")
          case Some(x) =>
            if (x.userName == params("owner")) {
              Some("This is current repository owner.")
            } else {
              params.get("repository").flatMap { repositoryName =>
                getRepositoryNamesOfUser(x.userName).find(_ == repositoryName).map { _ =>
                  "User already has same repository."
                }
              }
            }
        }
    }

  private def mergeOptions =
    new ValueType[Seq[String]] {
      override def convert(name: String, params: Map[String, Seq[String]], messages: Messages): Seq[String] = {
        params.getOrElse("mergeOptions", Nil)
      }
      override def validate(
        name: String,
        params: Map[String, Seq[String]],
        messages: Messages
      ): Seq[(String, String)] = {
        val mergeOptions = params.getOrElse("mergeOptions", Nil)
        if (mergeOptions.isEmpty) {
          Seq("mergeOptions" -> "At least one option must be enabled.")
        } else if (!mergeOptions.forall(x => Seq("merge-commit", "squash", "rebase").contains(x))) {
          Seq("mergeOptions" -> "mergeOptions are invalid.")
        } else {
          Nil
        }
      }
    }

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy