Newer
Older
GitBucket / src / main / scala / gitbucket / core / controller / SystemSettingsController.scala
@Hidetake Iwata Hidetake Iwata on 24 Jan 2018 21 KB Add OpenID Connect authentication feature
package gitbucket.core.controller

import java.io.FileInputStream

import com.github.zafarkhaja.semver.{Version => Semver}
import gitbucket.core.GitBucketCoreModule
import gitbucket.core.admin.html
import gitbucket.core.plugin.{PluginInfoBase, PluginRegistry, PluginRepository}
import gitbucket.core.service.SystemSettingsService._
import gitbucket.core.service.{AccountService, RepositoryService}
import gitbucket.core.ssh.SshServer
import gitbucket.core.util.Directory._
import gitbucket.core.util.Implicits._
import gitbucket.core.util.StringUtil._
import gitbucket.core.util.SyntaxSugars._
import gitbucket.core.util.{AdminAuthenticator, Mailer}
import org.apache.commons.io.IOUtils
import org.json4s.jackson.Serialization
import org.scalatra._
import org.scalatra.forms._
import org.scalatra.i18n.Messages

import scala.collection.JavaConverters._
import scala.collection.mutable.ListBuffer

class SystemSettingsController extends SystemSettingsControllerBase
  with AccountService with RepositoryService with AdminAuthenticator

case class Table(name: String, columns: Seq[Column])
case class Column(name: String, primaryKey: Boolean)

trait SystemSettingsControllerBase extends AccountManagementControllerBase {
  self: AccountService with RepositoryService with AdminAuthenticator =>

  private val form = mapping(
    "baseUrl"                  -> trim(label("Base URL", optional(text()))),
    "information"              -> trim(label("Information", optional(text()))),
    "allowAccountRegistration" -> trim(label("Account registration", boolean())),
    "allowAnonymousAccess"     -> trim(label("Anonymous access", boolean())),
    "isCreateRepoOptionPublic" -> trim(label("Default option to create a new repository", boolean())),
    "gravatar"                 -> trim(label("Gravatar", boolean())),
    "notification"             -> trim(label("Notification", boolean())),
    "activityLogLimit"         -> trim(label("Limit of activity logs", optional(number()))),
    "ssh"                      -> trim(label("SSH access", boolean())),
    "sshHost"                  -> trim(label("SSH host", optional(text()))),
    "sshPort"                  -> trim(label("SSH port", optional(number()))),
    "useSMTP"                  -> trim(label("SMTP", boolean())),
    "smtp"                     -> optionalIfNotChecked("useSMTP", mapping(
        "host"                     -> trim(label("SMTP Host", text(required))),
        "port"                     -> trim(label("SMTP Port", optional(number()))),
        "user"                     -> trim(label("SMTP User", optional(text()))),
        "password"                 -> trim(label("SMTP Password", optional(text()))),
        "ssl"                      -> trim(label("Enable SSL", optional(boolean()))),
        "starttls"                 -> trim(label("Enable STARTTLS", optional(boolean()))),
        "fromAddress"              -> trim(label("FROM Address", optional(text()))),
        "fromName"                 -> trim(label("FROM Name", optional(text())))
    )(Smtp.apply)),
    "ldapAuthentication"       -> trim(label("LDAP", boolean())),
    "ldap"                     -> optionalIfNotChecked("ldapAuthentication", mapping(
        "host"                     -> trim(label("LDAP host", text(required))),
        "port"                     -> trim(label("LDAP port", optional(number()))),
        "bindDN"                   -> trim(label("Bind DN", optional(text()))),
        "bindPassword"             -> trim(label("Bind Password", optional(text()))),
        "baseDN"                   -> trim(label("Base DN", text(required))),
        "userNameAttribute"        -> trim(label("User name attribute", text(required))),
        "additionalFilterCondition"-> trim(label("Additional filter condition", optional(text()))),
        "fullNameAttribute"        -> trim(label("Full name attribute", optional(text()))),
        "mailAttribute"            -> trim(label("Mail address attribute", optional(text()))),
        "tls"                      -> trim(label("Enable TLS", optional(boolean()))),
        "ssl"                      -> trim(label("Enable SSL", optional(boolean()))),
        "keystore"                 -> trim(label("Keystore", optional(text())))
    )(Ldap.apply)),
    "oidcAuthentication"       -> trim(label("OIDC", boolean())),
    "oidc"                     -> optionalIfNotChecked("oidcAuthentication", mapping(
        "issuer"                   -> trim(label("Issuer", text(required))),
        "clientID"                 -> trim(label("Client ID", text(required))),
        "clientSecret"             -> trim(label("Client secret", text(required))),
        "jwsAlgorithm"             -> trim(label("Signature algorithm", optional(text())))
    )(OIDC.apply)),
    "skinName" -> trim(label("AdminLTE skin name", text(required)))
  )(SystemSettings.apply).verifying { settings =>
    Vector(
      if(settings.ssh && settings.baseUrl.isEmpty){
        Some("baseUrl" -> "Base URL is required if SSH access is enabled.")
      } else None,
      if(settings.ssh && settings.sshHost.isEmpty){
        Some("sshHost" -> "SSH host is required if SSH access is enabled.")
      } else None
    ).flatten
  }

