/* 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 . */ package mdnet import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.readValue import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource import mdnet.cache.ImageStorage import mdnet.logging.info import mdnet.logging.warn import mdnet.settings.ClientSettings import org.ktorm.database.Database import org.slf4j.LoggerFactory import java.io.File import java.io.FileReader import java.io.IOException import java.nio.file.Path 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 // Exception class to handle when Client Settings have invalid values class ClientSettingsException(message: String) : Exception(message) class MangaDexClient(private val settingsFile: File, databaseFolder: Path, cacheFolder: Path) { // this must remain single-threaded because of how the state mechanism works private val executor = Executors.newSingleThreadScheduledExecutor() private var scheduledFuture: ScheduledFuture<*>? = null private val database: Database private val storage: ImageStorage private val dataSource: HikariDataSource private var settings: ClientSettings // state that must only be accessed from the thread on the executor private var imageServer: ServerManager? = null // end protected state init { settings = readClientSettings() LOGGER.info { "Client settings loaded: $settings" } Class.forName("org.sqlite.JDBC") val config = HikariConfig() val db = databaseFolder.resolve("metadata.db") config.jdbcUrl = "jdbc:sqlite:$db" config.addDataSourceProperty("cachePrepStmts", "true") config.addDataSourceProperty("prepStmtCacheSize", "100") config.addDataSourceProperty("prepStmtCacheSqlLimit", "1000") dataSource = HikariDataSource(config) database = Database.connect(dataSource) storage = ImageStorage( maxSize = (settings.maxCacheSizeInMebibytes * 1024 * 1024 * 0.95).toLong(), /* MiB to bytes */ cacheFolder, database ) } fun runLoop() { LOGGER.info { "Mangadex@Home Client initialized - starting normal operation" } startImageServer() scheduledFuture = executor.scheduleWithFixedDelay( { try { reloadClientSettings() } catch (e: Exception) { LOGGER.warn(e) { "Reload of ClientSettings failed" } } }, 1, 1, TimeUnit.MINUTES ) scheduledFuture = executor.scheduleWithFixedDelay( { try { if (imageServer == null) { LOGGER.info { "Restarting image server that failed to start" } startImageServer() } } catch (e: Exception) { LOGGER.warn(e) { "Image server restart failed" } } }, 1, 1, TimeUnit.MINUTES ) } // Precondition: settings must be filled with up-to-date settings private fun startImageServer() { require(imageServer == null) { "imageServer was not null" } LOGGER.info { "Server manager starting" } imageServer = ServerManager( settings, storage ).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" } } fun shutdown() { LOGGER.info { "Mangadex@Home Client shutting down" } val latch = CountDownLatch(1) scheduledFuture?.cancel(false) executor.schedule( { if (imageServer != null) { stopImageServer() } storage.close() dataSource.close() latch.countDown() }, 0, TimeUnit.SECONDS ) latch.await() executor.shutdown() executor.awaitTermination(10, TimeUnit.SECONDS) 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 } settings = newSettings LOGGER.info { "New settings loaded: $newSettings" } storage.maxSize = (newSettings.maxCacheSizeInMebibytes * 1024 * 1024 * 0.95).toLong() if (imageServer != null) { stopImageServer() } try { startImageServer() } catch (e: Exception) { LOGGER.warn(e) { "Error starting the image server" } } } 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 < 40960) { throw ClientSettingsException("Config Error: Invalid max cache size, must be >= 20480 MiB (20 GiB)") } fun isSecretValid(clientSecret: String): Boolean { return Pattern.matches("^[a-zA-Z0-9]{$CLIENT_KEY_LENGTH}$", clientSecret) } settings.serverSettings.let { if (!isSecretValid(it.secret)) { throw ClientSettingsException("Config Error: API Secret is invalid, must be 52 alphanumeric characters") } if (it.port == 0) { throw ClientSettingsException("Config Error: Invalid port number") } if (it.port in Constants.RESTRICTED_PORTS) { throw ClientSettingsException("Config Error: Unsafe port number") } 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") } } } 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 = ObjectMapper(YAMLFactory()).registerModule(KotlinModule()) } }