mirror of
https://gitlab.com/mangadex-pub/mangadex_at_home.git
synced 2024-01-19 02:48:37 +00:00
276 lines
9.8 KiB
Kotlin
276 lines
9.8 KiB
Kotlin
/*
|
|
Mangadex@Home
|
|
Copyright (c) 2020, MangaDex Network
|
|
This file is part of MangaDex@Home.
|
|
|
|
MangaDex@Home is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
MangaDex@Home is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
/* ktlint-disable no-wildcard-imports */
|
|
package mdnet.base
|
|
|
|
import com.fasterxml.jackson.core.JsonParser
|
|
import com.fasterxml.jackson.core.JsonProcessingException
|
|
import com.fasterxml.jackson.databind.ObjectMapper
|
|
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
|
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
import com.fasterxml.jackson.module.kotlin.readValue
|
|
import java.io.File
|
|
import java.io.FileReader
|
|
import java.io.IOException
|
|
import java.util.concurrent.CountDownLatch
|
|
import java.util.concurrent.Executors
|
|
import java.util.concurrent.ScheduledFuture
|
|
import java.util.concurrent.TimeUnit
|
|
import java.util.regex.Pattern
|
|
import mdnet.base.Main.dieWithError
|
|
import mdnet.base.server.getUiServer
|
|
import mdnet.base.settings.*
|
|
import mdnet.cache.DiskLruCache
|
|
import mdnet.cache.HeaderMismatchException
|
|
import org.http4k.server.Http4kServer
|
|
import org.jetbrains.exposed.sql.Database
|
|
import org.slf4j.LoggerFactory
|
|
|
|
// Exception class to handle when Client Settings have invalid values
|
|
class ClientSettingsException(message: String) : Exception(message)
|
|
|
|
class MangaDexClient(private val settingsFile: File, databaseFile: File, cacheFolder: File) {
|
|
// this must remain single-threaded because of how the state mechanism works
|
|
private val executor = Executors.newSingleThreadScheduledExecutor()
|
|
private lateinit var scheduledFuture: ScheduledFuture<*>
|
|
|
|
private val database: Database
|
|
private val cache: DiskLruCache
|
|
private var settings: ClientSettings
|
|
|
|
// state that must only be accessed from the thread on the executor
|
|
private var imageServer: ServerManager? = null
|
|
private var webUi: Http4kServer? = null
|
|
// end protected state
|
|
|
|
init {
|
|
settings = try {
|
|
readClientSettings()
|
|
} catch (e: UnrecognizedPropertyException) {
|
|
dieWithError("'${e.propertyName}' is not a valid setting")
|
|
} catch (e: JsonProcessingException) {
|
|
dieWithError(e)
|
|
} catch (e: ClientSettingsException) {
|
|
dieWithError(e)
|
|
} catch (e: IOException) {
|
|
dieWithError(e)
|
|
}
|
|
|
|
LOGGER.info { "Client settings loaded: $settings" }
|
|
|
|
database = Database.connect("jdbc:sqlite:$databaseFile", "org.sqlite.JDBC")
|
|
|
|
try {
|
|
cache = DiskLruCache.open(
|
|
cacheFolder, 1, 1,
|
|
(settings.maxCacheSizeInMebibytes * 1024 * 1024 * 0.8).toLong() /* MiB to bytes */
|
|
)
|
|
} catch (e: HeaderMismatchException) {
|
|
LOGGER.warn { "Cache version may be outdated - remove if necessary" }
|
|
dieWithError(e)
|
|
} catch (e: IOException) {
|
|
dieWithError(e)
|
|
}
|
|
}
|
|
|
|
fun runLoop() {
|
|
LOGGER.info { "Mangadex@Home Client initialized - starting normal operation." }
|
|
|
|
scheduledFuture = executor.scheduleWithFixedDelay({
|
|
try {
|
|
// this blocks the executor, so no worries about concurrency
|
|
reloadClientSettings()
|
|
} catch (e: Exception) {
|
|
LOGGER.warn(e) { "Reload of ClientSettings failed" }
|
|
}
|
|
}, 1, 1, TimeUnit.MINUTES)
|
|
|
|
startImageServer()
|
|
startWebUi()
|
|
}
|
|
|
|
// Precondition: settings must be filled with up-to-date settings and `imageServer` must not be null
|
|
private fun startWebUi() {
|
|
settings.webSettings?.let { webSettings ->
|
|
val imageServer = requireNotNull(imageServer)
|
|
|
|
if (webUi != null) {
|
|
throw AssertionError()
|
|
}
|
|
LOGGER.info { "WebUI starting" }
|
|
webUi = getUiServer(webSettings, imageServer.statistics, imageServer.statsMap).also {
|
|
it.start()
|
|
}
|
|
LOGGER.info { "WebUI started" }
|
|
}
|
|
}
|
|
|
|
// Precondition: settings must be filled with up-to-date settings
|
|
private fun startImageServer() {
|
|
if (imageServer != null) {
|
|
throw AssertionError()
|
|
}
|
|
LOGGER.info { "Server manager starting" }
|
|
imageServer = ServerManager(settings.serverSettings, settings.devSettings, settings.maxCacheSizeInMebibytes, cache, database).also {
|
|
it.start()
|
|
}
|
|
LOGGER.info { "Server manager started" }
|
|
}
|
|
|
|
private fun stopImageServer() {
|
|
LOGGER.info { "Server manager stopping" }
|
|
requireNotNull(imageServer).shutdown()
|
|
imageServer = null
|
|
LOGGER.info { "Server manager stopped" }
|
|
}
|
|
|
|
private fun stopWebUi() {
|
|
LOGGER.info { "WebUI stopping" }
|
|
requireNotNull(webUi).stop()
|
|
webUi = null
|
|
LOGGER.info { "WebUI stopped" }
|
|
}
|
|
|
|
fun shutdown() {
|
|
LOGGER.info { "Mangadex@Home Client shutting down" }
|
|
val latch = CountDownLatch(1)
|
|
|
|
scheduledFuture.cancel(false)
|
|
|
|
executor.schedule({
|
|
if (webUi != null) {
|
|
stopWebUi()
|
|
}
|
|
if (imageServer != null) {
|
|
stopImageServer()
|
|
}
|
|
|
|
try {
|
|
cache.close()
|
|
} catch (e: IOException) {
|
|
LOGGER.error(e) { "Cache failed to close" }
|
|
}
|
|
|
|
latch.countDown()
|
|
}, 0, TimeUnit.SECONDS)
|
|
|
|
latch.await()
|
|
executor.shutdown()
|
|
LOGGER.info { "Mangadex@Home Client has shut down" }
|
|
}
|
|
|
|
/**
|
|
* Reloads the client configuration and restarts the
|
|
* Web UI and/or the server if needed
|
|
*/
|
|
private fun reloadClientSettings() {
|
|
LOGGER.info { "Checking client settings" }
|
|
try {
|
|
val newSettings = readClientSettings()
|
|
|
|
if (newSettings == settings) {
|
|
LOGGER.info { "Client settings unchanged" }
|
|
return
|
|
}
|
|
LOGGER.info { "New settings loaded: $newSettings" }
|
|
|
|
cache.maxSize = (newSettings.maxCacheSizeInMebibytes * 1024 * 1024 * 0.8).toLong()
|
|
|
|
val restartServer = newSettings.serverSettings != settings.serverSettings ||
|
|
newSettings.devSettings != settings.devSettings
|
|
|
|
val stopWebUi = restartServer || newSettings.webSettings != settings.webSettings
|
|
val startWebUi = stopWebUi && newSettings.webSettings != null
|
|
|
|
if (stopWebUi) {
|
|
LOGGER.info { "Stopping WebUI to reload ClientSettings" }
|
|
if (webUi != null) {
|
|
stopWebUi()
|
|
}
|
|
}
|
|
|
|
if (restartServer) {
|
|
stopImageServer()
|
|
startImageServer()
|
|
}
|
|
|
|
if (startWebUi) {
|
|
startWebUi()
|
|
}
|
|
} catch (e: UnrecognizedPropertyException) {
|
|
LOGGER.warn { "Settings file is invalid: '$e.propertyName' is not a valid setting" }
|
|
} catch (e: JsonProcessingException) {
|
|
LOGGER.warn { "Settings file is invalid: $e.message" }
|
|
} catch (e: ClientSettingsException) {
|
|
LOGGER.warn { "Settings file is invalid: $e.message" }
|
|
} catch (e: IOException) {
|
|
LOGGER.warn { "Error loading settings file: $e.message" }
|
|
}
|
|
}
|
|
|
|
private fun validateSettings(settings: ClientSettings) {
|
|
if (settings.maxCacheSizeInMebibytes < 1024) {
|
|
throw ClientSettingsException("Config Error: Invalid max cache size, must be >= 1024 MiB (1GiB)")
|
|
}
|
|
|
|
fun isSecretValid(clientSecret: String): Boolean {
|
|
return Pattern.matches("^[a-zA-Z0-9]{$CLIENT_KEY_LENGTH}$", clientSecret)
|
|
}
|
|
|
|
settings.serverSettings.let {
|
|
if (!isSecretValid(it.clientSecret)) {
|
|
throw ClientSettingsException("Config Error: API Secret is invalid, must be 52 alphanumeric characters")
|
|
}
|
|
if (it.clientPort == 0) {
|
|
throw ClientSettingsException("Config Error: Invalid port number")
|
|
}
|
|
if (it.clientPort in Constants.RESTRICTED_PORTS) {
|
|
throw ClientSettingsException("Config Error: Unsafe port number")
|
|
}
|
|
if (it.threads < 4) {
|
|
throw ClientSettingsException("Config Error: Invalid number of threads, must be >= 4")
|
|
}
|
|
if (it.maxMebibytesPerHour < 0) {
|
|
throw ClientSettingsException("Config Error: Max bandwidth must be >= 0")
|
|
}
|
|
if (it.maxKilobitsPerSecond < 0) {
|
|
throw ClientSettingsException("Config Error: Max burst rate must be >= 0")
|
|
}
|
|
if (it.gracefulShutdownWaitSeconds < 15) {
|
|
throw ClientSettingsException("Config Error: Graceful shutdown wait must be >= 15")
|
|
}
|
|
}
|
|
settings.webSettings?.let {
|
|
if (it.uiPort == 0) {
|
|
throw ClientSettingsException("Config Error: Invalid UI port number")
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun readClientSettings(): ClientSettings {
|
|
return JACKSON.readValue<ClientSettings>(FileReader(settingsFile)).apply(::validateSettings)
|
|
}
|
|
|
|
companion object {
|
|
private const val CLIENT_KEY_LENGTH = 52
|
|
private val LOGGER = LoggerFactory.getLogger(MangaDexClient::class.java)
|
|
private val JACKSON: ObjectMapper = jacksonObjectMapper().configure(JsonParser.Feature.ALLOW_COMMENTS, true)
|
|
}
|
|
}
|