WIP: First "full-stack" implementation of plugin management

This commit is contained in:
grimsi
2024-10-30 23:26:29 +01:00
parent a4ce0826cc
commit 50bb5fa9b4
14 changed files with 262 additions and 29 deletions
@@ -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<any>();
const [igdbConfig, setIgdbConfig] = useState<any>();
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
initialValues={{
clientId: igdbConfig?.clientId,
clientSecret: igdbConfig?.clientSecret
}}
onSubmit={handleSubmit}
>
{(formik: { values: any; isSubmitting: any; }) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-2xl font-bold">Plugins</h2>
<div className="flex flex-row items-center gap-4">
<Button
color="primary"
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting || configSaved}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
</Button>
</div>
</div>
<div className="flex flex-row flex-1 justify-between gap-16">
<div className="flex flex-col flex-grow">
<Section title="IGDB"/>
{igdbConfigMeta && igdbConfigMeta.map((entry: any) => (
<Input key={entry.key} name={entry.key} label={entry.name} type="text"/>
))}
</div>
</div>
</Form>
)}
</Formik>
</>
);
}
+2
View File
@@ -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: <UserManagement/>},
{path: 'sso', element: <SsoManagement/>},
{path: 'messages', element: <MessageManagement/>},
{path: 'plugins', element: <PluginManagement/>},
{path: 'logs', element: <LogManagement/>}
]
}
@@ -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: <Envelope/>
},
{
title: "Plugins",
url: "plugins",
icon: <Plug/>
},
{
title: "Logs",
url: "logs",
@@ -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<PluginConfigElement> {
return pluginConfigService.getConfigMetadata(pluginId)
}
fun getConfig(pluginId: String): Map<String, String?> {
return pluginConfigService.getConfig(pluginId)
}
fun setConfigEntries(pluginId: String, config: Map<String, String>) {
pluginConfigService.setConfigEntries(pluginId, config)
}
fun setConfigEntry(pluginId: String, key: String, value: String) {
pluginConfigService.setConfigEntry(pluginId, key, value)
}
}
@@ -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
@@ -0,0 +1,8 @@
package de.grimsi.gameyfin.core.plugins
import org.springframework.data.jpa.repository.JpaRepository
interface PluginConfigRepository : JpaRepository<PluginConfigEntry, PluginConfigEntryKey> {
fun findAllById_PluginId(pluginId: String): List<PluginConfigEntry>
fun findById_PluginIdAndId_Key(pluginId: String, key: String): PluginConfigEntry?
}
@@ -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<PluginConfigElement> {
val plugin = pluginManager.getPlugin(pluginId).plugin as GameyfinPlugin
return plugin.getConfigMetadata()
}
fun getConfig(pluginId: String): Map<String, String?> {
return pluginConfigRepository.findAllById_PluginId(pluginId).associate { it.id.key to it.value }
}
fun setConfigEntries(pluginId: String, config: Map<String, String>) {
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)
}
}
@@ -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 }}" }
@@ -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<String, String?> {
return pluginConfigRepository.findAllById_PluginId(pluginId).map { it.id.key to it.value }.toMap()
}
}
@@ -1,5 +1,7 @@
package de.grimsi.gameyfin.pluginapi.core
import org.pf4j.Plugin
abstract class GameyfinPlugin(protected val context: PluginContext) : Plugin()
interface GameyfinPlugin {
fun getConfigMetadata(): List<PluginConfigElement>
fun getCurrentConfig(): Map<String, String?>
fun loadConfig(config: Map<String, String?>)
}
@@ -0,0 +1,7 @@
package de.grimsi.gameyfin.pluginapi.core
data class PluginConfigElement(
val key: String,
val name: String,
val description: String
)
@@ -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
}
}
@@ -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<PluginConfigElement> = listOf(
PluginConfigElement("clientId", "Twitch client ID", "Your Twitch Client ID"),
PluginConfigElement("clientSecret", "Twitch client secret", "Your Twitch Client Secret")
)
private var config: Map<String, String?> = configMetadata.associate { it.key to null }
override fun getConfigMetadata(): List<PluginConfigElement> {
return configMetadata
}
override fun getCurrentConfig(): Map<String, String?> {
return config
}
override fun loadConfig(config: Map<String, String?>) {
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")
@@ -1,6 +0,0 @@
package de.grimsi.gameyfin.plugins.igdb
data class IgdbPluginConfig(
val clientId: String?,
val clientSecret: String?
)