feat(api): move some configuration keys to API and database

note that the remember-me validity will not be migrated

Closes: #815
This commit is contained in:
Gauthier Roebroeck 2023-09-25 15:14:51 +08:00
parent ceef94a931
commit 48e9d325c4
14 changed files with 359 additions and 26 deletions

View file

@ -1,4 +1,3 @@
import org.apache.tools.ant.taskdefs.condition.Os
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.kotlin.util.prefixIfNot
@ -267,6 +266,8 @@ flyway {
placeholders = mapOf(
"library-file-hashing" to "true",
"library-scan-startup" to "false",
"delete-empty-collections" to "true",
"delete-empty-read-lists" to "true",
)
}
tasks.flywayMigrate {

View file

@ -0,0 +1,14 @@
CREATE TABLE SERVER_SETTINGS
(
KEY varchar NOT NULL PRIMARY KEY,
VALUE varchar NULL
);
INSERT INTO SERVER_SETTINGS
VALUES ('DELETE_EMPTY_COLLECTIONS', ${delete-empty-collections});
INSERT INTO SERVER_SETTINGS
VALUES ('DELETE_EMPTY_READLISTS', ${delete-empty-read-lists});
INSERT INTO SERVER_SETTINGS
VALUES ('REMEMBER_ME_KEY', hex(randomblob(32)));
INSERT INTO SERVER_SETTINGS
VALUES ('REMEMBER_ME_DURATION', 365);

View file

@ -25,7 +25,7 @@ import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.persistence.SidecarRepository
import org.gotson.komga.domain.persistence.ThumbnailBookRepository
import org.gotson.komga.infrastructure.configuration.KomgaProperties
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
import org.gotson.komga.infrastructure.hash.Hasher
import org.gotson.komga.language.notEquals
import org.gotson.komga.language.toIndexedMap
@ -49,7 +49,7 @@ class LibraryContentLifecycle(
private val collectionLifecycle: SeriesCollectionLifecycle,
private val readListLifecycle: ReadListLifecycle,
private val sidecarRepository: SidecarRepository,
private val komgaProperties: KomgaProperties,
private val komgaSettingsProvider: KomgaSettingsProvider,
private val taskEmitter: TaskEmitter,
private val transactionTemplate: TransactionTemplate,
private val hasher: Hasher,
@ -402,11 +402,11 @@ class LibraryContentLifecycle(
}
private fun cleanupEmptySets() {
if (komgaProperties.deleteEmptyCollections) {
if (komgaSettingsProvider.deleteEmptyCollections) {
collectionLifecycle.deleteEmptyCollections()
}
if (komgaProperties.deleteEmptyReadLists) {
if (komgaSettingsProvider.deleteEmptyReadLists) {
readListLifecycle.deleteEmptyReadLists()
}
}

View file

@ -34,13 +34,16 @@ class KomgaProperties {
@Deprecated("Moved to library options since 1.5.0")
var librariesScanDirectoryExclusions: List<String> = emptyList()
@Deprecated("Moved to server settings since 1.5.0")
var deleteEmptyReadLists: Boolean = true
@Deprecated("Moved to server settings since 1.5.0")
var deleteEmptyCollections: Boolean = true
@Positive
var pageHashing: Int = 3
@Deprecated("Moved to server settings since 1.5.0")
var rememberMe = RememberMe()
@DurationUnit(ChronoUnit.SECONDS)
@ -64,10 +67,13 @@ class KomgaProperties {
@Positive
var taskConsumersMax: Int = 1
@Deprecated("Moved to server settings since 1.5.0")
class RememberMe {
@Deprecated("Moved to server settings since 1.5.0")
@get:NotBlank
var key: String? = null
@Deprecated("Moved to server settings since 1.5.0")
@DurationUnit(ChronoUnit.SECONDS)
var validity: Duration = Duration.ofDays(14)
}

View file

@ -0,0 +1,50 @@
package org.gotson.komga.infrastructure.configuration
import org.apache.commons.lang3.RandomStringUtils
import org.gotson.komga.infrastructure.jooq.ServerSettingsDao
import org.springframework.stereotype.Service
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
@Service
class KomgaSettingsProvider(
private val serverSettingsDao: ServerSettingsDao,
) {
var deleteEmptyCollections: Boolean
get() =
serverSettingsDao.getSettingByKey(Settings.DELETE_EMPTY_COLLECTIONS.name, Boolean::class.java) ?: false
set(value) =
serverSettingsDao.saveSetting(Settings.DELETE_EMPTY_COLLECTIONS.name, value)
var deleteEmptyReadLists: Boolean
get() =
serverSettingsDao.getSettingByKey(Settings.DELETE_EMPTY_READLISTS.name, Boolean::class.java) ?: false
set(value) =
serverSettingsDao.saveSetting(Settings.DELETE_EMPTY_READLISTS.name, value)
var rememberMeKey: String
get() =
serverSettingsDao.getSettingByKey(Settings.REMEMBER_ME_KEY.name, String::class.java)
?: getRandomRememberMeKey().also { serverSettingsDao.saveSetting(Settings.REMEMBER_ME_KEY.name, it) }
set(value) =
serverSettingsDao.saveSetting(Settings.REMEMBER_ME_KEY.name, value)
fun renewRememberMeKey() {
serverSettingsDao.saveSetting(Settings.REMEMBER_ME_KEY.name, getRandomRememberMeKey())
}
private fun getRandomRememberMeKey() = RandomStringUtils.randomAlphanumeric(32)
var rememberMeDuration: Duration
get() =
(serverSettingsDao.getSettingByKey(Settings.REMEMBER_ME_DURATION.name, Int::class.java) ?: 365).days
set(value) =
serverSettingsDao.saveSetting(Settings.REMEMBER_ME_DURATION.name, value.inWholeDays.toInt())
}
private enum class Settings {
DELETE_EMPTY_COLLECTIONS,
DELETE_EMPTY_READLISTS,
REMEMBER_ME_KEY,
REMEMBER_ME_DURATION,
}

View file

@ -0,0 +1,39 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.jooq.Tables
import org.jooq.DSLContext
import org.springframework.stereotype.Component
@Component
class ServerSettingsDao(
private val dsl: DSLContext,
) {
private val s = Tables.SERVER_SETTINGS
fun <T> getSettingByKey(key: String, clazz: Class<T>): T? =
dsl.select(s.VALUE)
.from(s)
.where(s.KEY.eq(key))
.fetchOneInto(clazz)
fun saveSetting(key: String, value: String) {
dsl.insertInto(s)
.values(key, value)
.onDuplicateKeyUpdate()
.set(s.VALUE, value)
.execute()
}
fun saveSetting(key: String, value: Boolean) {
saveSetting(key, value.toString())
}
fun saveSetting(key: String, value: Int) {
saveSetting(key, value.toString())
}
fun deleteAll() {
dsl.deleteFrom(s).execute()
}
}

View file

@ -4,6 +4,7 @@ import mu.KotlinLogging
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_USER
import org.gotson.komga.infrastructure.configuration.KomgaProperties
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest
import org.springframework.boot.actuate.health.HealthEndpoint
import org.springframework.context.annotation.Bean
@ -33,6 +34,7 @@ private val logger = KotlinLogging.logger {}
@EnableMethodSecurity(prePostEnabled = true)
class SecurityConfiguration(
private val komgaProperties: KomgaProperties,
private val komgaSettingsProvider: KomgaSettingsProvider,
private val komgaUserDetailsLifecycle: UserDetailsService,
private val oauth2UserService: OAuth2UserService<OAuth2UserRequest, OAuth2User>,
private val oidcUserService: OAuth2UserService<OidcUserRequest, OidcUser>,
@ -120,20 +122,15 @@ class SecurityConfiguration(
}
}
if (!komgaProperties.rememberMe.key.isNullOrBlank()) {
logger.info { "RememberMe is active, validity: ${komgaProperties.rememberMe.validity}" }
http
.rememberMe {
it.rememberMeServices(
TokenBasedRememberMeServices(komgaProperties.rememberMe.key, komgaUserDetailsLifecycle).apply {
setTokenValiditySeconds(komgaProperties.rememberMe.validity.seconds.toInt())
setAlwaysRemember(true)
setAuthenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
},
)
}
}
http
.rememberMe {
it.rememberMeServices(
TokenBasedRememberMeServices(komgaSettingsProvider.rememberMeKey, komgaUserDetailsLifecycle).apply {
setTokenValiditySeconds(komgaSettingsProvider.rememberMeDuration.inWholeSeconds.toInt())
setAuthenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
},
)
}
return http.build()
}

View file

@ -0,0 +1,45 @@
package org.gotson.komga.interfaces.api.rest
import jakarta.validation.Valid
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
import org.gotson.komga.interfaces.api.rest.dto.SettingsDto
import org.gotson.komga.interfaces.api.rest.dto.SettingsUpdateDto
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import kotlin.time.Duration.Companion.days
@RestController
@RequestMapping(value = ["api/v1/settings"], produces = [MediaType.APPLICATION_JSON_VALUE])
@PreAuthorize("hasRole('$ROLE_ADMIN')")
class SettingsController(
private val komgaSettingsProvider: KomgaSettingsProvider,
) {
@GetMapping
fun getSettings(): SettingsDto =
SettingsDto(
komgaSettingsProvider.deleteEmptyCollections,
komgaSettingsProvider.deleteEmptyReadLists,
komgaSettingsProvider.rememberMeDuration.inWholeDays,
)
@PatchMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
fun updateSettings(
@Valid @RequestBody
newSettings: SettingsUpdateDto,
) {
newSettings.deleteEmptyCollections?.let { komgaSettingsProvider.deleteEmptyCollections = it }
newSettings.deleteEmptyReadLists?.let { komgaSettingsProvider.deleteEmptyReadLists = it }
newSettings.rememberMeDurationDays?.let { komgaSettingsProvider.rememberMeDuration = it.days }
if (newSettings.renewRememberMeKey == true) komgaSettingsProvider.renewRememberMeKey()
}
}

View file

@ -0,0 +1,7 @@
package org.gotson.komga.interfaces.api.rest.dto
data class SettingsDto(
val deleteEmptyCollections: Boolean,
val deleteEmptyReadLists: Boolean,
val rememberMeDurationDays: Long,
)

View file

@ -0,0 +1,11 @@
package org.gotson.komga.interfaces.api.rest.dto
import jakarta.validation.constraints.Positive
data class SettingsUpdateDto(
val deleteEmptyCollections: Boolean? = null,
val deleteEmptyReadLists: Boolean? = null,
@Positive
val rememberMeDurationDays: Long? = null,
val renewRememberMeKey: Boolean? = null,
)

View file

@ -1,10 +1,4 @@
komga:
remember-me:
key: changeMe!
validity: 30d
# libraries-scan-cron: "*/5 * * * * ?" #every 5 seconds
libraries-scan-cron: "-" #disable
libraries-scan-startup: false
database:
file: ":memory:"
cors.allowed-origins:

View file

@ -13,7 +13,6 @@ logging:
org.apache.activemq.audit: WARN
komga:
libraries-scan-cron: "0 0 */8 * * ?"
database:
file: \${komga.config-dir}/database.sqlite
lucene:
@ -29,6 +28,8 @@ spring:
placeholders:
library-file-hashing: \${komga.file-hashing:true}
library-scan-startup: \${komga.libraries-scan-startup:false}
delete-empty-collections: \${komga.delete-empty-collections:true}
delete-empty-read-lists: \${komga.delete-empty-read-lists:true}
thymeleaf:
prefix: classpath:/public/
mvc:

View file

@ -0,0 +1,56 @@
package org.gotson.komga.infrastructure.jooq
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
class ServerSettingsDaoTest(
@Autowired private val serverSettingsDao: ServerSettingsDao,
) {
@AfterEach
fun cleanup() {
serverSettingsDao.deleteAll()
}
@Test
fun `when saving String setting then it is persisted`() {
serverSettingsDao.saveSetting("setting", "value")
val fetch = serverSettingsDao.getSettingByKey("setting", String::class.java)
assertThat(fetch).isEqualTo("value")
}
@Test
fun `when saving Int setting then it is persisted`() {
serverSettingsDao.saveSetting("setting", 12)
val fetch = serverSettingsDao.getSettingByKey("setting", Int::class.java)
assertThat(fetch).isEqualTo(12)
}
@Test
fun `when saving Boolean setting then it is persisted`() {
serverSettingsDao.saveSetting("setting", true)
val fetch = serverSettingsDao.getSettingByKey("setting", Boolean::class.java)
assertThat(fetch).isTrue
}
@Test
fun `given existing setting when saving again then it is overriden`() {
serverSettingsDao.saveSetting("setting", "value")
val initial = serverSettingsDao.getSettingByKey("setting", String::class.java)
assertThat(initial).isEqualTo("value")
serverSettingsDao.saveSetting("setting", "updated")
val updated = serverSettingsDao.getSettingByKey("setting", String::class.java)
assertThat(updated).isEqualTo("updated")
}
}

View file

@ -0,0 +1,112 @@
package org.gotson.komga.interfaces.api.rest
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_USER
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.security.test.context.support.WithAnonymousUser
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.patch
import kotlin.time.Duration.Companion.days
@SpringBootTest
@AutoConfigureMockMvc(printOnlyOnFailure = false)
class SettingsControllerTest(
@Autowired private val mockMvc: MockMvc,
@Autowired private val komgaSettingsProvider: KomgaSettingsProvider,
) {
@Nested
inner class NonAdminUser {
@Test
@WithAnonymousUser
fun `given anonymous user when retrieving settings then returns unauthorized`() {
mockMvc.get("/api/v1/settings")
.andExpect {
status { isUnauthorized() }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_USER])
fun `given restricted user when retrieving settings then returns forbidden`() {
mockMvc.get("/api/v1/settings")
.andExpect {
status { isForbidden() }
}
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given admin user when retrieving settings then settings are returned`() {
komgaSettingsProvider.deleteEmptyCollections = true
komgaSettingsProvider.deleteEmptyReadLists = false
komgaSettingsProvider.rememberMeDuration = 5.days
mockMvc.get("/api/v1/settings")
.andExpect {
status { isOk() }
jsonPath("deleteEmptyCollections") { value(true) }
jsonPath("deleteEmptyReadLists") { value(false) }
jsonPath("rememberMeDurationDays") { value(5) }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given admin user when updating settings then settings are updated`() {
komgaSettingsProvider.deleteEmptyCollections = true
komgaSettingsProvider.deleteEmptyReadLists = true
komgaSettingsProvider.rememberMeDuration = 5.days
val rememberMeKey = komgaSettingsProvider.rememberMeKey
//language=JSON
val jsonString = """
{
"deleteEmptyCollections": false,
"rememberMeDurationDays": 15,
"renewRememberMeKey": true
}
""".trimIndent()
mockMvc.patch("/api/v1/settings") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}
.andExpect {
status { isNoContent() }
}
assertThat(komgaSettingsProvider.deleteEmptyCollections).isFalse
assertThat(komgaSettingsProvider.deleteEmptyReadLists).isTrue
assertThat(komgaSettingsProvider.rememberMeDuration).isEqualTo(15.days)
assertThat(komgaSettingsProvider.rememberMeKey).isNotEqualTo(rememberMeKey)
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given admin user when updating with invalid settings then returns bad request`() {
//language=JSON
val jsonString = """
{
"rememberMeDurationDays": 0
}
""".trimIndent()
mockMvc.patch("/api/v1/settings") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}
.andExpect {
status { isBadRequest() }
}
}
}