feat(api): add /api/v2/users and deprecate /api/v1/users

simplify the user DTO classes
make api logout endpoint to version agnostic
This commit is contained in:
Gauthier Roebroeck 2022-03-02 11:20:13 +08:00
parent f1ab136b5e
commit fa04d9511a
14 changed files with 508 additions and 71 deletions

View file

@ -142,7 +142,7 @@ export default Vue.extend({
modalDeleteUser: false, modalDeleteUser: false,
userToDelete: {} as UserDto, userToDelete: {} as UserDto,
modalEditSharedLibraries: false, modalEditSharedLibraries: false,
userToEditSharedLibraries: {} as UserWithSharedLibrariesDto, userToEditSharedLibraries: {} as UserDto,
modalEditUser: false, modalEditUser: false,
userToEdit: {} as UserDto, userToEdit: {} as UserDto,
modalChangePassword: false, modalChangePassword: false,
@ -150,7 +150,7 @@ export default Vue.extend({
usersLastActivity: {} as any, usersLastActivity: {} as any,
}), }),
computed: { computed: {
users(): UserWithSharedLibrariesDto[] { users(): UserDto[] {
return this.$store.state.komgaUsers.users return this.$store.state.komgaUsers.users
}, },
me(): UserDto { me(): UserDto {
@ -175,7 +175,7 @@ export default Vue.extend({
this.userToDelete = user this.userToDelete = user
this.modalDeleteUser = true this.modalDeleteUser = true
}, },
editSharedLibraries(user: UserWithSharedLibrariesDto) { editSharedLibraries(user: UserDto) {
this.userToEditSharedLibraries = user this.userToEditSharedLibraries = user
this.modalEditSharedLibraries = true this.modalEditSharedLibraries = true
}, },

View file

@ -104,11 +104,11 @@ export default Vue.extend({
}, },
async editUser() { async editUser() {
try { try {
const roles = { const patch = {
roles: this.roles, roles: this.roles,
} as RolesUpdateDto } as UserUpdateDto
await this.$store.dispatch('updateUserRoles', {userId: this.user.id, roles: roles}) await this.$store.dispatch('updateUser', {userId: this.user.id, patch: patch})
} catch (e) { } catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent) this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
} }

View file

@ -91,12 +91,12 @@ export default Vue.extend({
}, },
}, },
methods: { methods: {
dialogReset(user: UserWithSharedLibrariesDto) { dialogReset(user: UserDto) {
this.allLibraries = user.sharedAllLibraries this.allLibraries = user.sharedAllLibraries
if (user.sharedAllLibraries) { if (user.sharedAllLibraries) {
this.selectedLibraries = this.libraries.map(x => x.id) this.selectedLibraries = this.libraries.map(x => x.id)
} else { } else {
this.selectedLibraries = user.sharedLibraries.map(x => x.id) this.selectedLibraries = user.sharedLibrariesIds
} }
}, },
dialogCancel() { dialogCancel() {
@ -110,12 +110,14 @@ export default Vue.extend({
}, },
async editUser() { async editUser() {
try { try {
const sharedLibraries = { const patch = {
all: this.allLibraries, sharedLibraries: {
libraryIds: this.selectedLibraries, all: this.allLibraries,
} as SharedLibrariesUpdateDto libraryIds: this.selectedLibraries,
},
} as UserUpdateDto
await this.$store.dispatch('updateUserSharedLibraries', {user: this.user, sharedLibraries: sharedLibraries}) await this.$store.dispatch('updateUser', {userId: this.user.id, patch: patch})
} catch (e) { } catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent) this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
} }

View file

@ -9,7 +9,7 @@ let service: KomgaUsersService
const vuexModule: Module<any, any> = { const vuexModule: Module<any, any> = {
state: { state: {
me: {} as UserDto, me: {} as UserDto,
users: [] as UserWithSharedLibrariesDto[], users: [] as UserDto[],
}, },
getters: { getters: {
meAdmin: state => state.me.hasOwnProperty('roles') && state.me.roles.includes(UserRoles.ADMIN), meAdmin: state => state.me.hasOwnProperty('roles') && state.me.roles.includes(UserRoles.ADMIN),
@ -21,7 +21,7 @@ const vuexModule: Module<any, any> = {
setMe (state, user: UserDto) { setMe (state, user: UserDto) {
state.me = user state.me = user
}, },
setAllUsers (state, users: UserWithSharedLibrariesDto[]) { setAllUsers (state, users: UserDto[]) {
state.users = users state.users = users
}, },
}, },
@ -46,18 +46,14 @@ const vuexModule: Module<any, any> = {
await service.postUser(user) await service.postUser(user)
dispatch('getAllUsers') dispatch('getAllUsers')
}, },
async updateUserRoles ({ dispatch }, { userId, roles }: { userId: string, roles: RolesUpdateDto }) { async updateUser ({ dispatch }, { userId, patch }: { userId: string, patch: UserUpdateDto }) {
await service.patchUserRoles(userId, roles) await service.patchUser(userId, patch)
dispatch('getAllUsers') dispatch('getAllUsers')
}, },
async deleteUser ({ dispatch }, user: UserDto) { async deleteUser ({ dispatch }, user: UserDto) {
await service.deleteUser(user) await service.deleteUser(user)
dispatch('getAllUsers') dispatch('getAllUsers')
}, },
async updateUserSharedLibraries ({ dispatch }, { user, sharedLibraries }: { user: UserDto, sharedLibraries: SharedLibrariesUpdateDto }) {
await service.patchUserSharedLibraries(user, sharedLibraries)
dispatch('getAllUsers')
},
}, },
} }

View file

@ -2,7 +2,7 @@ import {AxiosInstance} from 'axios'
const qs = require('qs') const qs = require('qs')
const API_USERS = '/api/v1/users' const API_USERS = '/api/v2/users'
export default class KomgaUsersService { export default class KomgaUsersService {
private http: AxiosInstance private http: AxiosInstance
@ -50,7 +50,7 @@ export default class KomgaUsersService {
} }
} }
async getAll(): Promise<UserWithSharedLibrariesDto[]> { async getAll(): Promise<UserDto[]> {
try { try {
return (await this.http.get(`${API_USERS}`)).data return (await this.http.get(`${API_USERS}`)).data
} catch (e) { } catch (e) {
@ -74,9 +74,9 @@ export default class KomgaUsersService {
} }
} }
async patchUserRoles(userId: string, roles: RolesUpdateDto): Promise<UserDto> { async patchUser(userId: string, patch: UserUpdateDto) {
try { try {
return (await this.http.patch(`${API_USERS}/${userId}`, roles)).data await this.http.patch(`${API_USERS}/${userId}`, patch)
} catch (e) { } catch (e) {
let msg = `An error occurred while trying to patch user '${userId}'` let msg = `An error occurred while trying to patch user '${userId}'`
if (e.response.data.message) { if (e.response.data.message) {
@ -100,7 +100,7 @@ export default class KomgaUsersService {
async patchUserPassword(user: UserDto, newPassword: PasswordUpdateDto) { async patchUserPassword(user: UserDto, newPassword: PasswordUpdateDto) {
try { try {
return (await this.http.patch(`${API_USERS}/${user.id}/password`, newPassword)).data await this.http.patch(`${API_USERS}/${user.id}/password`, newPassword)
} catch (e) { } catch (e) {
let msg = `An error occurred while trying to update password for user ${user.email}` let msg = `An error occurred while trying to update password for user ${user.email}`
if (e.response.data.message) { if (e.response.data.message) {
@ -110,21 +110,9 @@ export default class KomgaUsersService {
} }
} }
async patchUserSharedLibraries(user: UserDto, sharedLibrariesUpdateDto: SharedLibrariesUpdateDto) {
try {
return (await this.http.patch(`${API_USERS}/${user.id}/shared-libraries`, sharedLibrariesUpdateDto)).data
} catch (e) {
let msg = `An error occurred while trying to update shared libraries for user ${user.email}`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async logout() { async logout() {
try { try {
await this.http.post(`${API_USERS}/logout`) await this.http.post('api/logout')
} catch (e) { } catch (e) {
let msg = 'An error occurred while trying to logout' let msg = 'An error occurred while trying to logout'
if (e.response.data.message) { if (e.response.data.message) {

View file

@ -1,19 +1,9 @@
interface UserDto { interface UserDto {
id: string,
email: string,
roles: string[]
}
interface UserWithSharedLibrariesDto {
id: string, id: string,
email: string, email: string,
roles: string[], roles: string[],
sharedAllLibraries: boolean, sharedAllLibraries: boolean,
sharedLibraries: SharedLibraryDto[] sharedLibrariesIds: string[]
}
interface SharedLibraryDto {
id: string
} }
interface UserCreationDto { interface UserCreationDto {
@ -25,13 +15,12 @@ interface PasswordUpdateDto {
password: string password: string
} }
interface SharedLibrariesUpdateDto { interface UserUpdateDto {
all: boolean, roles?: string[],
libraryIds: string[] sharedLibraries?: {
} all: boolean,
libraryIds: string[]
interface RolesUpdateDto { },
roles: string[]
} }
interface AuthenticationActivityDto { interface AuthenticationActivityDto {

View file

@ -67,7 +67,7 @@ class SecurityConfiguration(
it.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource) it.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
} }
.logout { .logout {
it.logoutUrl("/api/v1/users/logout") it.logoutUrl("/api/logout")
it.deleteCookies(sessionCookieName) it.deleteCookies(sessionCookieName)
it.invalidateHttpSession(true) it.invalidateHttpSession(true)
} }

View file

@ -2,8 +2,8 @@ package org.gotson.komga.interfaces.api.rest
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.service.KomgaUserLifecycle import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.interfaces.api.rest.dto.UserDto import org.gotson.komga.interfaces.api.rest.dto.UserDtoV2
import org.gotson.komga.interfaces.api.rest.dto.toDto import org.gotson.komga.interfaces.api.rest.dto.toDtoV2
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.validation.annotation.Validated import org.springframework.validation.annotation.Validated
@ -30,7 +30,7 @@ class ClaimController(
fun claimAdmin( fun claimAdmin(
@Email(regexp = ".+@.+\\..+") @RequestHeader("X-Komga-Email") email: String, @Email(regexp = ".+@.+\\..+") @RequestHeader("X-Komga-Email") email: String,
@NotBlank @RequestHeader("X-Komga-Password") password: String, @NotBlank @RequestHeader("X-Komga-Password") password: String,
): UserDto { ): UserDtoV2 {
if (userDetailsLifecycle.countUsers() > 0) if (userDetailsLifecycle.countUsers() > 0)
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "This server has already been claimed") throw ResponseStatusException(HttpStatus.BAD_REQUEST, "This server has already been claimed")
@ -40,7 +40,7 @@ class ClaimController(
password = password, password = password,
roleAdmin = true, roleAdmin = true,
), ),
).toDto() ).toDtoV2()
} }
data class ClaimStatus( data class ClaimStatus(

View file

@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")
package org.gotson.komga.interfaces.api.rest package org.gotson.komga.interfaces.api.rest
import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.Parameter
@ -46,9 +48,10 @@ import javax.validation.Valid
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@Deprecated("User api/v2/users instead")
@RestController @RestController
@RequestMapping("api/v1/users", produces = [MediaType.APPLICATION_JSON_VALUE]) @RequestMapping("api/v1/users", produces = [MediaType.APPLICATION_JSON_VALUE])
class UserController( class UserV1Controller(
private val userLifecycle: KomgaUserLifecycle, private val userLifecycle: KomgaUserLifecycle,
private val userRepository: KomgaUserRepository, private val userRepository: KomgaUserRepository,
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
@ -59,10 +62,12 @@ class UserController(
private val demo = env.activeProfiles.contains("demo") private val demo = env.activeProfiles.contains("demo")
@GetMapping("me") @GetMapping("me")
@Deprecated("User api/v2/users instead")
fun getMe(@AuthenticationPrincipal principal: KomgaPrincipal): UserDto = fun getMe(@AuthenticationPrincipal principal: KomgaPrincipal): UserDto =
principal.user.toDto() principal.user.toDto()
@PatchMapping("me/password") @PatchMapping("me/password")
@Deprecated("User api/v2/users instead")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
fun updateMyPassword( fun updateMyPassword(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@ -76,12 +81,14 @@ class UserController(
@GetMapping @GetMapping
@PreAuthorize("hasRole('$ROLE_ADMIN')") @PreAuthorize("hasRole('$ROLE_ADMIN')")
@Deprecated("User api/v2/users instead")
fun getAll(): List<UserWithSharedLibrariesDto> = fun getAll(): List<UserWithSharedLibrariesDto> =
userRepository.findAll().map { it.toWithSharedLibrariesDto() } userRepository.findAll().map { it.toWithSharedLibrariesDto() }
@PostMapping @PostMapping
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
@PreAuthorize("hasRole('$ROLE_ADMIN')") @PreAuthorize("hasRole('$ROLE_ADMIN')")
@Deprecated("User api/v2/users instead")
fun addOne(@Valid @RequestBody newUser: UserCreationDto): UserDto = fun addOne(@Valid @RequestBody newUser: UserCreationDto): UserDto =
try { try {
userLifecycle.createUser(newUser.toDomain()).toDto() userLifecycle.createUser(newUser.toDomain()).toDto()
@ -92,6 +99,7 @@ class UserController(
@DeleteMapping("{id}") @DeleteMapping("{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id") @PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id")
@Deprecated("User api/v2/users instead")
fun delete( fun delete(
@PathVariable id: String, @PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@ -104,6 +112,7 @@ class UserController(
@PatchMapping("{id}") @PatchMapping("{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id") @PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id")
@Deprecated("User api/v2/users instead")
fun updateUserRoles( fun updateUserRoles(
@PathVariable id: String, @PathVariable id: String,
@Valid @RequestBody patch: RolesUpdateDto, @Valid @RequestBody patch: RolesUpdateDto,
@ -123,6 +132,7 @@ class UserController(
@PatchMapping("{id}/password") @PatchMapping("{id}/password")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id") @PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id")
@Deprecated("User api/v2/users instead")
fun updatePassword( fun updatePassword(
@PathVariable id: String, @PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@ -137,6 +147,7 @@ class UserController(
@PatchMapping("{id}/shared-libraries") @PatchMapping("{id}/shared-libraries")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN')") @PreAuthorize("hasRole('$ROLE_ADMIN')")
@Deprecated("User api/v2/users instead")
fun updateSharesLibraries( fun updateSharesLibraries(
@PathVariable id: String, @PathVariable id: String,
@Valid @RequestBody sharedLibrariesUpdateDto: SharedLibrariesUpdateDto, @Valid @RequestBody sharedLibrariesUpdateDto: SharedLibrariesUpdateDto,
@ -156,6 +167,7 @@ class UserController(
@GetMapping("me/authentication-activity") @GetMapping("me/authentication-activity")
@PageableAsQueryParam @PageableAsQueryParam
@Deprecated("User api/v2/users instead")
fun getMyAuthenticationActivity( fun getMyAuthenticationActivity(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@ -180,6 +192,7 @@ class UserController(
@GetMapping("authentication-activity") @GetMapping("authentication-activity")
@PageableAsQueryParam @PageableAsQueryParam
@PreAuthorize("hasRole('$ROLE_ADMIN')") @PreAuthorize("hasRole('$ROLE_ADMIN')")
@Deprecated("User api/v2/users instead")
fun getAuthenticationActivity( fun getAuthenticationActivity(
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false, @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) page: Pageable, @Parameter(hidden = true) page: Pageable,
@ -201,6 +214,7 @@ class UserController(
@GetMapping("{id}/authentication-activity/latest") @GetMapping("{id}/authentication-activity/latest")
@PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id") @PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id")
@Deprecated("User api/v2/users instead")
fun getLatestAuthenticationActivityForUser( fun getLatestAuthenticationActivityForUser(
@PathVariable id: String, @PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,

View file

@ -0,0 +1,197 @@
package org.gotson.komga.interfaces.api.rest
import io.swagger.v3.oas.annotations.Parameter
import mu.KotlinLogging
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
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
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.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.security.KomgaPrincipal
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
import org.gotson.komga.interfaces.api.rest.dto.UserDtoV2
import org.gotson.komga.interfaces.api.rest.dto.UserUpdateDto
import org.gotson.komga.interfaces.api.rest.dto.toDto
import org.gotson.komga.interfaces.api.rest.dto.toDtoV2
import org.springdoc.core.converters.models.PageableAsQueryParam
import org.springframework.core.env.Environment
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import javax.validation.Valid
private val logger = KotlinLogging.logger {}
@RestController
@RequestMapping("api/v2/users", produces = [MediaType.APPLICATION_JSON_VALUE])
class UserV2Controller(
private val userLifecycle: KomgaUserLifecycle,
private val userRepository: KomgaUserRepository,
private val libraryRepository: LibraryRepository,
private val authenticationActivityRepository: AuthenticationActivityRepository,
env: Environment,
) {
private val demo = env.activeProfiles.contains("demo")
@GetMapping("me")
fun getMe(@AuthenticationPrincipal principal: KomgaPrincipal): UserDtoV2 =
principal.toDtoV2()
@PatchMapping("me/password")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun updateMyPassword(
@AuthenticationPrincipal principal: KomgaPrincipal,
@Valid @RequestBody newPasswordDto: PasswordUpdateDto,
) {
if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN)
userRepository.findByEmailIgnoreCaseOrNull(principal.username)?.let { user ->
userLifecycle.updatePassword(user, newPasswordDto.password, false)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@GetMapping
@PreAuthorize("hasRole('$ROLE_ADMIN')")
fun getAll(): List<UserDtoV2> =
userRepository.findAll().map { it.toDtoV2() }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@PreAuthorize("hasRole('$ROLE_ADMIN')")
fun addOne(@Valid @RequestBody newUser: UserCreationDto): UserDtoV2 =
try {
userLifecycle.createUser(newUser.toDomain()).toDtoV2()
} catch (e: UserEmailAlreadyExistsException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "A user with this email already exists")
}
@DeleteMapping("{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id")
fun delete(
@PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal,
) {
userRepository.findByIdOrNull(id)?.let {
userLifecycle.deleteUser(it)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PatchMapping("{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN') and #principal.user.id != #id")
fun updateUser(
@PathVariable id: String,
@Valid @RequestBody patch: UserUpdateDto,
@AuthenticationPrincipal principal: KomgaPrincipal,
) {
userRepository.findByIdOrNull(id)?.let { existing ->
val updatedUser = with(patch) {
existing.copy(
roleAdmin = if (isSet("roles")) roles!!.contains(ROLE_ADMIN) else existing.roleAdmin,
roleFileDownload = if (isSet("roles")) roles!!.contains(ROLE_FILE_DOWNLOAD) else existing.roleFileDownload,
rolePageStreaming = if (isSet("roles")) roles!!.contains(ROLE_PAGE_STREAMING) else existing.rolePageStreaming,
sharedAllLibraries = if (isSet("sharedLibraries")) sharedLibraries!!.all else existing.sharedAllLibraries,
sharedLibrariesIds = if (isSet("sharedLibraries")) {
if (sharedLibraries!!.all) emptySet()
else libraryRepository.findAllByIds(sharedLibraries!!.libraryIds).map { it.id }.toSet()
} else existing.sharedLibrariesIds,
)
}
userRepository.update(updatedUser)
logger.info { "Updated user: $updatedUser" }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PatchMapping("{id}/password")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id")
fun updatePassword(
@PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal,
@Valid @RequestBody newPasswordDto: PasswordUpdateDto,
) {
if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN)
userRepository.findByIdOrNull(id)?.let { user ->
userLifecycle.updatePassword(user, newPasswordDto.password, user.id != principal.user.id)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@GetMapping("me/authentication-activity")
@PageableAsQueryParam
fun getMyAuthenticationActivity(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) page: Pageable,
): Page<AuthenticationActivityDto> {
if (demo && !principal.user.roleAdmin) throw ResponseStatusException(HttpStatus.FORBIDDEN)
val sort =
if (page.sort.isSorted) page.sort
else Sort.by(Sort.Order.desc("dateTime"))
val pageRequest =
if (unpaged) UnpagedSorted(sort)
else PageRequest.of(
page.pageNumber,
page.pageSize,
sort,
)
return authenticationActivityRepository.findAllByUser(principal.user, pageRequest).map { it.toDto() }
}
@GetMapping("authentication-activity")
@PageableAsQueryParam
@PreAuthorize("hasRole('$ROLE_ADMIN')")
fun getAuthenticationActivity(
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) page: Pageable,
): Page<AuthenticationActivityDto> {
val sort =
if (page.sort.isSorted) page.sort
else Sort.by(Sort.Order.desc("dateTime"))
val pageRequest =
if (unpaged) UnpagedSorted(sort)
else PageRequest.of(
page.pageNumber,
page.pageSize,
sort,
)
return authenticationActivityRepository.findAll(pageRequest).map { it.toDto() }
}
@GetMapping("{id}/authentication-activity/latest")
@PreAuthorize("hasRole('$ROLE_ADMIN') or #principal.user.id == #id")
fun getLatestAuthenticationActivityForUser(
@PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal,
): AuthenticationActivityDto =
userRepository.findByIdOrNull(id)?.let { user ->
authenticationActivityRepository.findMostRecentByUser(user)?.toDto()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -8,12 +8,14 @@ import org.gotson.komga.infrastructure.security.KomgaPrincipal
import javax.validation.constraints.Email import javax.validation.constraints.Email
import javax.validation.constraints.NotBlank import javax.validation.constraints.NotBlank
@Deprecated("Deprecated since 0.153.0. Use UserDtoV2 instead")
data class UserDto( data class UserDto(
val id: String, val id: String,
val email: String, val email: String,
val roles: List<String>, val roles: List<String>,
) )
@Deprecated("Deprecated since 0.153.0. Use toDtoV2() instead")
fun KomgaUser.toDto() = fun KomgaUser.toDto() =
UserDto( UserDto(
id = id, id = id,
@ -21,8 +23,29 @@ fun KomgaUser.toDto() =
roles = roles().toList(), roles = roles().toList(),
) )
@Deprecated("Deprecated since 0.153.0. Use toDtoV2() instead")
fun KomgaPrincipal.toDto() = user.toDto() fun KomgaPrincipal.toDto() = user.toDto()
data class UserDtoV2(
val id: String,
val email: String,
val roles: Set<String>,
val sharedAllLibraries: Boolean,
val sharedLibrariesIds: Set<String>,
)
fun KomgaUser.toDtoV2() =
UserDtoV2(
id = id,
email = email,
roles = roles(),
sharedAllLibraries = sharedAllLibraries,
sharedLibrariesIds = sharedLibrariesIds,
)
fun KomgaPrincipal.toDtoV2() = user.toDtoV2()
@Deprecated("Deprecated since 0.153.0. Use UserDtoV2 instead")
data class UserWithSharedLibrariesDto( data class UserWithSharedLibrariesDto(
val id: String, val id: String,
val email: String, val email: String,
@ -31,10 +54,12 @@ data class UserWithSharedLibrariesDto(
val sharedLibraries: List<SharedLibraryDto>, val sharedLibraries: List<SharedLibraryDto>,
) )
@Deprecated("Deprecated since 0.153.0. Use UserDtoV2 instead")
data class SharedLibraryDto( data class SharedLibraryDto(
val id: String, val id: String,
) )
@Deprecated("Deprecated since 0.153.0. Use toDtoV2() instead")
fun KomgaUser.toWithSharedLibrariesDto() = fun KomgaUser.toWithSharedLibrariesDto() =
UserWithSharedLibrariesDto( UserWithSharedLibrariesDto(
id = id, id = id,
@ -63,11 +88,7 @@ data class PasswordUpdateDto(
@get:NotBlank val password: String, @get:NotBlank val password: String,
) )
data class SharedLibrariesUpdateDto( @Deprecated("Deprecated since 0.153.0")
val all: Boolean,
val libraryIds: Set<String>,
)
data class RolesUpdateDto( data class RolesUpdateDto(
val roles: List<String>, val roles: List<String>,
) )

View file

@ -0,0 +1,32 @@
package org.gotson.komga.interfaces.api.rest.dto
import kotlin.properties.Delegates
class UserUpdateDto {
private val isSet = mutableMapOf<String, Boolean>()
fun isSet(prop: String) = isSet.getOrDefault(prop, false)
// @get:NullOrNotBlank
// val title: String? = null
// @get:PositiveOrZero
// var ageRating: Int?
// by Delegates.observable(null) { prop, _, _ ->
// isSet[prop.name] = true
// }
var roles: Set<String>?
by Delegates.observable(null) { prop, _, _ ->
isSet[prop.name] = true
}
var sharedLibraries: SharedLibrariesUpdateDto?
by Delegates.observable(null) { prop, _, _ ->
isSet[prop.name] = true
}
}
data class SharedLibrariesUpdateDto(
val all: Boolean,
val libraryIds: Set<String>,
)

View file

@ -9,6 +9,7 @@ import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.patch import org.springframework.test.web.servlet.patch
@ExtendWith(SpringExtension::class) @ExtendWith(SpringExtension::class)
@ -24,11 +25,20 @@ class UserControllerDemoTest(
fun `given demo profile is active when a user tries to update its password via api then returns forbidden`() { fun `given demo profile is active when a user tries to update its password via api then returns forbidden`() {
val jsonString = """{"password":"new"}""" val jsonString = """{"password":"new"}"""
mockMvc.patch("/api/v1/users/me/password") { mockMvc.patch("/api/v2/users/me/password") {
contentType = MediaType.APPLICATION_JSON contentType = MediaType.APPLICATION_JSON
content = jsonString content = jsonString
}.andExpect { }.andExpect {
status { isForbidden() } status { isForbidden() }
} }
} }
@Test
@WithMockCustomUser
fun `given demo profile is active when a user tries to retrieve own authentication activity then returns forbidden`() {
mockMvc.get("/api/v2/users/me/authentication-activity")
.andExpect {
status { isForbidden() }
}
}
} }

View file

@ -1,6 +1,20 @@
package org.gotson.komga.interfaces.api.rest package org.gotson.komga.interfaces.api.rest
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.ROLE_ADMIN 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
import org.gotson.komga.domain.model.makeLibrary
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.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource import org.junit.jupiter.params.provider.ValueSource
@ -11,6 +25,7 @@ import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.patch
import org.springframework.test.web.servlet.post import org.springframework.test.web.servlet.post
@ExtendWith(SpringExtension::class) @ExtendWith(SpringExtension::class)
@ -19,19 +34,192 @@ import org.springframework.test.web.servlet.post
@ActiveProfiles("test") @ActiveProfiles("test")
class UserControllerTest( class UserControllerTest(
@Autowired private val mockMvc: MockMvc, @Autowired private val mockMvc: MockMvc,
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val libraryLifecycle: LibraryLifecycle,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val userLifecycle: KomgaUserLifecycle,
) { ) {
private val admin = KomgaUser("admin@example.org", "", true, id = "admin")
@BeforeAll
fun setup() {
libraryRepository.insert(makeLibrary(id = "1"))
libraryRepository.insert(makeLibrary(id = "2"))
userRepository.insert(admin)
}
@AfterAll
fun teardown() {
userRepository.findAll().forEach {
userLifecycle.deleteUser(it)
}
libraryRepository.findAll().forEach {
libraryLifecycle.deleteLibrary(it)
}
}
@AfterEach
fun deleteUsers() {
userRepository.findAll()
.filterNot { it.email == admin.email }
.forEach { userLifecycle.deleteUser(it) }
}
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = ["user", "user@domain"]) @ValueSource(strings = ["user", "user@domain"])
@WithMockCustomUser(roles = [ROLE_ADMIN]) @WithMockCustomUser(roles = [ROLE_ADMIN])
fun `when creating a user with invalid email then returns bad request`(email: String) { fun `when creating a user with invalid email then returns bad request`(email: String) {
val jsonString = """{"email":"$email","password":"password"}""" val jsonString = """{"email":"$email","password":"password"}"""
mockMvc.post("/api/v1/users") { mockMvc.post("/api/v2/users") {
contentType = MediaType.APPLICATION_JSON contentType = MediaType.APPLICATION_JSON
content = jsonString content = jsonString
}.andExpect { }.andExpect {
status { isBadRequest() } status { isBadRequest() }
} }
} }
@Nested
inner class Update {
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user without roles when updating roles then roles are updated`() {
val user = KomgaUser("user@example.org", "", false, id = "user", roleFileDownload = false, rolePageStreaming = false)
userLifecycle.createUser(user)
val jsonString = """
{
"roles": ["$ROLE_FILE_DOWNLOAD","$ROLE_PAGE_STREAMING"]
}
""".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!!.roleFileDownload).isTrue
assertThat(this.rolePageStreaming).isTrue
assertThat(this.roleAdmin).isFalse
}
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user with roles when updating roles then roles are updated`() {
val user = KomgaUser("user@example.org", "", true, id = "user", roleFileDownload = true, rolePageStreaming = true)
userLifecycle.createUser(user)
val jsonString = """
{
"roles": []
}
""".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!!.roleFileDownload).isFalse
assertThat(this.rolePageStreaming).isFalse
assertThat(this.roleAdmin).isFalse
}
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user with library restrictions when updating available libraries then they are updated`() {
val user = KomgaUser("user@example.org", "", false, id = "user", sharedAllLibraries = false, sharedLibrariesIds = setOf("1"))
userLifecycle.createUser(user)
val jsonString = """
{
"sharedLibraries": {
"all": "false",
"libraryIds" : ["1", "2"]
}
}
""".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!!.sharedAllLibraries).isFalse
assertThat(this.sharedLibrariesIds).containsExactlyInAnyOrder("1", "2")
}
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user without library restrictions when restricting libraries then they restrictions are updated`() {
val user = KomgaUser("user@example.org", "", false, id = "user", sharedAllLibraries = true)
userLifecycle.createUser(user)
val jsonString = """
{
"sharedLibraries": {
"all": "false",
"libraryIds" : ["2"]
}
}
""".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!!.sharedAllLibraries).isFalse
assertThat(this.sharedLibrariesIds).containsExactlyInAnyOrder("2")
}
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user with library restrictions when removing restrictions then they restrictions are updated`() {
val user = KomgaUser("user@example.org", "", false, id = "user", sharedAllLibraries = false, sharedLibrariesIds = setOf("2"))
userLifecycle.createUser(user)
val jsonString = """
{
"sharedLibraries": {
"all": "true",
"libraryIds": []
}
}
""".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!!.sharedAllLibraries).isTrue
assertThat(this.sharedLibrariesIds).isEmpty()
}
}
}
} }