diff --git a/project/build.scala b/project/build.scala index 51030c6..e206b02 100644 --- a/project/build.scala +++ b/project/build.scala @@ -39,7 +39,9 @@ "org.apache.httpcomponents" % "httpclient" % "4.3", "org.apache.sshd" % "apache-sshd" % "0.11.0", "com.typesafe.slick" %% "slick" % "1.0.1", + "org.mozilla" % "rhino" % "1.7R4", "com.novell.ldap" % "jldap" % "2009-10-07", + "org.quartz-scheduler" % "quartz" % "2.2.1", "com.h2database" % "h2" % "1.3.173", "ch.qos.logback" % "logback-classic" % "1.0.13" % "runtime", "org.eclipse.jetty" % "jetty-webapp" % "8.1.8.v20121106" % "container;provided", diff --git a/src/main/scala/ScalatraBootstrap.scala b/src/main/scala/ScalatraBootstrap.scala index 90fe415..25dbf5b 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -1,4 +1,4 @@ -import _root_.servlet.{BasicAuthenticationFilter, TransactionFilter} +import _root_.servlet.{PluginActionInvokeFilter, BasicAuthenticationFilter, TransactionFilter} import app._ //import jp.sf.amateras.scalatra.forms.ValidationJavaScriptProvider import org.scalatra._ @@ -10,6 +10,8 @@ // Register TransactionFilter and BasicAuthenticationFilter at first context.addFilter("transactionFilter", new TransactionFilter) context.getFilterRegistration("transactionFilter").addMappingForUrlPatterns(EnumSet.allOf(classOf[DispatcherType]), true, "/*") + context.addFilter("pluginActionInvokeFilter", new PluginActionInvokeFilter) + context.getFilterRegistration("pluginActionInvokeFilter").addMappingForUrlPatterns(EnumSet.allOf(classOf[DispatcherType]), true, "/*") context.addFilter("basicAuthenticationFilter", new BasicAuthenticationFilter) context.getFilterRegistration("basicAuthenticationFilter").addMappingForUrlPatterns(EnumSet.allOf(classOf[DispatcherType]), true, "/git/*") diff --git a/src/main/scala/app/SystemSettingsController.scala b/src/main/scala/app/SystemSettingsController.scala index 93036eb..3efb6c3 100644 --- a/src/main/scala/app/SystemSettingsController.scala +++ b/src/main/scala/app/SystemSettingsController.scala @@ -3,8 +3,13 @@ import service.{AccountService, SystemSettingsService} import SystemSettingsService._ import util.AdminAuthenticator +import util.Directory._ +import util.ControlUtil._ import jp.sf.amateras.scalatra.forms._ import ssh.SshServer +import org.apache.commons.io.FileUtils +import java.io.FileInputStream +import plugin.{Plugin, PluginSystem} class SystemSettingsController extends SystemSettingsControllerBase with AccountService with AdminAuthenticator @@ -47,6 +52,11 @@ } else Nil } + private val pluginForm = mapping( + "pluginId" -> list(trim(label("", text()))) + )(PluginForm.apply) + + case class PluginForm(pluginIds: List[String]) get("/admin/system")(adminOnly { admin.html.system(flash.get("info")) @@ -71,4 +81,103 @@ redirect("/admin/system") }) + get("/admin/plugins")(adminOnly { + val installedPlugins = plugin.PluginSystem.plugins + val updatablePlugins = getAvailablePlugins(installedPlugins).filter(_.status == "updatable") + admin.plugins.html.installed(installedPlugins, updatablePlugins) + }) + + post("/admin/plugins/_update", pluginForm)(adminOnly { form => + deletePlugins(form.pluginIds) + installPlugins(form.pluginIds) + redirect("/admin/plugins") + }) + + post("/admin/plugins/_delete", pluginForm)(adminOnly { form => + deletePlugins(form.pluginIds) + redirect("/admin/plugins") + }) + + get("/admin/plugins/available")(adminOnly { + val installedPlugins = plugin.PluginSystem.plugins + val availablePlugins = getAvailablePlugins(installedPlugins).filter(_.status == "available") + admin.plugins.html.available(availablePlugins) + }) + + post("/admin/plugins/_install", pluginForm)(adminOnly { form => + installPlugins(form.pluginIds) + redirect("/admin/plugins") + }) + +// get("/admin/plugins/console")(adminOnly { +// admin.plugins.html.console() +// }) +// +// post("/admin/plugins/console")(adminOnly { +// val script = request.getParameter("script") +// val result = plugin.JavaScriptPlugin.evaluateJavaScript(script) +// Ok(result) +// }) + + // TODO Move these methods to PluginSystem or Service? + private def deletePlugins(pluginIds: List[String]): Unit = { + pluginIds.foreach { pluginId => + plugin.PluginSystem.uninstall(pluginId) + val dir = new java.io.File(PluginHome, pluginId) + if(dir.exists && dir.isDirectory){ + FileUtils.deleteQuietly(dir) + PluginSystem.uninstall(pluginId) + } + } + } + + private def installPlugins(pluginIds: List[String]): Unit = { + val dir = getPluginCacheDir() + val installedPlugins = plugin.PluginSystem.plugins + getAvailablePlugins(installedPlugins).filter(x => pluginIds.contains(x.id)).foreach { plugin => + val pluginDir = new java.io.File(PluginHome, plugin.id) + if(!pluginDir.exists){ + FileUtils.copyDirectory(new java.io.File(dir, plugin.repository + "/" + plugin.id), pluginDir) + } + PluginSystem.installPlugin(plugin.id) + } + } + + private def getAvailablePlugins(installedPlugins: List[Plugin]): List[SystemSettingsControllerBase.AvailablePlugin] = { + val repositoryRoot = getPluginCacheDir() + + if(repositoryRoot.exists && repositoryRoot.isDirectory){ + PluginSystem.repositories.flatMap { repo => + val repoDir = new java.io.File(repositoryRoot, repo.id) + if(repoDir.exists && repoDir.isDirectory){ + repoDir.listFiles.filter(d => d.isDirectory && !d.getName.startsWith(".")).map { plugin => + val propertyFile = new java.io.File(plugin, "plugin.properties") + val properties = new java.util.Properties() + if(propertyFile.exists && propertyFile.isFile){ + using(new FileInputStream(propertyFile)){ in => + properties.load(in) + } + } + SystemSettingsControllerBase.AvailablePlugin( + repository = repo.id, + id = properties.getProperty("id"), + version = properties.getProperty("version"), + author = properties.getProperty("author"), + url = properties.getProperty("url"), + description = properties.getProperty("description"), + status = installedPlugins.find(_.id == properties.getProperty("id")) match { + case Some(x) if(PluginSystem.isUpdatable(x.version, properties.getProperty("version")))=> "updatable" + case Some(x) => "installed" + case None => "available" + }) + } + } else Nil + } + } else Nil + } +} + +object SystemSettingsControllerBase { + case class AvailablePlugin(repository: String, id: String, version: String, + author: String, url: String, description: String, status: String) } diff --git a/src/main/scala/plugin/JavaScriptPlugin.scala b/src/main/scala/plugin/JavaScriptPlugin.scala new file mode 100644 index 0000000..1a8d0e5 --- /dev/null +++ b/src/main/scala/plugin/JavaScriptPlugin.scala @@ -0,0 +1,88 @@ +package plugin + +import org.mozilla.javascript.{Context => JsContext} +import org.mozilla.javascript.{Function => JsFunction} +import scala.collection.mutable.ListBuffer +import plugin.PluginSystem.{Action, GlobalMenu, RepositoryMenu} + +class JavaScriptPlugin(val id: String, val version: String, + val author: String, val url: String, val description: String) extends Plugin { + + private val repositoryMenuList = ListBuffer[RepositoryMenu]() + private val globalMenuList = ListBuffer[GlobalMenu]() + private val repositoryActionList = ListBuffer[Action]() + private val globalActionList = ListBuffer[Action]() + + def repositoryMenus : List[RepositoryMenu] = repositoryMenuList.toList + def globalMenus : List[GlobalMenu] = globalMenuList.toList + def repositoryActions : List[Action] = repositoryActionList.toList + def globalActions : List[Action] = globalActionList.toList + + def addRepositoryMenu(label: String, name: String, url: String, icon: String, condition: JsFunction): Unit = { + repositoryMenuList += RepositoryMenu(label, name, url, icon, (context) => { + val context = JsContext.enter() + try { + condition.call(context, condition, condition, Array(context)).asInstanceOf[Boolean] + } finally { + JsContext.exit() + } + }) + } + + def addGlobalMenu(label: String, url: String, icon: String, condition: JsFunction): Unit = { + globalMenuList += GlobalMenu(label, url, icon, (context) => { + val context = JsContext.enter() + try { + condition.call(context, condition, condition, Array(context)).asInstanceOf[Boolean] + } finally { + JsContext.exit() + } + }) + } + + def addGlobalAction(path: String, function: JsFunction): Unit = { + globalActionList += Action(path, (request, response) => { + val context = JsContext.enter() + try { + function.call(context, function, function, Array(request, response)) + } finally { + JsContext.exit() + } + }) + } + + def addRepositoryAction(path: String, function: JsFunction): Unit = { + repositoryActionList += Action(path, (request, response) => { + val context = JsContext.enter() + try { + function.call(context, function, function, Array(request, response)) + } finally { + JsContext.exit() + } + }) + } + +} + +object JavaScriptPlugin { + + def define(id: String, version: String, author: String, url: String, description: String) + = new JavaScriptPlugin(id, version, author, url, description) + + def evaluateJavaScript(script: String, vars: Map[String, Any] = Map.empty): Any = { + val context = JsContext.enter() + try { + val scope = context.initStandardObjects() + scope.put("PluginSystem", scope, PluginSystem) + scope.put("JavaScriptPlugin", scope, this) + vars.foreach { case (key, value) => + scope.put(key, scope, value) + } + val result = context.evaluateString(scope, script, "", 1, null) + result + } finally { + JsContext.exit + } + } + +} \ No newline at end of file diff --git a/src/main/scala/plugin/Plugin.scala b/src/main/scala/plugin/Plugin.scala new file mode 100644 index 0000000..59961fe --- /dev/null +++ b/src/main/scala/plugin/Plugin.scala @@ -0,0 +1,16 @@ +package plugin + +import plugin.PluginSystem.{Action, GlobalMenu, RepositoryMenu} + +trait Plugin { + val id: String + val version: String + val author: String + val url: String + val description: String + + def repositoryMenus : List[RepositoryMenu] + def globalMenus : List[GlobalMenu] + def repositoryActions : List[Action] + def globalActions : List[Action] +} diff --git a/src/main/scala/plugin/PluginSystem.scala b/src/main/scala/plugin/PluginSystem.scala new file mode 100644 index 0000000..e40f860 --- /dev/null +++ b/src/main/scala/plugin/PluginSystem.scala @@ -0,0 +1,123 @@ +package plugin + +import app.Context +import javax.servlet.http.{HttpServletResponse, HttpServletRequest} +import org.slf4j.LoggerFactory +import java.util.concurrent.atomic.AtomicBoolean +import util.Directory._ +import util.ControlUtil._ +import org.apache.commons.io.FileUtils +import util.JGitUtil +import org.eclipse.jgit.api.Git + +/** + * Provides extension points to plug-ins. + */ +object PluginSystem { + + private val logger = LoggerFactory.getLogger(PluginSystem.getClass) + + private val initialized = new AtomicBoolean(false) + private val pluginsMap = scala.collection.mutable.Map[String, Plugin]() + private val repositoriesList = scala.collection.mutable.ListBuffer[PluginRepository]() + + def install(plugin: Plugin): Unit = { + pluginsMap.put(plugin.id, plugin) + } + + def plugins: List[Plugin] = pluginsMap.values.toList + + def uninstall(id: String): Unit = { + pluginsMap.remove(id) + } + + def repositories: List[PluginRepository] = repositoriesList.toList + + /** + * Initializes the plugin system. Load scripts from GITBUCKET_HOME/plugins. + */ + def init(): Unit = { + if(initialized.compareAndSet(false, true)){ + // Load installed plugins + val pluginDir = new java.io.File(PluginHome) + if(pluginDir.exists && pluginDir.isDirectory){ + pluginDir.listFiles.filter(f => f.isDirectory && !f.getName.startsWith(".")).foreach { dir => + installPlugin(dir.getName) + } + } + // Add default plugin repositories + repositoriesList += PluginRepository("central", "https://github.com/takezoe/gitbucket_plugins.git") + } + } + + // TODO Method name seems to not so good. + def installPlugin(id: String): Unit = { + val pluginDir = new java.io.File(PluginHome) + val javaScriptFile = new java.io.File(pluginDir, id + "/plugin.js") + + if(javaScriptFile.exists && javaScriptFile.isFile){ + val properties = new java.util.Properties() + using(new java.io.FileInputStream(new java.io.File(pluginDir, id + "/plugin.properties"))){ in => + properties.load(in) + } + + val script = FileUtils.readFileToString(javaScriptFile, "UTF-8") + try { + JavaScriptPlugin.evaluateJavaScript(script, Map( + "id" -> properties.getProperty("id"), + "version" -> properties.getProperty("version"), + "author" -> properties.getProperty("author"), + "url" -> properties.getProperty("url"), + "description" -> properties.getProperty("description") + )) + } catch { + case e: Exception => logger.warn(s"Error in plugin loading for ${javaScriptFile.getAbsolutePath}", e) + } + } + } + + def repositoryMenus : List[RepositoryMenu] = pluginsMap.values.flatMap(_.repositoryMenus).toList + def globalMenus : List[GlobalMenu] = pluginsMap.values.flatMap(_.globalMenus).toList + def repositoryActions : List[Action] = pluginsMap.values.flatMap(_.repositoryActions).toList + def globalActions : List[Action] = pluginsMap.values.flatMap(_.globalActions).toList + + // Case classes to hold plug-ins information internally in GitBucket + case class PluginRepository(id: String, url: String) + case class GlobalMenu(label: String, url: String, icon: String, condition: Context => Boolean) + case class RepositoryMenu(label: String, name: String, url: String, icon: String, condition: Context => Boolean) + case class Action(path: String, function: (HttpServletRequest, HttpServletResponse) => Any) + + /** + * Checks whether the plugin is updatable. + */ + def isUpdatable(oldVersion: String, newVersion: String): Boolean = { + if(oldVersion == newVersion){ + false + } else { + val dim1 = oldVersion.split("\\.").map(_.toInt) + val dim2 = newVersion.split("\\.").map(_.toInt) + dim1.zip(dim2).foreach { case (a, b) => + if(a < b){ + return true + } else if(a > b){ + return false + } + } + return false + } + } + + // TODO This is a test +// addGlobalMenu("Google", "http://www.google.co.jp/", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAEvwAABL8BkeKJvAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIgSURBVEiJtdZNiI1hFAfw36ORhSFFPgYLszOKJAsWRLGzks1gYyFZKFs7C7K2Y2XDRiwmq9kIJWQjJR9Tk48xRtTIRwjH4p473nm99yLNqdNTz/mf//+555x7ektEmEmbNaPs6OkUKKX0YBmWp6/IE8bwIs8xjEfEt0aiiJBl6sEuXMRLfEf8pX/PnIvJ0TPFWxE4+w+Ef/Kzbd5qDx5l8H8tkku7LG17gH7sxWatevdhEUoXsjda5RnDTZzH6jagtMe0lHIa23AJw3iOiSRZlmJ9mfcyfTzFl2AldmI3rkbEkbrAYKrX7S1eVRyWVnxhQ87eiLjQ+o2/mtyve+PuYy3W4+EfsP2/TVGKTHRI+Iz9Fdx8XOmAnZjGWRMYqoF/4ESW4hpOYk1iZ2WsLjDUTeBYBfgeuyux2XiNT5hXud+DD5W8Y90EtifoSfultfjx7MVtrKzcr8No5m7vJtCLx1hQJ8/4IZzClpyoy5ibsYUYQW81Z9o2jYgPeKr15+poEXE9+1XF9WIkOaasaV2P4k4pZUdDbEm+VEQcjIgtEfGxlLIVd/Gs6TX1MhzQquU3HK1t23f4IsuS94fxNXMO/MbXIDBg+tidw5yMbcCmylSdqWEH/kagYLKWeAt9Fcxi3KhhJuXq6SqQBMO15NDalvswmLWux4cbuToIbMS9BpJOfg8bm7imtmmTlVJWaa3hpnU9nufziBjtyDHTny0/AaA7Qnb4AM4aAAAAAElFTkSuQmCC") +// { context => context.loginAccount.isDefined } +// +// addRepositoryMenu("Board", "board", "/board", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAEvwAABL8BkeKJvAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIgSURBVEiJtdZNiI1hFAfw36ORhSFFPgYLszOKJAsWRLGzks1gYyFZKFs7C7K2Y2XDRiwmq9kIJWQjJR9Tk48xRtTIRwjH4p473nm99yLNqdNTz/mf//+555x7ektEmEmbNaPs6OkUKKX0YBmWp6/IE8bwIs8xjEfEt0aiiJBl6sEuXMRLfEf8pX/PnIvJ0TPFWxE4+w+Ef/Kzbd5qDx5l8H8tkku7LG17gH7sxWatevdhEUoXsjda5RnDTZzH6jagtMe0lHIa23AJw3iOiSRZlmJ9mfcyfTzFl2AldmI3rkbEkbrAYKrX7S1eVRyWVnxhQ87eiLjQ+o2/mtyve+PuYy3W4+EfsP2/TVGKTHRI+Iz9Fdx8XOmAnZjGWRMYqoF/4ESW4hpOYk1iZ2WsLjDUTeBYBfgeuyux2XiNT5hXud+DD5W8Y90EtifoSfultfjx7MVtrKzcr8No5m7vJtCLx1hQJ8/4IZzClpyoy5ibsYUYQW81Z9o2jYgPeKr15+poEXE9+1XF9WIkOaasaV2P4k4pZUdDbEm+VEQcjIgtEfGxlLIVd/Gs6TX1MhzQquU3HK1t23f4IsuS94fxNXMO/MbXIDBg+tidw5yMbcCmylSdqWEH/kagYLKWeAt9Fcxi3KhhJuXq6SqQBMO15NDalvswmLWux4cbuToIbMS9BpJOfg8bm7imtmmTlVJWaa3hpnU9nufziBjtyDHTny0/AaA7Qnb4AM4aAAAAAElFTkSuQmCC") +// { context => true} +// +// addGlobalAction("/hello"){ (request, response) => +// "Hello World!" +// } + +} + + diff --git a/src/main/scala/plugin/PluginUpdateJob.scala b/src/main/scala/plugin/PluginUpdateJob.scala new file mode 100644 index 0000000..f041e64 --- /dev/null +++ b/src/main/scala/plugin/PluginUpdateJob.scala @@ -0,0 +1,66 @@ +package plugin + +import util.Directory._ +import org.eclipse.jgit.api.Git +import org.slf4j.LoggerFactory +import org.quartz.{Scheduler, JobExecutionContext, Job} +import org.quartz.JobBuilder._ +import org.quartz.TriggerBuilder._ +import org.quartz.SimpleScheduleBuilder._ + +class PluginUpdateJob extends Job { + + private val logger = LoggerFactory.getLogger(classOf[PluginUpdateJob]) + private var failedCount = 0 + + /** + * Clone or pull all plugin repositories + * + * TODO Support plugin repository access through the proxy server + */ + override def execute(context: JobExecutionContext): Unit = { + try { + if(failedCount > 3){ + logger.error("Skip plugin information updating because failed count is over limit") + } else { + logger.info("Start plugin information updating") + PluginSystem.repositories.foreach { repository => + logger.info(s"Updating ${repository.id}: ${repository.url}...") + val dir = getPluginCacheDir() + val repo = new java.io.File(dir, repository.id) + if(repo.exists){ + // pull if the repository is already cloned + Git.open(repo).pull().call() + } else { + // clone if the repository is not exist + Git.cloneRepository().setURI(repository.url).setDirectory(repo).call() + } + } + logger.info("End plugin information updating") + } + } catch { + case e: Exception => { + failedCount = failedCount + 1 + logger.error("Failed to update plugin information", e) + } + } + } +} + +object PluginUpdateJob { + + def schedule(scheduler: Scheduler): Unit = { + val job = newJob(classOf[PluginUpdateJob]) + .withIdentity("pluginUpdateJob") + .build() + + val trigger = newTrigger() + .withIdentity("pluginUpdateTrigger") + .startNow() + .withSchedule(simpleSchedule().withIntervalInHours(24).repeatForever()) + .build() + + scheduler.scheduleJob(job, trigger) + } + +} \ No newline at end of file diff --git a/src/main/scala/plugin/ScalaPlugin.scala b/src/main/scala/plugin/ScalaPlugin.scala new file mode 100644 index 0000000..c0bb728 --- /dev/null +++ b/src/main/scala/plugin/ScalaPlugin.scala @@ -0,0 +1,38 @@ +package plugin + +import app.Context +import scala.collection.mutable.ListBuffer +import plugin.PluginSystem.{Action, GlobalMenu, RepositoryMenu} +import javax.servlet.http.{HttpServletResponse, HttpServletRequest} + +// TODO This is a sample implementation for Scala based plug-ins. +class ScalaPlugin(val id: String, val version: String, + val author: String, val url: String, val description: String) extends Plugin { + + private val repositoryMenuList = ListBuffer[RepositoryMenu]() + private val globalMenuList = ListBuffer[GlobalMenu]() + private val repositoryActionList = ListBuffer[Action]() + private val globalActionList = ListBuffer[Action]() + + def repositoryMenus : List[RepositoryMenu] = repositoryMenuList.toList + def globalMenus : List[GlobalMenu] = globalMenuList.toList + def repositoryActions : List[Action] = repositoryActionList.toList + def globalActions : List[Action] = globalActionList.toList + + def addRepositoryMenu(label: String, name: String, url: String, icon: String)(condition: (Context) => Boolean): Unit = { + repositoryMenuList += RepositoryMenu(label, name, url, icon, condition) + } + + def addGlobalMenu(label: String, url: String, icon: String)(condition: (Context) => Boolean): Unit = { + globalMenuList += GlobalMenu(label, url, icon, condition) + } + + def addGlobalAction(path: String)(function: (HttpServletRequest, HttpServletResponse) => Any): Unit = { + globalActionList += Action(path, function) + } + + def addRepositoryAction(path: String)(function: (HttpServletRequest, HttpServletResponse) => Any): Unit = { + repositoryActionList += Action(path, function) + } + +} diff --git a/src/main/scala/servlet/AutoUpdateListener.scala b/src/main/scala/servlet/AutoUpdateListener.scala index f47c489..e79faa5 100644 --- a/src/main/scala/servlet/AutoUpdateListener.scala +++ b/src/main/scala/servlet/AutoUpdateListener.scala @@ -10,6 +10,7 @@ import util.ControlUtil._ import org.eclipse.jgit.api.Git import util.Directory +import plugin.PluginUpdateJob object AutoUpdate { @@ -143,8 +144,14 @@ * Update database schema automatically in the context initializing. */ class AutoUpdateListener extends ServletContextListener { + import org.quartz.impl.StdSchedulerFactory + import org.quartz.JobBuilder._ + import org.quartz.TriggerBuilder._ + import org.quartz.SimpleScheduleBuilder._ import AutoUpdate._ + private val logger = LoggerFactory.getLogger(classOf[AutoUpdateListener]) + private val scheduler = StdSchedulerFactory.getDefaultScheduler override def contextInitialized(event: ServletContextEvent): Unit = { val datadir = event.getServletContext.getInitParameter("gitbucket.home") @@ -178,10 +185,19 @@ } } logger.debug("End schema update") + + logger.debug("Starting plugin system...") + plugin.PluginSystem.init() + + scheduler.start() + PluginUpdateJob.schedule(scheduler) + logger.debug("PluginUpdateJob is started.") + + logger.debug("Plugin system is initialized.") } def contextDestroyed(sce: ServletContextEvent): Unit = { - // Nothing to do. + scheduler.shutdown() } private def getConnection(servletContext: ServletContext): Connection = diff --git a/src/main/scala/servlet/PluginActionInvokeFilter.scala b/src/main/scala/servlet/PluginActionInvokeFilter.scala new file mode 100644 index 0000000..90d391c --- /dev/null +++ b/src/main/scala/servlet/PluginActionInvokeFilter.scala @@ -0,0 +1,81 @@ +package servlet + +import javax.servlet._ +import javax.servlet.http.{HttpServletResponse, HttpServletRequest} +import org.apache.commons.io.IOUtils +import twirl.api.Html +import service.{AccountService, RepositoryService, SystemSettingsService} +import model.Account +import util.{JGitUtil, Keys} + +class PluginActionInvokeFilter extends Filter with SystemSettingsService with RepositoryService with AccountService { + + def init(config: FilterConfig) = {} + + def destroy(): Unit = {} + + def doFilter(req: ServletRequest, res: ServletResponse, chain: FilterChain): Unit = { + (req, res) match { + case (request: HttpServletRequest, response: HttpServletResponse) => { + // TODO Why withTransaction is needed? + Database(req.getServletContext) withTransaction { + val path = req.asInstanceOf[HttpServletRequest].getRequestURI + if(!processGlobalAction(path, request, response) && !processRepositoryAction(path, request, response)){ + chain.doFilter(req, res) + } + } + } + } + } + + private def processGlobalAction(path: String, request: HttpServletRequest, response: HttpServletResponse): Boolean = { + plugin.PluginSystem.globalActions.find(_.path == path).map { action => + val result = action.function(request, response) + result match { + case x: String => { + response.setContentType("text/html; charset=UTF-8") + val loginAccount = request.getSession.getAttribute(Keys.Session.LoginAccount).asInstanceOf[Account] + implicit val context = app.Context(loadSystemSettings(), Option(loginAccount), request) + val html = _root_.html.main("GitBucket", None)(Html(x)) + IOUtils.write(html.toString.getBytes("UTF-8"), response.getOutputStream) + } + case x => { + // TODO returns as JSON? + response.setContentType("application/json; charset=UTF-8") + + } + } + true + } getOrElse false + } + + private def processRepositoryAction(path: String, request: HttpServletRequest, response: HttpServletResponse): Boolean = { + val elements = path.split("/") + if(elements.length > 3){ + val owner = elements(1) + val name = elements(2) + val remain = elements.drop(3).mkString("/", "/", "") + getRepository(owner, name, "").flatMap { repository => // TODO fill baseUrl + plugin.PluginSystem.repositoryActions.find(_.path == remain).map { action => + val result = action.function(request, response) + result match { + case x: String => { + response.setContentType("text/html; charset=UTF-8") + val loginAccount = request.getSession.getAttribute(Keys.Session.LoginAccount).asInstanceOf[Account] + implicit val context = app.Context(loadSystemSettings(), Option(loginAccount), request) + val html = _root_.html.main("GitBucket", None)(_root_.html.menu("", repository)(Html(x))) // TODO specify active side menu + IOUtils.write(html.toString.getBytes("UTF-8"), response.getOutputStream) + } + case x => { + // TODO returns as JSON? + response.setContentType("application/json; charset=UTF-8") + + } + } + true + } + } getOrElse false + } else false + } + +} diff --git a/src/main/scala/util/Directory.scala b/src/main/scala/util/Directory.scala index 1d15f1a..920b22a 100644 --- a/src/main/scala/util/Directory.scala +++ b/src/main/scala/util/Directory.scala @@ -34,6 +34,10 @@ val DatabaseHome = s"${GitBucketHome}/data" + val PluginHome = s"${GitBucketHome}/plugins" + + val TemporaryHome = s"${GitBucketHome}/tmp" + /** * Substance directory of the repository. */ @@ -55,13 +59,18 @@ * Root of temporary directories for the upload file. */ def getTemporaryDir(sessionId: String): File = - new File(s"${GitBucketHome}/tmp/_upload/${sessionId}") + new File(s"${TemporaryHome}/_upload/${sessionId}") /** * Root of temporary directories for the specified repository. */ def getTemporaryDir(owner: String, repository: String): File = - new File(s"${GitBucketHome}/tmp/${owner}/${repository}") + new File(s"${TemporaryHome}/${owner}/${repository}") + + /** + * Root of plugin cache directory. Plugin repositories are cloned into this directory. + */ + def getPluginCacheDir(): File = new File(s"${TemporaryHome}/_plugins") /** * Temporary directory which is used to create an archive to download repository contents. diff --git a/src/main/twirl/admin/menu.scala.html b/src/main/twirl/admin/menu.scala.html index 09bc2de..25cda19 100644 --- a/src/main/twirl/admin/menu.scala.html +++ b/src/main/twirl/admin/menu.scala.html @@ -11,6 +11,9 @@ System Settings + + Plugins +
  • H2 Console
  • diff --git a/src/main/twirl/admin/plugins/available.scala.html b/src/main/twirl/admin/plugins/available.scala.html new file mode 100644 index 0000000..fcf37a0 --- /dev/null +++ b/src/main/twirl/admin/plugins/available.scala.html @@ -0,0 +1,37 @@ +@(plugins: List[app.SystemSettingsControllerBase.AvailablePlugin])(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("Plugins"){ + @admin.html.menu("plugins"){ + @tab("available") +
    + + + + + + + + @plugins.zipWithIndex.map { case (plugin, i) => + + + + + + + } +
    IDVersionProviderDescription
    + + @plugin.id + @plugin.version@plugin.author@plugin.description
    + +
    + } +} + diff --git a/src/main/twirl/admin/plugins/console.scala.html b/src/main/twirl/admin/plugins/console.scala.html new file mode 100644 index 0000000..3c4158e --- /dev/null +++ b/src/main/twirl/admin/plugins/console.scala.html @@ -0,0 +1,37 @@ +@()(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("JavaScript Console"){ + @admin.html.menu("plugins"){ + @tab("console") +
    +
    +
    JavaScript Console
    +
    +
    +
    +
    +
    + +
    +
    + } +} + + \ No newline at end of file diff --git a/src/main/twirl/admin/plugins/installed.scala.html b/src/main/twirl/admin/plugins/installed.scala.html new file mode 100644 index 0000000..f85c149 --- /dev/null +++ b/src/main/twirl/admin/plugins/installed.scala.html @@ -0,0 +1,47 @@ +@(plugins: List[plugin.Plugin], + updatablePlugins: List[app.SystemSettingsControllerBase.AvailablePlugin])(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("Plugins"){ + @admin.html.menu("plugins"){ + @tab("installed") +
    + + + + + + + + @plugins.zipWithIndex.map { case (plugin, i) => + + + + + + + } +
    IDVersionProviderDescription
    + + @plugin.id + + @plugin.version + @updatablePlugins.find(_.id == plugin.id).map { x => + (@x.version is available) + } + @plugin.author@plugin.description
    + + +
    + } +} + diff --git a/src/main/twirl/admin/plugins/tab.scala.html b/src/main/twirl/admin/plugins/tab.scala.html new file mode 100644 index 0000000..2e9d1ac --- /dev/null +++ b/src/main/twirl/admin/plugins/tab.scala.html @@ -0,0 +1,9 @@ +@(active: String)(implicit context: app.Context) +@import context._ + diff --git a/src/main/twirl/main.scala.html b/src/main/twirl/main.scala.html index f16ab19..d401121 100644 --- a/src/main/twirl/main.scala.html +++ b/src/main/twirl/main.scala.html @@ -60,11 +60,21 @@
  • New group
  • + @plugin.PluginSystem.globalMenus.map { menu => + @if(menu.condition(context)){ + @if(menu.icon.nonEmpty){} else {@menu.label} + } + } @if(loginAccount.get.isAdmin){ } } else { + @plugin.PluginSystem.globalMenus.map { menu => + @if(menu.condition(context)){ + @if(menu.icon.nonEmpty){} else {@menu.label} + } + } Sign in } diff --git a/src/main/twirl/menu.scala.html b/src/main/twirl/menu.scala.html index 49860cf..9b1581f 100644 --- a/src/main/twirl/menu.scala.html +++ b/src/main/twirl/menu.scala.html @@ -23,6 +23,13 @@ } +@sidemenuPlugin(path: String, name: String, label: String, icon: String) = { +
  • +
    + @if(expand){ @label} +
  • +} +
    @if(repository.commitCount > 0){
    @@ -54,6 +61,11 @@ @sidemenu("/issues", "issues", "Issues", repository.issueCount) @sidemenu("/pulls" , "pulls" , "Pull Requests", repository.pullCount) @sidemenu("/wiki" , "wiki" , "Wiki") + @plugin.PluginSystem.repositoryMenus.map { menu => + @if(menu.condition(context)){ + @sidemenuPlugin(menu.url, menu.label, menu.label, menu.icon) + } + } @if(loginAccount.isDefined && (loginAccount.get.isAdmin || repository.managers.contains(loginAccount.get.userName))){ @sidemenu("/settings", "settings", "Settings") } diff --git a/src/main/webapp/assets/common/css/gitbucket.css b/src/main/webapp/assets/common/css/gitbucket.css index 0df3d56..7c4292a 100644 --- a/src/main/webapp/assets/common/css/gitbucket.css +++ b/src/main/webapp/assets/common/css/gitbucket.css @@ -97,6 +97,13 @@ padding-bottom: 6px; } +img.plugin-global-menu { + width: 16px; + height: 16px; + position: relative; + top: -2px; +} + /* ======================================================================== */ /* General Styles */ /* ======================================================================== */ diff --git a/src/test/scala/plugin/PluginSystemSpec.scala b/src/test/scala/plugin/PluginSystemSpec.scala new file mode 100644 index 0000000..10bfa51 --- /dev/null +++ b/src/test/scala/plugin/PluginSystemSpec.scala @@ -0,0 +1,22 @@ +package plugin + +import org.specs2.mutable._ + +class PluginSystemSpec extends Specification { + + "isUpdatable" should { + "return true for updattable plugin" in { + PluginSystem.isUpdatable("1.0.0", "1.0.1") must beTrue + PluginSystem.isUpdatable("1.0.0", "1.1.0") must beTrue + PluginSystem.isUpdatable("1.1.1", "1.2.0") must beTrue + PluginSystem.isUpdatable("1.2.1", "2.0.0") must beTrue + } + "return false for not updattable plugin" in { + PluginSystem.isUpdatable("1.0.0", "1.0.0") must beFalse + PluginSystem.isUpdatable("1.0.1", "1.0.0") must beFalse + PluginSystem.isUpdatable("1.1.1", "1.1.0") must beFalse + PluginSystem.isUpdatable("2.0.0", "1.2.1") must beFalse + } + } + +}