Implement dynamic config management

Start implementation of library management
This commit is contained in:
grimsi
2024-09-08 20:54:31 +02:00
parent f12da9e791
commit 0a3245ddf9
25 changed files with 385 additions and 28 deletions
@@ -2,9 +2,11 @@ package de.grimsi.gameyfin
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.transaction.annotation.EnableTransactionManagement
@SpringBootApplication
@EnableTransactionManagement
class GameyfinApplication
fun main(args: Array<String>) {
@@ -0,0 +1,28 @@
package de.grimsi.gameyfin.config
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.meta.Roles
import jakarta.annotation.security.RolesAllowed
@Endpoint
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
class ConfigController(
private val appConfigService: ConfigService
) {
fun getConfig(key: String): String {
return appConfigService.getConfigValue(key)
}
fun setConfig(config: Pair<String, String>) {
appConfigService.setConfigValue(config.first, config.second)
}
fun resetConfig(key: String) {
appConfigService.resetConfigValue(key)
}
fun deleteConfig(key: String) {
appConfigService.deleteConfig(key)
}
}
@@ -0,0 +1,38 @@
package de.grimsi.gameyfin.config
import java.io.Serializable
import kotlin.reflect.KClass
sealed class ConfigProperty<T : Serializable>(val type: KClass<T>, val key: String, val default: T? = null) {
/** Libraries */
// Allow access to game libraries without login
data object LibraryAllowPublicAccess :
ConfigProperty<Boolean>(Boolean::class, "library.allow-public-access", false)
// Enable automatic library scanning using file system watchers
data object LibraryEnableFilesystemWatcher :
ConfigProperty<Boolean>(Boolean::class, "library.scan.enable-filesystem-watcher", true)
// Enable periodic refresh of video game meta-data and set the schedule (default is once per week)
data object LibraryMetadataUpdateEnabled :
ConfigProperty<Boolean>(Boolean::class, "library.metadata.update.enabled", true)
data object LibraryMetadataUpdateSchedule :
ConfigProperty<String>(String::class, "library.metadata.update.schedule", "0 0 * * 0")
/** User management */
// Allow new users to sign up by themselves
data object UsersAllowNewSignUps : ConfigProperty<Boolean>(Boolean::class, "users.sign-ups.allow", false)
// If an administrator needs to confirm new sign-ups before they are allowed to log in
data object UsersConfirmNewSignUps :
ConfigProperty<Boolean>(Boolean::class, "users.sign-ups.confirm", false)
/** Notifications */
// Settings for the mail server used by Gameyfin to send notifications
data object NotificationsEmailHost : ConfigProperty<String>(String::class, "notifications.email.host")
data object NotificationsEmailPort : ConfigProperty<String>(String::class, "notifications.email.port")
data object NotificationsEmailUsername : ConfigProperty<String>(String::class, "notifications.email.username")
data object NotificationsEmailPassword : ConfigProperty<String>(String::class, "notifications.email.password")
}
@@ -0,0 +1,133 @@
package de.grimsi.gameyfin.config
import de.grimsi.gameyfin.config.entities.ConfigEntry
import de.grimsi.gameyfin.config.persistence.ConfigRepository
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import java.io.Serializable
import kotlin.reflect.safeCast
@Service
@Transactional
class ConfigService(
private val appConfigRepository: ConfigRepository
) {
/**
* Get the current value of a config property in a type-safe way.
* Used internally.
*
* @param configProperty: The config property containing necessary type information
* @return The current value if set or the default value
* @throws IllegalArgumentException if no value is set and no default value exists
*/
fun <T : Serializable> getConfigValue(configProperty: ConfigProperty<T>): T {
val appConfig = appConfigRepository.findById(configProperty.key).orElse(null)
return if (appConfig != null) {
getValue(appConfig.value, configProperty)
} else {
configProperty.default ?: throw IllegalArgumentException("No value found for key: ${configProperty.key}")
}
}
/**
* Get the current value of a config property in a *not* type-safe way.
* Used for the external API.
*
* @param key: The key of the config property
* @return The current value if set or the default value
* @throws IllegalArgumentException if no value is set and no default value exists
*/
fun getConfigValue(key: String): String {
val configProperty = findConfigProperty(key)
val appConfig = appConfigRepository.findById(configProperty.key).orElse(null)
return if (appConfig != null) {
getValue(appConfig.value, configProperty).toString()
} else {
configProperty.default?.toString()
?: throw IllegalArgumentException("No value found for key: ${configProperty.key}")
}
}
/**
* Set the value for a specified key.
* Checks if the value can be cast to the type defined for the config property.
*
* @param key: Key of the target config property
* @param value: Value to set the config property to
* @throws IllegalArgumentException if the value can't be cast to the type defined for the config property
*/
fun <T : Serializable> setConfigValue(key: String, value: T) {
val configKey = findConfigProperty(key)
if (configKey.type.safeCast(value) == null) {
throw IllegalArgumentException("Type mismatch for key: ${configKey.key}")
}
val appConfig = ConfigEntry(configKey.key, value.toString())
appConfigRepository.save(appConfig)
}
/**
* Reset a given config property to its default value if it has a default value.
* Otherwise, delete the config key from the database.
*
* @param key: Key of the config property
*/
fun resetConfigValue(key: String) {
val configKey = findConfigProperty(key)
if (configKey.default == null) {
deleteConfig(key)
return
}
val appConfig = appConfigRepository.findById(configKey.key).orElse(null)
if (appConfig != null) {
appConfig.value = configKey.default.toString()
appConfigRepository.save(appConfig)
}
}
/**
* Remove a config property from the database
*
* @param key: Key of the config property
*/
fun deleteConfig(key: String) {
val configKey = findConfigProperty(key)
appConfigRepository.deleteById(configKey.key)
}
/**
* Get the value of the config property in a type-safe way.
*/
@Suppress("UNCHECKED_CAST")
private fun <T : Serializable> getValue(value: String, configProperty: ConfigProperty<T>): T {
return when (configProperty.type) {
String::class -> value as T
Boolean::class -> value.toBoolean() as T
Number::class -> value.toInt() as T
Float::class -> value.toFloat() as T
else -> {
throw RuntimeException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}")
}
}
}
/**
* Returns a config property
*/
private fun findConfigProperty(key: String): ConfigProperty<*> {
// Use reflection to get all objects defined within ConfigKey
val configProperties = ConfigProperty::class.sealedSubclasses.flatMap { subclass ->
subclass.objectInstance?.let { listOf(it) } ?: listOf()
}
// Find the matching config key based on the string key
return configProperties.find { it.key == key }
?: throw IllegalArgumentException("Unknown configuration key: $key")
}
}
@@ -0,0 +1,19 @@
package de.grimsi.gameyfin.config.entities
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
@Entity
@Table(name = "app_config")
class ConfigEntry(
@Id
@NotNull
@Column(unique = true)
val key: String,
@NotNull
var value: String
)
@@ -0,0 +1,6 @@
package de.grimsi.gameyfin.config.persistence
import de.grimsi.gameyfin.config.entities.ConfigEntry
import org.springframework.data.jpa.repository.JpaRepository
interface ConfigRepository : JpaRepository<ConfigEntry, String>
@@ -0,0 +1,18 @@
package de.grimsi.gameyfin.libraries
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.libraries.entities.Library
import de.grimsi.gameyfin.meta.Roles
import jakarta.annotation.security.RolesAllowed
@Endpoint
class LibraryEndpoint(
private val libraryService: LibraryService
) {
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
fun getAllLibraries(): Collection<Library> {
return libraryService.getAllLibraries()
}
}
@@ -0,0 +1,26 @@
package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.libraries.entities.Library
import de.grimsi.gameyfin.libraries.persistence.LibraryRepository
import org.springframework.stereotype.Service
@Service
class LibraryService(
private val libraryRepository: LibraryRepository
) {
fun createLibrary(library: Library): Library {
return libraryRepository.save(library)
}
fun getAllLibraries(): Collection<Library> {
return libraryRepository.findAll()
}
fun deleteLibrary(library: Library) {
libraryRepository.delete(library)
}
fun updateLibrary(library: Library) {
libraryRepository.save(library)
}
}
@@ -0,0 +1,15 @@
package de.grimsi.gameyfin.libraries.entities
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
@Entity
class Library(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
var path: String
)
@@ -0,0 +1,6 @@
package de.grimsi.gameyfin.libraries.persistence
import de.grimsi.gameyfin.libraries.entities.Library
import org.springframework.data.jpa.repository.JpaRepository
interface LibraryRepository : JpaRepository<Library, Long>
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.config
package de.grimsi.gameyfin.meta
enum class Roles(val roleName: String) {
SUPERADMIN(Names.SUPERADMIN),
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.config
package de.grimsi.gameyfin.meta
import com.vaadin.flow.spring.security.VaadinWebSecurity
import org.springframework.context.annotation.Bean
@@ -0,0 +1,14 @@
package de.grimsi.gameyfin.meta
import de.grimsi.gameyfin.meta.annotations.DynamicAccessInterceptor
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class WebConfig(val dynamicAccessInterceptor: DynamicAccessInterceptor) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(dynamicAccessInterceptor)
}
}
@@ -0,0 +1,38 @@
package de.grimsi.gameyfin.meta.annotations
import de.grimsi.gameyfin.config.ConfigProperty
import de.grimsi.gameyfin.config.ConfigService
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor
@Component
class DynamicAccessInterceptor(
private val configService: ConfigService
) : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any
): Boolean {
val handlerMethod = (handler as? HandlerMethod) ?: return true
val method = handlerMethod.method
// Check if method is annotated with @DynamicPublicAccess
if (method.isAnnotationPresent(DynamicPublicAccess::class.java)) {
// Check if user is authenticated or public access is enabled
if (request.userPrincipal != null || configService.getConfigValue(ConfigProperty.LibraryAllowPublicAccess)) {
return true
}
// Deny access if user is not logged in and public access is disabled
response.status = HttpServletResponse.SC_UNAUTHORIZED
return false
}
return true
}
}
@@ -0,0 +1,14 @@
package de.grimsi.gameyfin.meta.annotations
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.annotation.AnnotationTarget.FUNCTION
/**
* This annotation is used on endpoint methods which can be switched between publicly accessible and
* only accessible for registered users.
* One example would be the main library view.
*/
@Target(FUNCTION)
@Retention(RUNTIME)
annotation class DynamicPublicAccess
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.setup
import de.grimsi.gameyfin.config.Roles
import de.grimsi.gameyfin.meta.Roles
import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.entities.Role
import de.grimsi.gameyfin.users.entities.User
@@ -1,14 +1,14 @@
package de.grimsi.gameyfin.setup
import com.vaadin.flow.server.auth.AnonymousAllowed
import de.grimsi.gameyfin.config.Roles
import de.grimsi.gameyfin.users.RoleService
import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.dto.UserInfo
import de.grimsi.gameyfin.users.dto.UserRegistration
import de.grimsi.gameyfin.users.entities.User
import com.vaadin.hilla.Endpoint
import com.vaadin.hilla.exception.EndpointException
import de.grimsi.gameyfin.meta.Roles
import de.grimsi.gameyfin.users.RoleService
import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.dto.UserInfoDto
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
import de.grimsi.gameyfin.users.entities.User
@Endpoint
class SetupEndpoint(
@@ -22,7 +22,7 @@ class SetupEndpoint(
}
@AnonymousAllowed
fun registerSuperAdmin(superAdminRegistration: UserRegistration): UserInfo {
fun registerSuperAdmin(superAdminRegistration: UserRegistrationDto): UserInfoDto {
if (setupService.isSetupCompleted()) throw EndpointException("Setup already completed")
val user = User(
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.setup
import de.grimsi.gameyfin.config.Roles
import de.grimsi.gameyfin.meta.Roles
import de.grimsi.gameyfin.users.RoleService
import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.entities.User
@@ -1,7 +1,7 @@
package de.grimsi.gameyfin.system
import de.grimsi.gameyfin.config.Roles
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.meta.Roles
import jakarta.annotation.security.RolesAllowed
@Endpoint
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.config.Roles
import de.grimsi.gameyfin.meta.Roles
import de.grimsi.gameyfin.users.entities.Role
import de.grimsi.gameyfin.users.persistence.RoleRepository
import jakarta.transaction.Transactional
@@ -1,10 +1,10 @@
package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.config.Roles
import de.grimsi.gameyfin.users.dto.UserInfo
import de.grimsi.gameyfin.users.dto.UserRegistration
import de.grimsi.gameyfin.users.entities.User
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.meta.Roles
import de.grimsi.gameyfin.users.dto.UserInfoDto
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
import de.grimsi.gameyfin.users.entities.User
import jakarta.annotation.security.PermitAll
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
@@ -16,19 +16,19 @@ class UserEndpoint(
) {
@PermitAll
fun getUserInfo(): UserInfo {
fun getUserInfo(): UserInfoDto {
val auth: Authentication = SecurityContextHolder.getContext().authentication
val authorities: List<String> = auth.authorities.map { g: GrantedAuthority -> g.authority }
return UserInfo(username = auth.name, roles = authorities)
return UserInfoDto(username = auth.name, roles = authorities)
}
@PermitAll
fun registerUser(registration: UserRegistration): UserInfo {
fun registerUser(registration: UserRegistrationDto): UserInfoDto {
val user: User = registerUser(registration, listOf(Roles.USER))
return userService.toUserInfo(user)
}
private fun registerUser(registration: UserRegistration, roles: List<Roles>): User {
private fun registerUser(registration: UserRegistrationDto, roles: List<Roles>): User {
val user = User(
username = registration.username,
password = registration.password,
@@ -1,7 +1,7 @@
package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.config.Roles
import de.grimsi.gameyfin.users.dto.UserInfo
import de.grimsi.gameyfin.meta.Roles
import de.grimsi.gameyfin.users.dto.UserInfoDto
import de.grimsi.gameyfin.users.entities.Role
import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.persistence.UserRepository
@@ -50,8 +50,8 @@ class UserService(
return userRepository.save(user)
}
fun toUserInfo(user: User): UserInfo {
return UserInfo(
fun toUserInfo(user: User): UserInfoDto {
return UserInfoDto(
username = user.username,
email = user.email,
roles = user.roles.map { r -> r.rolename }
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.users.dto
data class UserInfo(
data class UserInfoDto(
val username: String,
val email: String? = null,
val roles: List<String>
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.users.dto
data class UserRegistration(
data class UserRegistrationDto(
val username: String,
val password: String,
val email: String
@@ -3,7 +3,7 @@
package de.grimsi.gameyfin.users.util
import de.grimsi.gameyfin.config.Roles
import de.grimsi.gameyfin.meta.Roles
import org.springframework.security.core.userdetails.UserDetails
fun UserDetails.hasRole(role: Roles): Boolean {