diff --git a/LavalinkServer/build.gradle.kts b/LavalinkServer/build.gradle.kts index ddb62b35a..3a319436c 100644 --- a/LavalinkServer/build.gradle.kts +++ b/LavalinkServer/build.gradle.kts @@ -41,6 +41,8 @@ dependencies { implementation(projects.pluginApi) { exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat") } + implementation(libs.pf4j.spring) + implementation(libs.asm) implementation(libs.bundles.metrics) implementation(libs.bundles.spring) { @@ -122,7 +124,6 @@ tasks { archiveClassifier = "musl" } - withType { archiveFileName = "Lavalink.jar" diff --git a/LavalinkServer/src/main/java/lavalink/server/Launcher.kt b/LavalinkServer/src/main/java/lavalink/server/Launcher.kt index 8252b9a47..021cdd43a 100644 --- a/LavalinkServer/src/main/java/lavalink/server/Launcher.kt +++ b/LavalinkServer/src/main/java/lavalink/server/Launcher.kt @@ -23,18 +23,23 @@ package lavalink.server import com.sedmelluq.discord.lavaplayer.tools.PlayerLibrary -import lavalink.server.bootstrap.PluginManager +import lavalink.server.bootstrap.LavalinkPluginDescriptor +import lavalink.server.bootstrap.PluginComponentClassLoader +import lavalink.server.bootstrap.PluginDescriptor +import lavalink.server.bootstrap.PluginSystemImpl import lavalink.server.info.AppInfo import lavalink.server.info.GitRepoState +import org.pf4j.Extension import org.slf4j.LoggerFactory +import org.springframework.beans.factory.getBean import org.springframework.boot.Banner -import org.springframework.boot.SpringApplication import org.springframework.boot.WebApplicationType import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.builder.SpringApplicationBuilder import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent import org.springframework.boot.context.event.ApplicationFailedEvent import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.boot.runApplication import org.springframework.context.ApplicationListener import org.springframework.context.ConfigurableApplicationContext import org.springframework.context.annotation.ComponentScan @@ -47,10 +52,14 @@ import java.util.* @Suppress("SpringComponentScan") -@SpringBootApplication +@SpringBootApplication() @ComponentScan( value = ["\${componentScan}"], - excludeFilters = [ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = [PluginManager::class])] + excludeFilters = [ + ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = [PluginSystemImpl::class]), + // These are registered manually + ComponentScan.Filter(type = FilterType.ANNOTATION, classes = [Extension::class]) + ] ) class LavalinkApplication @@ -101,11 +110,11 @@ object Launcher { val defaultC = "" var vanity = ("g . r _ _ _ _ g__ _ _\n" - + "g /\\\\ r| | __ ___ ____ _| (_)_ __ | | __g\\ \\ \\ \\\n" - + "g ( ( )r| |/ _` \\ \\ / / _` | | | '_ \\| |/ /g \\ \\ \\ \\\n" - + "g \\\\/ r| | (_| |\\ V / (_| | | | | | | < g ) ) ) )\n" - + "g ' r|_|\\__,_| \\_/ \\__,_|_|_|_| |_|_|\\_\\g / / / /\n" - + "d =========================================g/_/_/_/d") + + "g /\\\\ r| | __ ___ ____ _| (_)_ __ | | __g\\ \\ \\ \\\n" + + "g ( ( )r| |/ _` \\ \\ / / _` | | | '_ \\| |/ /g \\ \\ \\ \\\n" + + "g \\\\/ r| | (_| |\\ V / (_| | | | | | | < g ) ) ) )\n" + + "g ' r|_|\\__,_| \\_/ \\__,_|_|_|_| |_|_|\\_\\g / / / /\n" + + "d =========================================g/_/_/_/d") vanity = vanity.replace("r".toRegex(), red) vanity = vanity.replace("g".toRegex(), green) @@ -126,32 +135,40 @@ object Launcher { launchMain(parent, args) } - private fun launchPluginBootstrap() = SpringApplication(PluginManager::class.java).run { - setBannerMode(Banner.Mode.OFF) - webApplicationType = WebApplicationType.NONE - run() - } + private fun launchPluginBootstrap() = runApplication { + setBannerMode(Banner.Mode.OFF) + webApplicationType = WebApplicationType.NONE + } private fun launchMain(parent: ConfigurableApplicationContext, args: Array) { - val pluginManager = parent.getBean(PluginManager::class.java) + val pluginManager = parent.getBean() val properties = Properties() - properties["componentScan"] = pluginManager.pluginManifests.map { it.path } - .toMutableList().apply { add("lavalink.server") } + properties["componentScan"] = pluginManager.manager.plugins + .asSequence() + .filter { (it.descriptor as LavalinkPluginDescriptor).manifestVersion == PluginDescriptor.Version.V1 } + .map { (it.descriptor as LavalinkPluginDescriptor).path } + .toList() + "lavalink.server" SpringApplicationBuilder() + .parent(parent) .sources(LavalinkApplication::class.java) .properties(properties) .web(WebApplicationType.SERVLET) .bannerMode(Banner.Mode.OFF) - .resourceLoader(DefaultResourceLoader(pluginManager.classLoader)) + .resourceLoader(DefaultResourceLoader(PluginComponentClassLoader(pluginManager.manager))) .listeners( ApplicationListener { event: Any -> when (event) { is ApplicationEnvironmentPreparedEvent -> { log.info(getVersionInfo()) + } is ApplicationReadyEvent -> { + pluginManager.manager.applicationContext = event.applicationContext + pluginManager.manager.startPlugins() + pluginManager.manager.injector.injectExtensions() + log.info("Lavalink is ready to accept connections.") } @@ -160,7 +177,7 @@ object Launcher { } } } - ).parent(parent) + ) .run(*args) } } diff --git a/LavalinkServer/src/main/java/lavalink/server/bootstrap/DevelopmentPluginLoader.kt b/LavalinkServer/src/main/java/lavalink/server/bootstrap/DevelopmentPluginLoader.kt new file mode 100644 index 000000000..aae8fc773 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/bootstrap/DevelopmentPluginLoader.kt @@ -0,0 +1,9 @@ +package lavalink.server.bootstrap + +import org.pf4j.BasePluginLoader +import org.pf4j.DevelopmentPluginClasspath +import org.pf4j.PluginManager + +private val developmentClasspath = DevelopmentPluginClasspath.GRADLE.addJarsDirectories("build/dependencies") + +class DevelopmentPluginLoader(pluginManager: PluginManager) : BasePluginLoader(pluginManager, developmentClasspath) diff --git a/LavalinkServer/src/main/java/lavalink/server/bootstrap/ExtensionInjector.kt b/LavalinkServer/src/main/java/lavalink/server/bootstrap/ExtensionInjector.kt new file mode 100644 index 000000000..5345ea8fa --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/bootstrap/ExtensionInjector.kt @@ -0,0 +1,80 @@ +package lavalink.server.bootstrap + +import lavalink.server.config.RequestHandlerMapping +import org.pf4j.ExtensionWrapper +import org.pf4j.spring.ExtensionsInjector +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.getBean +import org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConfigurationPropertiesBindingPostProcessor +import org.springframework.context.annotation.AnnotationConfigRegistry +import org.springframework.context.annotation.Configuration +import org.springframework.web.bind.annotation.RestController +import kotlin.reflect.full.hasAnnotation + +private val log = LoggerFactory.getLogger(LavalinkExtensionInjector::class.java) + +class LavalinkExtensionInjector(pluginManager: PluginLoader, factory: AbstractAutowireCapableBeanFactory) : + ExtensionsInjector(pluginManager, factory) { + + // We override this to use ExtensionWrappers instead, because the original does not respect ordinals + override fun injectExtensions() { + val pluginLoader = springPluginManager as PluginLoader + // add extensions from classpath (non plugin) + val internalExtensions = + springPluginManager.plugins.flatMap { pluginLoader.extensionFinder.find(null) } + + val pluginExtensions = springPluginManager.startedPlugins.flatMap { plugin -> + log.debug("Registering extensions of the plugin '{}' as beans", plugin.pluginId) + + pluginLoader.extensionFinder.find(plugin.pluginId) + } + (internalExtensions + pluginExtensions) + .sortedBy { it.ordinal } + .register() + } + + fun List>.register() = forEach { extensionWrapper -> + log.debug("Register extension '{}' as bean", extensionWrapper.descriptor.extensionClass.name) + try { + registerExtension(extensionWrapper.descriptor.extensionClass) + } catch (e: ClassNotFoundException) { + log.error(e.message, e) + } + } + + override fun registerExtension(extensionClass: Class<*>) { + val extensionBeanMap = springPluginManager.applicationContext.getBeansOfType(extensionClass) + if (extensionBeanMap.isEmpty()) { + val extension = springPluginManager.getExtensionFactory().create(extensionClass) + + if (extensionClass.kotlin.hasAnnotation()) { + (springPluginManager.applicationContext as AnnotationConfigRegistry).register(extensionClass) + } + + if (extensionClass.kotlin.hasAnnotation()) { + val configBinder = + springPluginManager.applicationContext.getBean() + configBinder.postProcessBeforeInitialization(extension, extensionClass.getName()) + } + + this.beanFactory.registerSingleton(extensionClass.getName(), extension) + this.beanFactory.autowireBean(extension) + + if (extension::class.hasAnnotation()) { + log.debug( + "Extension {} is annotated with @RestController, forwarding registration to request mapper", + extensionClass.getName() + ) + val mapping = + springPluginManager.applicationContext.getBean("requestMappingHandlerMapping") + + mapping.registerExtension(extension) + } + } else { + log.debug("Bean registeration aborted! Extension '{}' already existed as bean!", extensionClass.getName()) + } + + } +} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/bootstrap/FlexibleVersionManager.kt b/LavalinkServer/src/main/java/lavalink/server/bootstrap/FlexibleVersionManager.kt new file mode 100644 index 000000000..bdd20109a --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/bootstrap/FlexibleVersionManager.kt @@ -0,0 +1,32 @@ +package lavalink.server.bootstrap + +import org.pf4j.VersionManager +import com.github.zafarkhaja.semver.Version + +private val commitHashRegex = Regex("^[0-9a-fA-F]{7,40}$") + +/** + * Implementation of [VersionManager] which also accepts commit hashes as versions. + */ +class FlexibleVersionManager : VersionManager { + override fun checkVersionConstraint(version: String, constraint: String): Boolean { + if (constraint == "*") return true + return if(Version.isValid(version)) { + Version.parse(version).satisfies(constraint) + } else { + return version.matches(commitHashRegex) + } + } + + override fun compareVersions(v1: String, v2: String): Int { + if (v1.matches(commitHashRegex)) { + // Commit hashes cannot be compared + if (v2.matches(commitHashRegex)) return 0 + // Commit hash should always win + return 1 + } + // Commit hash should always win + if (v2.matches(commitHashRegex)) return -1 + return Version.parse(v1).compareTo(Version.parse(v2)) + } +} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginClassWrapper.kt b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginClassWrapper.kt new file mode 100644 index 000000000..0681bf334 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginClassWrapper.kt @@ -0,0 +1,34 @@ +package lavalink.server.bootstrap + +import org.pf4j.Plugin +import org.pf4j.PluginFactory +import org.pf4j.PluginWrapper +import org.pf4j.spring.SpringPlugin +import org.pf4j.spring.SpringPluginManager +import org.pf4j.util.FileUtils +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.AnnotationConfigApplicationContext +import kotlin.io.path.useLines + +class PluginClassWrapper(val context: PluginWrapper) : SpringPlugin(context) { + override fun createApplicationContext(): ApplicationContext { + val parent = (context.pluginManager as SpringPluginManager).applicationContext + return AnnotationConfigApplicationContext().apply { + this.parent = parent + classLoader = context.pluginClassLoader + FileUtils.getPath(context.pluginPath, "META-INF", "configurations.idx") + .useLines { + it.forEach { className -> + log.debug("Registering configuration {} from plugin {}", className, context.pluginId) + val clazz = context.pluginClassLoader.loadClass(className) + register(clazz) + } + } + refresh() + } + } +} + +object LavalinkPluginFactory : PluginFactory { + override fun create(pluginWrapper: PluginWrapper): Plugin = PluginClassWrapper(pluginWrapper) +} diff --git a/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginCompomentClassLoader.kt b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginCompomentClassLoader.kt new file mode 100644 index 000000000..67628e509 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginCompomentClassLoader.kt @@ -0,0 +1,138 @@ +package lavalink.server.bootstrap + +import org.pf4j.PluginWrapper +import org.pf4j.util.FileUtils +import org.slf4j.LoggerFactory +import java.io.InputStream +import java.net.URL +import java.nio.file.Path +import java.util.Enumeration +import java.util.stream.Stream +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries + +private val LOG = LoggerFactory.getLogger(PluginComponentClassLoader::class.java) + +/** + * Implementation of [ClassLoader] which is aware of separate plugin class loaders. + */ +class PluginComponentClassLoader(pluginLoader: PluginLoader) : ClassLoader() { + + // This list does not necessarily need to be up to date + // it just acts as a fast path to resolve these names more quickly + private val systemPackages = listOf( + "java", + "javax", + "lavalink", + "kotlin", + "kotlinx", + "jarkarte", + "git", + "org/springframework", + "org/jetbrains/kotlin", + "org/pf4j", + "com/sedmelluq", + "dev/arbjerg", + "io/prometheus", + "club/minnced", + "ch/qos/logback", + "io/sentry", + "com/github/oshi", + "freemarker", + "com/samskivert", + "groovy", + "org/thymeleaf/spring6", + "org/apache/jasper", + "org/aspectj", + "com/fasterxml/jackson/", + "com/hazelcast", + "com/couchbase", + "org/infinispan/spring", + "org/cache2k", + "com/github/benmanes/caffeine/cache", + "com/fasterxml/jackson/dataformat/xml", + "io/r2dbc/spi", + "reactor", + "org/eclipse/jetty", + "org/apache/catalina", + "org/apache/coyote", + "freemarker/template", + "com/samskivert/mustache", + "org/thymeleaf/spring6", + "org/apache/jasper/compiler", + "com/google/gson", + "META-INF" + ) + + private val cache = pluginLoader.plugins + .filter { (it.descriptor as LavalinkPluginDescriptor).manifestVersion == PluginDescriptor.Version.V1 } + .associateBy { (it.descriptor as LavalinkPluginDescriptor).path.replace('.', '/') } + .toMutableMap() + + // Due to the nature of the legacy class loading some plugins might produce packages outside their defined paths + // For this reason we need to build a map of those packages + private val legacyCache = pluginLoader.plugins + .asSequence() + .filter { (it.descriptor as LavalinkPluginDescriptor).manifestVersion == PluginDescriptor.Version.V1 } + .map { it to findClassesProvidedByPlugin(it.pluginPath) } + .toList() + + fun findClassesProvidedByPlugin(pluginPath: Path, vararg path: String): List { + val fixPath = FileUtils.getPath(pluginPath, "", *path) + val childPaths = fixPath.listDirectoryEntries() + .filter { it.isDirectory() } + return (childPaths + childPaths + .flatMap { + findClassesProvidedByPlugin(pluginPath, *path, it.fileName.toString()) + }) + // Filter out top-level directories + .filter { it.toString().contains('/') } + } + + + private fun findLegacyPackage(name: String): PluginWrapper? { + return legacyCache.firstOrNull { (_, classes) -> + classes.any { name.startsWith(it.toString()) } + }?.first ?: run { + val newPath = name.substringBeforeLast('/') + if (newPath == name) return null + findLegacyPackage(newPath) + } + } + + private fun findPackage(pack: String): PluginWrapper? { + return cache.toList().find { (key, _) -> + pack.startsWith(key) + }?.second ?: run { + val newPath = pack.substringBeforeLast('/') + if (newPath == pack) return null + findPackage(newPath) + } + } + + private fun findClassLoader(name: String?): ClassLoader? { + if (name == null) return null + // Sometimes .class files are requested, as paths + // other times the class name gets requested + val pack = if (name.endsWith(".class")) { + name.substringBeforeLast('/') + } else { + name.substringBeforeLast('.').replace('.', '/') + } + if (systemPackages.any { pack.startsWith(it) }) return javaClass.classLoader + + LOG.debug("Found package of {} to be {}", name, pack) + val plugin = cache.getOrPut(pack) { findPackage(pack) } ?: findLegacyPackage(name) + ?: return javaClass.classLoader + + LOG.debug("Found class {} to be provided by plugin {}", name, plugin.pluginId) + + return plugin.pluginClassLoader + } + + override fun loadClass(name: String?): Class<*>? = findClassLoader(name)?.loadClass(name) + override fun getResource(name: String?): URL? = findClassLoader(name)?.getResource(name) + override fun getResources(name: String?): Enumeration? = findClassLoader(name)?.getResources(name) + override fun getResourceAsStream(name: String?): InputStream? = findClassLoader(name)?.getResourceAsStream(name) + override fun resources(name: String?): Stream? = findClassLoader(name)?.resources(name) +} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginLoader.kt b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginLoader.kt new file mode 100644 index 000000000..c0b1dfb15 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginLoader.kt @@ -0,0 +1,67 @@ +package lavalink.server.bootstrap + +import lavalink.server.info.AppInfo +import org.pf4j.* +import org.pf4j.PluginDescriptor +import org.pf4j.spring.SpringExtensionFactory +import org.pf4j.spring.SpringPluginManager +import org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory +import org.springframework.stereotype.Component +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.extension +import org.pf4j.PluginLoader as BasePluginLoader + +@Component +class PluginLoader(private val pluginsConfig: PluginsConfig, private val appInfo: AppInfo) : + SpringPluginManager(Path(pluginsConfig.pluginsDir)) { + + val injector by lazy { + LavalinkExtensionInjector( + this, + applicationContext.autowireCapableBeanFactory as AbstractAutowireCapableBeanFactory + ) + } + + val extensionFinder get() = super.extensionFinder + + override fun getRuntimeMode(): RuntimeMode = + if (pluginsConfig.developmentMode) RuntimeMode.DEVELOPMENT else RuntimeMode.DEPLOYMENT + + override fun createVersionManager(): VersionManager = FlexibleVersionManager() + override fun getSystemVersion(): String = appInfo.versionBuild + override fun createPluginFactory(): PluginFactory = LavalinkPluginFactory + override fun createPluginLoader(): BasePluginLoader = CompoundPluginLoader() + .add(DevelopmentPluginLoader(this), this::isDevelopment) + .add(DefaultPluginLoader(this)) + .add(LegacyPluginLoader()) + + override fun createExtensionFinder(): ExtensionFinder = LegacyExtensionFinder(this).apply { + isCheckForExtensionDependencies = true + } + + // Add auto-wiring support to extensions + override fun createExtensionFactory(): ExtensionFactory = SpringExtensionFactory(this) + + override fun createPluginDescriptorFinder(): PluginDescriptorFinder? { + return CompoundPluginDescriptorFinder().apply { + add(LegacyLavalinkDescriptorFinder) + add(LavalinkDescriptorFinder) + } + } + + private inner class LegacyPluginLoader : BasePluginLoader { + override fun isApplicable(pluginPath: Path): Boolean = pluginPath.extension == "jar" + override fun loadPlugin(pluginPath: Path, pluginDescriptor: PluginDescriptor): ClassLoader { + val pluginClassLoader = PluginClassLoader( + this@PluginLoader, pluginDescriptor, javaClass.getClassLoader(), + // This is required because the old distribution format can contain classes that the server contains + // as well, so we need the server classes to take priority + ClassLoadingStrategy.APD + ) + pluginClassLoader.addFile(pluginPath.toFile()) + + return pluginClassLoader + } + } +} diff --git a/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginManager.kt b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginManager.kt deleted file mode 100644 index c3ed4cb73..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginManager.kt +++ /dev/null @@ -1,181 +0,0 @@ -package lavalink.server.bootstrap - -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.core.io.support.PathMatchingResourcePatternResolver -import java.io.File -import java.io.FileOutputStream -import java.io.InputStream -import java.net.URL -import java.net.URLClassLoader -import java.nio.channels.Channels -import java.util.* -import java.util.jar.JarFile - -@SpringBootApplication -class PluginManager(val config: PluginsConfig) { - companion object { - private val log: Logger = LoggerFactory.getLogger(PluginManager::class.java) - } - - final val pluginManifests: MutableList = mutableListOf() - - var classLoader = javaClass.classLoader - - init { - manageDownloads() - - pluginManifests.apply { - addAll(readClasspathManifests()) - addAll(loadJars()) - } - } - - private fun manageDownloads() { - if (config.plugins.isEmpty()) return - - val directory = File(config.pluginsDir) - directory.mkdir() - - val pluginJars = directory.listFiles()?.filter { it.extension == "jar" } - ?.flatMap { file -> - JarFile(file).use { jar -> - loadPluginManifests(jar).map { manifest -> PluginJar(manifest, file) } - } - } - ?.onEach { log.info("Found plugin '${it.manifest.name}' version ${it.manifest.version}") } - ?: return - - val declarations = config.plugins.map { declaration -> - if (declaration.dependency == null) throw RuntimeException("Illegal dependency declaration: null") - val fragments = declaration.dependency!!.split(":") - if (fragments.size != 3) throw RuntimeException("Invalid dependency \"${declaration.dependency}\"") - - val repository = declaration.repository - ?: config.defaultPluginSnapshotRepository.takeIf { declaration.snapshot } - ?: config.defaultPluginRepository - - Declaration(fragments[0], fragments[1], fragments[2], "${repository.removeSuffix("/")}/") - }.distinctBy { "${it.group}:${it.name}" } - - for (declaration in declarations) { - val jars = pluginJars.filter { it.manifest.name == declaration.name }.takeIf { it.isNotEmpty() } - ?: pluginJars.filter { matchName(it, declaration.name) } - - var hasCurrentVersion = false - - for (jar in jars) { - if (jar.manifest.version == declaration.version) { - hasCurrentVersion = true - // Don't clean up the jar if it's a current version. - continue - } - - // Delete versions of the plugin that aren't the same as declared version. - if (!jar.file.delete()) throw RuntimeException("Failed to delete ${jar.file.path}") - log.info("Deleted ${jar.file.path} (new version: ${declaration.version})") - - } - - if (!hasCurrentVersion) { - val url = declaration.url - val file = File(directory, declaration.canonicalJarName) - downloadJar(file, url) - } - } - } - - private fun downloadJar(output: File, url: String) { - log.info("Downloading $url") - - Channels.newChannel(URL(url).openStream()).use { - FileOutputStream(output).channel.transferFrom(it, 0, Long.MAX_VALUE) - } - } - - private fun readClasspathManifests(): List { - return PathMatchingResourcePatternResolver() - .getResources("classpath*:lavalink-plugins/*.properties") - .map { parsePluginManifest(it.inputStream) } - .onEach { log.info("Found plugin '${it.name}' version ${it.version}") } - } - - private fun loadJars(): List { - val directory = File(config.pluginsDir).takeIf { it.isDirectory } - ?: return emptyList() - - val jarsToLoad = directory.listFiles()?.filter { it.isFile && it.extension == "jar" } - ?.takeIf { it.isNotEmpty() } - ?: return emptyList() - - classLoader = URLClassLoader.newInstance( - jarsToLoad.map { URL("jar:file:${it.absolutePath}!/") }.toTypedArray(), - javaClass.classLoader - ) - - return jarsToLoad.flatMap { loadJar(it, classLoader) } - } - - private fun loadJar(file: File, cl: ClassLoader): List { - val jar = JarFile(file) - val manifests = loadPluginManifests(jar) - var classCount = 0 - - jar.use { - if (manifests.isEmpty()) { - throw RuntimeException("No plugin manifest found in ${file.path}") - } - - val allowedPaths = manifests.map { manifest -> manifest.path.replace(".", "/") } - - for (entry in it.entries()) { - if (entry.isDirectory || - !entry.name.endsWith(".class") || - allowedPaths.none(entry.name::startsWith)) continue - - cl.loadClass(entry.name.dropLast(6).replace("/", ".")) - classCount++ - } - } - - log.info("Loaded ${file.name} ($classCount classes)") - return manifests - } - - private fun loadPluginManifests(jar: JarFile): List { - return jar.entries().asSequence() - .filter { !it.isDirectory && it.name.startsWith("lavalink-plugins/") && it.name.endsWith(".properties") } - .map { parsePluginManifest(jar.getInputStream(it)) } - .toList() - } - - private fun parsePluginManifest(stream: InputStream): PluginManifest { - val props = stream.use { - Properties().apply { load(it) } - } - - val name = props.getProperty("name") ?: throw RuntimeException("Manifest is missing 'name'") - val path = props.getProperty("path") ?: throw RuntimeException("Manifest is missing 'path'") - val version = props.getProperty("version") ?: throw RuntimeException("Manifest is missing 'version'") - return PluginManifest(name, path, version) - } - - private fun matchName(jar: PluginJar, name: String): Boolean { - // removeSuffix removes names ending with "-v", such as -v1.0.0 - // and then the subsequent removeSuffix call removes trailing "-", which - // usually precedes a version number, such as my-plugin-1.0.0. - // We strip these to produce the name of the jar's file. - val jarName = jar.file.nameWithoutExtension.takeWhile { !it.isDigit() } - .removeSuffix("-v") - .removeSuffix("-") - - return name == jarName - } - - private data class PluginJar(val manifest: PluginManifest, val file: File) - private data class Declaration(val group: String, val name: String, val version: String, val repository: String) { - val canonicalJarName = "$name-$version.jar" - val url = "$repository${group.replace(".", "/")}/$name/$version/$name-$version.jar" - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginManifest.kt b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginManifest.kt index 3f86e7fd2..a740c8753 100644 --- a/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginManifest.kt +++ b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginManifest.kt @@ -1,7 +1,80 @@ package lavalink.server.bootstrap -data class PluginManifest( - val name: String, - val path: String, - val version: String -) \ No newline at end of file +import org.pf4j.DefaultPluginDescriptor +import org.pf4j.PropertiesPluginDescriptorFinder +import java.nio.file.Path +import java.util.Properties +import kotlin.io.path.inputStream +import kotlin.io.path.useDirectoryEntries +import org.pf4j.PluginDescriptor as BasePluginDescriptor + +interface PluginDescriptor : BasePluginDescriptor { + val path: String + val manifestVersion: Version + val springConfigurationFiles: List + override fun getVersion(): String + override fun getPluginId(): String + override fun getPluginClass(): Nothing? = null + + enum class Version { + /** + * Legacy version. + */ + V1, + + /** + * Current up-to-date version. + */ + V2 + } +} + +class LavalinkPluginDescriptor(override val manifestVersion: PluginDescriptor.Version) : DefaultPluginDescriptor(), + PluginDescriptor { + override lateinit var path: String + override lateinit var springConfigurationFiles: List + override fun getPluginClass(): Nothing? = super.getPluginClass() + override fun setPluginClass(pluginClassName: String?): BasePluginDescriptor = this + public override fun setPluginVersion(version: String): DefaultPluginDescriptor = super.setPluginVersion(version) +} + +object LavalinkDescriptorFinder : PropertiesPluginDescriptorFinder() { + override fun createPluginDescriptorInstance(): LavalinkPluginDescriptor = + LavalinkPluginDescriptor(PluginDescriptor.Version.V2) + + override fun createPluginDescriptor(properties: Properties): BasePluginDescriptor { + return super.createPluginDescriptor(properties).apply { + val configurations = properties.getProperty("plugin.configurations") + (this as LavalinkPluginDescriptor).springConfigurationFiles = if (configurations != null) { + configurations.split(",\\s*".toRegex()) + } else { + emptyList() + } + } + } +} + +object LegacyLavalinkDescriptorFinder : PropertiesPluginDescriptorFinder() { + override fun readProperties(pluginPath: Path): Properties { + val descriptorDirectory = getPropertiesPath(pluginPath, "lavalink-plugins") + val descriptor = descriptorDirectory.useDirectoryEntries("*.properties") { it.singleOrNull() } + ?: error("Found more than one descriptor in $descriptorDirectory") + + val properties = Properties() + descriptor.inputStream().use(properties::load) + + return properties + } + + override fun createPluginDescriptorInstance(): LavalinkPluginDescriptor = + LavalinkPluginDescriptor(PluginDescriptor.Version.V1) + + override fun createPluginDescriptor(properties: Properties): BasePluginDescriptor = + createPluginDescriptorInstance().apply { + path = properties.getProperty("path") ?: error("'path' is not specified in plugin properties") + pluginId = properties.getProperty("name") ?: error("'name' is not specified in plugin properties") + setPluginVersion( + properties.getProperty("version") ?: error("'version' is not specified in plugin properties") + ) + } +} diff --git a/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginSystemImpl.kt b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginSystemImpl.kt new file mode 100644 index 000000000..2afa86a54 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginSystemImpl.kt @@ -0,0 +1,92 @@ +package lavalink.server.bootstrap + +import dev.arbjerg.lavalink.api.PluginSystem +import lavalink.server.info.AppInfo +import org.pf4j.spring.ExtensionsInjector +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.context.annotation.Import +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.div + +@Import(AppInfo::class) +@SpringBootApplication +class PluginSystemImpl( + val config: PluginsConfig, + override val manager: PluginLoader, +) : PluginSystem { + val httpClient = HttpClient.newHttpClient() + + companion object { + private val log: Logger = LoggerFactory.getLogger(PluginSystemImpl::class.java) + } + + init { + manager.loadPlugins() + manageDownloads() + } + + @OptIn(ExperimentalPathApi::class) + private fun manageDownloads() { + if (config.plugins.isEmpty()) return + + val directory = Path(config.pluginsDir) + directory.createDirectories() + + val declarations = config.plugins.map { declaration -> + if (declaration.dependency == null) throw RuntimeException("Illegal dependency declaration: null") + val fragments = declaration.dependency!!.split(":") + if (fragments.size != 3) throw RuntimeException("Invalid dependency \"${declaration.dependency}\"") + + val repository = declaration.repository + ?: config.defaultPluginSnapshotRepository.takeIf { declaration.snapshot } + ?: config.defaultPluginRepository + + Declaration(fragments[0], fragments[1], fragments[2], "${repository.removeSuffix("/")}/") + }.distinctBy { "${it.group}:${it.name}" } + + val pluginManifests = manager.plugins.map { it.descriptor as LavalinkPluginDescriptor } + + for (declaration in declarations) { + val manifest = pluginManifests.firstOrNull { it.pluginId == declaration.name } + + if (manifest?.version != declaration.version) { + if (manifest != null) { + manager.deletePlugin(manifest.pluginId) + } + + val url = declaration.url + val file = directory / declaration.canonicalJarName + if (downloadJar(file, url)) { + manager.loadPlugin(file) + } + } + } + } + + private fun downloadJar(output: Path, url: String): Boolean { + log.info("Downloading {}", url) + + val request = HttpRequest.newBuilder(URI(url)).build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(output)) + if (response.statusCode() != 200) { + log.warn("Could not download {}, got unexpected status code {}", url, response.statusCode()) + return false + } + return response.statusCode() == 200 + } + + private data class Declaration(val group: String, val name: String, val version: String, val repository: String) { + val canonicalJarName = "$name-$version.jar" + val url = "$repository${group.replace(".", "/")}/$name/$version/$name-$version.jar" + } +} diff --git a/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginsConfig.kt b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginsConfig.kt index 5f6bd0421..7d5731a24 100644 --- a/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginsConfig.kt +++ b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginsConfig.kt @@ -8,6 +8,7 @@ import org.springframework.stereotype.Component class PluginsConfig { var plugins: List = emptyList() var pluginsDir: String = "./plugins" + var developmentMode: Boolean = false var defaultPluginRepository: String = "https://maven.lavalink.dev/releases" var defaultPluginSnapshotRepository: String = "https://maven.lavalink.dev/snapshots" } diff --git a/LavalinkServer/src/main/java/lavalink/server/config/AudioPlayerConfiguration.kt b/LavalinkServer/src/main/java/lavalink/server/config/AudioPlayerConfiguration.kt index 96571e689..afe402575 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/AudioPlayerConfiguration.kt +++ b/LavalinkServer/src/main/java/lavalink/server/config/AudioPlayerConfiguration.kt @@ -28,6 +28,7 @@ import org.apache.http.impl.client.BasicCredentialsProvider import org.slf4j.LoggerFactory import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Lazy import java.net.InetAddress import java.util.* import java.util.concurrent.TimeUnit @@ -41,6 +42,7 @@ class AudioPlayerConfiguration { private val log = LoggerFactory.getLogger(AudioPlayerConfiguration::class.java) + @Lazy // Only create an AudioPlayerManager after all config contributors were loaded @Bean fun audioPlayerManagerSupplier( sources: AudioSourcesConfig, diff --git a/LavalinkServer/src/main/java/lavalink/server/config/WebConfiguration.kt b/LavalinkServer/src/main/java/lavalink/server/config/WebConfiguration.kt index 3fca46db5..9f5b5cfd0 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/WebConfiguration.kt +++ b/LavalinkServer/src/main/java/lavalink/server/config/WebConfiguration.kt @@ -2,19 +2,26 @@ package lavalink.server.config import dev.arbjerg.lavalink.api.RestInterceptor import dev.arbjerg.lavalink.protocol.v4.json +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.format.support.FormattingConversionService import org.springframework.http.converter.HttpMessageConverter import org.springframework.http.converter.StringHttpMessageConverter import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter -import org.springframework.web.servlet.config.annotation.EnableWebMvc +import org.springframework.web.accept.ContentNegotiationManager +import org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration import org.springframework.web.servlet.config.annotation.InterceptorRegistry -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping +import org.springframework.web.servlet.resource.ResourceUrlProvider +class RequestHandlerMapping : RequestMappingHandlerMapping() { + fun registerExtension(extension: Any) = detectHandlerMethods(extension) +} @Configuration -@EnableWebMvc -class WebConfiguration(private val interceptors: List) : WebMvcConfigurer { +class WebConfiguration(private val interceptors: List) : DelegatingWebMvcConfiguration() { override fun configureMessageConverters(converters: MutableList>) { converters.add(StringHttpMessageConverter()) @@ -26,4 +33,15 @@ class WebConfiguration(private val interceptors: List) : WebMvc interceptors.forEach { registry.addInterceptor(it) } } + override fun createRequestMappingHandlerMapping(): RequestMappingHandlerMapping = RequestHandlerMapping() + + @Primary + @Bean + override fun requestMappingHandlerMapping( + contentNegotiationManager: ContentNegotiationManager, + conversionService: FormattingConversionService, + resourceUrlProvider: ResourceUrlProvider + ): RequestMappingHandlerMapping = RequestHandlerMapping().apply { + this.contentNegotiationManager = contentNegotiationManager + } } diff --git a/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.kt b/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.kt index 16c57eb61..719a160a3 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.kt +++ b/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.kt @@ -3,31 +3,69 @@ package lavalink.server.config import lavalink.server.io.HandshakeInterceptorImpl import lavalink.server.io.SocketServer import org.slf4j.LoggerFactory +import org.springframework.beans.factory.getBean +import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Configuration +import org.springframework.http.server.ServerHttpRequest +import org.springframework.http.server.ServerHttpResponse import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController +import org.springframework.web.socket.CloseStatus +import org.springframework.web.socket.TextMessage +import org.springframework.web.socket.WebSocketHandler +import org.springframework.web.socket.WebSocketSession import org.springframework.web.socket.config.annotation.EnableWebSocket import org.springframework.web.socket.config.annotation.WebSocketConfigurer import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry +import org.springframework.web.socket.handler.TextWebSocketHandler +import org.springframework.web.socket.server.HandshakeInterceptor +import java.lang.Exception @Configuration @EnableWebSocket @RestController -class WebsocketConfig( - private val server: SocketServer, - private val handshakeInterceptor: HandshakeInterceptorImpl, -) : WebSocketConfigurer { +class WebsocketConfig(private val context: ApplicationContext) : WebSocketConfigurer { companion object { private val log = LoggerFactory.getLogger(WebsocketConfig::class.java) } override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { - registry.addHandler(server, "/v4/websocket").addInterceptors(handshakeInterceptor) + val proxy = WebSocketProxy() + registry.addHandler(proxy, "/v4/websocket").addInterceptors(proxy) } @GetMapping("/", "/v3/websocket") fun oldWebsocket() { log.warn("This is the old Lavalink websocket endpoint. Please use /v4/websocket instead. If you are using a client library, please update it to a Lavalink v4 compatible version or use Lavalink v3 instead.") } + + // This is required to lazily evaluate registerWebSocketHandlers() + inner class WebSocketProxy : TextWebSocketHandler(), HandshakeInterceptor { + private val socket by lazy { context.getBean() } + private val handshaker by lazy { context.getBean() } + + override fun beforeHandshake( + request: ServerHttpRequest, + response: ServerHttpResponse, + wsHandler: WebSocketHandler, + attributes: Map + ): Boolean = handshaker.beforeHandshake(request, response, wsHandler, attributes) + + override fun afterHandshake( + request: ServerHttpRequest, + response: ServerHttpResponse, + wsHandler: WebSocketHandler, + exception: Exception? + ) = handshaker.afterHandshake(request, response, wsHandler, exception) + + override fun handleTextMessage(session: WebSocketSession, message: TextMessage) = + socket.handleMessage(session, message) + + override fun afterConnectionEstablished(session: WebSocketSession) = + socket.afterConnectionEstablished(session) + + override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) = + socket.afterConnectionClosed(session, status) + } } diff --git a/LavalinkServer/src/main/java/lavalink/server/info/InfoRestHandler.kt b/LavalinkServer/src/main/java/lavalink/server/info/InfoRestHandler.kt index 987af865c..72e1bceca 100644 --- a/LavalinkServer/src/main/java/lavalink/server/info/InfoRestHandler.kt +++ b/LavalinkServer/src/main/java/lavalink/server/info/InfoRestHandler.kt @@ -4,20 +4,23 @@ import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager import com.sedmelluq.discord.lavaplayer.tools.PlayerLibrary import dev.arbjerg.lavalink.api.AudioFilterExtension import dev.arbjerg.lavalink.protocol.v4.* -import lavalink.server.bootstrap.PluginManager +import lavalink.server.bootstrap.PluginSystemImpl import lavalink.server.config.ServerConfig +import org.pf4j.Extension import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController /** * Created by napster on 08.03.19. */ + +@Extension(ordinal = Int.MAX_VALUE) // Register this last, as we need to load plugin configuration contributors first @RestController class InfoRestHandler( appInfo: AppInfo, gitRepoState: GitRepoState, audioPlayerManager: AudioPlayerManager, - pluginManager: PluginManager, + pluginManager: PluginSystemImpl, serverConfig: ServerConfig, filterExtensions: List ) { @@ -45,8 +48,9 @@ class InfoRestHandler( PlayerLibrary.VERSION, audioPlayerManager.sourceManagers.map { it.sourceName }, enabledFilers, - Plugins(pluginManager.pluginManifests.map { - Plugin(it.name, it.version) + Plugins(pluginManager.manager.plugins.map { + val descriptor = it.descriptor + Plugin(descriptor.pluginId, descriptor.version) }) ) private val version = appInfo.versionBuild diff --git a/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.kt b/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.kt index 34a791516..9a4283e65 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.kt @@ -1,6 +1,7 @@ package lavalink.server.io import lavalink.server.config.ServerConfig +import org.pf4j.Extension import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus @@ -10,6 +11,7 @@ import org.springframework.stereotype.Controller import org.springframework.web.socket.WebSocketHandler import org.springframework.web.socket.server.HandshakeInterceptor +@Extension(ordinal = Int.MAX_VALUE) // Register this last, as we need to load plugin configuration contributors first @Controller class HandshakeInterceptorImpl @Autowired constructor(private val serverConfig: ServerConfig, private val socketServer: SocketServer) : HandshakeInterceptor { diff --git a/LavalinkServer/src/main/java/lavalink/server/io/SessionRestHandler.kt b/LavalinkServer/src/main/java/lavalink/server/io/SessionRestHandler.kt index 6bffb2479..45c3b616a 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/SessionRestHandler.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/SessionRestHandler.kt @@ -4,12 +4,14 @@ import dev.arbjerg.lavalink.protocol.v4.Session import dev.arbjerg.lavalink.protocol.v4.SessionUpdate import dev.arbjerg.lavalink.protocol.v4.ifPresent import lavalink.server.util.socketContext +import org.pf4j.Extension import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController +@Extension(ordinal = Int.MAX_VALUE) // Register this last, as we need to load plugin configuration contributors first @RestController class SessionRestHandler(private val socketServer: SocketServer) { diff --git a/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt b/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt index 4f2a72743..6e0ff15ea 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt @@ -32,6 +32,7 @@ import lavalink.server.config.ServerConfig import lavalink.server.player.LavalinkPlayer import moe.kyokobot.koe.Koe import moe.kyokobot.koe.KoeOptions +import org.pf4j.Extension import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.web.socket.CloseStatus @@ -40,6 +41,9 @@ import org.springframework.web.socket.WebSocketSession import org.springframework.web.socket.handler.TextWebSocketHandler import java.util.concurrent.ConcurrentHashMap +// Register this second-to-last, as we need to load plugin configuration contributors first +// Move it up by 1 as other things depend on this +@Extension(ordinal = Int.MAX_VALUE - 1) @Service final class SocketServer( private val serverConfig: ServerConfig, @@ -68,8 +72,8 @@ final class SocketServer( val connection = socketContext.getMediaConnection(player).gatewayConnection socketContext.sendMessage( - Message.Serializer, - Message.PlayerUpdateEvent( + Message.Serializer, + Message.PlayerUpdateEvent( PlayerState( System.currentTimeMillis(), player.audioPlayer.playingTrack?.position ?: 0, @@ -150,7 +154,7 @@ final class SocketServer( resumableSessions.remove(context.sessionId)?.let { removed -> log.warn( "Shutdown resumable session with id ${removed.sessionId} because it has the same id as a " + - "newly disconnected resumable session." + "newly disconnected resumable session." ) removed.shutdown() } @@ -159,7 +163,7 @@ final class SocketServer( context.pause() log.info( "Connection closed from ${session.remoteAddress} with status $status -- " + - "Session can be resumed within the next ${context.resumeTimeout} seconds with id ${context.sessionId}", + "Session can be resumed within the next ${context.resumeTimeout} seconds with id ${context.sessionId}", ) return } diff --git a/LavalinkServer/src/main/java/lavalink/server/io/StatsCollector.kt b/LavalinkServer/src/main/java/lavalink/server/io/StatsCollector.kt index 4623851ff..e420c8f59 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/StatsCollector.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/StatsCollector.kt @@ -24,12 +24,14 @@ package lavalink.server.io import dev.arbjerg.lavalink.protocol.v4.* import lavalink.server.Launcher import lavalink.server.player.AudioLossCounter +import org.pf4j.Extension import org.slf4j.LoggerFactory import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController import oshi.SystemInfo import kotlin.Exception +@Extension(ordinal = Int.MAX_VALUE) // Register this last, as we need to load plugin configuration contributors first @RestController class StatsCollector(val socketServer: SocketServer) { companion object { diff --git a/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.kt b/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.kt index 4fdadb43c..0e7b3dcc2 100644 --- a/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.kt +++ b/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.kt @@ -32,12 +32,14 @@ import dev.arbjerg.lavalink.protocol.v4.Track import dev.arbjerg.lavalink.protocol.v4.Tracks import jakarta.servlet.http.HttpServletRequest import lavalink.server.util.* +import org.pf4j.Extension import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import org.springframework.web.server.ResponseStatusException +@Extension(ordinal = Int.MAX_VALUE) // Register this last, as we need to load plugin configuration contributors first @RestController class AudioLoaderRestHandler( private val audioPlayerManager: AudioPlayerManager, diff --git a/LavalinkServer/src/main/java/lavalink/server/player/PlayerRestHandler.kt b/LavalinkServer/src/main/java/lavalink/server/player/PlayerRestHandler.kt index 801614dfe..873672382 100644 --- a/LavalinkServer/src/main/java/lavalink/server/player/PlayerRestHandler.kt +++ b/LavalinkServer/src/main/java/lavalink/server/player/PlayerRestHandler.kt @@ -11,12 +11,14 @@ import lavalink.server.io.SocketServer import lavalink.server.player.filters.FilterChain import lavalink.server.util.* import moe.kyokobot.koe.VoiceServerInfo +import org.pf4j.Extension import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import org.springframework.web.server.ResponseStatusException +@Extension(ordinal = Int.MAX_VALUE) // Register this last, as we need to load plugin configuration contributors first @RestController class PlayerRestHandler( private val socketServer: SocketServer, diff --git a/LavalinkServer/src/main/resources/META-INF/extensions.idx b/LavalinkServer/src/main/resources/META-INF/extensions.idx new file mode 100644 index 000000000..6eeb1b960 --- /dev/null +++ b/LavalinkServer/src/main/resources/META-INF/extensions.idx @@ -0,0 +1,9 @@ +lavalink.server.io.SessionRestHandler +lavalink.server.io.SocketServer +lavalink.server.io.SessionRestHandler +lavalink.server.io.HandshakeInterceptorImpl +lavalink.server.info.InfoRestHandler +lavalink.server.config.WebsocketConfig +lavalink.server.player.AudioLoaderRestHandler +lavalink.server.player.PlayerRestHandler +lavalink.server.io.StatsCollector \ No newline at end of file diff --git a/LavalinkServer/src/test/java/lavalink/server/bootrap/VersionManagerTest.kt b/LavalinkServer/src/test/java/lavalink/server/bootrap/VersionManagerTest.kt new file mode 100644 index 000000000..b8bfbf448 --- /dev/null +++ b/LavalinkServer/src/test/java/lavalink/server/bootrap/VersionManagerTest.kt @@ -0,0 +1,49 @@ +package lavalink.server.bootrap + +import lavalink.server.bootstrap.FlexibleVersionManager +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +class VersionManagerTest { + private val versionManager = FlexibleVersionManager() + + @Test + fun `test two commit hashes are compared as equal`() { + val hash1 = "0d7decb" + val hash2 = "184367d" + + Assertions.assertEquals(0, versionManager.compareVersions(hash1, hash2)) + } + + @Test + fun `test commit hash is always higher`() { + val hash1 = "0d7decb" + val hash2 = "2.0.0" + + Assertions.assertEquals(1, versionManager.compareVersions(hash1, hash2)) + } + + @Test + fun `test semver is always lower`() { + val hash1 = "0d7decb" + val hash2 = "2.0.0" + + Assertions.assertEquals(-1, versionManager.compareVersions(hash2, hash1)) + } + + @Test + fun `test hash meets constraint`() { + val constraint = ">= 2.0.0 && < 3.0.0" + val hash = "0d7decb" + + Assertions.assertTrue(versionManager.checkVersionConstraint(hash, constraint)) + } + + @Test + fun `test wilddard constraint`() { + val constraint = "*" + val hash = "0d7decb" + + Assertions.assertTrue(versionManager.checkVersionConstraint(hash, constraint)) + } +} diff --git a/LavalinkServer/src/test/java/lavalink/server/config/TestConfig.kt b/LavalinkServer/src/test/java/lavalink/server/config/TestConfig.kt index 64d529282..f5a39db3a 100644 --- a/LavalinkServer/src/test/java/lavalink/server/config/TestConfig.kt +++ b/LavalinkServer/src/test/java/lavalink/server/config/TestConfig.kt @@ -1,7 +1,9 @@ package lavalink.server.config -import lavalink.server.bootstrap.PluginManager +import lavalink.server.bootstrap.PluginLoader +import lavalink.server.bootstrap.PluginSystemImpl import lavalink.server.bootstrap.PluginsConfig +import lavalink.server.info.AppInfo import org.springframework.boot.test.context.TestConfiguration import org.springframework.context.annotation.Bean @@ -9,6 +11,6 @@ import org.springframework.context.annotation.Bean class TestConfig { @Bean - fun pluginManager() = PluginManager(PluginsConfig()) + fun pluginManager() = PluginSystemImpl(PluginsConfig(), PluginLoader(PluginsConfig(), AppInfo())) } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 1bb71a69f..ca5ce9d9c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,8 +22,8 @@ allprojects { version = versionFromTag() repositories { - mavenCentral() // main maven repo mavenLocal() // useful for developing + mavenCentral() // main maven repo maven("https://m2.dv8tion.net/releases") maven("https://maven.lavalink.dev/releases") jcenter() diff --git a/plugin-api/build.gradle.kts b/plugin-api/build.gradle.kts index f0fe1556c..1395fd44c 100644 --- a/plugin-api/build.gradle.kts +++ b/plugin-api/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { api(libs.spring.boot.web) api(libs.lavaplayer) api(libs.kotlinx.serialization.json) + api(libs.pf4j) } java { diff --git a/plugin-api/src/main/java/dev/arbjerg/lavalink/api/PluginSystem.kt b/plugin-api/src/main/java/dev/arbjerg/lavalink/api/PluginSystem.kt new file mode 100644 index 000000000..fa55d9610 --- /dev/null +++ b/plugin-api/src/main/java/dev/arbjerg/lavalink/api/PluginSystem.kt @@ -0,0 +1,12 @@ +package dev.arbjerg.lavalink.api + +import org.pf4j.PluginManager + +/** + * Interface to interact with Lavalinks plugin system. + * + * @property manager the [PluginManager] instance + */ +interface PluginSystem { + val manager: PluginManager +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 14cbf4aa8..6ff681c66 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -83,4 +83,7 @@ fun VersionCatalogBuilder.other() { plugin("maven-publish", "com.vanniktech.maven.publish").versionRef(mavenPublishPlugin) plugin("maven-publish-base", "com.vanniktech.maven.publish.base").versionRef(mavenPublishPlugin) + library("pf4j", "org.pf4j", "pf4j").version("3.13.0-SNAPSHOT") + library("pf4j-spring", "org.pf4j", "pf4j-spring").version("0.9.0") + library("asm", "org.ow2.asm", "asm").version("9.7.1") }