diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestriction.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestriction.kt index 352c74df1..76958c776 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestriction.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestriction.kt @@ -1,35 +1,39 @@ package org.gotson.komga.domain.model sealed class ContentRestriction { - sealed class AgeRestriction(val age: Int) : ContentRestriction() { + sealed class AgeRestriction : ContentRestriction() { + abstract val age: Int + /** * Allow only content that has an age rating equal to or under the provided [age] * * @param[age] the age rating to allow */ - class AllowOnlyUnder(age: Int) : AgeRestriction(age) + data class AllowOnlyUnder(override val age: Int) : AgeRestriction() /** * Exclude content that has an age rating equal to or over the provided [age] * * @param[age] the age rating to exclude */ - class ExcludeOver(age: Int) : AgeRestriction(age) + data class ExcludeOver(override val age: Int) : AgeRestriction() } - sealed class LabelsRestriction(val labels: Set) : ContentRestriction() { + sealed class LabelsRestriction : ContentRestriction() { + abstract val labels: Set + /** * Allow only content that has at least one of the provided sharing [labels] * * @param[labels] a set of sharing labels to allow access to */ - class AllowOnly(labels: Set) : LabelsRestriction(labels) + data class AllowOnly(override val labels: Set) : LabelsRestriction() /** * Exclude content that has at least one of the provided sharing [labels] * * @param[labels] a set of sharing labels to exclude */ - class Exclude(labels: Set) : LabelsRestriction(labels) + data class Exclude(override val labels: Set) : LabelsRestriction() } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestrictions.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestrictions.kt index 478b3b873..8715f083b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestrictions.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestrictions.kt @@ -2,12 +2,13 @@ package org.gotson.komga.domain.model import org.gotson.komga.language.lowerNotBlank import org.gotson.komga.language.toSetOrNull +import java.io.Serializable class ContentRestrictions( val ageRestriction: ContentRestriction.AgeRestriction? = null, labelsAllow: Set = emptySet(), labelsExclude: Set = emptySet(), -) { +) : Serializable { val labelsAllowRestriction = labelsAllow.lowerNotBlank().toSet() .minus(labelsExclude.lowerNotBlank().toSet()) @@ -19,4 +20,22 @@ class ContentRestrictions( fun isRestricted() = ageRestriction != null || labelsAllowRestriction != null || labelsExcludeRestriction != null override fun toString(): String = "ContentRestriction(ageRestriction=$ageRestriction, labelsAllowRestriction=$labelsAllowRestriction, labelsExcludeRestriction=$labelsExcludeRestriction)" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ContentRestrictions) return false + + if (ageRestriction != other.ageRestriction) return false + if (labelsAllowRestriction != other.labelsAllowRestriction) return false + if (labelsExcludeRestriction != other.labelsExcludeRestriction) return false + + return true + } + + override fun hashCode(): Int { + var result = ageRestriction?.hashCode() ?: 0 + result = 31 * result + (labelsAllowRestriction?.hashCode() ?: 0) + result = 31 * result + (labelsExcludeRestriction?.hashCode() ?: 0) + return result + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/DomainEvent.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/DomainEvent.kt index 6e9bb2469..971d6dc3f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/DomainEvent.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/DomainEvent.kt @@ -43,4 +43,7 @@ sealed class DomainEvent : Serializable { data class ThumbnailReadListAdded(val thumbnail: ThumbnailReadList) : DomainEvent() data class ThumbnailReadListDeleted(val thumbnail: ThumbnailReadList) : DomainEvent() + + data class UserUpdated(val user: KomgaUser, val expireSession: Boolean) : DomainEvent() + data class UserDeleted(val user: KomgaUser) : DomainEvent() } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt index 138abf468..3d84322cd 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt @@ -1,6 +1,8 @@ package org.gotson.komga.domain.service import mu.KotlinLogging +import org.gotson.komga.application.events.EventPublisher +import org.gotson.komga.domain.model.DomainEvent import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.UserEmailAlreadyExistsException import org.gotson.komga.domain.persistence.AuthenticationActivityRepository @@ -22,7 +24,7 @@ class KomgaUserLifecycle( private val passwordEncoder: PasswordEncoder, private val sessionRegistry: SessionRegistry, private val transactionTemplate: TransactionTemplate, - + private val eventPublisher: EventPublisher, ) { fun updatePassword(user: KomgaUser, newPassword: String, expireSessions: Boolean) { @@ -31,6 +33,26 @@ class KomgaUserLifecycle( userRepository.update(updatedUser) if (expireSessions) expireSessions(updatedUser) + + eventPublisher.publishEvent(DomainEvent.UserUpdated(updatedUser, expireSessions)) + } + + fun updateUser(user: KomgaUser) { + val existing = userRepository.findByIdOrNull(user.id) + requireNotNull(existing) { "User doesn't exist, cannot update: $user" } + + val toUpdate = user.copy(password = existing.password) + logger.info { "Update user: $toUpdate" } + userRepository.update(toUpdate) + + val expireSessions = existing.roles() != user.roles() || + existing.restrictions != user.restrictions || + existing.sharedAllLibraries != user.sharedAllLibraries || + existing.sharedLibrariesIds != user.sharedLibrariesIds + + if (expireSessions) expireSessions(toUpdate) + + eventPublisher.publishEvent(DomainEvent.UserUpdated(toUpdate, expireSessions)) } fun countUsers() = userRepository.count() @@ -56,9 +78,11 @@ class KomgaUserLifecycle( } expireSessions(user) + + eventPublisher.publishEvent(DomainEvent.UserUpdated(user, true)) } - private fun expireSessions(user: KomgaUser) { + fun expireSessions(user: KomgaUser) { logger.info { "Expiring all sessions for user: ${user.email}" } sessionRegistry .getAllSessions(KomgaPrincipal(user), false) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV1Controller.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV1Controller.kt index 00e28b0ac..2bd9c5a10 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV1Controller.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV1Controller.kt @@ -124,7 +124,7 @@ class UserV1Controller( roleFileDownload = patch.roles.contains(ROLE_FILE_DOWNLOAD), rolePageStreaming = patch.roles.contains(ROLE_PAGE_STREAMING), ) - userRepository.update(updatedUser) + userLifecycle.updateUser(updatedUser) logger.info { "Updated user roles: $updatedUser" } } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } @@ -160,7 +160,7 @@ class UserV1Controller( .map { it.id } .toSet(), ) - userRepository.update(updatedUser) + userLifecycle.updateUser(updatedUser) logger.info { "Updated user shared libraries: $updatedUser" } } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV2Controller.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV2Controller.kt index d5b62f7c1..2806f9254 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV2Controller.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserV2Controller.kt @@ -2,6 +2,8 @@ package org.gotson.komga.interfaces.api.rest import io.swagger.v3.oas.annotations.Parameter import mu.KotlinLogging +import org.gotson.komga.domain.model.ContentRestriction +import org.gotson.komga.domain.model.ContentRestrictions import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING @@ -12,6 +14,7 @@ import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.service.KomgaUserLifecycle import org.gotson.komga.infrastructure.jooq.UnpagedSorted import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.rest.dto.AllowExclude import org.gotson.komga.interfaces.api.rest.dto.AuthenticationActivityDto import org.gotson.komga.interfaces.api.rest.dto.PasswordUpdateDto import org.gotson.komga.interfaces.api.rest.dto.UserCreationDto @@ -118,10 +121,22 @@ class UserV2Controller( if (sharedLibraries!!.all) emptySet() else libraryRepository.findAllByIds(sharedLibraries!!.libraryIds).map { it.id }.toSet() } else existing.sharedLibrariesIds, + restrictions = ContentRestrictions( + ageRestriction = if (isSet("ageRestriction")) { + if (ageRestriction == null) null + else when (ageRestriction!!.restriction) { + AllowExclude.ALLOW_ONLY -> ContentRestriction.AgeRestriction.AllowOnlyUnder(ageRestriction!!.age) + AllowExclude.EXCLUDE -> ContentRestriction.AgeRestriction.ExcludeOver(ageRestriction!!.age) + } + } else existing.restrictions.ageRestriction, + labelsAllow = if (isSet("labelsAllow")) labelsAllow + ?: emptySet() else existing.restrictions.labelsAllowRestriction?.labels ?: emptySet(), + labelsExclude = if (isSet("labelsExclude")) labelsExclude + ?: emptySet() else existing.restrictions.labelsExcludeRestriction?.labels ?: emptySet(), + ), ) } - userRepository.update(updatedUser) - logger.info { "Updated user: $updatedUser" } + userLifecycle.updateUser(updatedUser) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt index 4d66c31d9..5d1b99861 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt @@ -1,5 +1,8 @@ +@file:Suppress("DEPRECATION") + package org.gotson.komga.interfaces.api.rest.dto +import org.gotson.komga.domain.model.ContentRestriction import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD @@ -32,8 +35,20 @@ data class UserDtoV2( val roles: Set, val sharedAllLibraries: Boolean, val sharedLibrariesIds: Set, + val labelsAllow: Set, + val labelsExclude: Set, + val ageRestriction: AgeRestrictionDto?, ) +data class AgeRestrictionDto( + val age: Int, + val restriction: AllowExclude, +) + +enum class AllowExclude { + ALLOW_ONLY, EXCLUDE, +} + fun KomgaUser.toDtoV2() = UserDtoV2( id = id, @@ -41,6 +56,13 @@ fun KomgaUser.toDtoV2() = roles = roles(), sharedAllLibraries = sharedAllLibraries, sharedLibrariesIds = sharedLibrariesIds, + labelsAllow = restrictions.labelsAllowRestriction?.labels ?: emptySet(), + labelsExclude = restrictions.labelsExcludeRestriction?.labels ?: emptySet(), + ageRestriction = when (restrictions.ageRestriction) { + is ContentRestriction.AgeRestriction.AllowOnlyUnder -> AgeRestrictionDto(restrictions.ageRestriction.age, AllowExclude.ALLOW_ONLY) + is ContentRestriction.AgeRestriction.ExcludeOver -> AgeRestrictionDto(restrictions.ageRestriction.age, AllowExclude.EXCLUDE) + null -> null + } ) fun KomgaPrincipal.toDtoV2() = user.toDtoV2() diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt index 7cffdcf87..8279197d2 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserUpdateDto.kt @@ -1,19 +1,28 @@ package org.gotson.komga.interfaces.api.rest.dto +import javax.validation.Valid +import javax.validation.constraints.PositiveOrZero import kotlin.properties.Delegates class UserUpdateDto { private val isSet = mutableMapOf() fun isSet(prop: String) = isSet.getOrDefault(prop, false) -// @get:NullOrNotBlank -// val title: String? = null + @get:Valid + var ageRestriction: AgeRestrictionUpdateDto? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } -// @get:PositiveOrZero -// var ageRating: Int? -// by Delegates.observable(null) { prop, _, _ -> -// isSet[prop.name] = true -// } + var labelsAllow: Set? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } + + var labelsExclude: Set? + by Delegates.observable(null) { prop, _, _ -> + isSet[prop.name] = true + } var roles: Set? by Delegates.observable(null) { prop, _, _ -> @@ -26,6 +35,12 @@ class UserUpdateDto { } } +data class AgeRestrictionUpdateDto( + @get:PositiveOrZero + val age: Int, + val restriction: AllowExclude, +) + data class SharedLibrariesUpdateDto( val all: Boolean, val libraryIds: Set, diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt index b104b43f2..12e78533f 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/UserControllerTest.kt @@ -1,6 +1,10 @@ package org.gotson.komga.interfaces.api.rest +import com.ninjasquad.springmockk.SpykBean +import io.mockk.verify import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.ContentRestriction +import org.gotson.komga.domain.model.ContentRestrictions import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD @@ -37,8 +41,11 @@ class UserControllerTest( @Autowired private val libraryRepository: LibraryRepository, @Autowired private val libraryLifecycle: LibraryLifecycle, @Autowired private val userRepository: KomgaUserRepository, - @Autowired private val userLifecycle: KomgaUserLifecycle, ) { + + @SpykBean + private lateinit var userLifecycle: KomgaUserLifecycle + private val admin = KomgaUser("admin@example.org", "", true, id = "admin") @BeforeAll @@ -106,6 +113,8 @@ class UserControllerTest( assertThat(this.rolePageStreaming).isTrue assertThat(this.roleAdmin).isFalse } + + verify(exactly = 1) { userLifecycle.expireSessions(any()) } } @Test @@ -133,6 +142,8 @@ class UserControllerTest( assertThat(this.rolePageStreaming).isFalse assertThat(this.roleAdmin).isFalse } + + verify(exactly = 1) { userLifecycle.expireSessions(any()) } } @Test @@ -162,6 +173,8 @@ class UserControllerTest( assertThat(this!!.sharedAllLibraries).isFalse assertThat(this.sharedLibrariesIds).containsExactlyInAnyOrder("1", "2") } + + verify(exactly = 1) { userLifecycle.expireSessions(any()) } } @Test @@ -191,11 +204,13 @@ class UserControllerTest( assertThat(this!!.sharedAllLibraries).isFalse assertThat(this.sharedLibrariesIds).containsExactlyInAnyOrder("2") } + + verify(exactly = 1) { userLifecycle.expireSessions(any()) } } @Test @WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN]) - fun `given user with library restrictions when removing restrictions then they restrictions are updated`() { + fun `given user with library restrictions when removing restrictions then the restrictions are updated`() { val user = KomgaUser("user@example.org", "", false, id = "user", sharedAllLibraries = false, sharedLibrariesIds = setOf("2")) userLifecycle.createUser(user) @@ -220,6 +235,196 @@ class UserControllerTest( assertThat(this!!.sharedAllLibraries).isTrue assertThat(this.sharedLibrariesIds).isEmpty() } + + verify(exactly = 1) { userLifecycle.expireSessions(any()) } + } + + @Test + @WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN]) + fun `given user without labels restrictions when adding restrictions then restrictions are updated`() { + val user = KomgaUser("user@example.org", "", false, id = "user") + userLifecycle.createUser(user) + + val jsonString = """ + { + "labelsAllow": ["cute", "kids"], + "labelsExclude": ["adult"] + } + """.trimIndent() + + mockMvc.patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.restrictions.labelsAllowRestriction!!.labels).containsExactlyInAnyOrder("cute", "kids") + assertThat(this.restrictions.labelsExcludeRestriction!!.labels).containsOnly("adult") + } + + verify(exactly = 1) { userLifecycle.expireSessions(any()) } + } + + @Test + @WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN]) + fun `given user with labels restrictions when removing restrictions then restrictions are updated`() { + val user = KomgaUser( + "user@example.org", "", false, id = "user", + restrictions = ContentRestrictions( + labelsAllow = setOf("kids", "cute"), + labelsExclude = setOf("adult"), + ), + ) + userLifecycle.createUser(user) + + val jsonString = """ + { + "labelsAllow": [], + "labelsExclude": null + } + """.trimIndent() + + mockMvc.patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.restrictions.labelsAllowRestriction).isNull() + assertThat(this.restrictions.labelsExcludeRestriction).isNull() + } + + verify(exactly = 1) { userLifecycle.expireSessions(any()) } + } + + @Test + @WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN]) + fun `given user without age restriction when adding restrictions then restrictions are updated`() { + val user = KomgaUser("user@example.org", "", false, id = "user") + userLifecycle.createUser(user) + + val jsonString = """ + { + "ageRestriction": { + "age": 12, + "restriction": "ALLOW_ONLY" + } + } + """.trimIndent() + + mockMvc.patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.restrictions.ageRestriction).isNotNull + assertThat(this.restrictions.ageRestriction).isInstanceOf(ContentRestriction.AgeRestriction.AllowOnlyUnder::class.java) + assertThat(this.restrictions.ageRestriction!!.age).isEqualTo(12) + } + + verify(exactly = 1) { userLifecycle.expireSessions(any()) } + } + + @Test + @WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN]) + fun `given user without age restriction when adding incorrect restrictions then bad request`() { + val user = KomgaUser("user@example.org", "", false, id = "user") + userLifecycle.createUser(user) + + val jsonString = """ + { + "ageRestriction": { + "age": -12, + "restriction": "ALLOW_ONLY" + } + } + """.trimIndent() + + mockMvc.patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isBadRequest() } + } + } + + @Test + @WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN]) + fun `given user with age restriction when removing restriction then restrictions are updated`() { + val user = KomgaUser( + "user@example.org", "", false, id = "user", + restrictions = ContentRestrictions( + ageRestriction = ContentRestriction.AgeRestriction.AllowOnlyUnder(12), + ), + ) + userLifecycle.createUser(user) + + val jsonString = """ + { + "ageRestriction": null + } + """.trimIndent() + + mockMvc.patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.restrictions.ageRestriction).isNull() + } + + verify(exactly = 1) { userLifecycle.expireSessions(any()) } + } + + @Test + @WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN]) + fun `given user with age restriction when changing restriction then restrictions are updated`() { + val user = KomgaUser( + "user@example.org", "", false, id = "user", + restrictions = ContentRestrictions( + ageRestriction = ContentRestriction.AgeRestriction.AllowOnlyUnder(12), + ), + ) + userLifecycle.createUser(user) + + val jsonString = """ + { + "ageRestriction": { + "age": 16, + "restriction": "EXCLUDE" + } + } + """.trimIndent() + + mockMvc.patch("/api/v2/users/${user.id}") { + contentType = MediaType.APPLICATION_JSON + content = jsonString + }.andExpect { + status { isNoContent() } + } + + with(userRepository.findByIdOrNull(user.id)) { + assertThat(this).isNotNull + assertThat(this!!.restrictions.ageRestriction).isNotNull + assertThat(this.restrictions.ageRestriction).isExactlyInstanceOf(ContentRestriction.AgeRestriction.ExcludeOver::class.java) + assertThat(this.restrictions.ageRestriction!!.age).isEqualTo(16) + } + + verify(exactly = 1) { userLifecycle.expireSessions(any()) } } } }