  private val sendMailForm = mapping(
    "smtp"        -> mapping(
      "host"        -> trim(label("SMTP Host", text(required))),
      "port"        -> trim(label("SMTP Port", optional(number()))),
      "user"        -> trim(label("SMTP User", optional(text()))),
      "password"    -> trim(label("SMTP Password", optional(text()))),
      "ssl"         -> trim(label("Enable SSL", optional(boolean()))),
      "starttls"    -> trim(label("Enable STARTTLS", optional(boolean()))),
      "fromAddress" -> trim(label("FROM Address", optional(text()))),
      "fromName"    -> trim(label("FROM Name", optional(text())))
    )(Smtp.apply),
    "testAddress" -> trim(label("", text(required)))
  )(SendMailForm.apply)

  case class SendMailForm(smtp: Smtp, testAddress: String)

  case class DataExportForm(tableNames: List[String])

  case class NewUserForm(userName: String, password: String, fullName: String,
                         mailAddress: String, isAdmin: Boolean,
                         description: Option[String], url: Option[String], fileId: Option[String])

  case class EditUserForm(userName: String, password: Option[String], fullName: String,
                          mailAddress: String, isAdmin: Boolean, description: Option[String], url: Option[String],
                          fileId: Option[String], clearImage: Boolean, isRemoved: Boolean)

  case class NewGroupForm(groupName: String, description: Option[String], url: Option[String], fileId: Option[String],
                          members: String)

  case class EditGroupForm(groupName: String, description: Option[String], url: Option[String], fileId: Option[String],
                           members: String, clearImage: Boolean, isRemoved: Boolean)


  val newUserForm = mapping(
    "userName"    -> trim(label("Username"     ,text(required, maxlength(100), identifier, uniqueUserName, reservedNames))),
    "password"    -> trim(label("Password"     ,text(required, maxlength(20), password))),
    "fullName"    -> trim(label("Full Name"    ,text(required, maxlength(100)))),
    "mailAddress" -> trim(label("Mail Address" ,text(required, maxlength(100), uniqueMailAddress()))),
    "isAdmin"     -> trim(label("User Type"    ,boolean())),
    "description" -> trim(label("bio"          ,optional(text()))),
    "url"         -> trim(label("URL"          ,optional(text(maxlength(200))))),
    "fileId"      -> trim(label("File ID"      ,optional(text())))
  )(NewUserForm.apply)

  val editUserForm = mapping(
    "userName"    -> trim(label("Username"     ,text(required, maxlength(100), identifier))),
    "password"    -> trim(label("Password"     ,optional(text(maxlength(20), password)))),
    "fullName"    -> trim(label("Full Name"    ,text(required, maxlength(100)))),
    "mailAddress" -> trim(label("Mail Address" ,text(required, maxlength(100), uniqueMailAddress("userName")))),
    "isAdmin"     -> trim(label("User Type"    ,boolean())),
    "description" -> trim(label("bio"          ,optional(text()))),
    "url"         -> trim(label("URL"          ,optional(text(maxlength(200))))),
    "fileId"      -> trim(label("File ID"      ,optional(text()))),
    "clearImage"  -> trim(label("Clear image"  ,boolean())),
    "removed"     -> trim(label("Disable"      ,boolean(disableByNotYourself("userName"))))
  )(EditUserForm.apply)

