From 50bb5fa9b481ba78089ebfcb7fbf3eec45a74ea1 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Wed, 30 Oct 2024 23:26:29 +0100 Subject: [PATCH] WIP: First "full-stack" implementation of plugin management --- .../administration/PluginManagement.tsx | 68 +++++++++++++++++++ gameyfin/src/main/frontend/routes.tsx | 2 + .../frontend/views/AdministrationView.tsx | 7 +- .../core/plugins/PluginConfigEndpoint.kt | 28 ++++++++ .../core/plugins/PluginConfigEntry.kt | 33 +++++++++ .../core/plugins/PluginConfigRepository.kt | 8 +++ .../core/plugins/PluginConfigService.kt | 33 +++++++++ .../core/plugins/PluginManagerConfig.kt | 8 ++- .../plugins/SpringDevtoolsPluginManager.kt | 40 ++++++++++- .../gameyfin/pluginapi/core/GameyfinPlugin.kt | 8 ++- .../pluginapi/core/PluginConfigElement.kt | 7 ++ .../gameyfin/pluginapi/core/PluginContext.kt | 9 --- .../gameyfin/plugins/igdb/IgdbPlugin.kt | 34 ++++++++-- .../gameyfin/plugins/igdb/IgdbPluginConfig.kt | 6 -- 14 files changed, 262 insertions(+), 29 deletions(-) create mode 100644 gameyfin/src/main/frontend/components/administration/PluginManagement.tsx create mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigEndpoint.kt create mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigEntry.kt create mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigRepository.kt create mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigService.kt create mode 100644 plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement.kt delete mode 100644 plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/PluginContext.kt delete mode 100644 plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPluginConfig.kt diff --git a/gameyfin/src/main/frontend/components/administration/PluginManagement.tsx b/gameyfin/src/main/frontend/components/administration/PluginManagement.tsx new file mode 100644 index 0000000..e7fceb9 --- /dev/null +++ b/gameyfin/src/main/frontend/components/administration/PluginManagement.tsx @@ -0,0 +1,68 @@ +import React, {useEffect, useState} from "react"; +import Section from "Frontend/components/general/Section"; +import {PluginConfigEndpoint} from "Frontend/generated/endpoints"; +import {Form, Formik} from "formik"; +import {Check} from "@phosphor-icons/react"; +import {Button} from "@nextui-org/react"; +import Input from "Frontend/components/general/Input"; + +export default function PluginManagement() { + const [configSaved, setConfigSaved] = useState(false); + const [igdbConfigMeta, setIgdbConfigMeta] = useState(); + const [igdbConfig, setIgdbConfig] = useState(); + + useEffect(() => { + PluginConfigEndpoint.getConfigMetadata("igdb").then(setIgdbConfigMeta); + PluginConfigEndpoint.getConfig("igdb").then(setIgdbConfig); + }, []); + + useEffect(() => { + if (configSaved) { + setTimeout(() => setConfigSaved(false), 2000); + } + }, [configSaved]) + + async function handleSubmit(values: any) { + await PluginConfigEndpoint.setConfigEntries("igdb", values); + setConfigSaved(true); + } + + return ( + <> + + {(formik: { values: any; isSubmitting: any; }) => ( +
+
+

Plugins

+
+ +
+
+ +
+
+
+ {igdbConfigMeta && igdbConfigMeta.map((entry: any) => ( + + ))} +
+
+
+ )} +
+ + ); +} \ No newline at end of file diff --git a/gameyfin/src/main/frontend/routes.tsx b/gameyfin/src/main/frontend/routes.tsx index 0fcb65e..f2bc69c 100644 --- a/gameyfin/src/main/frontend/routes.tsx +++ b/gameyfin/src/main/frontend/routes.tsx @@ -17,6 +17,7 @@ import {LogManagement} from "Frontend/components/administration/LogManagement"; import PasswordResetView from "Frontend/views/PasswordResetView"; import EmailConfirmationView from "Frontend/views/EmailConfirmationView"; import InvitationRegistrationView from "Frontend/views/InvitationRegistrationView"; +import PluginManagement from "Frontend/components/administration/PluginManagement"; export const routes = protectRoutes([ { @@ -46,6 +47,7 @@ export const routes = protectRoutes([ {path: 'users', element: }, {path: 'sso', element: }, {path: 'messages', element: }, + {path: 'plugins', element: }, {path: 'logs', element: } ] } diff --git a/gameyfin/src/main/frontend/views/AdministrationView.tsx b/gameyfin/src/main/frontend/views/AdministrationView.tsx index af8f3fa..ddf403b 100644 --- a/gameyfin/src/main/frontend/views/AdministrationView.tsx +++ b/gameyfin/src/main/frontend/views/AdministrationView.tsx @@ -1,4 +1,4 @@ -import {Envelope, GameController, LockKey, Log, Users} from "@phosphor-icons/react"; +import {Envelope, GameController, LockKey, Log, Plug, Users} from "@phosphor-icons/react"; import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu"; const menuItems: MenuItem[] = [ @@ -22,6 +22,11 @@ const menuItems: MenuItem[] = [ url: "messages", icon: }, + { + title: "Plugins", + url: "plugins", + icon: + }, { title: "Logs", url: "logs", diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigEndpoint.kt new file mode 100644 index 0000000..97094fd --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigEndpoint.kt @@ -0,0 +1,28 @@ +package de.grimsi.gameyfin.core.plugins + +import com.vaadin.hilla.Endpoint +import de.grimsi.gameyfin.core.Role +import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement +import jakarta.annotation.security.RolesAllowed + +@Endpoint +@RolesAllowed(Role.Names.ADMIN) +class PluginConfigEndpoint( + private val pluginConfigService: PluginConfigService +) { + fun getConfigMetadata(pluginId: String): List { + return pluginConfigService.getConfigMetadata(pluginId) + } + + fun getConfig(pluginId: String): Map { + return pluginConfigService.getConfig(pluginId) + } + + fun setConfigEntries(pluginId: String, config: Map) { + pluginConfigService.setConfigEntries(pluginId, config) + } + + fun setConfigEntry(pluginId: String, key: String, value: String) { + pluginConfigService.setConfigEntry(pluginId, key, value) + } +} \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigEntry.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigEntry.kt new file mode 100644 index 0000000..dca5aab --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigEntry.kt @@ -0,0 +1,33 @@ +package de.grimsi.gameyfin.core.plugins + +import de.grimsi.gameyfin.core.security.EncryptionConverter +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Embeddable +import jakarta.persistence.EmbeddedId +import jakarta.persistence.Entity +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import java.io.Serializable + +@Entity +@Table(name = "plugin_config") +data class PluginConfigEntry( + @NotNull + @EmbeddedId + val id: PluginConfigEntryKey, + + @NotNull + @Column(name = "`value`") + @Convert(converter = EncryptionConverter::class) + val value: String +) + +@Embeddable +data class PluginConfigEntryKey( + @Column(name = "plugin_id") + val pluginId: String, + + @Column(name = "`key`") + val key: String +) : Serializable \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigRepository.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigRepository.kt new file mode 100644 index 0000000..1dc4eea --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigRepository.kt @@ -0,0 +1,8 @@ +package de.grimsi.gameyfin.core.plugins + +import org.springframework.data.jpa.repository.JpaRepository + +interface PluginConfigRepository : JpaRepository { + fun findAllById_PluginId(pluginId: String): List + fun findById_PluginIdAndId_Key(pluginId: String, key: String): PluginConfigEntry? +} \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigService.kt new file mode 100644 index 0000000..fbab6d2 --- /dev/null +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginConfigService.kt @@ -0,0 +1,33 @@ +package de.grimsi.gameyfin.core.plugins + +import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin +import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement +import org.springframework.stereotype.Service + +@Service +class PluginConfigService( + private val pluginConfigRepository: PluginConfigRepository, + private val pluginManager: SpringDevtoolsPluginManager +) { + + fun getConfigMetadata(pluginId: String): List { + val plugin = pluginManager.getPlugin(pluginId).plugin as GameyfinPlugin + return plugin.getConfigMetadata() + } + + fun getConfig(pluginId: String): Map { + return pluginConfigRepository.findAllById_PluginId(pluginId).associate { it.id.key to it.value } + } + + fun setConfigEntries(pluginId: String, config: Map) { + val entries = config.map { PluginConfigEntry(PluginConfigEntryKey(pluginId, it.key), it.value) } + pluginConfigRepository.saveAll(entries) + pluginManager.restart(pluginId) + } + + fun setConfigEntry(pluginId: String, key: String, value: String) { + val entry = PluginConfigEntry(PluginConfigEntryKey(pluginId, key), value) + pluginConfigRepository.save(entry) + pluginManager.restart(pluginId) + } +} \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginManagerConfig.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginManagerConfig.kt index 5e919d2..980d968 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginManagerConfig.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginManagerConfig.kt @@ -8,15 +8,17 @@ import org.springframework.context.event.EventListener import java.nio.file.Path @Configuration -class PluginManagerConfig { +class PluginManagerConfig( + private val pluginConfigRepository: PluginConfigRepository +) { private val log = KotlinLogging.logger {} private val pluginPath = System.getProperty("pf4j.pluginsDir", "plugins") @Bean - fun pluginManager() = SpringDevtoolsPluginManager(Path.of(pluginPath)) + fun pluginManager() = SpringDevtoolsPluginManager(Path.of(pluginPath), pluginConfigRepository) @EventListener(ApplicationReadyEvent::class) - fun loadedPlugins() { + fun loadPlugins() { pluginManager().loadPlugins() pluginManager().startPlugins() log.info { "Loaded plugins: ${pluginManager().plugins.map { it.pluginId }}" } diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/SpringDevtoolsPluginManager.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/SpringDevtoolsPluginManager.kt index 30986f6..62494db 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/SpringDevtoolsPluginManager.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/SpringDevtoolsPluginManager.kt @@ -1,5 +1,7 @@ package de.grimsi.gameyfin.core.plugins +import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin +import io.github.oshai.kotlinlogging.KotlinLogging import org.pf4j.CompoundPluginLoader import org.pf4j.CompoundPluginRepository import org.pf4j.DefaultPluginLoader @@ -10,12 +12,18 @@ import org.pf4j.JarPluginLoader import org.pf4j.JarPluginRepository import org.pf4j.PluginLoader import org.pf4j.PluginRepository +import org.pf4j.PluginWrapper import java.nio.file.Path /** * @see https://stackoverflow.com/questions/73654174/my-application-cant-find-the-extension-with-pf4j */ -class SpringDevtoolsPluginManager(path: Path) : DefaultPluginManager(path) { +class SpringDevtoolsPluginManager( + path: Path, + private val pluginConfigRepository: PluginConfigRepository +) : DefaultPluginManager(path) { + + private val log = KotlinLogging.logger {} override fun createPluginRepository(): PluginRepository { return CompoundPluginRepository() @@ -35,4 +43,34 @@ class SpringDevtoolsPluginManager(path: Path) : DefaultPluginManager(path) { .add(jarPluginLoader, this::isNotDevelopment) .add(defaultPluginLoader, this::isNotDevelopment) } + + override fun loadPluginFromPath(pluginPath: Path?): PluginWrapper? { + val pluginWrapper = super.loadPluginFromPath(pluginPath) + + // Inject config after loading, before starting + if (pluginWrapper != null) { + configurePlugin(pluginWrapper) + } + + return pluginWrapper + } + + fun restart(pluginId: String) { + val plugin = getPlugin(pluginId)?.plugin ?: return + plugin.stop() + (plugin as GameyfinPlugin).loadConfig(getConfig(pluginId)) + plugin.start() + } + + private fun configurePlugin(pluginWrapper: PluginWrapper) { + val plugin = pluginWrapper.plugin + if (plugin is GameyfinPlugin) { + val config = getConfig(pluginWrapper.pluginId) + plugin.loadConfig(config) + } + } + + private fun getConfig(pluginId: String): Map { + return pluginConfigRepository.findAllById_PluginId(pluginId).map { it.id.key to it.value }.toMap() + } } \ No newline at end of file diff --git a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/GameyfinPlugin.kt b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/GameyfinPlugin.kt index 3496eac..48f6c50 100644 --- a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/GameyfinPlugin.kt +++ b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/GameyfinPlugin.kt @@ -1,5 +1,7 @@ package de.grimsi.gameyfin.pluginapi.core -import org.pf4j.Plugin - -abstract class GameyfinPlugin(protected val context: PluginContext) : Plugin() \ No newline at end of file +interface GameyfinPlugin { + fun getConfigMetadata(): List + fun getCurrentConfig(): Map + fun loadConfig(config: Map) +} \ No newline at end of file diff --git a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement.kt b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement.kt new file mode 100644 index 0000000..f7a6c14 --- /dev/null +++ b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement.kt @@ -0,0 +1,7 @@ +package de.grimsi.gameyfin.pluginapi.core + +data class PluginConfigElement( + val key: String, + val name: String, + val description: String +) \ No newline at end of file diff --git a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/PluginContext.kt b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/PluginContext.kt deleted file mode 100644 index bf96ba6..0000000 --- a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/PluginContext.kt +++ /dev/null @@ -1,9 +0,0 @@ -package de.grimsi.gameyfin.pluginapi.core - -import org.pf4j.RuntimeMode - -class PluginContext(private val runtimeMode: RuntimeMode) { - fun getRuntimeMode(): RuntimeMode { - return runtimeMode - } -} \ No newline at end of file diff --git a/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPlugin.kt b/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPlugin.kt index 279e2ec..d7f8d48 100644 --- a/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPlugin.kt +++ b/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPlugin.kt @@ -4,6 +4,8 @@ import com.api.igdb.apicalypse.APICalypse import com.api.igdb.request.IGDBWrapper import com.api.igdb.request.TwitchAuthenticator import com.api.igdb.request.games +import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin +import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement import de.grimsi.gameyfin.pluginapi.core.PluginConfigError import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataFetcher @@ -16,16 +18,36 @@ import org.pf4j.PluginWrapper import java.time.Instant import kotlin.collections.filter -class IgdbPlugin(wrapper: PluginWrapper) : Plugin(wrapper) { +class IgdbPlugin(wrapper: PluginWrapper) : Plugin(wrapper), GameyfinPlugin { private val log = KotlinLogging.logger {} - companion object { - val config: IgdbPluginConfig = IgdbPluginConfig(null, null) + private val configMetadata: List = listOf( + PluginConfigElement("clientId", "Twitch client ID", "Your Twitch Client ID"), + PluginConfigElement("clientSecret", "Twitch client secret", "Your Twitch Client Secret") + ) + + private var config: Map = configMetadata.associate { it.key to null } + + override fun getConfigMetadata(): List { + return configMetadata } + override fun getCurrentConfig(): Map { + return config + } + + override fun loadConfig(config: Map) { + this.config = config + } + + override fun start() { - authenticate() + try { + authenticate() + } catch (e: PluginConfigError) { + log.error { e.message } + } } override fun stop() { @@ -35,8 +57,8 @@ class IgdbPlugin(wrapper: PluginWrapper) : Plugin(wrapper) { private fun authenticate() { log.debug { "Authenticating on Twitch API..." } - val clientId: String = config.clientId ?: throw PluginConfigError("Twitch Client ID not set") - val clientSecret: String = config.clientSecret ?: throw PluginConfigError("Twitch Client Secret not set") + val clientId: String = config["clientId"] ?: throw PluginConfigError("Twitch Client ID not set") + val clientSecret: String = config["clientSecret"] ?: throw PluginConfigError("Twitch Client Secret not set") val token = TwitchAuthenticator.requestTwitchToken(clientId, clientSecret) ?: throw PluginConfigError("Failed to authenticate on Twitch API") diff --git a/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPluginConfig.kt b/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPluginConfig.kt deleted file mode 100644 index 29957d7..0000000 --- a/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPluginConfig.kt +++ /dev/null @@ -1,6 +0,0 @@ -package de.grimsi.gameyfin.plugins.igdb - -data class IgdbPluginConfig( - val clientId: String?, - val clientSecret: String? -) \ No newline at end of file