From 6b81d3904c0d3334fc15369918563d1853c0b28e Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Sun, 31 Aug 2025 15:56:56 +0200 Subject: [PATCH 01/32] Start implementation of game requests --- .../core/plugins/dto/ExternalProviderIdDto.kt | 10 ++++ .../app/core/security/SecurityUtils.kt | 7 ++- .../org/gameyfin/app/games/GameEndpoint.kt | 13 ++++- .../org/gameyfin/app/games/GameService.kt | 18 +++--- .../app/games/dto/GameSearchResultDto.kt | 12 +--- .../org/gameyfin/app/media/ImageEndpoint.kt | 7 +-- .../gameyfin/app/messages/MessageService.kt | 5 +- .../org/gameyfin/app/requests/GameRequest.kt | 42 ++++++++++++++ .../app/requests/GameRequestRepository.kt | 5 ++ .../app/requests/GameRequestService.kt | 57 +++++++++++++++++++ .../requests/dto/GameRequestCreationDto.kt | 10 ++++ .../app/requests/dto/GameRequestDto.kt | 13 +++++ .../app/requests/dto/GameRequestEvent.kt | 9 +++ .../extensions/GameRequestExtensions.kt | 15 +++++ .../app/requests/status/GameRequestStatus.kt | 8 +++ .../org/gameyfin/app/setup/SetupEndpoint.kt | 4 +- .../org/gameyfin/app/setup/SetupService.kt | 6 +- .../org/gameyfin/app/users/SessionService.kt | 14 ++--- .../org/gameyfin/app/users/UserEndpoint.kt | 16 +++--- .../org/gameyfin/app/users/UserService.kt | 18 +++--- .../{UserInfoDto.kt => UserInfoAdminDto.kt} | 2 +- .../gameyfin/app/users/dto/UserInfoUserDto.kt | 7 +++ .../EmailConfirmationEndpoint.kt | 7 +-- .../preferences/UserPreferencesService.kt | 6 +- .../users/registration/InvitationService.kt | 12 ++-- 25 files changed, 250 insertions(+), 73 deletions(-) create mode 100644 app/src/main/kotlin/org/gameyfin/app/core/plugins/dto/ExternalProviderIdDto.kt create mode 100644 app/src/main/kotlin/org/gameyfin/app/requests/GameRequest.kt create mode 100644 app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt create mode 100644 app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt create mode 100644 app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestCreationDto.kt create mode 100644 app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestDto.kt create mode 100644 app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestEvent.kt create mode 100644 app/src/main/kotlin/org/gameyfin/app/requests/extensions/GameRequestExtensions.kt create mode 100644 app/src/main/kotlin/org/gameyfin/app/requests/status/GameRequestStatus.kt rename app/src/main/kotlin/org/gameyfin/app/users/dto/{UserInfoDto.kt => UserInfoAdminDto.kt} (91%) create mode 100644 app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoUserDto.kt diff --git a/app/src/main/kotlin/org/gameyfin/app/core/plugins/dto/ExternalProviderIdDto.kt b/app/src/main/kotlin/org/gameyfin/app/core/plugins/dto/ExternalProviderIdDto.kt new file mode 100644 index 0000000..5881d5c --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/core/plugins/dto/ExternalProviderIdDto.kt @@ -0,0 +1,10 @@ +package org.gameyfin.app.core.plugins.dto + +class ExternalProviderIdDto( + val pluginId: String, + val externalProviderId: String, +) { + override fun toString(): String { + return "$pluginId:$externalProviderId" + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityUtils.kt b/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityUtils.kt index 1b0adf1..45fc1de 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityUtils.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityUtils.kt @@ -1,9 +1,14 @@ package org.gameyfin.app.core.security import org.gameyfin.app.core.Role +import org.springframework.security.core.Authentication import org.springframework.security.core.context.SecurityContextHolder +fun getCurrentAuth(): Authentication { + return SecurityContextHolder.getContext().authentication +} + fun isCurrentUserAdmin(): Boolean { - return SecurityContextHolder.getContext().authentication?.authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN } + return getCurrentAuth().authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN } ?: false } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt index 5ecd8dd..9f33c51 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt @@ -5,8 +5,12 @@ import com.vaadin.hilla.Endpoint import jakarta.annotation.security.RolesAllowed import org.gameyfin.app.core.Role import org.gameyfin.app.core.annotations.DynamicPublicAccess +import org.gameyfin.app.core.plugins.dto.ExternalProviderIdDto import org.gameyfin.app.core.security.isCurrentUserAdmin -import org.gameyfin.app.games.dto.* +import org.gameyfin.app.games.dto.GameDto +import org.gameyfin.app.games.dto.GameEvent +import org.gameyfin.app.games.dto.GameSearchResultDto +import org.gameyfin.app.games.dto.GameUpdateDto import org.gameyfin.app.libraries.LibraryCoreService import org.gameyfin.app.libraries.LibraryService import reactor.core.publisher.Flux @@ -45,7 +49,12 @@ class GameEndpoint( } @RolesAllowed(Role.Names.ADMIN) - fun matchManually(originalIds: Map, path: String, libraryId: Long, replaceGameId: Long?) { + fun matchManually( + originalIds: Map, + path: String, + libraryId: Long, + replaceGameId: Long? + ) { val library = libraryService.getById(libraryId) val game = gameService.matchManually(originalIds, Path.of(path), library, replaceGameId) if (game != null) { diff --git a/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt b/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt index 751082e..5d42048 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt @@ -12,10 +12,12 @@ import org.gameyfin.app.core.alphaNumeric import org.gameyfin.app.core.filesystem.FilesystemService import org.gameyfin.app.core.filterValuesNotNull import org.gameyfin.app.core.plugins.PluginService +import org.gameyfin.app.core.plugins.dto.ExternalProviderIdDto import org.gameyfin.app.core.plugins.management.GameyfinPluginDescriptor import org.gameyfin.app.core.plugins.management.GameyfinPluginManager import org.gameyfin.app.core.plugins.management.PluginManagementEntry import org.gameyfin.app.core.replaceRomanNumerals +import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.games.dto.* import org.gameyfin.app.games.entities.* import org.gameyfin.app.games.extensions.toDtos @@ -25,7 +27,6 @@ import org.gameyfin.app.media.ImageService import org.gameyfin.app.users.UserService import org.gameyfin.pluginapi.gamemetadata.* import org.springframework.data.repository.findByIdOrNull -import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.oauth2.core.oidc.user.OidcUser import org.springframework.stereotype.Service @@ -149,8 +150,7 @@ class GameService( val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id) ?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found") - val userDetails = SecurityContextHolder.getContext().authentication.principal - val user = when (userDetails) { + val user = when (val userDetails = getCurrentAuth().principal) { is UserDetails -> userService.getByUsernameNonNull(userDetails.username) is OidcUser -> userService.getByUsernameNonNull(userDetails.preferredUsername) else -> throw IllegalStateException("Unkown user type: ${userDetails::class.java.name}") @@ -260,12 +260,12 @@ class GameService( val game = getById(game.id!!) - val originalIds: Map = game.metadata.originalIds + val originalIds: Map = game.metadata.originalIds .map { (provider, originalId) -> val providerId = pluginManager.getExtensions(provider.pluginId).first()?.javaClass?.name ?: return null val pluginId = provider.pluginId val originalId = originalId - providerId to OriginalIdDto(pluginId, originalId) + providerId to ExternalProviderIdDto(pluginId, originalId) } .toMap() @@ -504,12 +504,12 @@ class GameService( sorted.mapNotNull { selector(it.second) }.firstOrNull { it.isNotEmpty() } // Collect originalIds for this group - val originalIds: Map = group + val originalIds: Map = group .mapNotNull { (provider, metadata) -> val providerId = provider.javaClass.name val pluginId = providerToManagementEntry[provider]?.pluginId ?: return@mapNotNull null val originalId = metadata.originalId - if (providerId != null) providerId to OriginalIdDto(pluginId, originalId) else null + if (providerId != null) providerId to ExternalProviderIdDto(pluginId, originalId) else null } .toMap() @@ -567,7 +567,7 @@ class GameService( } fun matchManually( - originalIds: Map, + originalIds: Map, path: Path, library: Library, replaceGameId: Long? = null, @@ -578,7 +578,7 @@ class GameService( coroutineScope { metadataPlugins.associateWith { plugin -> async { - val originalId = originalIds[plugin.javaClass.name]?.originalId ?: return@async null + val originalId = originalIds[plugin.javaClass.name]?.externalProviderId ?: return@async null try { return@async plugin.fetchById(originalId) } catch (e: Exception) { diff --git a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameSearchResultDto.kt b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameSearchResultDto.kt index 45f98c5..4af2da0 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/dto/GameSearchResultDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/dto/GameSearchResultDto.kt @@ -1,5 +1,6 @@ package org.gameyfin.app.games.dto +import org.gameyfin.app.core.plugins.dto.ExternalProviderIdDto import java.time.Instant import java.util.* @@ -11,18 +12,9 @@ class GameSearchResultDto( val release: Instant?, val publishers: Collection?, val developers: Collection?, - val originalIds: Map + val originalIds: Map ) -class OriginalIdDto( - val pluginId: String, - val originalId: String, -) { - override fun toString(): String { - return "$pluginId:$originalId" - } -} - class UrlWithSourceDto( val url: String, val pluginId: String diff --git a/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt index 0d8b74b..725df8a 100644 --- a/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt @@ -7,6 +7,7 @@ import org.gameyfin.app.core.Role import org.gameyfin.app.core.Utils import org.gameyfin.app.core.annotations.DynamicPublicAccess import org.gameyfin.app.core.plugins.PluginService +import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.games.entities.Image import org.gameyfin.app.games.entities.ImageType import org.gameyfin.app.users.UserService @@ -15,8 +16,6 @@ import org.springframework.core.io.InputStreamResource import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.http.ResponseEntity -import org.springframework.security.core.Authentication -import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile @@ -61,7 +60,7 @@ class ImageEndpoint( @PermitAll @PostMapping("/avatar/upload") fun uploadAvatar(@RequestParam("file") file: MultipartFile) { - val auth: Authentication = SecurityContextHolder.getContext().authentication + val auth = getCurrentAuth() val image: Image = if (!userService.hasAvatar(auth.name)) { imageService.createFile(ImageType.AVATAR, file.inputStream, file.contentType!!) @@ -76,7 +75,7 @@ class ImageEndpoint( @PermitAll @PostMapping("/avatar/delete") fun deleteAvatar() { - val auth: Authentication = SecurityContextHolder.getContext().authentication + val auth = getCurrentAuth() userService.deleteAvatar(auth.name) } diff --git a/app/src/main/kotlin/org/gameyfin/app/messages/MessageService.kt b/app/src/main/kotlin/org/gameyfin/app/messages/MessageService.kt index 68a1605..a0e48b8 100644 --- a/app/src/main/kotlin/org/gameyfin/app/messages/MessageService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/messages/MessageService.kt @@ -3,6 +3,7 @@ package org.gameyfin.app.messages import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.gameyfin.app.core.events.* +import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.messages.providers.AbstractMessageProvider import org.gameyfin.app.messages.templates.MessageTemplateService import org.gameyfin.app.messages.templates.MessageTemplates @@ -10,8 +11,6 @@ import org.gameyfin.app.users.UserService import org.springframework.context.ApplicationContext import org.springframework.context.event.EventListener import org.springframework.scheduling.annotation.Async -import org.springframework.security.core.Authentication -import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Service import java.util.* @@ -61,7 +60,7 @@ class MessageService( } try { - val auth: Authentication = SecurityContextHolder.getContext().authentication + val auth = getCurrentAuth() val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found") val template = templateService.getMessageTemplate(templateKey) sendNotification(user.email, "[Gameyfin] Test Notification", template, placeholders) diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequest.kt b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequest.kt new file mode 100644 index 0000000..6808044 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequest.kt @@ -0,0 +1,42 @@ +package org.gameyfin.app.requests + +import jakarta.persistence.* +import org.gameyfin.app.requests.status.GameRequestStatus +import org.gameyfin.app.users.entities.User +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.UpdateTimestamp +import java.time.Instant + +typealias ExternalProviderIds = Map + +@Entity +class GameRequest( + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + var id: Long? = null, + + @CreationTimestamp + @Column(nullable = false, updatable = false) + var createdAt: Instant? = null, + + @UpdateTimestamp + @Column(nullable = false) + var updatedAt: Instant? = null, + + @Column(nullable = false) + val title: String, + + @Column(nullable = false) + val release: Instant, + + @Column(nullable = false) + var status: GameRequestStatus, + + @ManyToOne(fetch = FetchType.EAGER) + var requester: User? = null, + + var voters: MutableList = mutableListOf(), + + @ElementCollection + val externalProviderIds: ExternalProviderIds +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt new file mode 100644 index 0000000..20ea18b --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt @@ -0,0 +1,5 @@ +package org.gameyfin.app.requests + +import org.springframework.data.jpa.repository.JpaRepository + +interface GameRequestRepository : JpaRepository \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt new file mode 100644 index 0000000..0ea279b --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt @@ -0,0 +1,57 @@ +package org.gameyfin.app.requests + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gameyfin.app.core.security.getCurrentAuth +import org.gameyfin.app.games.dto.GameUserEvent +import org.gameyfin.app.requests.dto.GameRequestCreationDto +import org.gameyfin.app.requests.status.GameRequestStatus +import org.gameyfin.app.users.UserService +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.Sinks +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.toJavaDuration + +@Service +class GameRequestService( + private val gameRequestRepository: GameRequestRepository, + private val userService: UserService +) { + + companion object { + private val log = KotlinLogging.logger {} + + /* Websockets */ + private val gameRequestEvents = Sinks.many().multicast().onBackpressureBuffer(1024, false) + + fun subscribe(): Flux> { + log.debug { "New user subscription for gameRequestEvents" } + return gameRequestEvents.asFlux() + .buffer(100.milliseconds.toJavaDuration()) + .doOnSubscribe { + log.debug { "Subscriber added to gameRequestEvents [${gameRequestEvents.currentSubscriberCount()}]" } + } + .doFinally { + log.debug { "Subscriber removed from gameRequestEvents with signal type $it [${gameRequestEvents.currentSubscriberCount()}]" } + } + } + + fun emit(event: GameUserEvent) { + gameRequestEvents.tryEmitNext(event) + } + } + + fun createRequest(gameRequest: GameRequestCreationDto) { + val currentUser = userService.getByUsername(getCurrentAuth().name) + + val gameRequest = GameRequest( + title = gameRequest.title, + release = gameRequest.release, + status = GameRequestStatus.PENDING, + externalProviderIds = gameRequest.externalProviderIds, + requester = currentUser + ) + + gameRequestRepository.save(gameRequest) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestCreationDto.kt b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestCreationDto.kt new file mode 100644 index 0000000..a31f8ed --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestCreationDto.kt @@ -0,0 +1,10 @@ +package org.gameyfin.app.requests.dto + +import java.time.Instant + + +class GameRequestCreationDto( + val title: String, + val release: Instant, + val externalProviderIds: Map +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestDto.kt b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestDto.kt new file mode 100644 index 0000000..1a4169d --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestDto.kt @@ -0,0 +1,13 @@ +package org.gameyfin.app.requests.dto + +import org.gameyfin.app.requests.status.GameRequestStatus +import org.gameyfin.app.users.dto.UserInfoAdminDto +import java.time.Instant + +class GameRequestDto( + val id: Long, + val title: String, + val release: Instant, + val status: GameRequestStatus, + val requester: UserInfoAdminDto +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestEvent.kt b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestEvent.kt new file mode 100644 index 0000000..f006d3a --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestEvent.kt @@ -0,0 +1,9 @@ +package org.gameyfin.app.requests.dto + +sealed class GameRequestEvent { + abstract val type: String + + data class Created(val game: GameRequestDto, override val type: String = "created") : GameRequestEvent() + data class Updated(val game: GameRequestDto, override val type: String = "updated") : GameRequestEvent() + data class Deleted(val gameRequestId: Long, override val type: String = "deleted") : GameRequestEvent() +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/extensions/GameRequestExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/requests/extensions/GameRequestExtensions.kt new file mode 100644 index 0000000..8136b13 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/requests/extensions/GameRequestExtensions.kt @@ -0,0 +1,15 @@ +package org.gameyfin.app.requests.extensions + +import org.gameyfin.app.requests.GameRequest +import org.gameyfin.app.requests.dto.GameRequestDto + +fun GameRequest.toDto(): GameRequestDto { + return GameRequestDto( + id = this.id!!, + title = this.title, + release = this.release, + externalProviderIds = this.externalProviderIds, + status = this.status, + requester = this.requester.toDto() + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/status/GameRequestStatus.kt b/app/src/main/kotlin/org/gameyfin/app/requests/status/GameRequestStatus.kt new file mode 100644 index 0000000..290c677 --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/requests/status/GameRequestStatus.kt @@ -0,0 +1,8 @@ +package org.gameyfin.app.requests.status + +enum class GameRequestStatus { + PENDING, + APPROVED, + FULFILLED, + REJECTED +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/setup/SetupEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/setup/SetupEndpoint.kt index f9be5a1..e020434 100644 --- a/app/src/main/kotlin/org/gameyfin/app/setup/SetupEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/setup/SetupEndpoint.kt @@ -3,7 +3,7 @@ package org.gameyfin.app.setup import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.hilla.Endpoint import com.vaadin.hilla.exception.EndpointException -import org.gameyfin.app.users.dto.UserInfoDto +import org.gameyfin.app.users.dto.UserInfoAdminDto import org.gameyfin.app.users.dto.UserRegistrationDto @Endpoint @@ -16,7 +16,7 @@ class SetupEndpoint( } @AnonymousAllowed - fun registerSuperAdmin(superAdminRegistration: UserRegistrationDto): UserInfoDto { + fun registerSuperAdmin(superAdminRegistration: UserRegistrationDto): UserInfoAdminDto { if (setupService.isSetupCompleted()) throw EndpointException("Setup already completed") return setupService.createInitialAdminUser(superAdminRegistration) } diff --git a/app/src/main/kotlin/org/gameyfin/app/setup/SetupService.kt b/app/src/main/kotlin/org/gameyfin/app/setup/SetupService.kt index f54fd77..1a6c441 100644 --- a/app/src/main/kotlin/org/gameyfin/app/setup/SetupService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/setup/SetupService.kt @@ -1,9 +1,9 @@ package org.gameyfin.app.setup -import org.gameyfin.app.users.UserService import org.gameyfin.app.core.Role import org.gameyfin.app.users.RoleService -import org.gameyfin.app.users.dto.UserInfoDto +import org.gameyfin.app.users.UserService +import org.gameyfin.app.users.dto.UserInfoAdminDto import org.gameyfin.app.users.dto.UserRegistrationDto import org.gameyfin.app.users.entities.User import org.springframework.stereotype.Service @@ -26,7 +26,7 @@ class SetupService( /** * Creates the initial user with Super-Admin permissions */ - fun createInitialAdminUser(registration: UserRegistrationDto): UserInfoDto { + fun createInitialAdminUser(registration: UserRegistrationDto): UserInfoAdminDto { val superAdmin = User( username = registration.username, password = registration.password, diff --git a/app/src/main/kotlin/org/gameyfin/app/users/SessionService.kt b/app/src/main/kotlin/org/gameyfin/app/users/SessionService.kt index ad5ceb1..3aa2d6c 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/SessionService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/SessionService.kt @@ -1,7 +1,7 @@ package org.gameyfin.app.users +import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.users.entities.User -import org.springframework.security.core.Authentication import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.session.SessionInformation import org.springframework.security.core.session.SessionRegistry @@ -11,14 +11,12 @@ import org.springframework.stereotype.Service class SessionService(private val sessionRegistry: SessionRegistry) { fun logoutAllSessions() { - val auth: Authentication? = SecurityContextHolder.getContext().authentication - if (auth != null) { - val sessions: List = sessionRegistry.getAllSessions(auth.principal, false) - for (sessionInfo in sessions) { - sessionInfo.expireNow() - } - SecurityContextHolder.clearContext() + val auth = getCurrentAuth() + val sessions: List = sessionRegistry.getAllSessions(auth.principal, false) + for (sessionInfo in sessions) { + sessionInfo.expireNow() } + SecurityContextHolder.clearContext() } fun logoutAllSessions(user: User) { diff --git a/app/src/main/kotlin/org/gameyfin/app/users/UserEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/users/UserEndpoint.kt index 1fc3774..a114da8 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/UserEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/UserEndpoint.kt @@ -5,11 +5,11 @@ import com.vaadin.hilla.Endpoint import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed import org.gameyfin.app.core.Role -import org.gameyfin.app.users.dto.UserInfoDto +import org.gameyfin.app.core.security.getCurrentAuth +import org.gameyfin.app.users.dto.UserInfoAdminDto import org.gameyfin.app.users.dto.UserUpdateDto import org.gameyfin.app.users.enums.RoleAssignmentResult import org.springframework.security.core.Authentication -import org.springframework.security.core.context.SecurityContextHolder @Endpoint @@ -18,15 +18,15 @@ class UserEndpoint( private val roleService: RoleService ) { @AnonymousAllowed - fun getUserInfo(): UserInfoDto? { - val auth = SecurityContextHolder.getContext().authentication + fun getUserInfo(): UserInfoAdminDto? { + val auth = getCurrentAuth() if (!auth.isAuthenticated || auth.principal == "anonymousUser") return null return userService.getUserInfo() } @PermitAll fun updateUser(updates: UserUpdateDto) { - val auth: Authentication = SecurityContextHolder.getContext().authentication + val auth: Authentication = getCurrentAuth() userService.updateUser(auth.name, updates) } @@ -36,7 +36,7 @@ class UserEndpoint( } @RolesAllowed(Role.Names.ADMIN) - fun getAllUsers(): List { + fun getAllUsers(): List { return userService.getAllUsers() } @@ -52,7 +52,7 @@ class UserEndpoint( @PermitAll fun deleteUser() { - val auth: Authentication = SecurityContextHolder.getContext().authentication + val auth: Authentication = getCurrentAuth() userService.deleteUser(auth.name) } @@ -68,7 +68,7 @@ class UserEndpoint( @RolesAllowed(Role.Names.ADMIN) fun getRolesBelow(): List { - val auth: Authentication = SecurityContextHolder.getContext().authentication + val auth: Authentication = getCurrentAuth() return roleService.getRolesBelowAuth(auth).map { it.roleName } } diff --git a/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt b/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt index 82ec4ab..e22bcda 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt @@ -6,9 +6,10 @@ import org.gameyfin.app.config.ConfigService import org.gameyfin.app.core.Role import org.gameyfin.app.core.Utils import org.gameyfin.app.core.events.* +import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.games.entities.Image import org.gameyfin.app.media.ImageService -import org.gameyfin.app.users.dto.UserInfoDto +import org.gameyfin.app.users.dto.UserInfoAdminDto import org.gameyfin.app.users.dto.UserRegistrationDto import org.gameyfin.app.users.dto.UserUpdateDto import org.gameyfin.app.users.emailconfirmation.EmailConfirmationService @@ -17,7 +18,6 @@ import org.gameyfin.app.users.persistence.UserRepository import org.springframework.context.ApplicationEventPublisher import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority -import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.User import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService @@ -77,7 +77,7 @@ class UserService( fun findByOidcProviderId(oidcProviderId: String): org.gameyfin.app.users.entities.User? = userRepository.findByOidcProviderId(oidcProviderId) - fun getAllUsers(): List { + fun getAllUsers(): List { return userRepository.findAll().map { u -> toUserInfo(u) } } @@ -93,8 +93,8 @@ class UserService( return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'") } - fun getUserInfo(): UserInfoDto { - val auth = SecurityContextHolder.getContext().authentication + fun getUserInfo(): UserInfoAdminDto { + val auth = getCurrentAuth() val principal = auth.principal if (principal is OidcUser) { @@ -238,7 +238,7 @@ class UserService( return RoleAssignmentResult.NO_ROLES_PROVIDED } - val currentUser = SecurityContextHolder.getContext().authentication + val currentUser = getCurrentAuth() val targetUser = getByUsernameNonNull(username) if (!canManage(targetUser)) { @@ -266,7 +266,7 @@ class UserService( } fun canManage(targetUser: org.gameyfin.app.users.entities.User): Boolean { - val currentUser = SecurityContextHolder.getContext().authentication + val currentUser = getCurrentAuth() val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel return currentUserLevel > targetUserLevel @@ -285,8 +285,8 @@ class UserService( eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.Companion.getBaseUrl())) } - fun toUserInfo(user: org.gameyfin.app.users.entities.User): UserInfoDto { - return UserInfoDto( + fun toUserInfo(user: org.gameyfin.app.users.entities.User): UserInfoAdminDto { + return UserInfoAdminDto( username = user.username, email = user.email, emailConfirmed = user.emailConfirmed, diff --git a/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoDto.kt b/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoAdminDto.kt similarity index 91% rename from app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoDto.kt rename to app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoAdminDto.kt index 26d3d96..5a93cde 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoAdminDto.kt @@ -2,7 +2,7 @@ package org.gameyfin.app.users.dto import org.gameyfin.app.core.Role -data class UserInfoDto( +data class UserInfoAdminDto( val username: String, val managedBySso: Boolean, val email: String, diff --git a/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoUserDto.kt b/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoUserDto.kt new file mode 100644 index 0000000..5bbbfbc --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoUserDto.kt @@ -0,0 +1,7 @@ +package org.gameyfin.app.users.dto + +data class UserInfoUserDto( + val username: String, + val hasAvatar: Boolean, + val avatarId: Long? = null, +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/users/emailconfirmation/EmailConfirmationEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/users/emailconfirmation/EmailConfirmationEndpoint.kt index ddb1975..4a84f83 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/emailconfirmation/EmailConfirmationEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/emailconfirmation/EmailConfirmationEndpoint.kt @@ -1,11 +1,10 @@ package org.gameyfin.app.users.emailconfirmation import com.vaadin.hilla.Endpoint -import org.gameyfin.app.users.UserService import jakarta.annotation.security.PermitAll +import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.shared.token.TokenValidationResult -import org.springframework.security.core.Authentication -import org.springframework.security.core.context.SecurityContextHolder +import org.gameyfin.app.users.UserService @Endpoint class EmailConfirmationEndpoint( @@ -20,7 +19,7 @@ class EmailConfirmationEndpoint( @PermitAll fun resendEmailConfirmation() { - val auth: Authentication = SecurityContextHolder.getContext().authentication + val auth = getCurrentAuth() userService.getByUsername(auth.name)?.let { emailConfirmationService.resendEmailConfirmation(it) } diff --git a/app/src/main/kotlin/org/gameyfin/app/users/preferences/UserPreferencesService.kt b/app/src/main/kotlin/org/gameyfin/app/users/preferences/UserPreferencesService.kt index 963644e..617b5c8 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/preferences/UserPreferencesService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/preferences/UserPreferencesService.kt @@ -1,8 +1,8 @@ package org.gameyfin.app.users.preferences -import org.gameyfin.app.users.UserService import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.security.core.context.SecurityContextHolder +import org.gameyfin.app.core.security.getCurrentAuth +import org.gameyfin.app.users.UserService import org.springframework.stereotype.Service import java.io.Serializable @@ -135,7 +135,7 @@ class UserPreferencesService( } private fun id(key: String): UserPreferenceKey { - val auth = SecurityContextHolder.getContext().authentication + val auth = getCurrentAuth() val user = userService.getByUsernameNonNull(auth.name) return UserPreferenceKey(key, user.id!!) } diff --git a/app/src/main/kotlin/org/gameyfin/app/users/registration/InvitationService.kt b/app/src/main/kotlin/org/gameyfin/app/users/registration/InvitationService.kt index b27602c..81d1974 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/registration/InvitationService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/registration/InvitationService.kt @@ -1,18 +1,17 @@ package org.gameyfin.app.users.registration +import org.gameyfin.app.core.Utils import org.gameyfin.app.core.events.AccountStatusChangedEvent import org.gameyfin.app.core.events.UserInvitationEvent +import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.shared.token.TokenDto import org.gameyfin.app.shared.token.TokenRepository -import org.gameyfin.app.users.UserService -import org.gameyfin.app.core.Utils import org.gameyfin.app.shared.token.TokenService import org.gameyfin.app.shared.token.TokenType +import org.gameyfin.app.users.UserService import org.gameyfin.app.users.dto.UserRegistrationDto import org.gameyfin.app.users.enums.UserInvitationAcceptanceResult import org.springframework.context.ApplicationEventPublisher -import org.springframework.security.core.Authentication -import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Service @Service @@ -30,7 +29,7 @@ class InvitationService( if (userService.existsByEmail(email)) throw IllegalStateException("User with email ${Utils.Companion.maskEmail(email)} is already registered") - val auth: Authentication = SecurityContextHolder.getContext().authentication + val auth = getCurrentAuth() val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found") val payload = mapOf(EMAIL_KEY to email) val token = super.generateWithPayload(user, payload) @@ -45,7 +44,8 @@ class InvitationService( } fun acceptInvitation(secret: String, registration: UserRegistrationDto): UserInvitationAcceptanceResult { - val invitationToken = super.get(secret, TokenType.Invitation) ?: return UserInvitationAcceptanceResult.TOKEN_INVALID + val invitationToken = + super.get(secret, TokenType.Invitation) ?: return UserInvitationAcceptanceResult.TOKEN_INVALID val email = invitationToken.payload[EMAIL_KEY] ?: return UserInvitationAcceptanceResult.TOKEN_INVALID if (invitationToken.expired) return UserInvitationAcceptanceResult.TOKEN_EXPIRED From 0f2b45f5e9b75b61cca864ed4eb033e975c49993 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:01:17 +0200 Subject: [PATCH 02/32] BE implementation of request system --- app/package.json | 2 +- .../app/core/filesystem/FilesystemService.kt | 4 +- .../org/gameyfin/app/games/GameService.kt | 2 +- .../org/gameyfin/app/games/entities/Game.kt | 2 +- .../app/games/entities/GameEntityListener.kt | 12 ++--- .../app/libraries/LibraryCoreService.kt | 1 + .../app/libraries/LibraryRepository.kt | 1 + .../app/libraries/LibraryScanService.kt | 5 +- .../gameyfin/app/libraries/LibraryService.kt | 2 + .../app/libraries/dto/LibraryScanProgress.kt | 1 - .../libraries/{ => dto}/LibraryScanResult.kt | 2 +- .../{ => entities}/DirectoryMapping.kt | 2 +- .../app/libraries/{ => entities}/Library.kt | 5 +- .../entities/LibraryEntityListener.kt | 3 +- .../libraries/extensions/LibraryExtensions.kt | 2 +- .../app/requests/GameRequestEndpoint.kt | 47 ++++++++++++++++ .../app/requests/GameRequestRepository.kt | 1 + .../app/requests/GameRequestService.kt | 48 +++++++++++++++-- .../app/requests/dto/GameRequestDto.kt | 9 +++- .../requests/{ => entities}/GameRequest.kt | 25 +++++---- .../entities/GameRequestEntityListener.kt | 25 +++++++++ .../extensions/GameRequestExtensions.kt | 12 ++++- .../org/gameyfin/app/setup/SetupEndpoint.kt | 4 +- .../org/gameyfin/app/setup/SetupService.kt | 7 +-- .../org/gameyfin/app/users/UserEndpoint.kt | 6 +-- .../org/gameyfin/app/users/UserService.kt | 53 +++++++------------ ...InfoAdminDto.kt => ExtendedUserInfoDto.kt} | 2 +- .../{UserInfoUserDto.kt => UserInfoDto.kt} | 2 +- .../app/users/extensions/UserExtensions.kt | 33 ++++++++++++ 29 files changed, 233 insertions(+), 87 deletions(-) rename app/src/main/kotlin/org/gameyfin/app/libraries/{ => dto}/LibraryScanResult.kt (94%) rename app/src/main/kotlin/org/gameyfin/app/libraries/{ => entities}/DirectoryMapping.kt (84%) rename app/src/main/kotlin/org/gameyfin/app/libraries/{ => entities}/Library.kt (90%) rename app/src/main/kotlin/org/gameyfin/app/{games => libraries}/entities/LibraryEntityListener.kt (92%) create mode 100644 app/src/main/kotlin/org/gameyfin/app/requests/GameRequestEndpoint.kt rename app/src/main/kotlin/org/gameyfin/app/requests/{ => entities}/GameRequest.kt (75%) create mode 100644 app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequestEntityListener.kt rename app/src/main/kotlin/org/gameyfin/app/users/dto/{UserInfoAdminDto.kt => ExtendedUserInfoDto.kt} (90%) rename app/src/main/kotlin/org/gameyfin/app/users/dto/{UserInfoUserDto.kt => UserInfoDto.kt} (81%) create mode 100644 app/src/main/kotlin/org/gameyfin/app/users/extensions/UserExtensions.kt diff --git a/app/package.json b/app/package.json index 4d7345d..59d3828 100644 --- a/app/package.json +++ b/app/package.json @@ -265,4 +265,4 @@ "disableUsageStatistics": true, "hash": "962eccc3fa0735d5234901be4f9e384096113c45bec22564a53688096d62aef4" } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/core/filesystem/FilesystemService.kt b/app/src/main/kotlin/org/gameyfin/app/core/filesystem/FilesystemService.kt index f126d77..3d81dab 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/filesystem/FilesystemService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/filesystem/FilesystemService.kt @@ -1,10 +1,10 @@ package org.gameyfin.app.core.filesystem -import org.gameyfin.app.config.ConfigService import io.github.oshai.kotlinlogging.KotlinLogging import org.apache.commons.io.FilenameUtils import org.gameyfin.app.config.ConfigProperties -import org.gameyfin.app.libraries.Library +import org.gameyfin.app.config.ConfigService +import org.gameyfin.app.libraries.entities.Library import org.springframework.stereotype.Service import java.io.File import java.nio.file.FileSystems diff --git a/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt b/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt index 5d42048..ba0b461 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/GameService.kt @@ -22,7 +22,7 @@ import org.gameyfin.app.games.dto.* import org.gameyfin.app.games.entities.* import org.gameyfin.app.games.extensions.toDtos import org.gameyfin.app.games.repositories.GameRepository -import org.gameyfin.app.libraries.Library +import org.gameyfin.app.libraries.entities.Library import org.gameyfin.app.media.ImageService import org.gameyfin.app.users.UserService import org.gameyfin.pluginapi.gamemetadata.* diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt index ce6991d..2cdbcdd 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/Game.kt @@ -1,7 +1,7 @@ package org.gameyfin.app.games.entities import jakarta.persistence.* -import org.gameyfin.app.libraries.Library +import org.gameyfin.app.libraries.entities.Library import org.gameyfin.pluginapi.gamemetadata.GameFeature import org.gameyfin.pluginapi.gamemetadata.Genre import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt index efe500b..9a04b3e 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt @@ -12,19 +12,19 @@ import org.gameyfin.app.games.extensions.toUserDto class GameEntityListener { @PostPersist fun created(game: Game) { - GameService.Companion.emitUser(GameUserEvent.Created(game.toUserDto())) - GameService.Companion.emitAdmin(GameAdminEvent.Created(game.toAdminDto())) + GameService.emitUser(GameUserEvent.Created(game.toUserDto())) + GameService.emitAdmin(GameAdminEvent.Created(game.toAdminDto())) } @PostUpdate fun updated(game: Game) { - GameService.Companion.emitUser(GameUserEvent.Updated(game.toUserDto())) - GameService.Companion.emitAdmin(GameAdminEvent.Updated(game.toAdminDto())) + GameService.emitUser(GameUserEvent.Updated(game.toUserDto())) + GameService.emitAdmin(GameAdminEvent.Updated(game.toAdminDto())) } @PostRemove fun deleted(game: Game) { - GameService.Companion.emitUser(GameUserEvent.Deleted(game.id!!)) - GameService.Companion.emitAdmin(GameAdminEvent.Deleted(game.id!!)) + GameService.emitUser(GameUserEvent.Deleted(game.id!!)) + GameService.emitAdmin(GameAdminEvent.Deleted(game.id!!)) } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryCoreService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryCoreService.kt index 6fc27eb..0f04c26 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryCoreService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryCoreService.kt @@ -2,6 +2,7 @@ package org.gameyfin.app.libraries import org.gameyfin.app.games.GameService import org.gameyfin.app.games.entities.Game +import org.gameyfin.app.libraries.entities.Library import org.springframework.stereotype.Service import java.time.Instant diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryRepository.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryRepository.kt index b60aefc..560f8de 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryRepository.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryRepository.kt @@ -1,5 +1,6 @@ package org.gameyfin.app.libraries +import org.gameyfin.app.libraries.entities.Library import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt index e66d23e..7ac4798 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt @@ -4,9 +4,8 @@ import io.github.oshai.kotlinlogging.KotlinLogging import org.gameyfin.app.core.filesystem.FilesystemService import org.gameyfin.app.games.GameService import org.gameyfin.app.games.entities.Game -import org.gameyfin.app.libraries.dto.LibraryScanProgress -import org.gameyfin.app.libraries.dto.LibraryScanStatus -import org.gameyfin.app.libraries.dto.LibraryScanStep +import org.gameyfin.app.libraries.dto.* +import org.gameyfin.app.libraries.entities.Library import org.gameyfin.app.libraries.enums.ScanType import org.gameyfin.app.libraries.scan.* import org.gameyfin.app.media.ImageService diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt index f9c61f5..c359e00 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryService.kt @@ -3,6 +3,8 @@ package org.gameyfin.app.libraries import com.vaadin.hilla.exception.EndpointException import io.github.oshai.kotlinlogging.KotlinLogging import org.gameyfin.app.libraries.dto.* +import org.gameyfin.app.libraries.entities.DirectoryMapping +import org.gameyfin.app.libraries.entities.Library import org.gameyfin.app.libraries.enums.ScanType import org.gameyfin.app.libraries.extensions.toDtos import org.springframework.data.repository.findByIdOrNull diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryScanProgress.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryScanProgress.kt index 3666cea..5e648b7 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryScanProgress.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryScanProgress.kt @@ -1,6 +1,5 @@ package org.gameyfin.app.libraries.dto -import org.gameyfin.app.libraries.LibraryScanResult import org.gameyfin.app.libraries.enums.ScanType import java.time.Instant import java.util.* diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanResult.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryScanResult.kt similarity index 94% rename from app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanResult.kt rename to app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryScanResult.kt index 0454ea3..779b751 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanResult.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/dto/LibraryScanResult.kt @@ -1,4 +1,4 @@ -package org.gameyfin.app.libraries +package org.gameyfin.app.libraries.dto interface LibraryScanResult { /** diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/DirectoryMapping.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/entities/DirectoryMapping.kt similarity index 84% rename from app/src/main/kotlin/org/gameyfin/app/libraries/DirectoryMapping.kt rename to app/src/main/kotlin/org/gameyfin/app/libraries/entities/DirectoryMapping.kt index 48e0d51..627d955 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/DirectoryMapping.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/entities/DirectoryMapping.kt @@ -1,4 +1,4 @@ -package org.gameyfin.app.libraries +package org.gameyfin.app.libraries.entities import jakarta.persistence.* diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/Library.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/entities/Library.kt similarity index 90% rename from app/src/main/kotlin/org/gameyfin/app/libraries/Library.kt rename to app/src/main/kotlin/org/gameyfin/app/libraries/entities/Library.kt index 0b1b93d..938b877 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/Library.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/entities/Library.kt @@ -1,8 +1,7 @@ -package org.gameyfin.app.libraries +package org.gameyfin.app.libraries.entities -import org.gameyfin.app.games.entities.Game import jakarta.persistence.* -import org.gameyfin.app.games.entities.LibraryEntityListener +import org.gameyfin.app.games.entities.Game import org.hibernate.annotations.CreationTimestamp import org.hibernate.annotations.UpdateTimestamp import java.time.Instant diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/LibraryEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/entities/LibraryEntityListener.kt similarity index 92% rename from app/src/main/kotlin/org/gameyfin/app/games/entities/LibraryEntityListener.kt rename to app/src/main/kotlin/org/gameyfin/app/libraries/entities/LibraryEntityListener.kt index c035ea2..1ec83d2 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/LibraryEntityListener.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/entities/LibraryEntityListener.kt @@ -1,9 +1,8 @@ -package org.gameyfin.app.games.entities +package org.gameyfin.app.libraries.entities import jakarta.persistence.PostPersist import jakarta.persistence.PostRemove import jakarta.persistence.PostUpdate -import org.gameyfin.app.libraries.Library import org.gameyfin.app.libraries.LibraryService import org.gameyfin.app.libraries.dto.LibraryAdminEvent import org.gameyfin.app.libraries.dto.LibraryUserEvent diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensions.kt index a73f0d7..0e73176 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensions.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/extensions/LibraryExtensions.kt @@ -1,8 +1,8 @@ package org.gameyfin.app.libraries.extensions import org.gameyfin.app.core.security.isCurrentUserAdmin -import org.gameyfin.app.libraries.Library import org.gameyfin.app.libraries.dto.* +import org.gameyfin.app.libraries.entities.Library fun Library.toDto(): LibraryDto { diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestEndpoint.kt new file mode 100644 index 0000000..4cc235a --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestEndpoint.kt @@ -0,0 +1,47 @@ +package org.gameyfin.app.requests + +import com.vaadin.flow.server.auth.AnonymousAllowed +import com.vaadin.hilla.Endpoint +import jakarta.annotation.security.PermitAll +import jakarta.annotation.security.RolesAllowed +import org.gameyfin.app.core.Role +import org.gameyfin.app.core.annotations.DynamicPublicAccess +import org.gameyfin.app.requests.dto.GameRequestCreationDto +import org.gameyfin.app.requests.dto.GameRequestEvent +import org.gameyfin.app.requests.status.GameRequestStatus +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux + +@Endpoint +@Service +@DynamicPublicAccess +@AnonymousAllowed +class GameRequestEndpoint( + private val gameRequestService: GameRequestService +) { + + fun subscribe(): Flux> { + return GameRequestService.subscribe() + } + + fun getAll() = gameRequestService.getAll() + + fun create(gameRequest: GameRequestCreationDto) { + gameRequestService.createRequest(gameRequest) + } + + @PermitAll + fun toggleVote(gameRequestId: Long) { + gameRequestService.toggleRequestVote(gameRequestId) + } + + @RolesAllowed(Role.Names.ADMIN) + fun changeStatus(gameRequestId: Long, newStatus: GameRequestStatus) { + gameRequestService.changeRequestStatus(gameRequestId, newStatus) + } + + @RolesAllowed(Role.Names.ADMIN) + fun delete(gameRequestId: Long) { + gameRequestService.deleteRequest(gameRequestId) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt index 20ea18b..c045b4d 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt @@ -1,5 +1,6 @@ package org.gameyfin.app.requests +import org.gameyfin.app.requests.entities.GameRequest import org.springframework.data.jpa.repository.JpaRepository interface GameRequestRepository : JpaRepository \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt index 0ea279b..dfef3f4 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt @@ -2,8 +2,11 @@ package org.gameyfin.app.requests import io.github.oshai.kotlinlogging.KotlinLogging import org.gameyfin.app.core.security.getCurrentAuth -import org.gameyfin.app.games.dto.GameUserEvent import org.gameyfin.app.requests.dto.GameRequestCreationDto +import org.gameyfin.app.requests.dto.GameRequestDto +import org.gameyfin.app.requests.dto.GameRequestEvent +import org.gameyfin.app.requests.entities.GameRequest +import org.gameyfin.app.requests.extensions.toDtos import org.gameyfin.app.requests.status.GameRequestStatus import org.gameyfin.app.users.UserService import org.springframework.stereotype.Service @@ -22,9 +25,9 @@ class GameRequestService( private val log = KotlinLogging.logger {} /* Websockets */ - private val gameRequestEvents = Sinks.many().multicast().onBackpressureBuffer(1024, false) + private val gameRequestEvents = Sinks.many().multicast().onBackpressureBuffer(1024, false) - fun subscribe(): Flux> { + fun subscribe(): Flux> { log.debug { "New user subscription for gameRequestEvents" } return gameRequestEvents.asFlux() .buffer(100.milliseconds.toJavaDuration()) @@ -36,11 +39,16 @@ class GameRequestService( } } - fun emit(event: GameUserEvent) { + fun emit(event: GameRequestEvent) { gameRequestEvents.tryEmitNext(event) } } + fun getAll(): List { + val entities = gameRequestRepository.findAll() + return entities.toDtos() + } + fun createRequest(gameRequest: GameRequestCreationDto) { val currentUser = userService.getByUsername(getCurrentAuth().name) @@ -54,4 +62,36 @@ class GameRequestService( gameRequestRepository.save(gameRequest) } + + fun deleteRequest(id: Long) { + val gameRequest = gameRequestRepository.findById(id) + .orElseThrow { NoSuchElementException("No game request found with id $id") } + gameRequestRepository.delete(gameRequest) + } + + fun changeRequestStatus(id: Long, status: GameRequestStatus) { + val gameRequest = gameRequestRepository.findById(id) + .orElseThrow { NoSuchElementException("No game request found with id $id") } + gameRequest.status = status + gameRequestRepository.save(gameRequest) + } + + fun toggleRequestVote(id: Long) { + val currentUser = + userService.getByUsername(getCurrentAuth().name) ?: throw IllegalStateException("Current user not found") + val gameRequest = gameRequestRepository.findById(id) + .orElseThrow { NoSuchElementException("No game request found with id $id") } + + if (gameRequest.requester?.id == currentUser.id) { + throw IllegalStateException("You cannot vote for your own request") + } + + if (gameRequest.voters.contains(currentUser)) { + gameRequest.voters.remove(currentUser) + } else { + gameRequest.voters.add(currentUser) + } + + gameRequestRepository.save(gameRequest) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestDto.kt b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestDto.kt index 1a4169d..58f6743 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestDto.kt @@ -1,13 +1,18 @@ package org.gameyfin.app.requests.dto +import org.gameyfin.app.requests.entities.ExternalProviderIds import org.gameyfin.app.requests.status.GameRequestStatus -import org.gameyfin.app.users.dto.UserInfoAdminDto +import org.gameyfin.app.users.dto.UserInfoDto import java.time.Instant class GameRequestDto( val id: Long, val title: String, val release: Instant, + val externalProviderIds: ExternalProviderIds, val status: GameRequestStatus, - val requester: UserInfoAdminDto + val requester: UserInfoDto?, + val voters: List, + val createdAt: Instant?, + val updatedAt: Instant? ) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequest.kt b/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequest.kt similarity index 75% rename from app/src/main/kotlin/org/gameyfin/app/requests/GameRequest.kt rename to app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequest.kt index 6808044..20947f4 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequest.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequest.kt @@ -1,42 +1,45 @@ -package org.gameyfin.app.requests +package org.gameyfin.app.requests.entities import jakarta.persistence.* import org.gameyfin.app.requests.status.GameRequestStatus import org.gameyfin.app.users.entities.User import org.hibernate.annotations.CreationTimestamp import org.hibernate.annotations.UpdateTimestamp +import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.Instant typealias ExternalProviderIds = Map @Entity +@EntityListeners(GameRequestEntityListener::class, AuditingEntityListener::class) class GameRequest( @Id @GeneratedValue(strategy = GenerationType.AUTO) var id: Long? = null, - @CreationTimestamp - @Column(nullable = false, updatable = false) - var createdAt: Instant? = null, - - @UpdateTimestamp - @Column(nullable = false) - var updatedAt: Instant? = null, - @Column(nullable = false) val title: String, @Column(nullable = false) val release: Instant, + @ElementCollection + val externalProviderIds: ExternalProviderIds, + @Column(nullable = false) var status: GameRequestStatus, @ManyToOne(fetch = FetchType.EAGER) var requester: User? = null, + @OneToMany var voters: MutableList = mutableListOf(), - @ElementCollection - val externalProviderIds: ExternalProviderIds + @CreationTimestamp + @Column(nullable = false, updatable = false) + var createdAt: Instant? = null, + + @UpdateTimestamp + @Column(nullable = false) + var updatedAt: Instant? = null ) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequestEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequestEntityListener.kt new file mode 100644 index 0000000..625010b --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequestEntityListener.kt @@ -0,0 +1,25 @@ +package org.gameyfin.app.requests.entities + +import jakarta.persistence.PostPersist +import jakarta.persistence.PostRemove +import jakarta.persistence.PostUpdate +import org.gameyfin.app.requests.GameRequestService +import org.gameyfin.app.requests.dto.GameRequestEvent +import org.gameyfin.app.requests.extensions.toDto + +class GameRequestEntityListener { + @PostPersist + fun created(gameRequest: GameRequest) { + GameRequestService.emit(GameRequestEvent.Created(gameRequest.toDto())) + } + + @PostUpdate + fun updated(gameRequest: GameRequest) { + GameRequestService.emit(GameRequestEvent.Updated(gameRequest.toDto())) + } + + @PostRemove + fun deleted(gameRequest: GameRequest) { + GameRequestService.emit(GameRequestEvent.Deleted(gameRequest.id!!)) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/extensions/GameRequestExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/requests/extensions/GameRequestExtensions.kt index 8136b13..7301814 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/extensions/GameRequestExtensions.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/extensions/GameRequestExtensions.kt @@ -1,7 +1,8 @@ package org.gameyfin.app.requests.extensions -import org.gameyfin.app.requests.GameRequest import org.gameyfin.app.requests.dto.GameRequestDto +import org.gameyfin.app.requests.entities.GameRequest +import org.gameyfin.app.users.extensions.toUserInfoDto fun GameRequest.toDto(): GameRequestDto { return GameRequestDto( @@ -10,6 +11,13 @@ fun GameRequest.toDto(): GameRequestDto { release = this.release, externalProviderIds = this.externalProviderIds, status = this.status, - requester = this.requester.toDto() + requester = this.requester?.toUserInfoDto(), + voters = this.voters.map { it.toUserInfoDto() }, + createdAt = this.createdAt, + updatedAt = this.updatedAt ) +} + +fun Collection.toDtos(): List { + return this.map { it.toDto() } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/setup/SetupEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/setup/SetupEndpoint.kt index e020434..443f8b0 100644 --- a/app/src/main/kotlin/org/gameyfin/app/setup/SetupEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/setup/SetupEndpoint.kt @@ -3,7 +3,7 @@ package org.gameyfin.app.setup import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.hilla.Endpoint import com.vaadin.hilla.exception.EndpointException -import org.gameyfin.app.users.dto.UserInfoAdminDto +import org.gameyfin.app.users.dto.ExtendedUserInfoDto import org.gameyfin.app.users.dto.UserRegistrationDto @Endpoint @@ -16,7 +16,7 @@ class SetupEndpoint( } @AnonymousAllowed - fun registerSuperAdmin(superAdminRegistration: UserRegistrationDto): UserInfoAdminDto { + fun registerSuperAdmin(superAdminRegistration: UserRegistrationDto): ExtendedUserInfoDto { if (setupService.isSetupCompleted()) throw EndpointException("Setup already completed") return setupService.createInitialAdminUser(superAdminRegistration) } diff --git a/app/src/main/kotlin/org/gameyfin/app/setup/SetupService.kt b/app/src/main/kotlin/org/gameyfin/app/setup/SetupService.kt index 1a6c441..02eaa3e 100644 --- a/app/src/main/kotlin/org/gameyfin/app/setup/SetupService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/setup/SetupService.kt @@ -3,9 +3,10 @@ package org.gameyfin.app.setup import org.gameyfin.app.core.Role import org.gameyfin.app.users.RoleService import org.gameyfin.app.users.UserService -import org.gameyfin.app.users.dto.UserInfoAdminDto +import org.gameyfin.app.users.dto.ExtendedUserInfoDto import org.gameyfin.app.users.dto.UserRegistrationDto import org.gameyfin.app.users.entities.User +import org.gameyfin.app.users.extensions.toExtendedUserInfoDto import org.springframework.stereotype.Service @Service @@ -26,7 +27,7 @@ class SetupService( /** * Creates the initial user with Super-Admin permissions */ - fun createInitialAdminUser(registration: UserRegistrationDto): UserInfoAdminDto { + fun createInitialAdminUser(registration: UserRegistrationDto): ExtendedUserInfoDto { val superAdmin = User( username = registration.username, password = registration.password, @@ -36,6 +37,6 @@ class SetupService( ) val user = userService.registerOrUpdateUser(superAdmin) - return userService.toUserInfo(user) + return user.toExtendedUserInfoDto() } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/users/UserEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/users/UserEndpoint.kt index a114da8..d7c5739 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/UserEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/UserEndpoint.kt @@ -6,7 +6,7 @@ import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed import org.gameyfin.app.core.Role import org.gameyfin.app.core.security.getCurrentAuth -import org.gameyfin.app.users.dto.UserInfoAdminDto +import org.gameyfin.app.users.dto.ExtendedUserInfoDto import org.gameyfin.app.users.dto.UserUpdateDto import org.gameyfin.app.users.enums.RoleAssignmentResult import org.springframework.security.core.Authentication @@ -18,7 +18,7 @@ class UserEndpoint( private val roleService: RoleService ) { @AnonymousAllowed - fun getUserInfo(): UserInfoAdminDto? { + fun getUserInfo(): ExtendedUserInfoDto? { val auth = getCurrentAuth() if (!auth.isAuthenticated || auth.principal == "anonymousUser") return null return userService.getUserInfo() @@ -36,7 +36,7 @@ class UserEndpoint( } @RolesAllowed(Role.Names.ADMIN) - fun getAllUsers(): List { + fun getAllUsers(): List { return userService.getAllUsers() } diff --git a/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt b/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt index e22bcda..dad6137 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt @@ -9,15 +9,15 @@ import org.gameyfin.app.core.events.* import org.gameyfin.app.core.security.getCurrentAuth import org.gameyfin.app.games.entities.Image import org.gameyfin.app.media.ImageService -import org.gameyfin.app.users.dto.UserInfoAdminDto +import org.gameyfin.app.users.dto.ExtendedUserInfoDto import org.gameyfin.app.users.dto.UserRegistrationDto import org.gameyfin.app.users.dto.UserUpdateDto import org.gameyfin.app.users.emailconfirmation.EmailConfirmationService import org.gameyfin.app.users.enums.RoleAssignmentResult +import org.gameyfin.app.users.extensions.toAuthorities +import org.gameyfin.app.users.extensions.toExtendedUserInfoDto import org.gameyfin.app.users.persistence.UserRepository import org.springframework.context.ApplicationEventPublisher -import org.springframework.security.core.GrantedAuthority -import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.User import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService @@ -56,7 +56,7 @@ class UserService( true, true, true, - toAuthorities(user.roles) + user.roles.toAuthorities() ) } @@ -67,7 +67,7 @@ class UserService( true, true, true, - toAuthorities(user.roles) + user.roles.toAuthorities() ) } @@ -77,8 +77,8 @@ class UserService( fun findByOidcProviderId(oidcProviderId: String): org.gameyfin.app.users.entities.User? = userRepository.findByOidcProviderId(oidcProviderId) - fun getAllUsers(): List { - return userRepository.findAll().map { u -> toUserInfo(u) } + fun getAllUsers(): List { + return userRepository.findAll().map { it.toExtendedUserInfoDto() } } fun getByEmail(email: String): org.gameyfin.app.users.entities.User? { @@ -93,20 +93,20 @@ class UserService( return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'") } - fun getUserInfo(): UserInfoAdminDto { + fun getUserInfo(): ExtendedUserInfoDto { val auth = getCurrentAuth() val principal = auth.principal if (principal is OidcUser) { val oidcUser = org.gameyfin.app.users.entities.User(principal) - val userInfoDto = toUserInfo(oidcUser) + val userInfoDto = oidcUser.toExtendedUserInfoDto() userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities) - .mapNotNull { Role.Companion.safeValueOf(it.authority) } + .mapNotNull { Role.safeValueOf(it.authority) } return userInfoDto } val user = getByUsernameNonNull(auth.name) - return toUserInfo(user) + return user.toExtendedUserInfoDto() } fun getAvatar(username: String): Image? { @@ -158,7 +158,7 @@ class UserService( RegistrationAttemptWithExistingEmailEvent( this, it, - Utils.Companion.getBaseUrl() + Utils.getBaseUrl() ) ) return @@ -179,12 +179,12 @@ class UserService( if (adminNeedsToApprove) { eventPublisher.publishEvent(UserRegistrationWaitingForApprovalEvent(this, user)) } else { - eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.Companion.getBaseUrl())) + eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl())) } if (!user.emailConfirmed) { val token = emailConfirmationService.generate(user) - eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.Companion.getBaseUrl())) + eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.getBaseUrl())) } } @@ -222,7 +222,7 @@ class UserService( user.email = it user.emailConfirmed = false val token = emailConfirmationService.generate(user) - eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.Companion.getBaseUrl())) + eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.getBaseUrl())) } userRepository.save(user) @@ -246,7 +246,7 @@ class UserService( return RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH } - val newAssignedRoles = roleNames.mapNotNull { r -> Role.Companion.safeValueOf(r) } + val newAssignedRoles = roleNames.mapNotNull { r -> Role.safeValueOf(r) } val newAssignedRolesLevel = roleService.getHighestRole(newAssignedRoles).powerLevel val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel @@ -276,29 +276,12 @@ class UserService( val user = getByUsernameNonNull(username) user.enabled = enabled userRepository.save(user) - eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.Companion.getBaseUrl())) + eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl())) } fun deleteUser(username: String) { val user = getByUsernameNonNull(username) userRepository.delete(user) - eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.Companion.getBaseUrl())) - } - - fun toUserInfo(user: org.gameyfin.app.users.entities.User): UserInfoAdminDto { - return UserInfoAdminDto( - username = user.username, - email = user.email, - emailConfirmed = user.emailConfirmed, - enabled = user.enabled, - hasAvatar = user.avatar != null, - avatarId = user.avatar?.id, - managedBySso = user.oidcProviderId != null, - roles = user.roles - ) - } - - private fun toAuthorities(roles: Collection): List { - return roles.map { r -> SimpleGrantedAuthority(r.roleName) } + eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.getBaseUrl())) } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoAdminDto.kt b/app/src/main/kotlin/org/gameyfin/app/users/dto/ExtendedUserInfoDto.kt similarity index 90% rename from app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoAdminDto.kt rename to app/src/main/kotlin/org/gameyfin/app/users/dto/ExtendedUserInfoDto.kt index 5a93cde..d125756 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoAdminDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/dto/ExtendedUserInfoDto.kt @@ -2,7 +2,7 @@ package org.gameyfin.app.users.dto import org.gameyfin.app.core.Role -data class UserInfoAdminDto( +data class ExtendedUserInfoDto( val username: String, val managedBySso: Boolean, val email: String, diff --git a/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoUserDto.kt b/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoDto.kt similarity index 81% rename from app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoUserDto.kt rename to app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoDto.kt index 5bbbfbc..d975a85 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoUserDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoDto.kt @@ -1,6 +1,6 @@ package org.gameyfin.app.users.dto -data class UserInfoUserDto( +data class UserInfoDto( val username: String, val hasAvatar: Boolean, val avatarId: Long? = null, diff --git a/app/src/main/kotlin/org/gameyfin/app/users/extensions/UserExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/users/extensions/UserExtensions.kt new file mode 100644 index 0000000..77e715f --- /dev/null +++ b/app/src/main/kotlin/org/gameyfin/app/users/extensions/UserExtensions.kt @@ -0,0 +1,33 @@ +package org.gameyfin.app.users.extensions + +import org.gameyfin.app.core.Role +import org.gameyfin.app.users.dto.ExtendedUserInfoDto +import org.gameyfin.app.users.dto.UserInfoDto +import org.gameyfin.app.users.entities.User +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority + +fun User.toUserInfoDto(): UserInfoDto { + return UserInfoDto( + username = this.username, + hasAvatar = this.avatar != null, + avatarId = this.avatar?.id + ) +} + +fun User.toExtendedUserInfoDto(): ExtendedUserInfoDto { + return ExtendedUserInfoDto( + username = this.username, + email = this.email, + emailConfirmed = this.emailConfirmed, + enabled = this.enabled, + hasAvatar = this.avatar != null, + avatarId = this.avatar?.id, + managedBySso = this.oidcProviderId != null, + roles = this.roles + ) +} + +fun Collection.toAuthorities(): List { + return this.map { r -> SimpleGrantedAuthority(r.roleName) } +} \ No newline at end of file From 9908c401e6b6fc7dc4ad30edf6c2375b25f60737 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:58:48 +0200 Subject: [PATCH 03/32] Update imports in LibraryScanService --- .../kotlin/org/gameyfin/app/libraries/LibraryScanService.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt index bbc28e9..62a53c7 100644 --- a/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/libraries/LibraryScanService.kt @@ -5,9 +5,8 @@ import org.gameyfin.app.core.filesystem.FilesystemService import org.gameyfin.app.games.GameService import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.Image -import org.gameyfin.app.libraries.dto.LibraryScanProgress -import org.gameyfin.app.libraries.dto.LibraryScanStatus -import org.gameyfin.app.libraries.dto.LibraryScanStep +import org.gameyfin.app.libraries.dto.* +import org.gameyfin.app.libraries.entities.Library import org.gameyfin.app.libraries.enums.ScanType import org.gameyfin.app.libraries.scan.* import org.gameyfin.app.media.ImageService From 3377f770f6d05143c68a0912c750458ff57a46ce Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:22:49 +0200 Subject: [PATCH 04/32] Implement automatic "mark as fulfilled" for requests --- .../app/games/entities/GameEntityListener.kt | 23 ++++++++++++++- .../app/requests/GameRequestRepository.kt | 10 ++++++- .../app/requests/GameRequestService.kt | 29 +++++++++++++++++++ .../app/requests/dto/GameRequestEvent.kt | 4 +-- .../app/requests/entities/GameRequest.kt | 2 ++ 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt index 9a04b3e..f1f8e53 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt @@ -8,12 +8,33 @@ import org.gameyfin.app.games.dto.GameAdminEvent import org.gameyfin.app.games.dto.GameUserEvent import org.gameyfin.app.games.extensions.toAdminDto import org.gameyfin.app.games.extensions.toUserDto +import org.gameyfin.app.requests.GameRequestService +import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationContextAware +import org.springframework.stereotype.Component + +@Component +class GameEntityListener : ApplicationContextAware { + + companion object { + private lateinit var applicationContext: ApplicationContext + } + + override fun setApplicationContext(context: ApplicationContext) { + applicationContext = context + } + + private fun getGameRequestService(): GameRequestService { + return applicationContext.getBean(GameRequestService::class.java) + } -class GameEntityListener { @PostPersist fun created(game: Game) { GameService.emitUser(GameUserEvent.Created(game.toUserDto())) GameService.emitAdmin(GameAdminEvent.Created(game.toAdminDto())) + + // After a game is created, mark any matching game requests as FULFILLED + getGameRequestService().completeMatchingRequests(game) } @PostUpdate diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt index c045b4d..a8d2a95 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt @@ -2,5 +2,13 @@ package org.gameyfin.app.requests import org.gameyfin.app.requests.entities.GameRequest import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.Instant -interface GameRequestRepository : JpaRepository \ No newline at end of file +interface GameRequestRepository : JpaRepository { + fun findByTitleAndRelease(title: String, release: Instant): List + + @Query("SELECT g FROM GameRequest g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release)") + fun findByTitleAndReleaseYear(@Param("title") title: String, @Param("release") release: Instant): List +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt index dfef3f4..9860d84 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt @@ -2,10 +2,12 @@ package org.gameyfin.app.requests import io.github.oshai.kotlinlogging.KotlinLogging import org.gameyfin.app.core.security.getCurrentAuth +import org.gameyfin.app.games.entities.Game import org.gameyfin.app.requests.dto.GameRequestCreationDto import org.gameyfin.app.requests.dto.GameRequestDto import org.gameyfin.app.requests.dto.GameRequestEvent import org.gameyfin.app.requests.entities.GameRequest +import org.gameyfin.app.requests.extensions.toDto import org.gameyfin.app.requests.extensions.toDtos import org.gameyfin.app.requests.status.GameRequestStatus import org.gameyfin.app.users.UserService @@ -94,4 +96,31 @@ class GameRequestService( gameRequestRepository.save(gameRequest) } + + fun completeMatchingRequests(game: Game) { + val gameTitle = game.title + val gameRelease = game.release + + if (gameTitle == null || gameRelease == null) { + log.debug { "Game '${game.id}' is missing title and/or release date, cannot complete matching requests" } + return + } + + // First match by exact title and release date, if not result could be found then by title and release year only + val matchingRequestsByExactRelease = gameRequestRepository.findByTitleAndRelease(gameTitle, gameRelease) + val matchingRequestsByReleaseYear = matchingRequestsByExactRelease.ifEmpty { + gameRequestRepository.findByTitleAndReleaseYear( + gameTitle, + gameRelease + ) + } + + matchingRequestsByReleaseYear.forEach { request -> + request.status = GameRequestStatus.FULFILLED + request.linkedGameId = game.id + val persistedRequest = gameRequestRepository.save(request) + emit(GameRequestEvent.Updated(persistedRequest.toDto())) + log.info { "Marked game request '${request.title}' (${request.release}) as FULFILLED because game is now available" } + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestEvent.kt b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestEvent.kt index f006d3a..d59bd9a 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestEvent.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestEvent.kt @@ -3,7 +3,7 @@ package org.gameyfin.app.requests.dto sealed class GameRequestEvent { abstract val type: String - data class Created(val game: GameRequestDto, override val type: String = "created") : GameRequestEvent() - data class Updated(val game: GameRequestDto, override val type: String = "updated") : GameRequestEvent() + data class Created(val gameRequest: GameRequestDto, override val type: String = "created") : GameRequestEvent() + data class Updated(val gameRequest: GameRequestDto, override val type: String = "updated") : GameRequestEvent() data class Deleted(val gameRequestId: Long, override val type: String = "deleted") : GameRequestEvent() } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequest.kt b/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequest.kt index 20947f4..832322c 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequest.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequest.kt @@ -35,6 +35,8 @@ class GameRequest( @OneToMany var voters: MutableList = mutableListOf(), + var linkedGameId: Long? = null, + @CreationTimestamp @Column(nullable = false, updatable = false) var createdAt: Instant? = null, From 989a5ef18985366e976df5b49fccf069f5835c42 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:13:46 +0200 Subject: [PATCH 05/32] Display toast when Gameyfin restarts --- .../administration/SystemManagement.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/frontend/components/administration/SystemManagement.tsx b/app/src/main/frontend/components/administration/SystemManagement.tsx index 0057fde..54246ed 100644 --- a/app/src/main/frontend/components/administration/SystemManagement.tsx +++ b/app/src/main/frontend/components/administration/SystemManagement.tsx @@ -1,14 +1,25 @@ import React from "react"; import {SystemEndpoint} from "Frontend/generated/endpoints"; import withConfigPage from "Frontend/components/administration/withConfigPage"; -import {Button} from "@heroui/react"; +import {addToast, Button} from "@heroui/react"; import Section from "Frontend/components/general/Section"; function SystemManagementLayout() { + + function restart() { + SystemEndpoint.restart().then(() => + addToast({ + title: "Restarting", + description: "Gameyfin is restarting. This may take a few moments.", + color: "success" + }) + ); + } + return (
- +
); } From 45e9f562e85e9a98072a6e90600360c291a387e8 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:14:30 +0200 Subject: [PATCH 06/32] Migrate UserInfoDto -> ExtendedUserInfoDto --- .../frontend/components/administration/UserManagement.tsx | 4 ++-- .../components/general/cards/UserManagementCard.tsx | 6 +++--- .../frontend/components/general/modals/AssignRolesModal.tsx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/frontend/components/administration/UserManagement.tsx b/app/src/main/frontend/components/administration/UserManagement.tsx index 4c38313..99a4f69 100644 --- a/app/src/main/frontend/components/administration/UserManagement.tsx +++ b/app/src/main/frontend/components/administration/UserManagement.tsx @@ -3,16 +3,16 @@ import ConfigFormField from "Frontend/components/administration/ConfigFormField" import withConfigPage from "Frontend/components/administration/withConfigPage"; import Section from "Frontend/components/general/Section"; import {UserEndpoint} from "Frontend/generated/endpoints"; -import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto"; import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard"; import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; import {Info, UserPlus} from "@phosphor-icons/react"; import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react"; import InviteUserModal from "Frontend/components/general/modals/InviteUserModal"; +import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto"; function UserManagementLayout({getConfig, formik}: any) { const inviteUserModal = useDisclosure(); - const [users, setUsers] = useState([]); + const [users, setUsers] = useState([]); useEffect(() => { UserEndpoint.getAllUsers().then( diff --git a/app/src/main/frontend/components/general/cards/UserManagementCard.tsx b/app/src/main/frontend/components/general/cards/UserManagementCard.tsx index 3bdc20e..5d75a38 100644 --- a/app/src/main/frontend/components/general/cards/UserManagementCard.tsx +++ b/app/src/main/frontend/components/general/cards/UserManagementCard.tsx @@ -7,11 +7,11 @@ import Avatar from "Frontend/components/general/Avatar"; import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal"; import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal"; import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto"; -import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto"; import RoleChip from "Frontend/components/general/RoleChip"; import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal"; +import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto"; -export function UserManagementCard({user}: { user: UserInfoDto }) { +export function UserManagementCard({user}: { user: ExtendedUserInfoDto }) { const userDeletionConfirmationModal = useDisclosure(); const passwordResetTokenModal = useDisclosure(); const roleAssignmentModal = useDisclosure(); @@ -141,7 +141,7 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {

{user.username}

{user.email}

{user.roles?.map((role) => ( - + ))} diff --git a/app/src/main/frontend/components/general/modals/AssignRolesModal.tsx b/app/src/main/frontend/components/general/modals/AssignRolesModal.tsx index 58598c1..2fda413 100644 --- a/app/src/main/frontend/components/general/modals/AssignRolesModal.tsx +++ b/app/src/main/frontend/components/general/modals/AssignRolesModal.tsx @@ -12,14 +12,14 @@ import { SelectItem } from "@heroui/react"; import {UserEndpoint} from "Frontend/generated/endpoints"; -import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto"; import RoleChip from "Frontend/components/general/RoleChip"; import RoleAssignmentResult from "Frontend/generated/org/gameyfin/app/users/enums/RoleAssignmentResult"; +import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto"; interface AssignRolesModalProps { isOpen: boolean; onOpenChange: () => void; - user: UserInfoDto; + user: ExtendedUserInfoDto; } interface Role { From deb0a20bc0a114c645b78f74d99df6bf8b37d38a Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:14:40 +0200 Subject: [PATCH 07/32] Rename run configuration --- .run/{GameyfinApplication.run.xml => Gameyfin.run.xml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .run/{GameyfinApplication.run.xml => Gameyfin.run.xml} (84%) diff --git a/.run/GameyfinApplication.run.xml b/.run/Gameyfin.run.xml similarity index 84% rename from .run/GameyfinApplication.run.xml rename to .run/Gameyfin.run.xml index 31c486d..a35cfdb 100644 --- a/.run/GameyfinApplication.run.xml +++ b/.run/Gameyfin.run.xml @@ -1,5 +1,5 @@ - +