diff --git a/etc/deploy-assemby-jar.sh b/etc/deploy-assemby-jar.sh new file mode 100644 index 000000000..11f276d1d --- /dev/null +++ b/etc/deploy-assemby-jar.sh @@ -0,0 +1,9 @@ +#!/bin/sh +mvn deploy:deploy-file \ + -DgroupId=jp.sf.amateras\ + -DartifactId=gitbucket-assembly\ + -Dversion=0.0.1\ + -Dpackaging=jar\ + -Dfile=../target/scala-2.11/gitbucket-assembly-0.0.1.jar\ + -DrepositoryId=sourceforge.jp\ + -Durl=scp://shell.sourceforge.jp/home/groups/a/am/amateras/htdocs/mvn/ \ No newline at end of file diff --git a/etc/pom.xml b/etc/pom.xml new file mode 100644 index 000000000..40693f237 --- /dev/null +++ b/etc/pom.xml @@ -0,0 +1,17 @@ + + + 4.0.0 + jp.sf.amateras + gitbucket-assembly + 0.0.1 + + + + org.apache.maven.wagon + wagon-ssh + 1.0-beta-6 + + + + \ No newline at end of file diff --git a/project/build.scala b/project/build.scala index e76707879..7d4153809 100644 --- a/project/build.scala +++ b/project/build.scala @@ -4,6 +4,8 @@ import org.scalatra.sbt._ import com.typesafe.sbteclipse.plugin.EclipsePlugin.EclipseKeys import play.twirl.sbt.SbtTwirl import play.twirl.sbt.Import.TwirlKeys._ +import sbtassembly._ +import sbtassembly.AssemblyKeys._ object MyBuild extends Build { val Organization = "jp.sf.amateras" @@ -17,6 +19,17 @@ object MyBuild extends Build { file(".") ) .settings(ScalatraPlugin.scalatraWithJRebel: _*) + .settings( + test in assembly := {}, + assemblyMergeStrategy in assembly := { + case PathList("META-INF", xs @ _*) => + (xs map {_.toLowerCase}) match { + case ("manifest.mf" :: Nil) => MergeStrategy.discard + case _ => MergeStrategy.discard + } + case x => MergeStrategy.first + } + ) .settings( sourcesInBase := false, organization := Organization, @@ -45,7 +58,7 @@ object MyBuild extends Build { "com.typesafe.slick" %% "slick" % "2.1.0", "com.novell.ldap" % "jldap" % "2009-10-07", "com.h2database" % "h2" % "1.4.180", - "ch.qos.logback" % "logback-classic" % "1.0.13" % "runtime", +// "ch.qos.logback" % "logback-classic" % "1.0.13" % "runtime", "org.eclipse.jetty" % "jetty-webapp" % "8.1.8.v20121106" % "container;provided", "org.eclipse.jetty.orbit" % "javax.servlet" % "3.0.0.v201112011016" % "container;provided;test" artifacts Artifact("javax.servlet", "jar", "jar"), "junit" % "junit" % "4.11" % "test", diff --git a/project/plugins.sbt b/project/plugins.sbt index de95ab6af..dee3cdf87 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -7,3 +7,5 @@ addSbtPlugin("org.scalatra.sbt" % "scalatra-sbt" % "0.3.5") addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.0.2") addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.1.4") + +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.12.0") \ No newline at end of file diff --git a/src/main/scala/ScalatraBootstrap.scala b/src/main/scala/ScalatraBootstrap.scala index bdbd89867..cfa37e62d 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -1,5 +1,7 @@ import _root_.servlet.{BasicAuthenticationFilter, TransactionFilter} import app._ +import plugin.PluginRegistry + //import jp.sf.amateras.scalatra.forms.ValidationJavaScriptProvider import org.scalatra._ import javax.servlet._ @@ -15,6 +17,11 @@ class ScalatraBootstrap extends LifeCycle { // Register controllers context.mount(new AnonymousAccessController, "/*") + + PluginRegistry().getControllers.foreach { case (controller, path) => + context.mount(controller, path) + } + context.mount(new IndexController, "/") context.mount(new SearchController, "/") context.mount(new FileUploadController, "/upload") diff --git a/src/main/scala/plugin/Plugin.scala b/src/main/scala/plugin/Plugin.scala new file mode 100644 index 000000000..c17687466 --- /dev/null +++ b/src/main/scala/plugin/Plugin.scala @@ -0,0 +1,30 @@ +package plugin + +import javax.servlet.ServletContext + +import util.Version + +/** + * Trait for define plugin interface. + * To provide plugin, put Plugin class which mixed in this trait into the package root. + */ +trait Plugin { + + val pluginId: String + val pluginName: String + val description: String + val versions: Seq[Version] + + /** + * This method is invoked in initialization of plugin system. + * Register plugin functionality to PluginRegistry. + */ + def initialize(registry: PluginRegistry): Unit + + /** + * This method is invoked in shutdown of plugin system. + * If the plugin has any resources, release them in this method. + */ + def shutdown(registry: PluginRegistry): Unit + +} diff --git a/src/main/scala/plugin/PluginRegistory.scala b/src/main/scala/plugin/PluginRegistory.scala new file mode 100644 index 000000000..bb7c2cb3b --- /dev/null +++ b/src/main/scala/plugin/PluginRegistory.scala @@ -0,0 +1,145 @@ +package plugin + +import java.io.{FilenameFilter, File} +import java.net.URLClassLoader +import javax.servlet.ServletContext +import javax.servlet.http.{HttpServletRequest, HttpServletResponse} + +import org.slf4j.LoggerFactory +import service.RepositoryService.RepositoryInfo +import util.Directory._ +import util.JDBCUtil._ +import util.{Version, Versions} + +import scala.collection.mutable.ListBuffer +import app.{ControllerBase, Context} + +class PluginRegistry { + + private val plugins = new ListBuffer[PluginInfo] + private val javaScripts = new ListBuffer[(String, String)] + private val controllers = new ListBuffer[(ControllerBase, String)] + + def addPlugin(pluginInfo: PluginInfo): Unit = { + plugins += pluginInfo + } + + def getPlugins(): List[PluginInfo] = plugins.toList + + def addController(controller: ControllerBase, path: String): Unit = { + controllers += ((controller, path)) + } + + def getControllers(): List[(ControllerBase, String)] = controllers.toList + + def addJavaScript(path: String, script: String): Unit = { + javaScripts += Tuple2(path, script) + } + + //def getJavaScripts(): List[(String, String)] = javaScripts.toList + + def getJavaScript(currentPath: String): Option[String] = { + javaScripts.find(x => currentPath.matches(x._1)).map(_._2) + } + + private case class GlobalAction( + method: String, + path: String, + function: (HttpServletRequest, HttpServletResponse, Context) => Any + ) + + private case class RepositoryAction( + method: String, + path: String, + function: (HttpServletRequest, HttpServletResponse, Context, RepositoryInfo) => Any + ) + +} + +/** + * Provides entry point to PluginRegistry. + */ +object PluginRegistry { + + private val logger = LoggerFactory.getLogger(classOf[PluginRegistry]) + + private val instance = new PluginRegistry() + + /** + * Returns the PluginRegistry singleton instance. + */ + def apply(): PluginRegistry = instance + + /** + * Initializes all installed plugins. + */ + def initialize(context: ServletContext, conn: java.sql.Connection): Unit = { + val pluginDir = new File(PluginHome) + if(pluginDir.exists && pluginDir.isDirectory){ + pluginDir.listFiles(new FilenameFilter { + override def accept(dir: File, name: String): Boolean = name.endsWith(".jar") + }).foreach { pluginJar => + val classLoader = new URLClassLoader(Array(pluginJar.toURI.toURL), Thread.currentThread.getContextClassLoader) + try { + val plugin = classLoader.loadClass("Plugin").newInstance().asInstanceOf[Plugin] + + // Migration + val headVersion = plugin.versions.head + val currentVersion = conn.find("SELECT * FROM PLUGIN WHERE PLUGIN_ID = ?", plugin.pluginId)(_.getString("VERSION")) match { + case Some(x) => { + val dim = x.split("\\.") + Version(dim(0).toInt, dim(1).toInt) + } + case None => Version(0, 0) + } + + Versions.update(conn, headVersion, currentVersion, plugin.versions, new URLClassLoader(Array(pluginJar.toURI.toURL))){ conn => + currentVersion.versionString match { + case "0.0" => + conn.update("INSERT INTO PLUGIN (PLUGIN_ID, VERSION) VALUES (?, ?)", plugin.pluginId, headVersion.versionString) + case _ => + conn.update("UPDATE PLUGIN SET VERSION = ? WHERE PLUGIN_ID = ?", headVersion.versionString, plugin.pluginId) + } + } + + // Initialize + plugin.initialize(instance) + instance.addPlugin(PluginInfo( + pluginId = plugin.pluginId, + pluginName = plugin.pluginName, + version = plugin.versions.head.versionString, + description = plugin.description, + pluginClass = plugin + )) + + } catch { + case e: Exception => { + logger.error(s"Error during plugin initialization", e) + } + } + } + } + } + + def shutdown(context: ServletContext): Unit = { + instance.getPlugins().foreach { pluginInfo => + try { + pluginInfo.pluginClass.shutdown(instance) + } catch { + case e: Exception => { + logger.error(s"Error during plugin shutdown", e) + } + } + } + } + + +} + +case class PluginInfo( + pluginId: String, + pluginName: String, + version: String, + description: String, + pluginClass: Plugin +) \ No newline at end of file diff --git a/src/main/scala/plugin/Results.scala b/src/main/scala/plugin/Results.scala new file mode 100644 index 000000000..18fdb7fc4 --- /dev/null +++ b/src/main/scala/plugin/Results.scala @@ -0,0 +1,11 @@ +package plugin + +import play.twirl.api.Html + +/** + * Defines result case classes returned by plugin controller. + */ +object Results { + case class Redirect(path: String) + case class Fragment(html: Html) +} diff --git a/src/main/scala/plugin/Sessions.scala b/src/main/scala/plugin/Sessions.scala new file mode 100644 index 000000000..7398c9a8d --- /dev/null +++ b/src/main/scala/plugin/Sessions.scala @@ -0,0 +1,11 @@ +package plugin + +import slick.jdbc.JdbcBackend.Session + +/** + * Provides Slick Session to Plug-ins. + */ +object Sessions { + val sessions = new ThreadLocal[Session] + implicit def session: Session = sessions.get() +} diff --git a/src/main/scala/servlet/AutoUpdateListener.scala b/src/main/scala/servlet/InitializeListener.scala similarity index 69% rename from src/main/scala/servlet/AutoUpdateListener.scala rename to src/main/scala/servlet/InitializeListener.scala index 30610bcb9..fdc3c48bf 100644 --- a/src/main/scala/servlet/AutoUpdateListener.scala +++ b/src/main/scala/servlet/InitializeListener.scala @@ -4,49 +4,16 @@ import java.io.File import java.sql.{DriverManager, Connection} import org.apache.commons.io.FileUtils import javax.servlet.{ServletContextListener, ServletContextEvent} -import org.apache.commons.io.IOUtils import org.slf4j.LoggerFactory import util.Directory._ import util.ControlUtil._ import util.JDBCUtil._ import org.eclipse.jgit.api.Git +import util.{Version, Versions} +import plugin._ import util.{DatabaseConfig, Directory} object AutoUpdate { - - /** - * Version of GitBucket - * - * @param majorVersion the major version - * @param minorVersion the minor version - */ - case class Version(majorVersion: Int, minorVersion: Int){ - - private val logger = LoggerFactory.getLogger(classOf[servlet.AutoUpdate.Version]) - - /** - * Execute update/MAJOR_MINOR.sql to update schema to this version. - * If corresponding SQL file does not exist, this method do nothing. - */ - def update(conn: Connection): Unit = { - val sqlPath = s"update/${majorVersion}_${minorVersion}.sql" - - using(Thread.currentThread.getContextClassLoader.getResourceAsStream(sqlPath)){ in => - if(in != null){ - val sql = IOUtils.toString(in, "UTF-8") - using(conn.createStatement()){ stmt => - logger.debug(sqlPath + "=" + sql) - stmt.executeUpdate(sql) - } - } - } - } - - /** - * MAJOR.MINOR - */ - val versionString = s"${majorVersion}.${minorVersion}" - } /** * The history of versions. A head of this sequence is the current BitBucket version. @@ -54,8 +21,8 @@ object AutoUpdate { val versions = Seq( new Version(2, 8), new Version(2, 7) { - override def update(conn: Connection): Unit = { - super.update(conn) + override def update(conn: Connection, cl: ClassLoader): Unit = { + super.update(conn, cl) conn.select("SELECT * FROM REPOSITORY"){ rs => // Rename attached files directory from /issues to /comments val userName = rs.getString("USER_NAME") @@ -93,8 +60,8 @@ object AutoUpdate { new Version(2, 5), new Version(2, 4), new Version(2, 3) { - override def update(conn: Connection): Unit = { - super.update(conn) + override def update(conn: Connection, cl: ClassLoader): Unit = { + super.update(conn, cl) conn.select("SELECT ACTIVITY_ID, ADDITIONAL_INFO FROM ACTIVITY WHERE ACTIVITY_TYPE='push'"){ rs => val curInfo = rs.getString("ADDITIONAL_INFO") val newInfo = curInfo.split("\n").filter(_ matches "^[0-9a-z]{40}:.*").mkString("\n") @@ -102,20 +69,22 @@ object AutoUpdate { conn.update("UPDATE ACTIVITY SET ADDITIONAL_INFO = ? WHERE ACTIVITY_ID = ?", newInfo, rs.getInt("ACTIVITY_ID")) } } - FileUtils.deleteDirectory(Directory.getPluginCacheDir()) - FileUtils.deleteDirectory(new File(Directory.PluginHome)) + ignore { + FileUtils.deleteDirectory(Directory.getPluginCacheDir()) + //FileUtils.deleteDirectory(new File(Directory.PluginHome)) + } } }, new Version(2, 2), new Version(2, 1), new Version(2, 0){ - override def update(conn: Connection): Unit = { + override def update(conn: Connection, cl: ClassLoader): Unit = { import eu.medsea.mimeutil.{MimeUtil2, MimeType} val mimeUtil = new MimeUtil2() mimeUtil.registerMimeDetector("eu.medsea.mimeutil.detector.MagicMimeMimeDetector") - super.update(conn) + super.update(conn, cl) conn.select("SELECT USER_NAME, REPOSITORY_NAME FROM REPOSITORY"){ rs => defining(Directory.getAttachedDir(rs.getString("USER_NAME"), rs.getString("REPOSITORY_NAME"))){ dir => if(dir.exists && dir.isDirectory){ @@ -143,8 +112,8 @@ object AutoUpdate { Version(1, 5), Version(1, 4), new Version(1, 3){ - override def update(conn: Connection): Unit = { - super.update(conn) + override def update(conn: Connection, cl: ClassLoader): Unit = { + super.update(conn, cl) // Fix wiki repository configuration conn.select("SELECT USER_NAME, REPOSITORY_NAME FROM REPOSITORY"){ rs => using(Git.open(getWikiRepositoryDir(rs.getString("USER_NAME"), rs.getString("REPOSITORY_NAME")))){ git => @@ -193,14 +162,14 @@ object AutoUpdate { } /** - * Update database schema automatically in the context initializing. + * Initialize GitBucket system. + * Update database schema and load plug-ins automatically in the context initializing. */ -class AutoUpdateListener extends ServletContextListener { +class InitializeListener extends ServletContextListener { import AutoUpdate._ - private val logger = LoggerFactory.getLogger(classOf[AutoUpdateListener]) -// private val scheduler = StdSchedulerFactory.getDefaultScheduler - + private val logger = LoggerFactory.getLogger(classOf[InitializeListener]) + override def contextInitialized(event: ServletContextEvent): Unit = { val dataDir = event.getServletContext.getInitParameter("gitbucket.home") if(dataDir != null){ @@ -209,31 +178,21 @@ class AutoUpdateListener extends ServletContextListener { org.h2.Driver.load() defining(getConnection()){ conn => + // Migration logger.debug("Start schema update") - try { - defining(getCurrentVersion()){ currentVersion => - if(currentVersion == headVersion){ - logger.debug("No update") - } else if(!versions.contains(currentVersion)){ - logger.warn(s"Skip migration because ${currentVersion.versionString} is illegal version.") - } else { - versions.takeWhile(_ != currentVersion).reverse.foreach(_.update(conn)) - FileUtils.writeStringToFile(versionFile, headVersion.versionString, "UTF-8") - logger.debug(s"Updated from ${currentVersion.versionString} to ${headVersion.versionString}") - } - } - } catch { - case ex: Throwable => { - logger.error("Failed to schema update", ex) - ex.printStackTrace() - conn.rollback() - } + Versions.update(conn, headVersion, getCurrentVersion(), versions, Thread.currentThread.getContextClassLoader){ conn => + FileUtils.writeStringToFile(versionFile, headVersion.versionString, "UTF-8") } - logger.debug("End schema update") + // Load plugins + logger.debug("Initialize plugins") + PluginRegistry.initialize(event.getServletContext, conn) } + } - def contextDestroyed(sce: ServletContextEvent): Unit = { + def contextDestroyed(event: ServletContextEvent): Unit = { + // Shutdown plugins + PluginRegistry.shutdown(event.getServletContext) } private def getConnection(): Connection = diff --git a/src/main/scala/util/ControlUtil.scala b/src/main/scala/util/ControlUtil.scala index c231fb01f..7945f32a5 100644 --- a/src/main/scala/util/ControlUtil.scala +++ b/src/main/scala/util/ControlUtil.scala @@ -37,4 +37,10 @@ object ControlUtil { def using[T](treeWalk: TreeWalk)(f: TreeWalk => T): T = try f(treeWalk) finally treeWalk.release() + def ignore[T](f: => Unit): Unit = try { + f + } catch { + case e: Exception => () + } + } diff --git a/src/main/scala/util/JDBCUtil.scala b/src/main/scala/util/JDBCUtil.scala index 375ea1464..5d880d0c5 100644 --- a/src/main/scala/util/JDBCUtil.scala +++ b/src/main/scala/util/JDBCUtil.scala @@ -18,6 +18,14 @@ object JDBCUtil { } } + def find[T](sql: String, params: Any*)(f: ResultSet => T): Option[T] = { + execute(sql, params: _*){ stmt => + using(stmt.executeQuery()){ rs => + if(rs.next) Some(f(rs)) else None + } + } + } + def select[T](sql: String, params: Any*)(f: ResultSet => T): Seq[T] = { execute(sql, params: _*){ stmt => using(stmt.executeQuery()){ rs => diff --git a/src/main/scala/util/Version.scala b/src/main/scala/util/Version.scala new file mode 100644 index 000000000..fed74a8e0 --- /dev/null +++ b/src/main/scala/util/Version.scala @@ -0,0 +1,67 @@ +package util + +import java.sql.Connection + +import org.apache.commons.io.IOUtils +import org.slf4j.LoggerFactory +import util.ControlUtil._ + +case class Version(majorVersion: Int, minorVersion: Int) { + + private val logger = LoggerFactory.getLogger(classOf[Version]) + + /** + * Execute update/MAJOR_MINOR.sql to update schema to this version. + * If corresponding SQL file does not exist, this method do nothing. + */ + def update(conn: Connection, cl: ClassLoader): Unit = { + val sqlPath = s"update/${majorVersion}_${minorVersion}.sql" + + using(cl.getResourceAsStream(sqlPath)){ in => + if(in != null){ + val sql = IOUtils.toString(in, "UTF-8") + using(conn.createStatement()){ stmt => + logger.debug(sqlPath + "=" + sql) + stmt.executeUpdate(sql) + } + } + } + } + + + /** + * MAJOR.MINOR + */ + val versionString = s"${majorVersion}.${minorVersion}" + +} + +object Versions { + + private val logger = LoggerFactory.getLogger(Versions.getClass) + + def update(conn: Connection, headVersion: Version, currentVersion: Version, versions: Seq[Version], cl: ClassLoader) + (save: Connection => Unit): Unit = { + logger.debug("Start schema update") + try { + if(currentVersion == headVersion){ + logger.debug("No update") + } else if(currentVersion.versionString != "0.0" && !versions.contains(currentVersion)){ + logger.warn(s"Skip migration because ${currentVersion.versionString} is illegal version.") + } else { + versions.takeWhile(_ != currentVersion).reverse.foreach(_.update(conn, cl)) + save(conn) + logger.debug(s"Updated from ${currentVersion.versionString} to ${headVersion.versionString}") + } + } catch { + case ex: Throwable => { + logger.error("Failed to schema update", ex) + ex.printStackTrace() + conn.rollback() + } + } + logger.debug("End schema update") + } + +} + diff --git a/src/main/twirl/main.scala.html b/src/main/twirl/main.scala.html index 9b8ae0280..6d35ff1d1 100644 --- a/src/main/twirl/main.scala.html +++ b/src/main/twirl/main.scala.html @@ -82,5 +82,10 @@ }); }); + @plugin.PluginRegistry().getJavaScript(request.getRequestURI).map { script => + + } diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 51513a1e8..c015d240d 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -12,10 +12,10 @@ - + - servlet.AutoUpdateListener + servlet.InitializeListener