feat(api): user creation supports restrictions

This commit is contained in:
Gauthier Roebroeck 2025-05-27 15:16:24 +08:00
parent e2a0b9450e
commit 1f0817bbe7
6 changed files with 186 additions and 30 deletions

View file

@ -7,6 +7,7 @@ import jakarta.validation.Valid
import org.gotson.komga.domain.model.AgeRestriction
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.model.UserRoles
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
@ -16,6 +17,7 @@ import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.openapi.OpenApiConfiguration.TagNames
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.interfaces.api.rest.dto.AllowExcludeDto
import org.gotson.komga.interfaces.api.rest.dto.ApiKeyDto
import org.gotson.komga.interfaces.api.rest.dto.ApiKeyRequestDto
import org.gotson.komga.interfaces.api.rest.dto.AuthenticationActivityDto
@ -95,8 +97,35 @@ class UserController(
newUser: UserCreationDto,
): UserDto =
try {
userLifecycle.createUser(newUser.toDomain()).toDto()
} catch (e: UserEmailAlreadyExistsException) {
userLifecycle
.createUser(
with(newUser) {
KomgaUser(
email,
password,
roles = UserRoles.Companion.valuesOf(roles),
// keep existing behaviour before those properties were added, by default new user has access to all libraries
sharedAllLibraries = sharedLibraries == null || sharedLibraries.all,
sharedLibrariesIds =
if (sharedLibraries == null || sharedLibraries.all)
emptySet()
else
libraryRepository.findAllByIds(sharedLibraries.libraryIds).map { it.id }.toSet(),
// keep existing behaviour before those properties were added, by default no restrictions are applied
restrictions =
ContentRestrictions(
ageRestriction =
if (ageRestriction == null || ageRestriction.restriction == AllowExcludeDto.NONE)
null
else
AgeRestriction(ageRestriction.age, ageRestriction.restriction.toDomain()),
labelsAllow = labelsAllow ?: emptySet(),
labelsExclude = labelsExclude ?: emptySet(),
),
)
},
).toDto()
} catch (_: UserEmailAlreadyExistsException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "A user with this email already exists")
}
@ -142,10 +171,10 @@ class UserController(
ContentRestrictions(
ageRestriction =
if (isSet("ageRestriction")) {
if (ageRestriction == null)
if (ageRestriction == null || ageRestriction?.restriction == AllowExcludeDto.NONE)
null
else
AgeRestriction(ageRestriction!!.age, ageRestriction!!.restriction)
AgeRestriction(ageRestriction!!.age, ageRestriction!!.restriction.toDomain())
} else {
existing.restrictions.ageRestriction
},

View file

@ -0,0 +1,7 @@
package org.gotson.komga.interfaces.api.rest.dto
import jakarta.validation.constraints.NotBlank
data class PasswordUpdateDto(
@get:NotBlank val password: String,
)

View file

@ -0,0 +1,16 @@
package org.gotson.komga.interfaces.api.rest.dto
import jakarta.validation.Valid
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
data class UserCreationDto(
@get:Email(regexp = ".+@.+\\..+") val email: String,
@get:NotBlank val password: String,
val roles: List<String> = emptyList(),
// new fields supported
@get:Valid val ageRestriction: AgeRestrictionUpdateDto?,
val labelsAllow: Set<String>?,
val labelsExclude: Set<String>?,
val sharedLibraries: SharedLibrariesUpdateDto?,
)

View file

@ -1,11 +1,8 @@
package org.gotson.komga.interfaces.api.rest.dto
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import org.gotson.komga.domain.model.AgeRestriction
import org.gotson.komga.domain.model.AllowExclude
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.UserRoles
import org.gotson.komga.infrastructure.security.KomgaPrincipal
data class UserDto(
@ -39,20 +36,3 @@ fun KomgaUser.toDto() =
)
fun KomgaPrincipal.toDto() = user.toDto()
data class UserCreationDto(
@get:Email(regexp = ".+@.+\\..+") val email: String,
@get:NotBlank val password: String,
val roles: List<String> = emptyList(),
) {
fun toDomain(): KomgaUser =
KomgaUser(
email,
password,
roles = UserRoles.valuesOf(roles),
)
}
data class PasswordUpdateDto(
@get:NotBlank val password: String,
)

View file

@ -40,10 +40,24 @@ class UserUpdateDto {
data class AgeRestrictionUpdateDto(
@get:PositiveOrZero
val age: Int,
val restriction: AllowExclude,
val restriction: AllowExcludeDto,
)
data class SharedLibrariesUpdateDto(
val all: Boolean,
val libraryIds: Set<String>,
)
enum class AllowExcludeDto {
ALLOW_ONLY,
EXCLUDE,
NONE,
;
fun toDomain() =
when (this) {
ALLOW_ONLY -> AllowExclude.ALLOW_ONLY
EXCLUDE -> AllowExclude.EXCLUDE
NONE -> throw IllegalArgumentException()
}
}

View file

@ -11,6 +11,9 @@ import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.domain.service.LibraryLifecycle
import org.gotson.komga.interfaces.api.rest.dto.AllowExcludeDto
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.hasItem
import org.hamcrest.text.MatchesPattern
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
@ -69,6 +72,8 @@ class UserControllerTest(
.forEach { userLifecycle.deleteUser(it) }
}
@Nested
inner class Create {
@ParameterizedTest
@ValueSource(strings = ["user", "user@domain"])
@WithMockCustomUser(roles = ["ADMIN"])
@ -85,6 +90,111 @@ class UserControllerTest(
}
}
@Test
@WithMockCustomUser(roles = ["ADMIN"])
fun `when creating a user with limited fields then returns created user`() {
// language=JSON
val jsonString =
"""
{
"email":"newuser@example.org",
"password":"password"
}
""".trimIndent()
mockMvc
.post("/api/v2/users") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isCreated() }
jsonPath("$.roles") { value(hasItem("USER")) }
jsonPath("$.sharedAllLibraries") { value(true) }
jsonPath("$.sharedLibrariesId") { doesNotExist() }
jsonPath("$.labelsAllow") { isEmpty() }
jsonPath("$.labelsExclude") { isEmpty() }
jsonPath("$.ageRestriction") { isEmpty() }
}
}
}
@Test
@WithMockCustomUser(roles = ["ADMIN"])
fun `when creating a user with all fields then returns created user`() {
// language=JSON
val jsonString =
"""
{
"email":"newuser@example.org",
"password":"password",
"roles": ["${UserRoles.FILE_DOWNLOAD.name}"],
"ageRestriction": {
"age": 5,
"restriction": "${AllowExcludeDto.NONE}"
},
"labelsAllow": ["allowTag"],
"labelsExclude": ["excludeTag"],
"sharedLibraries": {
"all": "false",
"libraryIds" : ["1", "157"]
}
}
""".trimIndent()
mockMvc
.post("/api/v2/users") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isCreated() }
jsonPath("$.roles") { value(allOf(hasItem("USER"), hasItem(UserRoles.FILE_DOWNLOAD.name))) }
jsonPath("$.labelsAllow") { value(hasItem("allowtag")) }
jsonPath("$.labelsExclude") { value(hasItem("excludetag")) }
jsonPath("$.ageRestriction") { doesNotExist() }
jsonPath("$.sharedAllLibraries") { value(false) }
jsonPath("$.sharedLibrariesIds") { value(hasItem("1")) }
}
}
@Test
@WithMockCustomUser(roles = ["ADMIN"])
fun `when creating a user with some fields then returns created user`() {
// language=JSON
val jsonString =
"""
{
"email":"newuser@example.org",
"password":"password",
"roles": ["${UserRoles.FILE_DOWNLOAD.name}"],
"ageRestriction": {
"age": 5,
"restriction": "${AllowExcludeDto.ALLOW_ONLY}"
},
"labelsAllow": ["allowTag"],
"labelsExclude": ["excludeTag"],
"sharedLibraries": {
"all": "false",
"libraryIds" : ["1", "157"]
}
}
""".trimIndent()
mockMvc
.post("/api/v2/users") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isCreated() }
jsonPath("$.roles") { value(allOf(hasItem("USER"), hasItem(UserRoles.FILE_DOWNLOAD.name))) }
jsonPath("$.labelsAllow") { value(hasItem("allowtag")) }
jsonPath("$.labelsExclude") { value(hasItem("excludetag")) }
jsonPath("$.ageRestriction.age") { value(5) }
jsonPath("$.ageRestriction.restriction") { value(AllowExclude.ALLOW_ONLY.name) }
jsonPath("$.sharedAllLibraries") { value(false) }
jsonPath("$.sharedLibrariesIds") { value(hasItem("1")) }
}
}
@Nested
inner class Update {
@Test