mirror of
https://github.com/gitbucket/gitbucket.git
synced 2026-07-05 03:48:14 +02:00
Merge pull request #1487 from gitbucket/feature/plugin-hotdeploy
Plugin hot deployment and bundle some plugins
This commit is contained in:
@@ -50,6 +50,7 @@ libraryDependencies ++= Seq(
|
||||
"org.cache2k" % "cache2k-all" % "1.0.0.CR1",
|
||||
"com.enragedginger" %% "akka-quartz-scheduler" % "1.6.0-akka-2.4.x" exclude("c3p0","c3p0"),
|
||||
"net.coobird" % "thumbnailator" % "0.4.8",
|
||||
"com.github.zafarkhaja" % "java-semver" % "0.9.0",
|
||||
"org.eclipse.jetty" % "jetty-webapp" % JettyVersion % "provided",
|
||||
"javax.servlet" % "javax.servlet-api" % "3.1.0" % "provided",
|
||||
"junit" % "junit" % "4.12" % "test",
|
||||
|
||||
BIN
src/main/resources/plugins/gitbucket-emoji-plugin_2.12-4.4.0.jar
Normal file
BIN
src/main/resources/plugins/gitbucket-emoji-plugin_2.12-4.4.0.jar
Normal file
Binary file not shown.
BIN
src/main/resources/plugins/gitbucket-gist-plugin_2.12-4.9.0.jar
Normal file
BIN
src/main/resources/plugins/gitbucket-gist-plugin_2.12-4.9.0.jar
Normal file
Binary file not shown.
Binary file not shown.
41
src/main/resources/plugins/plugins.json
Normal file
41
src/main/resources/plugins/plugins.json
Normal file
@@ -0,0 +1,41 @@
|
||||
[
|
||||
{
|
||||
"id": "notifications",
|
||||
"name": "Notifications Plugin",
|
||||
"description": "Provides Notifications feature on GitBucket.",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"range": ">4.15.0-SNAPSHOT",
|
||||
"file": "gitbucket-gist-notifications_2.12-1.0.0.jar"
|
||||
}
|
||||
],
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"id": "emoji",
|
||||
"name": "Emoji Plugin",
|
||||
"description": "Provides Emoji support for GitBucket.",
|
||||
"versions": [
|
||||
{
|
||||
"version": "4.4.0",
|
||||
"range": ">=4.10.0",
|
||||
"file": "gitbucket-emoji-plugin_2.12-4.4.0.jar"
|
||||
}
|
||||
],
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"id": "gist",
|
||||
"name": "Gist Plugin",
|
||||
"description": "Provides Gist feature on GitBucket.",
|
||||
"versions": [
|
||||
{
|
||||
"version": "4.9.0",
|
||||
"range": ">=4.14.0",
|
||||
"file": "gitbucket-gist-plugin_2.12-4.9.0.jar"
|
||||
}
|
||||
],
|
||||
"default": false
|
||||
}
|
||||
]
|
||||
@@ -31,9 +31,8 @@ class ScalatraBootstrap extends LifeCycle with SystemSettingsService {
|
||||
// Register controllers
|
||||
context.mount(new AnonymousAccessController, "/*")
|
||||
|
||||
PluginRegistry().getControllers.foreach { case (controller, path) =>
|
||||
context.mount(controller, path)
|
||||
}
|
||||
context.addFilter("pluginControllerFilter", new PluginControllerFilter)
|
||||
context.getFilterRegistration("pluginControllerFilter").addMappingForUrlPatterns(EnumSet.allOf(classOf[DispatcherType]), true, "/*")
|
||||
|
||||
context.mount(new IndexController, "/")
|
||||
context.mount(new ApiController, "/api/v3")
|
||||
|
||||
@@ -6,7 +6,7 @@ import gitbucket.core.admin.html
|
||||
import gitbucket.core.service.{AccountService, RepositoryService, SystemSettingsService}
|
||||
import gitbucket.core.util.{AdminAuthenticator, Mailer}
|
||||
import gitbucket.core.ssh.SshServer
|
||||
import gitbucket.core.plugin.PluginRegistry
|
||||
import gitbucket.core.plugin.{PluginInfoBase, PluginRegistry, PluginRepository}
|
||||
import SystemSettingsService._
|
||||
import gitbucket.core.util.Implicits._
|
||||
import gitbucket.core.util.SyntaxSugars._
|
||||
@@ -15,6 +15,10 @@ import gitbucket.core.util.StringUtil._
|
||||
import io.github.gitbucket.scalatra.forms._
|
||||
import org.apache.commons.io.{FileUtils, IOUtils}
|
||||
import org.scalatra.i18n.Messages
|
||||
import com.github.zafarkhaja.semver.{Version => Semver}
|
||||
import gitbucket.core.GitBucketCoreModule
|
||||
import scala.collection.JavaConverters._
|
||||
|
||||
|
||||
class SystemSettingsController extends SystemSettingsControllerBase
|
||||
with AccountService with RepositoryService with AdminAuthenticator
|
||||
@@ -181,7 +185,71 @@ trait SystemSettingsControllerBase extends AccountManagementControllerBase {
|
||||
})
|
||||
|
||||
get("/admin/plugins")(adminOnly {
|
||||
html.plugins(PluginRegistry().getPlugins())
|
||||
// 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")
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -315,11 +315,17 @@ abstract class Plugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is invoked in shutdown of plugin system.
|
||||
* This method is invoked when the plugin system is shutting down.
|
||||
* If the plugin has any resources, release them in this method.
|
||||
*/
|
||||
def shutdown(registry: PluginRegistry, context: ServletContext, settings: SystemSettings): Unit = {}
|
||||
|
||||
// /**
|
||||
// * This method is invoked when this plugin is uninstalled.
|
||||
// * Cleanup database or any other resources in this method if necessary.
|
||||
// */
|
||||
// def uninstall(registry: PluginRegistry, context: ServletContext, settings: SystemSettings): Unit = {}
|
||||
|
||||
/**
|
||||
* Helper method to get a resource from classpath.
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ package gitbucket.core.plugin
|
||||
|
||||
import java.io.{File, FilenameFilter, InputStream}
|
||||
import java.net.URLClassLoader
|
||||
import java.nio.file.{Files, Paths, StandardWatchEventKinds}
|
||||
import java.util.Base64
|
||||
import javax.servlet.ServletContext
|
||||
|
||||
@@ -9,6 +10,7 @@ import gitbucket.core.controller.{Context, ControllerBase}
|
||||
import gitbucket.core.model.{Account, Issue}
|
||||
import gitbucket.core.service.ProtectedBranchService.ProtectedBranchReceiveHook
|
||||
import gitbucket.core.service.RepositoryService.RepositoryInfo
|
||||
import gitbucket.core.service.SystemSettingsService
|
||||
import gitbucket.core.service.SystemSettingsService.SystemSettings
|
||||
import gitbucket.core.util.SyntaxSugars._
|
||||
import gitbucket.core.util.DatabaseConfig
|
||||
@@ -16,11 +18,13 @@ import gitbucket.core.util.Directory._
|
||||
import io.github.gitbucket.solidbase.Solidbase
|
||||
import io.github.gitbucket.solidbase.manager.JDBCVersionManager
|
||||
import io.github.gitbucket.solidbase.model.Module
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import play.twirl.api.Html
|
||||
|
||||
import scala.collection.mutable
|
||||
import scala.collection.mutable.ListBuffer
|
||||
import com.github.zafarkhaja.semver.{Version => Semver}
|
||||
|
||||
class PluginRegistry {
|
||||
|
||||
@@ -39,10 +43,8 @@ class PluginRegistry {
|
||||
|
||||
private val repositoryHooks = new ListBuffer[RepositoryHook]
|
||||
private val issueHooks = new ListBuffer[IssueHook]
|
||||
issueHooks += new gitbucket.core.util.Notifier.IssueHook()
|
||||
|
||||
private val pullRequestHooks = new ListBuffer[PullRequestHook]
|
||||
pullRequestHooks += new gitbucket.core.util.Notifier.PullRequestHook()
|
||||
|
||||
private val repositoryHeaders = new ListBuffer[(RepositoryInfo, Context) => Option[Html]]
|
||||
private val globalMenus = new ListBuffer[(Context) => Option[Link]]
|
||||
@@ -185,36 +187,101 @@ object PluginRegistry {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(classOf[PluginRegistry])
|
||||
|
||||
private val instance = new PluginRegistry()
|
||||
private var instance = new PluginRegistry()
|
||||
|
||||
private var watcher: PluginWatchThread = null
|
||||
|
||||
/**
|
||||
* Returns the PluginRegistry singleton instance.
|
||||
*/
|
||||
def apply(): PluginRegistry = instance
|
||||
|
||||
/**
|
||||
* Reload all plugins.
|
||||
*/
|
||||
def reload(context: ServletContext, settings: SystemSettings, conn: java.sql.Connection): Unit = synchronized {
|
||||
shutdown(context, settings)
|
||||
instance = new PluginRegistry()
|
||||
initialize(context, settings, conn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall a specified plugin.
|
||||
*/
|
||||
def uninstall(pluginId: String, context: ServletContext, settings: SystemSettings, conn: java.sql.Connection): Unit = synchronized {
|
||||
instance.getPlugins()
|
||||
.collect { case plugin if plugin.pluginId == pluginId => plugin }
|
||||
.foreach { plugin =>
|
||||
// try {
|
||||
// plugin.pluginClass.uninstall(instance, context, settings)
|
||||
// } catch {
|
||||
// case e: Exception =>
|
||||
// logger.error(s"Error during uninstalling plugin: ${plugin.pluginJar.getName}", e)
|
||||
// }
|
||||
shutdown(context, settings)
|
||||
plugin.pluginJar.delete()
|
||||
instance = new PluginRegistry()
|
||||
initialize(context, settings, conn)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a plugin from a specified jar file.
|
||||
*/
|
||||
def install(file: File, context: ServletContext, settings: SystemSettings, conn: java.sql.Connection): Unit = synchronized {
|
||||
FileUtils.copyFile(file, new File(PluginHome, file.getName))
|
||||
|
||||
shutdown(context, settings)
|
||||
instance = new PluginRegistry()
|
||||
initialize(context, settings, conn)
|
||||
}
|
||||
|
||||
private def listPluginJars(dir: File): Seq[File] = {
|
||||
dir.listFiles(new FilenameFilter {
|
||||
override def accept(dir: File, name: String): Boolean = name.endsWith(".jar")
|
||||
}).toSeq.sortBy(_.getName).reverse
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all installed plugins.
|
||||
*/
|
||||
def initialize(context: ServletContext, settings: SystemSettings, conn: java.sql.Connection): Unit = {
|
||||
def initialize(context: ServletContext, settings: SystemSettings, conn: java.sql.Connection): Unit = synchronized {
|
||||
val pluginDir = new File(PluginHome)
|
||||
val manager = new JDBCVersionManager(conn)
|
||||
|
||||
if(pluginDir.exists && pluginDir.isDirectory){
|
||||
// Clean installed directory
|
||||
val installedDir = new File(PluginHome, ".installed")
|
||||
if(installedDir.exists){
|
||||
FileUtils.deleteDirectory(installedDir)
|
||||
}
|
||||
installedDir.mkdir()
|
||||
|
||||
if(pluginDir.exists && pluginDir.isDirectory) {
|
||||
pluginDir.listFiles(new FilenameFilter {
|
||||
override def accept(dir: File, name: String): Boolean = name.endsWith(".jar")
|
||||
}).sortBy(_.getName).foreach { pluginJar =>
|
||||
val classLoader = new URLClassLoader(Array(pluginJar.toURI.toURL), Thread.currentThread.getContextClassLoader)
|
||||
}).toSeq.sortBy(_.getName).reverse.foreach { pluginJar =>
|
||||
|
||||
val installedJar = new File(installedDir, pluginJar.getName)
|
||||
FileUtils.copyFile(pluginJar, installedJar)
|
||||
|
||||
logger.info(s"Initialize ${pluginJar.getName}")
|
||||
val classLoader = new URLClassLoader(Array(installedJar.toURI.toURL), Thread.currentThread.getContextClassLoader)
|
||||
try {
|
||||
val plugin = classLoader.loadClass("Plugin").getDeclaredConstructor().newInstance().asInstanceOf[Plugin]
|
||||
val pluginId = plugin.pluginId
|
||||
// Check duplication
|
||||
instance.getPlugins().find(_.pluginId == pluginId).foreach { x =>
|
||||
throw new IllegalStateException(s"Plugin ${pluginId} is duplicated. ${x.pluginJar.getName} is available.")
|
||||
}
|
||||
|
||||
// Migration
|
||||
val solidbase = new Solidbase()
|
||||
solidbase.migrate(conn, classLoader, DatabaseConfig.liquiDriver, new Module(plugin.pluginId, plugin.versions: _*))
|
||||
|
||||
// Check version
|
||||
// Check database version
|
||||
val databaseVersion = manager.getCurrentVersion(plugin.pluginId)
|
||||
val pluginVersion = plugin.versions.last.getVersion
|
||||
if(databaseVersion != pluginVersion){
|
||||
if (databaseVersion != pluginVersion) {
|
||||
throw new IllegalStateException(s"Plugin version is ${pluginVersion}, but database version is ${databaseVersion}")
|
||||
}
|
||||
|
||||
@@ -225,39 +292,107 @@ object PluginRegistry {
|
||||
pluginName = plugin.pluginName,
|
||||
pluginVersion = plugin.versions.last.getVersion,
|
||||
description = plugin.description,
|
||||
pluginClass = plugin
|
||||
pluginClass = plugin,
|
||||
pluginJar = pluginJar,
|
||||
classLoader = classLoader
|
||||
))
|
||||
|
||||
} catch {
|
||||
case e: Throwable => {
|
||||
logger.error(s"Error during plugin initialization: ${pluginJar.getAbsolutePath}", e)
|
||||
}
|
||||
case e: Throwable => logger.error(s"Error during plugin initialization: ${pluginJar.getName}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(watcher == null){
|
||||
watcher = new PluginWatchThread(context)
|
||||
watcher.start()
|
||||
}
|
||||
}
|
||||
|
||||
def shutdown(context: ServletContext, settings: SystemSettings): Unit = {
|
||||
instance.getPlugins().foreach { pluginInfo =>
|
||||
def shutdown(context: ServletContext, settings: SystemSettings): Unit = synchronized {
|
||||
instance.getPlugins().foreach { plugin =>
|
||||
try {
|
||||
pluginInfo.pluginClass.shutdown(instance, context, settings)
|
||||
plugin.pluginClass.shutdown(instance, context, settings)
|
||||
} catch {
|
||||
case e: Exception => {
|
||||
logger.error(s"Error during plugin shutdown", e)
|
||||
logger.error(s"Error during plugin shutdown: ${plugin.pluginJar.getName}", e)
|
||||
}
|
||||
} finally {
|
||||
plugin.classLoader.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
case class Link(id: String, label: String, path: String, icon: Option[String] = None)
|
||||
case class Link(
|
||||
id: String,
|
||||
label: String,
|
||||
path: String,
|
||||
icon: Option[String] = None
|
||||
)
|
||||
|
||||
class PluginInfoBase(
|
||||
val pluginId: String,
|
||||
val pluginName: String,
|
||||
val pluginVersion: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
case class PluginInfo(
|
||||
pluginId: String,
|
||||
pluginName: String,
|
||||
pluginVersion: String,
|
||||
description: String,
|
||||
pluginClass: Plugin
|
||||
)
|
||||
override val pluginId: String,
|
||||
override val pluginName: String,
|
||||
override val pluginVersion: String,
|
||||
override val description: String,
|
||||
pluginClass: Plugin,
|
||||
pluginJar: File,
|
||||
classLoader: URLClassLoader
|
||||
) extends PluginInfoBase(pluginId, pluginName, pluginVersion, description)
|
||||
|
||||
class PluginWatchThread(context: ServletContext) extends Thread with SystemSettingsService {
|
||||
import gitbucket.core.model.Profile.profile.blockingApi._
|
||||
import scala.collection.JavaConverters._
|
||||
|
||||
private val logger = LoggerFactory.getLogger(classOf[PluginWatchThread])
|
||||
|
||||
override def run(): Unit = {
|
||||
val path = Paths.get(PluginHome)
|
||||
if(!Files.exists(path)){
|
||||
Files.createDirectories(path)
|
||||
}
|
||||
val fs = path.getFileSystem
|
||||
val watcher = fs.newWatchService
|
||||
|
||||
val watchKey = path.register(watcher,
|
||||
StandardWatchEventKinds.ENTRY_CREATE,
|
||||
StandardWatchEventKinds.ENTRY_MODIFY,
|
||||
StandardWatchEventKinds.ENTRY_DELETE,
|
||||
StandardWatchEventKinds.OVERFLOW)
|
||||
|
||||
logger.info("Start PluginWatchThread: " + path)
|
||||
|
||||
try {
|
||||
while (watchKey.isValid()) {
|
||||
val detectedWatchKey = watcher.take()
|
||||
val events = detectedWatchKey.pollEvents.asScala.filter(_.context.toString != ".installed")
|
||||
if(events.nonEmpty){
|
||||
events.foreach { event =>
|
||||
logger.info(event.kind + ": " + event.context)
|
||||
}
|
||||
|
||||
gitbucket.core.servlet.Database() withTransaction { session =>
|
||||
logger.info("Reloading plugins...")
|
||||
PluginRegistry.reload(context, loadSystemSettings(), session.conn)
|
||||
logger.info("Reloading finished.")
|
||||
}
|
||||
}
|
||||
detectedWatchKey.reset()
|
||||
}
|
||||
} catch {
|
||||
case _: InterruptedException => watchKey.cancel()
|
||||
}
|
||||
|
||||
logger.info("Shutdown PluginWatchThread")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
41
src/main/scala/gitbucket/core/plugin/PluginRepository.scala
Normal file
41
src/main/scala/gitbucket/core/plugin/PluginRepository.scala
Normal file
@@ -0,0 +1,41 @@
|
||||
package gitbucket.core.plugin
|
||||
|
||||
import org.json4s._
|
||||
import gitbucket.core.util.Directory._
|
||||
import org.apache.commons.io.FileUtils
|
||||
|
||||
object PluginRepository {
|
||||
implicit val formats = DefaultFormats
|
||||
|
||||
def parsePluginJson(json: String): Seq[PluginMetadata] = {
|
||||
org.json4s.jackson.JsonMethods.parse(json).extract[Seq[PluginMetadata]]
|
||||
}
|
||||
|
||||
lazy val LocalRepositoryDir = new java.io.File(PluginHome, ".repository")
|
||||
lazy val LocalRepositoryIndexFile = new java.io.File(LocalRepositoryDir, "plugins.json")
|
||||
|
||||
def getPlugins(): Seq[PluginMetadata] = {
|
||||
if(LocalRepositoryIndexFile.exists){
|
||||
parsePluginJson(FileUtils.readFileToString(LocalRepositoryIndexFile, "UTF-8"))
|
||||
} else Nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Mapped from plugins.json
|
||||
case class PluginMetadata(
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
versions: Seq[VersionDef],
|
||||
default: Boolean = false
|
||||
){
|
||||
lazy val latestVersion: VersionDef = versions.last
|
||||
}
|
||||
|
||||
case class VersionDef(
|
||||
version: String,
|
||||
file: String,
|
||||
range: String
|
||||
)
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
package gitbucket.core.servlet
|
||||
|
||||
import java.io.File
|
||||
import java.io.{File, FileOutputStream}
|
||||
|
||||
import akka.event.Logging
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import gitbucket.core.GitBucketCoreModule
|
||||
import gitbucket.core.plugin.PluginRegistry
|
||||
import gitbucket.core.plugin.{PluginRegistry, PluginRepository}
|
||||
import gitbucket.core.service.{ActivityService, SystemSettingsService}
|
||||
import gitbucket.core.util.DatabaseConfig
|
||||
import gitbucket.core.util.Directory._
|
||||
import gitbucket.core.util.SyntaxSugars._
|
||||
import gitbucket.core.util.JDBCUtil._
|
||||
import gitbucket.core.model.Profile.profile.blockingApi._
|
||||
import io.github.gitbucket.solidbase.Solidbase
|
||||
import io.github.gitbucket.solidbase.manager.JDBCVersionManager
|
||||
import javax.servlet.{ServletContextListener, ServletContextEvent}
|
||||
import org.apache.commons.io.FileUtils
|
||||
import javax.servlet.{ServletContextEvent, ServletContextListener}
|
||||
|
||||
import org.apache.commons.io.{FileUtils, IOUtils}
|
||||
import org.slf4j.LoggerFactory
|
||||
import akka.actor.{Actor, Props, ActorSystem}
|
||||
import akka.actor.{Actor, ActorSystem, Props}
|
||||
import com.typesafe.akka.extension.quartz.QuartzSchedulerExtension
|
||||
import com.github.zafarkhaja.semver.{Version => Semver}
|
||||
|
||||
import scala.collection.JavaConverters._
|
||||
|
||||
|
||||
/**
|
||||
* Initialize GitBucket system.
|
||||
* Update database schema and load plug-ins automatically in the context initializing.
|
||||
@@ -54,44 +59,11 @@ class InitializeListener extends ServletContextListener with SystemSettingsServi
|
||||
val manager = new JDBCVersionManager(conn)
|
||||
|
||||
// Check version
|
||||
val versionFile = new File(GitBucketHome, "version")
|
||||
|
||||
if(versionFile.exists()){
|
||||
val version = FileUtils.readFileToString(versionFile, "UTF-8")
|
||||
if(version == "3.14"){
|
||||
// Initialization for GitBucket 3.14
|
||||
logger.info("Migration to GitBucket 4.x start")
|
||||
|
||||
// Backup current data
|
||||
val dataMvFile = new File(GitBucketHome, "data.mv.db")
|
||||
if(dataMvFile.exists) {
|
||||
FileUtils.copyFile(dataMvFile, new File(GitBucketHome, "data.mv.db_3.14"))
|
||||
}
|
||||
val dataTraceFile = new File(GitBucketHome, "data.trace.db")
|
||||
if(dataTraceFile.exists) {
|
||||
FileUtils.copyFile(dataTraceFile, new File(GitBucketHome, "data.trace.db_3.14"))
|
||||
}
|
||||
|
||||
// Change form
|
||||
manager.initialize()
|
||||
manager.updateVersion(GitBucketCoreModule.getModuleId, "4.0.0")
|
||||
conn.select("SELECT PLUGIN_ID, VERSION FROM PLUGIN"){ rs =>
|
||||
manager.updateVersion(rs.getString("PLUGIN_ID"), rs.getString("VERSION"))
|
||||
}
|
||||
conn.update("DROP TABLE PLUGIN")
|
||||
versionFile.delete()
|
||||
|
||||
logger.info("Migration to GitBucket 4.x completed")
|
||||
|
||||
} else {
|
||||
throw new Exception("GitBucket can't migrate from this version. Please update to 3.14 at first.")
|
||||
}
|
||||
}
|
||||
checkVersion(manager, conn)
|
||||
|
||||
// Run normal migration
|
||||
logger.info("Start schema update")
|
||||
val solidbase = new Solidbase()
|
||||
solidbase.migrate(conn, Thread.currentThread.getContextClassLoader, DatabaseConfig.liquiDriver, GitBucketCoreModule)
|
||||
new Solidbase().migrate(conn, Thread.currentThread.getContextClassLoader, DatabaseConfig.liquiDriver, GitBucketCoreModule)
|
||||
|
||||
// Rescue code for users who updated from 3.14 to 4.0.0
|
||||
// https://github.com/gitbucket/gitbucket/issues/1227
|
||||
@@ -106,6 +78,9 @@ class InitializeListener extends ServletContextListener with SystemSettingsServi
|
||||
throw new IllegalStateException(s"Initialization failed. GitBucket version is ${gitbucketVersion}, but database version is ${databaseVersion}.")
|
||||
}
|
||||
|
||||
// Install bundled plugins
|
||||
extractBundledPlugins(gitbucketVersion)
|
||||
|
||||
// Load plugins
|
||||
logger.info("Initialize plugins")
|
||||
PluginRegistry.initialize(event.getServletContext, loadSystemSettings(), conn)
|
||||
@@ -117,7 +92,74 @@ class InitializeListener extends ServletContextListener with SystemSettingsServi
|
||||
scheduler.schedule("Daily", system.actorOf(Props[DeleteOldActivityActor]), "DeleteOldActivity")
|
||||
}
|
||||
|
||||
private def checkVersion(manager: JDBCVersionManager, conn: java.sql.Connection): Unit = {
|
||||
logger.info("Check version")
|
||||
val versionFile = new File(GitBucketHome, "version")
|
||||
|
||||
if(versionFile.exists()){
|
||||
val version = FileUtils.readFileToString(versionFile, "UTF-8")
|
||||
if(version == "3.14"){
|
||||
// Initialization for GitBucket 3.14
|
||||
logger.info("Migration to GitBucket 4.x start")
|
||||
|
||||
// Backup current data
|
||||
val dataMvFile = new File(GitBucketHome, "data.mv.db")
|
||||
if(dataMvFile.exists) {
|
||||
FileUtils.copyFile(dataMvFile, new File(GitBucketHome, "data.mv.db_3.14"))
|
||||
}
|
||||
val dataTraceFile = new File(GitBucketHome, "data.trace.db")
|
||||
if(dataTraceFile.exists) {
|
||||
FileUtils.copyFile(dataTraceFile, new File(GitBucketHome, "data.trace.db_3.14"))
|
||||
}
|
||||
|
||||
// Change form
|
||||
manager.initialize()
|
||||
manager.updateVersion(GitBucketCoreModule.getModuleId, "4.0.0")
|
||||
conn.select("SELECT PLUGIN_ID, VERSION FROM PLUGIN"){ rs =>
|
||||
manager.updateVersion(rs.getString("PLUGIN_ID"), rs.getString("VERSION"))
|
||||
}
|
||||
conn.update("DROP TABLE PLUGIN")
|
||||
versionFile.delete()
|
||||
|
||||
logger.info("Migration to GitBucket 4.x completed")
|
||||
|
||||
} else {
|
||||
throw new Exception("GitBucket can't migrate from this version. Please update to 3.14 at first.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def extractBundledPlugins(gitbucketVersion: String): Unit = {
|
||||
logger.info("Extract bundled plugins")
|
||||
val cl = Thread.currentThread.getContextClassLoader
|
||||
try {
|
||||
using(cl.getResourceAsStream("plugins/plugins.json")){ pluginsFile =>
|
||||
val pluginsJson = IOUtils.toString(pluginsFile, "UTF-8")
|
||||
|
||||
FileUtils.forceMkdir(PluginRepository.LocalRepositoryDir)
|
||||
FileUtils.write(PluginRepository.LocalRepositoryIndexFile, pluginsJson, "UTF-8")
|
||||
|
||||
val plugins = PluginRepository.parsePluginJson(pluginsJson)
|
||||
plugins.foreach { plugin =>
|
||||
plugin.versions.sortBy { x => Semver.valueOf(x.version) }.reverse.zipWithIndex.foreach { case (version, i) =>
|
||||
val file = new File(PluginRepository.LocalRepositoryDir, version.file)
|
||||
if(!file.exists) {
|
||||
logger.info(s"Copy ${plugin} to ${file.getAbsolutePath}")
|
||||
FileUtils.forceMkdirParent(file)
|
||||
using(cl.getResourceAsStream("plugins/" + version.file), new FileOutputStream(file)){ case (in, out) => IOUtils.copy(in, out) }
|
||||
|
||||
if(plugin.default && i == 0){
|
||||
logger.info(s"Enable ${file.getName} in default")
|
||||
FileUtils.copyFile(file, new File(PluginHome, version.file))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
case e: Exception => logger.error("Error in extracting bundled plugin", e)
|
||||
}
|
||||
}
|
||||
|
||||
override def contextDestroyed(event: ServletContextEvent): Unit = {
|
||||
// Shutdown Quartz scheduler
|
||||
@@ -146,4 +188,4 @@ class DeleteOldActivityActor extends Actor with SystemSettingsService with Activ
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package gitbucket.core.servlet
|
||||
|
||||
import javax.servlet._
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
|
||||
import gitbucket.core.plugin.PluginRegistry
|
||||
|
||||
class PluginControllerFilter extends Filter {
|
||||
|
||||
private var filterConfig: FilterConfig = null
|
||||
|
||||
override def init(filterConfig: FilterConfig): Unit = {
|
||||
this.filterConfig = filterConfig
|
||||
}
|
||||
|
||||
override def destroy(): Unit = {
|
||||
PluginRegistry().getControllers().foreach { case (controller, _) =>
|
||||
controller.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
override def doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain): Unit = {
|
||||
val controller = PluginRegistry().getControllers().find { case (_, path) =>
|
||||
val requestUri = request.asInstanceOf[HttpServletRequest].getRequestURI
|
||||
path.endsWith("/*") && requestUri.startsWith(path.replaceFirst("/\\*$", "/"))
|
||||
}
|
||||
|
||||
controller.map { case (controller, _) =>
|
||||
if(controller.config == null){
|
||||
controller.init(filterConfig)
|
||||
}
|
||||
controller.doFilter(request, response, chain)
|
||||
}.getOrElse{
|
||||
chain.doFilter(request, response)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
package gitbucket.core.util
|
||||
|
||||
import gitbucket.core.model.{Session, Issue, Account}
|
||||
import gitbucket.core.model.{Session, Account}
|
||||
import gitbucket.core.model.Profile.profile.blockingApi._
|
||||
import gitbucket.core.service.{RepositoryService, AccountService, IssuesService, SystemSettingsService}
|
||||
import gitbucket.core.service.SystemSettingsService
|
||||
import gitbucket.core.servlet.Database
|
||||
import gitbucket.core.view.Markdown
|
||||
|
||||
import scala.concurrent._
|
||||
import scala.util.{Success, Failure}
|
||||
@@ -31,125 +30,6 @@ object Notifier {
|
||||
case settings if (settings.notification && settings.useSMTP) => new Mailer(settings.smtp.get)
|
||||
case _ => new MockMailer
|
||||
}
|
||||
|
||||
|
||||
// TODO This class is temporary keeping the current feature until Notifications Plugin is available.
|
||||
class IssueHook extends gitbucket.core.plugin.IssueHook
|
||||
with RepositoryService with AccountService with IssuesService {
|
||||
|
||||
override def created(issue: Issue, r: RepositoryService.RepositoryInfo)(implicit context: Context): Unit = {
|
||||
Notifier().toNotify(
|
||||
subject(issue, r),
|
||||
message(issue.content getOrElse "", r)(content => s"""
|
||||
|$content<br/>
|
||||
|--<br/>
|
||||
|<a href="${s"${context.baseUrl}/${r.owner}/${r.name}/issues/${issue.issueId}"}">View it on GitBucket</a>
|
||||
""".stripMargin)
|
||||
)(recipients(issue))
|
||||
}
|
||||
|
||||
override def addedComment(commentId: Int, content: String, issue: Issue, r: RepositoryService.RepositoryInfo)(implicit context: Context): Unit = {
|
||||
Notifier().toNotify(
|
||||
subject(issue, r),
|
||||
message(content, r)(content => s"""
|
||||
|$content<br/>
|
||||
|--<br/>
|
||||
|<a href="${s"${context.baseUrl}/${r.owner}/${r.name}/issues/${issue.issueId}#comment-$commentId"}">View it on GitBucket</a>
|
||||
""".stripMargin)
|
||||
)(recipients(issue))
|
||||
}
|
||||
|
||||
override def closed(issue: Issue, r: RepositoryService.RepositoryInfo)(implicit context: Context): Unit = {
|
||||
Notifier().toNotify(
|
||||
subject(issue, r),
|
||||
message("close", r)(content => s"""
|
||||
|$content <a href="${s"${context.baseUrl}/${r.owner}/${r.name}/issues/${issue.issueId}"}">#${issue.issueId}</a>
|
||||
""".stripMargin)
|
||||
)(recipients(issue))
|
||||
}
|
||||
|
||||
override def reopened(issue: Issue, r: RepositoryService.RepositoryInfo)(implicit context: Context): Unit = {
|
||||
Notifier().toNotify(
|
||||
subject(issue, r),
|
||||
message("reopen", r)(content => s"""
|
||||
|$content <a href="${s"${context.baseUrl}/${r.owner}/${r.name}/issues/${issue.issueId}"}">#${issue.issueId}</a>
|
||||
""".stripMargin)
|
||||
)(recipients(issue))
|
||||
}
|
||||
|
||||
|
||||
protected def subject(issue: Issue, r: RepositoryService.RepositoryInfo): String =
|
||||
s"[${r.owner}/${r.name}] ${issue.title} (#${issue.issueId})"
|
||||
|
||||
protected def message(content: String, r: RepositoryService.RepositoryInfo)(msg: String => String)(implicit context: Context): String =
|
||||
msg(Markdown.toHtml(
|
||||
markdown = content,
|
||||
repository = r,
|
||||
enableWikiLink = false,
|
||||
enableRefsLink = true,
|
||||
enableAnchor = false,
|
||||
enableLineBreaks = false
|
||||
))
|
||||
|
||||
protected val recipients: Issue => Account => Session => Seq[String] = {
|
||||
issue => loginAccount => implicit session =>
|
||||
(
|
||||
// individual repository's owner
|
||||
issue.userName ::
|
||||
// group members of group repository
|
||||
getGroupMembers(issue.userName).map(_.userName) :::
|
||||
// collaborators
|
||||
getCollaboratorUserNames(issue.userName, issue.repositoryName) :::
|
||||
// participants
|
||||
issue.openedUserName ::
|
||||
getComments(issue.userName, issue.repositoryName, issue.issueId).map(_.commentedUserName)
|
||||
)
|
||||
.distinct
|
||||
.withFilter ( _ != loginAccount.userName ) // the operation in person is excluded
|
||||
.flatMap (
|
||||
getAccountByUserName(_)
|
||||
.filterNot (_.isGroupAccount)
|
||||
.filterNot (LDAPUtil.isDummyMailAddress)
|
||||
.map (_.mailAddress)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO This class is temporary keeping the current feature until Notifications Plugin is available.
|
||||
class PullRequestHook extends IssueHook with gitbucket.core.plugin.PullRequestHook {
|
||||
override def created(issue: Issue, r: RepositoryService.RepositoryInfo)(implicit context: Context): Unit = {
|
||||
val url = s"${context.baseUrl}/${r.owner}/${r.name}/pull/${issue.issueId}"
|
||||
Notifier().toNotify(
|
||||
subject(issue, r),
|
||||
message(issue.content getOrElse "", r)(content => s"""
|
||||
|$content<hr/>
|
||||
|View, comment on, or merge it at:<br/>
|
||||
|<a href="$url">$url</a>
|
||||
""".stripMargin)
|
||||
)(recipients(issue))
|
||||
}
|
||||
|
||||
override def addedComment(commentId: Int, content: String, issue: Issue, r: RepositoryService.RepositoryInfo)(implicit context: Context): Unit = {
|
||||
Notifier().toNotify(
|
||||
subject(issue, r),
|
||||
message(content, r)(content => s"""
|
||||
|$content<br/>
|
||||
|--<br/>
|
||||
|<a href="${s"${context.baseUrl}/${r.owner}/${r.name}/pull/${issue.issueId}#comment-$commentId"}">View it on GitBucket</a>
|
||||
""".stripMargin)
|
||||
)(recipients(issue))
|
||||
}
|
||||
|
||||
override def merged(issue: Issue, r: RepositoryService.RepositoryInfo)(implicit context: Context): Unit = {
|
||||
Notifier().toNotify(
|
||||
subject(issue, r),
|
||||
message("merge", r)(content => s"""
|
||||
|$content <a href="${s"${context.baseUrl}/${r.owner}/${r.name}/pull/${issue.issueId}"}">#${issue.issueId}</a>
|
||||
""".stripMargin)
|
||||
)(recipients(issue))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Mailer(private val smtp: Smtp) extends Notifier {
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
@(plugins: List[gitbucket.core.plugin.PluginInfo])(implicit context: gitbucket.core.controller.Context)
|
||||
@(plugins: List[(gitbucket.core.plugin.PluginInfoBase, Boolean)], info: Option[Any])(implicit context: gitbucket.core.controller.Context)
|
||||
@gitbucket.core.html.main("Plugins"){
|
||||
@gitbucket.core.admin.html.menu("plugins") {
|
||||
<h1>Installed plugins</h1>
|
||||
|
||||
@gitbucket.core.helper.html.information(info)
|
||||
<form action="@context.path/admin/plugins/_reload" method="POST" class="pull-right">
|
||||
<input type="submit" value="Reload plugins" class="btn btn-default">
|
||||
</form>
|
||||
<h1>Plugins</h1>
|
||||
@if(plugins.size > 0) {
|
||||
<ul>
|
||||
@plugins.map { plugin =>
|
||||
@plugins.map { case (plugin, enabled) =>
|
||||
<li><a href="#@plugin.pluginId">@plugin.pluginId:@plugin.pluginVersion</a></li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@plugins.map { plugin =>
|
||||
@plugins.map { case (plugin, enabled) =>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading strong" id="@plugin.pluginId">@plugin.pluginName</div>
|
||||
<div class="panel-heading strong" id="@plugin.pluginId">
|
||||
@if(enabled){
|
||||
<form action="@{context.path}/admin/plugins/@{plugin.pluginId}/@{plugin.pluginVersion}/_uninstall" method="POST" class="pull-right uninstall-form">
|
||||
<input type="submit" value="Uninstall" class="btn btn-danger btn-sm" style="position: relative; top: -5px; left: 10px;" data-name="@plugin.pluginName">
|
||||
</form>
|
||||
} else {
|
||||
<form action="@{context.path}/admin/plugins/@{plugin.pluginId}/@{plugin.pluginVersion}/_install" method="POST" class="pull-right install-form">
|
||||
<input type="submit" value="Install" class="btn btn-success btn-sm" style="position: relative; top: -5px; left: 10px;" data-name="@plugin.pluginName">
|
||||
</form>
|
||||
}
|
||||
@plugin.pluginName
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<label class="col-md-2">Id</label>
|
||||
@@ -38,3 +52,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
<script>
|
||||
$(function(){
|
||||
$('.uninstall-form').click(function(e){
|
||||
var name = $(e.target).data('name');
|
||||
return confirm('Uninstall ' + name + '. Are you sure?');
|
||||
});
|
||||
|
||||
$('.install-form').click(function(e){
|
||||
var name = $(e.target).data('name');
|
||||
return confirm('Install ' + name + '. Are you sure?');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user