From f82fa04ccd40c6a64776b144448898b2d3e7fc9c Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Sat, 5 Oct 2024 10:37:47 +0200 Subject: [PATCH] Implement user preference storage in backend --- .../components/theming/ThemeSelector.tsx | 8 +- src/main/frontend/views/MainLayout.tsx | 17 +++ .../de/grimsi/gameyfin/users/UserService.kt | 28 ++-- .../users/preferences/UserPreference.kt | 27 ++++ .../preferences/UserPreferenceRepository.kt | 5 + .../users/preferences/UserPreferences.kt | 12 ++ .../preferences/UserPreferencesEndpoint.kt | 18 +++ .../preferences/UserPreferencesService.kt | 141 ++++++++++++++++++ 8 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreference.kt create mode 100644 src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferenceRepository.kt create mode 100644 src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferences.kt create mode 100644 src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferencesEndpoint.kt create mode 100644 src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferencesService.kt diff --git a/src/main/frontend/components/theming/ThemeSelector.tsx b/src/main/frontend/components/theming/ThemeSelector.tsx index 6353797..b30a4e0 100644 --- a/src/main/frontend/components/theming/ThemeSelector.tsx +++ b/src/main/frontend/components/theming/ThemeSelector.tsx @@ -4,6 +4,7 @@ import {Button, Card, Divider, Select, Selection, SelectItem} from "@nextui-org/ import {themes} from "Frontend/theming/themes"; import {Theme} from "Frontend/theming/theme"; import ThemePreview from "Frontend/components/theming/ThemePreview"; +import {UserPreferencesEndpoint} from "Frontend/generated/endpoints"; export function ThemeSelector() { @@ -19,8 +20,11 @@ export function ThemeSelector() { useEffect(updateTheme, [selectedTheme, selectedMode]); function updateTheme() { - if (selectedMode instanceof Set) - setTheme(`${selectedTheme}-${selectedMode.values().next().value}`) + if (selectedMode instanceof Set) { + let theme = `${selectedTheme}-${selectedMode.values().next().value}`; + setTheme(theme); + UserPreferencesEndpoint.set("preferred-theme", theme).catch(console.error); + } } return ( diff --git a/src/main/frontend/views/MainLayout.tsx b/src/main/frontend/views/MainLayout.tsx index daf1173..e9a043c 100644 --- a/src/main/frontend/views/MainLayout.tsx +++ b/src/main/frontend/views/MainLayout.tsx @@ -8,16 +8,20 @@ import {Outlet, useNavigate} from "react-router-dom"; import {useAuth} from "Frontend/util/auth"; import {Heart} from "@phosphor-icons/react"; import Confetti, {ConfettiProps} from "react-confetti-boom"; +import {UserPreferencesEndpoint} from "Frontend/generated/endpoints"; +import {useTheme} from "next-themes"; export default function MainLayout() { const navigate = useNavigate(); const auth = useAuth(); const routeMetadata = useRouteMetadata(); + const {setTheme} = useTheme(); const [isExploding, setIsExploding] = useState(false); useEffect(() => { let newTitle = `Gameyfin - ${routeMetadata?.title}` ?? 'Gameyfin'; window.addEventListener('popstate', () => document.title = newTitle); + loadUserTheme().catch(console.error); }, []); const confettiProps: ConfettiProps = { @@ -30,6 +34,19 @@ export default function MainLayout() { effectInterval: 10000 } + async function loadUserTheme() { + let theme = localStorage.getItem('theme'); + + if (theme) { + await UserPreferencesEndpoint.set("preferred-theme", theme); + } else { + let preferredTheme = await UserPreferencesEndpoint.get("preferred-theme"); + if (preferredTheme) { + setTheme(preferredTheme); + } + } + } + function easterEgg() { if (isExploding) return; setIsExploding(true); diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt index ec9a6d9..4739d55 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt @@ -50,7 +50,7 @@ class UserService( get() = config.get(ConfigProperties.Users.SignUps.Allow) == true override fun loadUserByUsername(username: String): UserDetails { - val user = userByUsername(username) + val user = getByUsernameNonNull(username) return org.springframework.security.core.userdetails.User( user.username, @@ -80,6 +80,10 @@ class UserService( return userRepository.findByUsername(username) } + fun getByUsernameNonNull(username: String): User { + return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'") + } + fun getUserInfo(auth: Authentication): UserInfoDto { val principal = auth.principal @@ -92,12 +96,12 @@ class UserService( return userInfoDto } - val user = userByUsername(auth.name) + val user = getByUsernameNonNull(auth.name) return toUserInfo(user) } fun getAvatar(username: String): Avatar? { - val user = userByUsername(username) + val user = getByUsernameNonNull(username) return user.avatar } @@ -106,7 +110,7 @@ class UserService( } fun setAvatar(username: String, file: MultipartFile) { - val user = userByUsername(username) + val user = getByUsernameNonNull(username) if (user.avatar == null) { user.avatar = Avatar(mimeType = file.contentType) @@ -117,7 +121,7 @@ class UserService( } fun deleteAvatar(username: String) { - val user = userByUsername(username) + val user = getByUsernameNonNull(username) if (user.avatar == null) return @@ -188,7 +192,7 @@ class UserService( } fun updateUser(username: String, updates: UserUpdateDto) { - val user = userByUsername(username) + val user = getByUsernameNonNull(username) updates.username?.let { user.username = it } @@ -218,7 +222,7 @@ class UserService( } val currentUser = SecurityContextHolder.getContext().authentication - val targetUser = userByUsername(username) + val targetUser = getByUsernameNonNull(username) if (!canManage(targetUser)) { log.error { "User ${currentUser.name} tried to assign roles to user with higher or equal power level to their own" } @@ -240,7 +244,7 @@ class UserService( } fun canManage(targetUsername: String): Boolean { - val targetUser = userByUsername(targetUsername) + val targetUser = getByUsernameNonNull(targetUsername) return canManage(targetUser) } @@ -252,14 +256,14 @@ class UserService( } fun setUserEnabled(username: String, enabled: Boolean) { - val user = userByUsername(username) + val user = getByUsernameNonNull(username) user.enabled = enabled userRepository.save(user) eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl())) } fun deleteUser(username: String) { - val user = userByUsername(username) + val user = getByUsernameNonNull(username) userRepository.delete(user) eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.getBaseUrl())) } @@ -279,8 +283,4 @@ class UserService( private fun toAuthorities(roles: Collection): List { return roles.map { r -> SimpleGrantedAuthority(r.roleName) } } - - private fun userByUsername(username: String): User { - return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'") - } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreference.kt b/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreference.kt new file mode 100644 index 0000000..3e734bf --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreference.kt @@ -0,0 +1,27 @@ +package de.grimsi.gameyfin.users.preferences + +import de.grimsi.gameyfin.core.security.EncryptionConverter +import jakarta.persistence.* +import jakarta.validation.constraints.NotNull +import java.io.Serializable + +@Entity +class UserPreference( + @NotNull + @EmbeddedId + val id: UserPreferenceKey, + + @NotNull + @Column(name = "`value`") + @Convert(converter = EncryptionConverter::class) + var value: String +) + +@Embeddable +data class UserPreferenceKey( + @Column(name = "`key`") + val key: String, + + @Column(name = "user_id") + val userId: Long +) : Serializable \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferenceRepository.kt b/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferenceRepository.kt new file mode 100644 index 0000000..624a184 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferenceRepository.kt @@ -0,0 +1,5 @@ +package de.grimsi.gameyfin.users.preferences + +import org.springframework.data.jpa.repository.JpaRepository + +interface UserPreferenceRepository : JpaRepository \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferences.kt b/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferences.kt new file mode 100644 index 0000000..b1558f6 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferences.kt @@ -0,0 +1,12 @@ +package de.grimsi.gameyfin.users.preferences + +import java.io.Serializable +import kotlin.reflect.KClass + +sealed class UserPreferences( + val type: KClass, + val key: String, + val allowedValues: List? = null +) { + data object PreferredTheme : UserPreferences(String::class, "preferred-theme") +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferencesEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferencesEndpoint.kt new file mode 100644 index 0000000..a68927b --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferencesEndpoint.kt @@ -0,0 +1,18 @@ +package de.grimsi.gameyfin.users.preferences + +import com.vaadin.hilla.Endpoint +import jakarta.annotation.security.PermitAll + +@Endpoint +@PermitAll +class UserPreferencesEndpoint( + private val userPreferences: UserPreferencesService +) { + fun get(key: String): String? { + return userPreferences.get(key) + } + + fun set(key: String, value: String) { + userPreferences.set(key, value) + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferencesService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferencesService.kt new file mode 100644 index 0000000..cf0d3fb --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/preferences/UserPreferencesService.kt @@ -0,0 +1,141 @@ +package de.grimsi.gameyfin.users.preferences + +import de.grimsi.gameyfin.users.UserService +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.transaction.Transactional +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Service +import java.io.Serializable + +@Service +@Transactional +class UserPreferencesService( + private val userPreferenceRepository: UserPreferenceRepository, + private val userService: UserService +) { + private val log = KotlinLogging.logger {} + + /** + * Get the current value of a user preference in a type-safe way. + * Used internally. + * + * @param userPreference: The user preference containing necessary type information + * @return The current value if set or the default value or null if no value is set and no default value exists + */ + fun get(userPreference: UserPreferences): T? { + log.info { "Getting user preference '${userPreference.key}'" } + + val id = id(userPreference.key) + val appConfig = userPreferenceRepository.findById(id).orElse(null) + + return if (appConfig != null) { + getValue(appConfig.value, userPreference) + } else { + return null + } + } + + /** + * Get the current value of a user preference in a *not* type-safe way. + * Used for the external API. + * + * @param key: The key of the user preference + * @return The current value if set or the default value or null if no value is set and no default value exists + */ + fun get(key: String): String? { + + log.info { "Getting user preference '$key'" } + + val userPreference = findUserPreference(key) + val id = id(key) + val appConfig = userPreferenceRepository.findById(id).orElse(null) + + return if (appConfig != null) { + getValue(appConfig.value, userPreference).toString() + } else { + return null + } + } + + /** + * Set the value for a specified key in a type-safe way. + * + * @param userPreference: The target user preference + * @param value: Value to set the user preference to + * @throws IllegalArgumentException if the value can't be cast to the type defined for the user preference + */ + fun set(userPreference: UserPreferences, value: T) { + return set(userPreference.key, value) + } + + /** + * Set the value for a specified key. + * Checks if the value can be cast to the type defined for the user preference. + * + * @param key: Key of the target user preference + * @param value: Value to set the user preference to + * @throws IllegalArgumentException if the value can't be cast to the type defined for the user preference + */ + fun set(key: String, value: T) { + log.info { "Set user preference '$key'" } + + val userPreferenceKey = findUserPreference(key) + + // Check if the value can be cast to the type defined for the user preference + val castedValue = getValue(value.toString(), userPreferenceKey) + + val id = id(key) + var userPreference = userPreferenceRepository.findById(id).orElse(null) + + if (userPreference == null) { + userPreference = UserPreference(id, castedValue.toString()) + } else { + userPreference.value = castedValue.toString() + } + + userPreferenceRepository.save(userPreference) + } + + /** + * Get the value of the user preference in a type-safe way. + */ + @Suppress("UNCHECKED_CAST") + private fun getValue(value: String, userPreference: UserPreferences): T { + return when (userPreference.type) { + String::class -> value as T + Boolean::class -> value.toBoolean() as T + Int::class -> value.toFloat().toInt() as T + Float::class -> value.toFloat() as T + else -> { + if (userPreference.type.java.isEnum) { + val enumConstants = userPreference.type.java.enumConstants + enumConstants.firstOrNull { it.toString() == value } + ?: throw IllegalArgumentException("Unknown enum value '$value' for key ${userPreference.key}") + } else { + throw IllegalArgumentException("Unknown config type ${userPreference.type}: '$value' for key ${userPreference.key}") + } + } + } + } + + /** + * Returns a user preference + */ + private fun findUserPreference(key: String): UserPreferences<*> { + // Use reflection to get all objects defined within ConfigKey + val configProperties = UserPreferences::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 user preference key: $key") + } + + private fun id(key: String): UserPreferenceKey { + val auth = SecurityContextHolder.getContext().authentication + val user = userService.getByUsernameNonNull(auth.name) + return UserPreferenceKey(key, user.id!!) + } +} +