  val newGroupForm = mapping(
    "groupName" -> trim(label("Group name" ,text(required, maxlength(100), identifier, uniqueUserName, reservedNames))),
    "description" -> trim(label("Group description", optional(text()))),
    "url"       -> trim(label("URL"        ,optional(text(maxlength(200))))),
    "fileId"    -> trim(label("File ID"    ,optional(text()))),
    "members"   -> trim(label("Members"    ,text(required, members)))
  )(NewGroupForm.apply)

  val editGroupForm = mapping(
    "groupName"  -> trim(label("Group name"  ,text(required, maxlength(100), identifier))),
    "description" -> trim(label("Group description", optional(text()))),
    "url"        -> trim(label("URL"         ,optional(text(maxlength(200))))),
    "fileId"     -> trim(label("File ID"     ,optional(text()))),
    "members"    -> trim(label("Members"     ,text(required, members))),
    "clearImage" -> trim(label("Clear image" ,boolean())),
    "removed"    -> trim(label("Disable"     ,boolean()))
  )(EditGroupForm.apply)


  get("/admin/dbviewer")(adminOnly {
    val conn = request2Session(request).conn
    val meta = conn.getMetaData
    val tables = ListBuffer[Table]()
    using(meta.getTables(null, "%", "%", Array("TABLE", "VIEW"))){ rs =>
      while(rs.next()){
        val tableName = rs.getString("TABLE_NAME")

        val pkColumns = ListBuffer[String]()
        using(meta.getPrimaryKeys(null, null, tableName)){ rs =>
          while(rs.next()){
            pkColumns += rs.getString("COLUMN_NAME").toUpperCase
          }
        }

        val columns = ListBuffer[Column]()
        using(meta.getColumns(null, "%", tableName, "%")){ rs =>
          while(rs.next()){
            val columnName = rs.getString("COLUMN_NAME").toUpperCase
            columns += Column(columnName, pkColumns.contains(columnName))
          }
        }

        tables += Table(tableName.toUpperCase, columns)
      }
    }
    html.dbviewer(tables)
  })

  post("/admin/dbviewer/_query")(adminOnly {
    contentType = formats("json")
    params.get("query").collectFirst { case query if query.trim.nonEmpty =>
      val trimmedQuery = query.trim
      if(trimmedQuery.nonEmpty){
        try {
          val conn = request2Session(request).conn
          using(conn.prepareStatement(query)){ stmt =>
            if(trimmedQuery.toUpperCase.startsWith("SELECT")){
              using(stmt.executeQuery()){ rs =>
                val meta = rs.getMetaData
                val columns = for(i <- 1 to meta.getColumnCount) yield {
                  meta.getColumnName(i)
                }
                val result = ListBuffer[Map[String, String]]()
                while(rs.next()){
                  val row = columns.map { columnName =>
                    columnName -> Option(rs.getObject(columnName)).map(_.toString).getOrElse("<NULL>")
                  }.toMap
                  result += row
                }
                Ok(Serialization.write(Map("type" -> "query", "columns" -> columns, "rows" -> result)))
              }
            } else {
              val rows = stmt.executeUpdate()
              Ok(Serialization.write(Map("type" -> "update", "rows" -> rows)))
            }
          }
        } catch {
          case e: Exception =>
            Ok(Serialization.write(Map("type" -> "error", "message" -> e.toString)))
        }
      }
    } getOrElse Ok(Serialization.write(Map("type" -> "error", "message" -> "query is empty")))
  })

  get("/admin/system")(adminOnly {
    html.system(flash.get("info"))
  })

  post("/admin/system", form)(adminOnly { form =>
    saveSystemSettings(form)

    if (form.sshAddress != context.settings.sshAddress) {
      SshServer.stop()
       for {
         sshAddress <- form.sshAddress
         baseUrl    <- form.baseUrl
       }
       SshServer.start(sshAddress, baseUrl)
    }

    flash += "info" -> "System settings has been updated."
    redirect("/admin/system")
  })

  post("/admin/system/sendmail", sendMailForm)(adminOnly { form =>
    try {
      new Mailer(context.settings.copy(smtp = Some(form.smtp), notification = true)).send(
        to           = form.testAddress,
        subject      = "Test message from GitBucket",
        textMsg      = "This is a test message from GitBucket.",
        htmlMsg      = None,
        loginAccount = context.loginAccount
      )

      "Test mail has been sent to: " + form.testAddress

    } catch {
      case e: Exception => "[Error] " + e.toString
    }
  })

