mirror of
https://github.com/gotson/komga.git
synced 2026-05-05 02:53:09 +02:00
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:
parent
ceef94a931
commit
48e9d325c4
14 changed files with 359 additions and 26 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package org.gotson.komga.interfaces.api.rest.dto
|
||||
|
||||
data class SettingsDto(
|
||||
val deleteEmptyCollections: Boolean,
|
||||
val deleteEmptyReadLists: Boolean,
|
||||
val rememberMeDurationDays: Long,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue