/* 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 . */ /* 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.Executors 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 import java.util.concurrent.atomic.AtomicBoolean // 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 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." } 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() { executor.schedule({ LOGGER.info { "Mangadex@Home Client shutting down" } if (webUi != null) { stopWebUi() } if (imageServer != null) { stopImageServer() } LOGGER.info { "Mangadex@Home Client has shut down" } try { cache.close() } catch (e: IOException) { LOGGER.error(e) { "Cache failed to close" } } }, 0, TimeUnit.SECONDS) } /** * 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(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) } }