  get("/admin/plugins")(adminOnly {
    // Installed plugins
    val enabledPlugins = PluginRegistry().getPlugins()

    val gitbucketVersion = Semver.valueOf(GitBucketCoreModule.getVersions.asScala.last.getVersion)

    // Plugins in the local repository
    val repositoryPlugins = PluginRepository.getPlugins()
      .filterNot { meta =>
        enabledPlugins.exists { plugin => plugin.pluginId == meta.id &&
          Semver.valueOf(plugin.pluginVersion).greaterThanOrEqualTo(Semver.valueOf(meta.latestVersion.version))
        }
      }.map { meta =>
        (meta, meta.versions.reverse.find { version => gitbucketVersion.satisfies(version.range) })
      }.collect { case (meta, Some(version)) =>
        new PluginInfoBase(
          pluginId      = meta.id,
          pluginName    = meta.name,
          pluginVersion = version.version,
          description   = meta.description
        )
      }

    // Merge
    val plugins = enabledPlugins.map((_, true)) ++ repositoryPlugins.map((_, false))

    html.plugins(plugins, flash.get("info"))
  })

  post("/admin/plugins/_reload")(adminOnly {
    PluginRegistry.reload(request.getServletContext(), loadSystemSettings(), request2Session(request).conn)
    flash += "info" -> "All plugins were reloaded."
    redirect("/admin/plugins")
  })

  post("/admin/plugins/:pluginId/:version/_uninstall")(adminOnly {
    val pluginId = params("pluginId")
    val version  = params("version")
    PluginRegistry().getPlugins()
      .collect { case plugin if (plugin.pluginId == pluginId && plugin.pluginVersion == version) => plugin }
      .foreach { _ =>
        PluginRegistry.uninstall(pluginId, request.getServletContext, loadSystemSettings(), request2Session(request).conn)
        flash += "info" -> s"${pluginId} was uninstalled."
      }
    redirect("/admin/plugins")
  })

  post("/admin/plugins/:pluginId/:version/_install")(adminOnly {
    val pluginId = params("pluginId")
    val version  = params("version")
    /// TODO!!!!
    PluginRepository.getPlugins()
      .collect { case meta if meta.id == pluginId => (meta, meta.versions.find(_.version == version) )}
      .foreach { case (meta, version) =>
        version.foreach { version =>
          // TODO Install version!
          PluginRegistry.install(
            new java.io.File(PluginHome, s".repository/${version.file}"),
            request.getServletContext,
            loadSystemSettings(),
            request2Session(request).conn
          )
          flash += "info" -> s"${pluginId} was installed."
        }
      }
    redirect("/admin/plugins")
  })


  get("/admin/users")(adminOnly {
    val includeRemoved = params.get("includeRemoved").map(_.toBoolean).getOrElse(false)
    val includeGroups = params.get("includeGroups").map(_.toBoolean).getOrElse(false)
    val users          = getAllUsers(includeRemoved, includeGroups)
    val members        = users.collect { case account if(account.isGroupAccount) =>
      account.userName -> getGroupMembers(account.userName).map(_.userName)
    }.toMap

    html.userlist(users, members, includeRemoved, includeGroups)
  })

  get("/admin/users/_newuser")(adminOnly {
    html.user(None)
  })

  post("/admin/users/_newuser", newUserForm)(adminOnly { form =>
    createAccount(form.userName, sha1(form.password), form.fullName, form.mailAddress, form.isAdmin, form.description, form.url)
    updateImage(form.userName, form.fileId, false)
    redirect("/admin/users")
  })

  get("/admin/users/:userName/_edituser")(adminOnly {
    val userName = params("userName")
    html.user(getAccountByUserName(userName, true), flash.get("error"))
  })

  post("/admin/users/:name/_edituser", editUserForm)(adminOnly { form =>
    val userName = params("userName")
    getAccountByUserName(userName, true).map { account =>
      if(account.isAdmin && (form.isRemoved || !form.isAdmin) && isLastAdministrator(account)){
        flash += "error" -> "Account can't be turned off because this is last one administrator."
        redirect(s"/admin/users/${userName}/_edituser")
      } else {
        if(form.isRemoved){
          // Remove repositories
          //        getRepositoryNamesOfUser(userName).foreach { repositoryName =>
          //          deleteRepository(userName, repositoryName)
          //          FileUtils.deleteDirectory(getRepositoryDir(userName, repositoryName))
          //          FileUtils.deleteDirectory(getWikiRepositoryDir(userName, repositoryName))
          //          FileUtils.deleteDirectory(getTemporaryDir(userName, repositoryName))
          //        }
          // Remove from GROUP_MEMBER and COLLABORATOR
          removeUserRelatedData(userName)
        }

        updateAccount(account.copy(
          password     = form.password.map(sha1).getOrElse(account.password),
          fullName     = form.fullName,
          mailAddress  = form.mailAddress,
          isAdmin      = form.isAdmin,
          description  = form.description,
          url          = form.url,
          isRemoved    = form.isRemoved))

        updateImage(userName, form.fileId, form.clearImage)

        // call hooks
        if(form.isRemoved) PluginRegistry().getAccountHooks.foreach(_.deleted(userName))

        redirect("/admin/users")
      }
    } getOrElse NotFound()
  })

  get("/admin/users/_newgroup")(adminOnly {
    html.usergroup(None, Nil)
  })

  post("/admin/users/_newgroup", newGroupForm)(adminOnly { form =>
    createGroup(form.groupName, form.description, form.url)
    updateGroupMembers(form.groupName, form.members.split(",").map {
      _.split(":") match {
        case Array(userName, isManager) => (userName, isManager.toBoolean)
      }
    }.toList)
    updateImage(form.groupName, form.fileId, false)
    redirect("/admin/users")
  })

  get("/admin/users/:groupName/_editgroup")(adminOnly {
    defining(params("groupName")){ groupName =>
      html.usergroup(getAccountByUserName(groupName, true), getGroupMembers(groupName))
    }
  })

  post("/admin/users/:groupName/_editgroup", editGroupForm)(adminOnly { form =>
    defining(params("groupName"), form.members.split(",").map {
      _.split(":") match {
        case Array(userName, isManager) => (userName, isManager.toBoolean)
      }
    }.toList){ case (groupName, members) =>
      getAccountByUserName(groupName, true).map { account =>
        updateGroup(groupName, form.description, form.url, form.isRemoved)

        if(form.isRemoved){
          // Remove from GROUP_MEMBER
          updateGroupMembers(form.groupName, Nil)
//          // Remove repositories
//          getRepositoryNamesOfUser(form.groupName).foreach { repositoryName =>
//            deleteRepository(groupName, repositoryName)
//            FileUtils.deleteDirectory(getRepositoryDir(groupName, repositoryName))
//            FileUtils.deleteDirectory(getWikiRepositoryDir(groupName, repositoryName))
//            FileUtils.deleteDirectory(getTemporaryDir(groupName, repositoryName))
//          }
        } else {
          // Update GROUP_MEMBER
          updateGroupMembers(form.groupName, members)
//          // Update COLLABORATOR for group repositories
//          getRepositoryNamesOfUser(form.groupName).foreach { repositoryName =>
//            removeCollaborators(form.groupName, repositoryName)
//            members.foreach { case (userName, isManager) =>
//              addCollaborator(form.groupName, repositoryName, userName)
//            }
//          }
        }

        updateImage(form.groupName, form.fileId, form.clearImage)
        redirect("/admin/users")

      } getOrElse NotFound()
    }
  })

  get("/admin/data")(adminOnly {
    import gitbucket.core.util.JDBCUtil._
    val session = request2Session(request)
    html.data(session.conn.allTableNames())
  })

  post("/admin/export")(adminOnly {
    import gitbucket.core.util.JDBCUtil._
    val file = request2Session(request).conn.exportAsSQL(request.getParameterValues("tableNames").toSeq)

    contentType = "application/octet-stream"
    response.setHeader("Content-Disposition", "attachment; filename=" + file.getName)
    response.setContentLength(file.length.toInt)

    using(new FileInputStream(file)){ in =>
      IOUtils.copy(in, response.outputStream)
    }

    ()
  })

  private def members: Constraint = new Constraint(){
    override def validate(name: String, value: String, messages: Messages): Option[String] = {
      if(value.split(",").exists {
        _.split(":") match { case Array(userName, isManager) => isManager.toBoolean }
      }) None else Some("Must select one manager at least.")
    }
  }

  protected def disableByNotYourself(paramName: String): Constraint = new Constraint() {
    override def validate(name: String, value: String, messages: Messages): Option[String] = {
      params.get(paramName).flatMap { userName =>
        if(userName == context.loginAccount.get.userName && params.get("removed") == Some("true"))
          Some("You can't disable your account yourself")
        else
          None
      }
    }
  }

}