Compare commits

..

No commits in common. "master" and "webui_update" have entirely different histories.

28 changed files with 636 additions and 4403 deletions

View file

@ -1,7 +1,6 @@
stages:
- build
- publish
- publish_latest
- publish_docker
cache:
@ -27,22 +26,8 @@ publish:
name: "mangadex_at_home"
paths:
- "*.jar"
- "mangadex_at_home-*.zip"
- settings.sample.json
publish_latest:
image: alpine
stage: publish
before_script:
- apk update && apk add git
- export VERSION=`git describe --tags --dirty`
script:
- cp build/libs/mangadex_at_home-${VERSION}-all.jar build/libs/mangadex_at_home-latest-all.jar
artifacts:
name: "mangadex_at_home-latest"
paths:
- "build/libs/mangadex_at_home-latest-all.jar"
publish_docker:
image: docker:git
stage: publish

View file

@ -6,8 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- [2020-07-05] Added basic graph creation interface to dash [@RedMatriz].
### Changed
- [2020-07-05] Changed mobile dash to allow for graph creation [@RedMatriz].
- [2020-07-05] Minor improvements to graph load [@RedMatriz].
### Deprecated
@ -17,53 +20,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security
## [1.2.2] - 2020-08-21
### Changed
- [2020-08-11] Moved to a Java implementation of NaCl [@carbotaniuman].
### Fixed
- [2020-08-11] Revert WebUI changes in 1.2.1 [@carbotaniuman].
## [1.2.1] - 2020-08-11
### Added
- [2020-08-11] New CLI for specifying database location, cache folder, and settings [@carbotaniuman].
### Changed
- [2020-08-11] Change logging defaults [@carbotaniuman].
### Fixed
- [2020-08-11] Bugs relating to `settings.json` changes [@carbotaniuman].
- [2020-08-11] Logs taking up an absurd amount of space [@carbotaniuman].
- [2020-08-11] Random crashes for no reason [@carbotaniuman].
- [2020-08-11] SQLException is now properly handled [@carbotaniuman].
## [1.2.0] - 2020-08-10
### Added
- [2020-07-13] Added reloading client setting without stopping client by [@radonbark].
### Changed
- [2020-07-29] Disallow unsafe ports [@m3ch_mania].
### Fixed
- [2020-07-29] Fixed stupid libsodium bugs [@carbotaniuman].
- [2020-07-29] Fixed issues from the Great Cache Propagation [@carbotaniuman].
- [2020-08-03] Fix `client_hostname` stuff [@carbotaniuman].
## [1.1.5] - 2020-07-05
### Added
- [2020-07-05] Added basic graph creation interface to dash [@RedMatriz].
### Changed
- [2020-07-04] Changed GitLab CI to build on every push irregardless of tagging by [@carbotaniuman].
- [2020-07-05] Added `mangadex.network` as allowed domain for `Referer`. Allow blank or missing `Referer` by [@AviKav].
- [2020-07-05] Changed mobile dash to allow for graph creation [@RedMatriz].
- [2020-07-05] Minor improvements to graph load [@RedMatriz].
### Fixed
- [2020-07-04] Fixed typo on `access-control-allow-methods` by [@carbotaniuman].
- [2020-07-05] Fixed minor graph update issues [@RedMatriz].
### Security
- [2020-07-05] Prevent `Referer` matching on subdomains such as `mangadex.org.example.com` by [@AviKav].
@ -242,10 +205,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- [2020-06-11] Tweaked logging configuration to reduce log file sizes by [@carbotaniuman].
[Unreleased]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.2.2...HEAD
[1.2.2]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.2.1...1.2.2
[1.2.1]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.2.0...1.2.1
[1.2.0]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.1.5...1.2.0
[Unreleased]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.1.5...HEAD
[1.1.5]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.1.4...1.1.5
[1.1.4]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.1.3...1.1.4
[1.1.3]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.1.2...1.1.3

View file

@ -1,7 +1,7 @@
FROM openjdk:15-alpine
WORKDIR /mangahome
COPY /build/libs/mangadex_at_home.jar .
RUN apk update
RUN apk update && apk add --no-cache libsodium
VOLUME "/mangahome/cache"
EXPOSE 443 8080
CMD java -Dfile-level=off -Dstdout-level=trace -jar mangadex_at_home.jar

View file

@ -1,11 +1,10 @@
plugins {
id "java"
id "org.jetbrains.kotlin.jvm" version "1.4.0"
id "org.jetbrains.kotlin.kapt" version "1.4.0"
id "org.jetbrains.kotlin.jvm" version "1.3.72"
id "application"
id "com.github.johnrengelman.shadow" version "5.2.0"
id "com.diffplug.spotless" version "5.2.0"
id "dev.afanasev.sekret" version "0.0.7"
id "com.diffplug.gradle.spotless" version "4.4.0"
id "dev.afanasev.sekret" version "0.0.3"
}
group = "com.mangadex"
@ -23,7 +22,7 @@ configurations {
}
dependencies {
compileOnly group: "dev.afanasev", name: "sekret-annotation", version: "0.0.7"
compileOnly group: "dev.afanasev", name: "sekret-annotation", version: "0.0.3"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
implementation "org.jetbrains.kotlin:kotlin-reflect"
@ -33,25 +32,19 @@ dependencies {
implementation group: "org.http4k", name: "http4k-core", version: "$http_4k_version"
implementation group: "org.http4k", name: "http4k-format-jackson", version: "$http_4k_version"
implementation group: "com.fasterxml.jackson.datatype", name: "jackson-datatype-jsr310", version: "2.11.1"
implementation group: "org.http4k", name: "http4k-client-apache4", version: "$http_4k_version"
implementation group: "org.http4k", name: "http4k-client-apache", version: "$http_4k_version"
implementation group: "org.http4k", name: "http4k-server-netty", version: "$http_4k_version"
runtimeOnly group: "io.netty", name: "netty-tcnative-boringssl-static", version: "2.0.34.Final"
runtimeOnly group: "io.netty", name: "netty-tcnative-boringssl-static", version: "2.0.30.Final"
implementation group: "ch.qos.logback", name: "logback-classic", version: "1.3.0-alpha4"
implementation group: "org.jetbrains.exposed", name: "exposed-core", version: "$exposed_version"
implementation group: "org.jetbrains.exposed", name: "exposed-dao", version: "$exposed_version"
implementation group: "org.jetbrains.exposed", name: "exposed-jdbc", version: "$exposed_version"
implementation group: "org.xerial", name: "sqlite-jdbc", version: "3.32.3.2"
implementation group: "org.xerial", name: "sqlite-jdbc", version: "3.30.1"
implementation "info.picocli:picocli:4.5.0"
kapt "info.picocli:picocli-codegen:4.5.0"
}
kapt {
arguments {
arg("project", "${project.group}/${project.name}")
}
implementation "com.goterl.lazycode:lazysodium-java:4.2.6"
implementation "net.java.dev.jna:jna:5.5.0"
}
java {
@ -60,7 +53,6 @@ java {
}
spotless {
lineEndings 'UNIX'
java {
targetExclude("build/generated/**/*")
eclipse()
@ -84,41 +76,3 @@ tasks.register("generateVersion", Copy) {
}
sourceSets.main.java.srcDir generateVersion.outputs.files
tasks.register("depsize") {
description = 'Prints dependencies for "default" configuration'
doLast() {
listConfigurationDependencies(configurations.default)
}
}
tasks.register("depsize-all-configurations") {
description = 'Prints dependencies for all available configurations'
doLast() {
configurations
.findAll { it.isCanBeResolved() }
.each { listConfigurationDependencies(it) }
}
}
def listConfigurationDependencies(Configuration configuration) {
def formatStr = "%,10.2f"
def size = configuration.collect { it.length() / (1024 * 1024) }.sum()
def out = new StringBuffer()
out << "\nConfiguration name: \"${configuration.name}\"\n"
if (size) {
out << 'Total dependencies size:'.padRight(65)
out << "${String.format(formatStr, size)} Mb\n\n"
configuration.sort { -it.length() }
.each {
out << "${it.name}".padRight(65)
out << "${String.format(formatStr, (it.length() / 1024))} kb\n"
}
} else {
out << 'No dependencies found';
}
println(out)
}

View file

@ -1,2 +1,2 @@
http_4k_version=3.258.0
exposed_version=0.26.2
http_4k_version=3.251.0
exposed_version=0.24.1

View file

@ -254,9 +254,7 @@ public final class DiskLruCache implements Closeable {
try {
readJournalLine(reader.readLine());
lineCount++;
} catch (UnexpectedJournalLineException ignored) {
// just continue and hope nothing breaks
} catch (EOFException e) {
} catch (EOFException endOfJournal) {
break;
}
}
@ -275,7 +273,7 @@ public final class DiskLruCache implements Closeable {
private void readJournalLine(String line) throws IOException {
int firstSpace = line.indexOf(' ');
if (firstSpace == -1) {
throw new UnexpectedJournalLineException(line);
throw new IOException("unexpected journal line: " + line);
}
int keyBegin = firstSpace + 1;
@ -307,7 +305,7 @@ public final class DiskLruCache implements Closeable {
} else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) {
// This work was already done by calling lruEntries.get().
} else {
throw new UnexpectedJournalLineException(line);
throw new IOException("unexpected journal line: " + line);
}
}

View file

@ -1,9 +0,0 @@
package mdnet.cache;
import java.io.IOException;
public class UnexpectedJournalLineException extends IOException {
public UnexpectedJournalLineException(String unexpectedLine) {
super("unexpected journal line: " + unexpectedLine);
}
}

File diff suppressed because it is too large Load diff

View file

@ -21,84 +21,9 @@ package mdnet.base
import java.time.Duration
object Constants {
const val CLIENT_BUILD = 19
@JvmField val MAX_AGE_CACHE: Duration = Duration.ofDays(14)
const val CLIENT_BUILD = 16
val MAX_AGE_CACHE: Duration = Duration.ofDays(14)
const val MAX_READ_TIME_SECONDS = 300
const val MAX_WRITE_TIME_SECONDS = 60
// General list of ports to which Firefox and Chromium will not send HTTP requests for security reasons
// See:
// * https://chromium.googlesource.com/chromium/src.git/+/refs/heads/master/net/base/port_util.cc
// * https://developer.mozilla.org/en-US/docs/Mozilla/Mozilla_Port_Blocking#Blocked_Ports
@JvmField val RESTRICTED_PORTS = intArrayOf(
1, // tcpmux
7, // echo
9, // discard
11, // systat
13, // daytime
15, // netstat
17, // qotd
19, // chargen
20, // ftp data
21, // ftp access
22, // ssh
23, // telnet
25, // smtp
37, // time
42, // name
43, // nicname
53, // domain
77, // priv-rjs
79, // finger
87, // ttylink
95, // supdup
101, // hostriame
102, // iso-tsap
103, // gppitnp
104, // acr-nema
109, // pop2
110, // pop3
111, // sunrpc
113, // auth
115, // sftp
117, // uucp-path
119, // nntp
123, // NTP
135, // loc-srv /epmap
139, // netbios
143, // imap2
179, // BGP
389, // ldap
427, // SLP (Also used by Apple Filing Protocol)
465, // smtp+ssl
512, // print / exec
513, // login
514, // shell
515, // printer
526, // tempo
530, // courier
531, // chat
532, // netnews
540, // uucp
548, // AFP (Apple Filing Protocol)
556, // remotefs
563, // nntp+ssl
587, // smtp (rfc6409)
601, // syslog-conn (rfc3195)
636, // ldap+ssl
993, // ldap+ssl
995, // pop3+ssl
2049, // nfs
3659, // apple-sasl / PasswordServer
4045, // lockd
6000, // X11
6665, // Alternate IRC [Apple addition]
6666, // Alternate IRC [Apple addition]
6667, // Standard IRC [Apple addition]
6668, // Alternate IRC [Apple addition]
6669, // Alternate IRC [Apple addition]
6697 // IRC + TLS
)
}

View file

@ -19,48 +19,28 @@ along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
package mdnet.base
import ch.qos.logback.classic.LoggerContext
import java.io.File
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import java.io.FileReader
import java.io.FileWriter
import java.io.IOException
import java.util.regex.Pattern
import kotlin.system.exitProcess
import mdnet.BuildInfo
import mdnet.base.settings.ClientSettings
import org.slf4j.LoggerFactory
import picocli.CommandLine
object Main {
private val LOGGER = LoggerFactory.getLogger(Main::class.java)
private val JACKSON: ObjectMapper = jacksonObjectMapper().enable(SerializationFeature.INDENT_OUTPUT).configure(JsonParser.Feature.ALLOW_COMMENTS, true)
@JvmStatic
fun main(args: Array<String>) {
CommandLine(ClientArgs()).execute(*args)
}
fun dieWithError(e: Throwable): Nothing {
LOGGER.error(e) { "Critical Error" }
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
exitProcess(1)
}
fun dieWithError(error: String): Nothing {
LOGGER.error { "Critical Error: $error" }
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
exitProcess(1)
}
}
@CommandLine.Command(name = "java -jar <jar>", usageHelpWidth = 120, version = ["Client Version ${BuildInfo.VERSION} (Build ${Constants.CLIENT_BUILD})"])
data class ClientArgs(
@field:CommandLine.Option(names = ["-s", "--settings"], defaultValue = "settings.json", paramLabel = "<settings>", description = ["the settings file (default: \${DEFAULT-VALUE})"])
var settingsFile: File = File("settings.json"),
@field:CommandLine.Option(names = ["-d", "--database"], defaultValue = "cache\${sys:file.separator}data.db", paramLabel = "<settings>", description = ["the database file (default: \${DEFAULT-VALUE})"])
var databaseFile: File = File("cache${File.separator}data.db"),
@field:CommandLine.Option(names = ["-c", "--cache"], defaultValue = "cache", paramLabel = "<settings>", description = ["the cache folder (default: \${DEFAULT-VALUE})"])
var cacheFolder: File = File("cache"),
@field:CommandLine.Option(names = ["-h", "--help"], usageHelp = true, description = ["show this help message and exit"])
var helpRequested: Boolean = false,
@field:CommandLine.Option(names = ["-v", "--version"], versionHelp = true, description = ["show the version message and exit"])
var versionRequested: Boolean = false
) : Runnable {
override fun run() {
println(
"Mangadex@Home Client Version ${BuildInfo.VERSION} (Build ${Constants.CLIENT_BUILD}) initializing"
)
@ -81,11 +61,78 @@ data class ClientArgs(
along with Mangadex@Home. If not, see <https://www.gnu.org/licenses/>.
""".trimIndent())
val client = MangaDexClient(settingsFile, databaseFile, cacheFolder)
Runtime.getRuntime().addShutdownHook(Thread {
client.shutdown()
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
})
var file = "settings.json"
if (args.size == 1) {
file = args[0]
} else if (args.isNotEmpty()) {
dieWithError("Expected one argument: path to config file, or nothing")
}
val settings = try {
JACKSON.readValue<ClientSettings>(FileReader(file))
} catch (e: UnrecognizedPropertyException) {
dieWithError("'${e.propertyName}' is not a valid setting")
} catch (e: JsonProcessingException) {
dieWithError(e)
} catch (ignored: IOException) {
ClientSettings().also {
LOGGER.warn("Settings file {} not found, generating file", file)
try {
FileWriter(file).use { writer -> JACKSON.writeValue(writer, it) }
} catch (e: IOException) {
dieWithError(e)
}
}
}.apply(::validateSettings)
LOGGER.info { "Client settings loaded: $settings" }
val client = MangaDexClient(settings)
Runtime.getRuntime().addShutdownHook(Thread { client.shutdown() })
client.runLoop()
}
fun dieWithError(e: Throwable): Nothing {
LOGGER.error(e) { "Critical Error" }
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
exitProcess(1)
}
fun dieWithError(error: String): Nothing {
LOGGER.error { "Critical Error: $error" }
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
exitProcess(1)
}
private fun validateSettings(settings: ClientSettings) {
if (!isSecretValid(settings.clientSecret)) dieWithError("Config Error: API Secret is invalid, must be 52 alphanumeric characters")
if (settings.clientPort == 0) {
dieWithError("Config Error: Invalid port number")
}
if (settings.maxCacheSizeInMebibytes < 1024) {
dieWithError("Config Error: Invalid max cache size, must be >= 1024 MiB (1GiB)")
}
if (settings.threads < 4) {
dieWithError("Config Error: Invalid number of threads, must be >= 4")
}
if (settings.maxMebibytesPerHour < 0) {
dieWithError("Config Error: Max bandwidth must be >= 0")
}
if (settings.maxKilobitsPerSecond < 0) {
dieWithError("Config Error: Max burst rate must be >= 0")
}
if (settings.gracefulShutdownWaitSeconds < 15) {
dieWithError("Config Error: Graceful shutdown wait be >= 15")
}
if (settings.webSettings != null) {
if (settings.webSettings.uiPort == 0) {
dieWithError("Config Error: Invalid UI port number")
}
}
}
private const val CLIENT_KEY_LENGTH = 52
private fun isSecretValid(clientSecret: String): Boolean {
return Pattern.matches("^[a-zA-Z0-9]{$CLIENT_KEY_LENGTH}$", clientSecret)
}
}

View file

@ -19,259 +19,285 @@ 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 ch.qos.logback.classic.LoggerContext
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.time.Instant
import java.util.*
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 java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import mdnet.base.Main.dieWithError
import mdnet.base.data.Statistics
import mdnet.base.server.getServer
import mdnet.base.server.getUiServer
import mdnet.base.settings.*
import mdnet.base.settings.ClientSettings
import mdnet.base.settings.ServerSettings
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)
sealed class State
// server is not running
object Uninitialized : State()
// server has shut down
object Shutdown : State()
// server is in the process of shutting down
data class GracefulShutdown(val lastRunning: Running, val counts: Int = 0, val nextState: State = Uninitialized, val action: () -> Unit = {}) : State()
// server is currently running
data class Running(val server: Http4kServer, val settings: ServerSettings) : State()
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<*>
class MangaDexClient(private val clientSettings: ClientSettings) {
// this must remain singlethreaded because of how the state mechanism works
private val executorService = Executors.newSingleThreadScheduledExecutor()
// state must only be accessed from the thread on the executorService
private var state: State = Uninitialized
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 val serverHandler: ServerHandler = ServerHandler(clientSettings)
private val statsMap: MutableMap<Instant, Statistics> = Collections
.synchronizedMap(object : LinkedHashMap<Instant, Statistics>(240) {
override fun removeEldestEntry(eldest: Map.Entry<Instant, Statistics>): Boolean {
return this.size > 240
}
})
private val statistics: AtomicReference<Statistics> = AtomicReference(
Statistics()
)
private val isHandled: AtomicBoolean = AtomicBoolean(false)
private var webUi: Http4kServer? = null
// end protected state
private val cache: DiskLruCache
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 */
File("cache"), 1, 1,
clientSettings.maxCacheSizeInMebibytes * 1024 * 1024 /* MiB to bytes */
)
cache.get("statistics")?.use {
statistics.set(JACKSON.readValue<Statistics>(it.getInputStream(0)))
}
} catch (e: HeaderMismatchException) {
LOGGER.warn { "Cache version may be outdated - remove if necessary" }
dieWithError(e)
} catch (e: IOException) {
LOGGER.warn { "Cache may be corrupt - remove if necessary" }
dieWithError(e)
}
}
fun runLoop() {
LOGGER.info { "Mangadex@Home Client initialized - starting normal operation." }
loginAndStartServer()
statsMap[Instant.now()] = statistics.get()
scheduledFuture = executor.scheduleWithFixedDelay({
if (clientSettings.webSettings != null) {
webUi = getUiServer(clientSettings.webSettings, statistics, statsMap)
webUi!!.start()
}
LOGGER.info { "Mangadex@Home Client initialized. Starting normal operation." }
executorService.scheduleAtFixedRate({
try {
// this blocks the executor, so no worries about concurrency
reloadClientSettings()
if (state is Running || state is GracefulShutdown || state is Uninitialized) {
statistics.updateAndGet {
it.copy(bytesOnDisk = cache.size())
}
statsMap[Instant.now()] = statistics.get()
val editor = cache.edit("statistics")
if (editor != null) {
JACKSON.writeValue(editor.newOutputStream(0), statistics.get())
editor.commit()
}
}
} catch (e: Exception) {
LOGGER.warn(e) { "Reload of ClientSettings failed" }
LOGGER.warn(e) { "Statistics update failed" }
}
}, 1, 1, TimeUnit.MINUTES)
}, 15, 15, TimeUnit.SECONDS)
startImageServer()
startWebUi()
var lastBytesSent = statistics.get().bytesSent
executorService.scheduleAtFixedRate({
try {
lastBytesSent = statistics.get().bytesSent
val state = this.state
if (state is GracefulShutdown) {
LOGGER.info { "Aborting graceful shutdown started due to hourly bandwidth limit" }
this.state = state.lastRunning
}
if (state is Uninitialized) {
LOGGER.info { "Restarting server stopped due to hourly bandwidth limit" }
loginAndStartServer()
}
} catch (e: Exception) {
LOGGER.warn(e) { "Hourly bandwidth check failed" }
}
}, 1, 1, TimeUnit.HOURS)
val timesToWait = clientSettings.gracefulShutdownWaitSeconds / 15
executorService.scheduleAtFixedRate({
try {
val state = this.state
if (state is GracefulShutdown) {
when {
state.counts == 0 -> {
LOGGER.info { "Starting graceful shutdown" }
logout()
isHandled.set(false)
this.state = state.copy(counts = state.counts + 1)
}
state.counts == timesToWait || !isHandled.get() -> {
if (!isHandled.get()) {
LOGGER.info { "No requests received, shutting down" }
} else {
LOGGER.info { "Max tries attempted (${state.counts} out of $timesToWait), shutting down" }
}
stopServer(state.nextState)
state.action()
}
else -> {
LOGGER.info {
"Waiting another 15 seconds for graceful shutdown (${state.counts} out of $timesToWait)"
}
isHandled.set(false)
this.state = state.copy(counts = state.counts + 1)
}
}
}
} catch (e: Exception) {
LOGGER.warn("Main loop failed", e)
}
}, 15, 15, TimeUnit.SECONDS)
executorService.scheduleWithFixedDelay({
try {
val state = this.state
if (state is Running) {
val currentBytesSent = statistics.get().bytesSent - lastBytesSent
if (clientSettings.maxMebibytesPerHour != 0L && clientSettings.maxMebibytesPerHour * 1024 * 1024 /* MiB to bytes */ < currentBytesSent) {
LOGGER.info { "Shutting down server as hourly bandwidth limit reached" }
this.state = GracefulShutdown(lastRunning = state)
} else {
pingControl()
}
}
} catch (e: Exception) {
LOGGER.warn(e) { "Graceful shutdown checker failed" }
}
}, 45, 45, TimeUnit.SECONDS)
}
// 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)
private fun pingControl() {
val state = this.state as Running
if (webUi != null) {
throw AssertionError()
val newSettings = serverHandler.pingControl(state.settings)
if (newSettings != null) {
LOGGER.info { "Server settings received: $newSettings" }
if (newSettings.latestBuild > Constants.CLIENT_BUILD) {
LOGGER.warn {
"Outdated build detected! Latest: ${newSettings.latestBuild}, Current: ${Constants.CLIENT_BUILD}"
}
}
LOGGER.info { "WebUI starting" }
webUi = getUiServer(webSettings, imageServer.statistics, imageServer.statsMap).also {
it.start()
if (newSettings.tls != null || newSettings.imageServer != state.settings.imageServer) {
// certificates or upstream url must have changed, restart webserver
LOGGER.info { "Doing internal restart of HTTP server to refresh certs/upstream URL" }
this.state = GracefulShutdown(lastRunning = state) {
loginAndStartServer()
}
}
LOGGER.info { "WebUI started" }
} else {
LOGGER.info { "Server ping failed - ignoring" }
}
}
// Precondition: settings must be filled with up-to-date settings
private fun startImageServer() {
if (imageServer != null) {
throw AssertionError()
private fun loginAndStartServer() {
this.state as Uninitialized
val serverSettings = serverHandler.loginToControl()
?: dieWithError("Failed to get a login response from server - check API secret for validity")
val server = getServer(cache, serverSettings, clientSettings, statistics, isHandled).start()
if (serverSettings.latestBuild > Constants.CLIENT_BUILD) {
LOGGER.warn {
"Outdated build detected! Latest: ${serverSettings.latestBuild}, Current: ${Constants.CLIENT_BUILD}"
}
}
LOGGER.info { "Server manager starting" }
imageServer = ServerManager(settings.serverSettings, settings.devSettings, settings.maxCacheSizeInMebibytes, cache, database).also {
it.start()
}
LOGGER.info { "Server manager started" }
state = Running(server, serverSettings)
LOGGER.info { "Internal HTTP server was successfully started" }
}
private fun stopImageServer() {
LOGGER.info { "Server manager stopping" }
requireNotNull(imageServer).shutdown()
imageServer = null
LOGGER.info { "Server manager stopped" }
private fun logout() {
serverHandler.logoutFromControl()
}
private fun stopWebUi() {
LOGGER.info { "WebUI stopping" }
requireNotNull(webUi).stop()
webUi = null
LOGGER.info { "WebUI stopped" }
private fun stopServer(nextState: State = Uninitialized) {
val state = this.state.let {
when (it) {
is Running ->
it
is GracefulShutdown ->
it.lastRunning
else ->
throw AssertionError()
}
}
LOGGER.info { "Shutting down HTTP server" }
state.server.stop()
LOGGER.info { "Internal HTTP server has shut down" }
this.state = nextState
}
fun shutdown() {
LOGGER.info { "Mangadex@Home Client shutting down" }
LOGGER.info { "Mangadex@Home Client stopping" }
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()
executorService.schedule({
val state = this.state
if (state is Running) {
this.state = GracefulShutdown(state, nextState = Shutdown) {
latch.countDown()
}
} else if (state is GracefulShutdown) {
this.state = state.copy(nextState = Shutdown) {
latch.countDown()
}
} else if (state is Uninitialized || state is Shutdown) {
this.state = Shutdown
latch.countDown()
}
}, 0, TimeUnit.SECONDS)
latch.await()
if (restartServer) {
stopImageServer()
startImageServer()
}
if (startWebUi) {
startWebUi()
}
settings = newSettings
} 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" }
webUi?.close()
try {
cache.close()
} 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)")
LOGGER.error(e) { "Cache failed to close" }
}
fun isSecretValid(clientSecret: String): Boolean {
return Pattern.matches("^[a-zA-Z0-9]{$CLIENT_KEY_LENGTH}$", clientSecret)
}
executorService.shutdown()
LOGGER.info { "Mangadex@Home Client stopped" }
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)
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
}
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)
private val JACKSON: ObjectMapper = jacksonObjectMapper()
}
}

View file

@ -20,14 +20,10 @@ package mdnet.base
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.KotlinModule
import java.net.InetAddress
import mdnet.base.ServerHandlerJackson.auto
import mdnet.base.settings.DevSettings
import mdnet.base.settings.RemoteSettings
import mdnet.base.settings.ClientSettings
import mdnet.base.settings.ServerSettings
import org.apache.http.client.config.RequestConfig
import org.apache.http.impl.client.HttpClients
import org.http4k.client.Apache4Client
import org.http4k.client.ApacheClient
import org.http4k.core.Body
import org.http4k.core.Method
import org.http4k.core.Request
@ -44,22 +40,13 @@ object ServerHandlerJackson : ConfigurableJackson(
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
)
class ServerHandler(private val serverSettings: ServerSettings, private val devSettings: DevSettings, private val maxCacheSizeInMebibytes: Long) {
private val client = Apache4Client(client = HttpClients.custom()
.setDefaultRequestConfig(
RequestConfig.custom()
.apply {
if (serverSettings.clientHostname != "0.0.0.0") {
setLocalAddress(InetAddress.getByName(serverSettings.clientHostname))
}
}
.build())
.build())
class ServerHandler(private val settings: ClientSettings) {
private val client = ApacheClient()
fun logoutFromControl(): Boolean {
LOGGER.info { "Disconnecting from the control server" }
val params = mapOf<String, Any>(
"secret" to serverSettings.clientSecret
"secret" to settings.clientSecret
)
val request = STRING_ANY_MAP_LENS(params, Request(Method.POST, getServerAddress() + "stop"))
@ -70,16 +57,16 @@ class ServerHandler(private val serverSettings: ServerSettings, private val devS
private fun getPingParams(tlsCreatedAt: String? = null): Map<String, Any> =
mapOf<String, Any>(
"secret" to serverSettings.clientSecret,
"secret" to settings.clientSecret,
"port" to let {
if (serverSettings.clientExternalPort != 0) {
serverSettings.clientExternalPort
if (settings.clientExternalPort != 0) {
settings.clientExternalPort
} else {
serverSettings.clientPort
settings.clientPort
}
},
"disk_space" to maxCacheSizeInMebibytes * 1024 * 1024,
"network_speed" to serverSettings.maxKilobitsPerSecond * 1000 / 8,
"disk_space" to settings.maxCacheSizeInMebibytes * 1024 * 1024,
"network_speed" to settings.maxKilobitsPerSecond * 1000 / 8,
"build_version" to Constants.CLIENT_BUILD
).let {
if (tlsCreatedAt != null) {
@ -89,7 +76,7 @@ class ServerHandler(private val serverSettings: ServerSettings, private val devS
}
}
fun loginToControl(): RemoteSettings? {
fun loginToControl(): ServerSettings? {
LOGGER.info { "Connecting to the control server" }
val request = STRING_ANY_MAP_LENS(getPingParams(), Request(Method.POST, getServerAddress() + "ping"))
@ -102,7 +89,7 @@ class ServerHandler(private val serverSettings: ServerSettings, private val devS
}
}
fun pingControl(old: RemoteSettings): RemoteSettings? {
fun pingControl(old: ServerSettings): ServerSettings? {
LOGGER.info { "Pinging the control server" }
val request = STRING_ANY_MAP_LENS(getPingParams(old.tls!!.createdAt), Request(Method.POST, getServerAddress() + "ping"))
@ -116,7 +103,7 @@ class ServerHandler(private val serverSettings: ServerSettings, private val devS
}
private fun getServerAddress(): String {
return if (!devSettings.isDev)
return if (settings.devSettings?.isDev != true)
SERVER_ADDRESS
else
SERVER_ADDRESS_DEV
@ -125,8 +112,8 @@ class ServerHandler(private val serverSettings: ServerSettings, private val devS
companion object {
private val LOGGER = LoggerFactory.getLogger(ServerHandler::class.java)
private val STRING_ANY_MAP_LENS = Body.auto<Map<String, Any>>().toLens()
private val SERVER_SETTINGS_LENS = Body.auto<RemoteSettings>().toLens()
private const val SERVER_ADDRESS = "http://fakemdnet/"
private val SERVER_SETTINGS_LENS = Body.auto<ServerSettings>().toLens()
private const val SERVER_ADDRESS = "https://api.mangadex.network/"
private const val SERVER_ADDRESS_DEV = "https://mangadex-test.net/"
}
}

View file

@ -1,269 +0,0 @@
package mdnet.base
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import java.lang.RuntimeException
import java.time.Instant
import java.util.Collections
import java.util.LinkedHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import mdnet.base.data.Statistics
import mdnet.base.server.getServer
import mdnet.base.settings.DevSettings
import mdnet.base.settings.RemoteSettings
import mdnet.base.settings.ServerSettings
import mdnet.cache.DiskLruCache
import org.http4k.server.Http4kServer
import org.jetbrains.exposed.sql.Database
import org.slf4j.LoggerFactory
sealed class State
// server is not running
data class Uninitialized(val serverSettings: ServerSettings, val devSettings: DevSettings) : State()
// server has shut down
object Shutdown : State()
// server is in the process of stopping
data class GracefulStop(val lastRunning: Running, val counts: Int = 0, val nextState: State = Uninitialized(lastRunning.serverSettings, lastRunning.devSettings), val action: () -> Unit = {}) : State()
// server is currently running
data class Running(val server: Http4kServer, val settings: RemoteSettings, val serverSettings: ServerSettings, val devSettings: DevSettings) : State()
class ServerManager(serverSettings: ServerSettings, devSettings: DevSettings, maxCacheSizeInMebibytes: Long, private val cache: DiskLruCache, private val database: Database) {
// this must remain single-threaded because of how the state mechanism works
private val executor = Executors.newSingleThreadScheduledExecutor()
// state that must only be accessed from the thread on the executor
private var state: State
private var serverHandler: ServerHandler
// end protected state
val statsMap: MutableMap<Instant, Statistics> = Collections
.synchronizedMap(object : LinkedHashMap<Instant, Statistics>(240) {
override fun removeEldestEntry(eldest: Map.Entry<Instant, Statistics>): Boolean {
return this.size > 240
}
})
val statistics: AtomicReference<Statistics> = AtomicReference(
Statistics()
)
private val isHandled: AtomicBoolean = AtomicBoolean(false)
init {
state = Uninitialized(serverSettings, devSettings)
serverHandler = ServerHandler(serverSettings, devSettings, maxCacheSizeInMebibytes)
cache.get("statistics")?.use {
try {
statistics.set(JACKSON.readValue<Statistics>(it.getInputStream(0)))
} catch (_: JsonProcessingException) {
cache.remove("statistics")
}
}
}
fun start() {
LOGGER.info { "Image server starting" }
loginAndStartServer()
statsMap[Instant.now()] = statistics.get()
executor.scheduleAtFixedRate({
try {
if (state is Running || state is GracefulStop || state is Uninitialized) {
statistics.updateAndGet {
it.copy(bytesOnDisk = cache.size())
}
statsMap[Instant.now()] = statistics.get()
val editor = cache.edit("statistics")
if (editor != null) {
JACKSON.writeValue(editor.newOutputStream(0), statistics.get())
editor.commit()
}
}
} catch (e: Exception) {
LOGGER.warn(e) { "Statistics update failed" }
}
}, 15, 15, TimeUnit.SECONDS)
var lastBytesSent = statistics.get().bytesSent
executor.scheduleAtFixedRate({
try {
lastBytesSent = statistics.get().bytesSent
val state = this.state
if (state is GracefulStop) {
LOGGER.info { "Aborting graceful shutdown started due to hourly bandwidth limit" }
this.state = state.lastRunning
}
if (state is Uninitialized) {
LOGGER.info { "Restarting server stopped due to hourly bandwidth limit" }
loginAndStartServer()
}
} catch (e: Exception) {
LOGGER.warn(e) { "Hourly bandwidth check failed" }
}
}, 1, 1, TimeUnit.HOURS)
executor.scheduleAtFixedRate({
try {
val state = this.state
if (state is GracefulStop) {
val timesToWait = state.lastRunning.serverSettings.gracefulShutdownWaitSeconds / 15
when {
state.counts == 0 -> {
LOGGER.info { "Starting graceful stop" }
logout()
isHandled.set(false)
this.state = state.copy(counts = state.counts + 1)
}
state.counts == timesToWait || !isHandled.get() -> {
if (!isHandled.get()) {
LOGGER.info { "No requests received, stopping" }
} else {
LOGGER.info { "Max tries attempted (${state.counts} out of $timesToWait), shutting down" }
}
stopServer(state.nextState)
state.action()
}
else -> {
LOGGER.info {
"Waiting another 15 seconds for graceful stop (${state.counts} out of $timesToWait)"
}
isHandled.set(false)
this.state = state.copy(counts = state.counts + 1)
}
}
}
} catch (e: Exception) {
LOGGER.error(e) { "Main loop failed" }
}
}, 15, 15, TimeUnit.SECONDS)
executor.scheduleWithFixedDelay({
try {
val state = this.state
if (state is Running) {
val currentBytesSent = statistics.get().bytesSent - lastBytesSent
if (state.serverSettings.maxMebibytesPerHour != 0L && state.serverSettings.maxMebibytesPerHour * 1024 * 1024 /* MiB to bytes */ < currentBytesSent) {
LOGGER.info { "Stopping image server as hourly bandwidth limit reached" }
this.state = GracefulStop(lastRunning = state)
} else {
pingControl()
}
}
} catch (e: Exception) {
LOGGER.warn(e) { "Graceful shutdown checker failed" }
}
}, 45, 45, TimeUnit.SECONDS)
LOGGER.info { "Image server has started" }
}
private fun pingControl() {
val state = this.state as Running
val newSettings = serverHandler.pingControl(state.settings)
if (newSettings != null) {
LOGGER.info { "Server settings received: $newSettings" }
if (newSettings.latestBuild > Constants.CLIENT_BUILD) {
LOGGER.warn {
"Outdated build detected! Latest: ${newSettings.latestBuild}, Current: ${Constants.CLIENT_BUILD}"
}
}
if (newSettings.tls != null || newSettings.imageServer != state.settings.imageServer) {
// certificates or upstream url must have changed, restart webserver
LOGGER.info { "Doing internal restart of HTTP server to refresh certs/upstream URL" }
this.state = GracefulStop(lastRunning = state) {
loginAndStartServer()
}
}
} else {
LOGGER.info { "Server ping failed - ignoring" }
}
}
private fun loginAndStartServer() {
val state = this.state as Uninitialized
val remoteSettings = serverHandler.loginToControl()
?: throw RuntimeException("Failed to get a login response from server")
val server = getServer(cache, database, remoteSettings, state.serverSettings, statistics, isHandled).start()
if (remoteSettings.latestBuild > Constants.CLIENT_BUILD) {
LOGGER.warn {
"Outdated build detected! Latest: ${remoteSettings.latestBuild}, Current: ${Constants.CLIENT_BUILD}"
}
}
this.state = Running(server, remoteSettings, state.serverSettings, state.devSettings)
LOGGER.info { "Internal HTTP server was successfully started" }
}
private fun logout() {
serverHandler.logoutFromControl()
}
private fun stopServer(nextState: State) {
val state = this.state.let {
when (it) {
is Running ->
it
is GracefulStop ->
it.lastRunning
else ->
throw AssertionError()
}
}
LOGGER.info { "Image server stopping" }
state.server.stop()
LOGGER.info { "Image server has stopped" }
this.state = nextState
}
fun shutdown() {
LOGGER.info { "Image server shutting down" }
val latch = CountDownLatch(1)
executor.schedule({
val state = this.state
if (state is Running) {
this.state = GracefulStop(state, nextState = Shutdown) {
latch.countDown()
}
} else if (state is GracefulStop) {
this.state = state.copy(nextState = Shutdown) {
latch.countDown()
}
} else if (state is Uninitialized || state is Shutdown) {
this.state = Shutdown
latch.countDown()
}
}, 0, TimeUnit.SECONDS)
latch.await()
executor.shutdown()
LOGGER.info { "Image server has shut down" }
}
companion object {
private val LOGGER = LoggerFactory.getLogger(ServerManager::class.java)
private val JACKSON: ObjectMapper = jacksonObjectMapper().enable(SerializationFeature.INDENT_OUTPUT)
}
}

View file

@ -42,12 +42,13 @@ import java.net.SocketException
import java.security.PrivateKey
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import javax.net.ssl.SSLException
import mdnet.base.Constants
import mdnet.base.data.Statistics
import mdnet.base.info
import mdnet.base.settings.ServerSettings
import mdnet.base.settings.ClientSettings
import mdnet.base.settings.TlsCert
import mdnet.base.trace
import org.http4k.core.HttpHandler
@ -56,17 +57,17 @@ import org.http4k.server.Http4kServer
import org.http4k.server.ServerConfig
import org.slf4j.LoggerFactory
private val LOGGER = LoggerFactory.getLogger("AppNetty")
private val LOGGER = LoggerFactory.getLogger("Application")
class Netty(private val tls: TlsCert, private val serverSettings: ServerSettings, private val statistics: AtomicReference<Statistics>) : ServerConfig {
class Netty(private val tls: TlsCert, private val clientSettings: ClientSettings, private val statistics: AtomicReference<Statistics>) : ServerConfig {
override fun toServer(httpHandler: HttpHandler): Http4kServer = object : Http4kServer {
private val masterGroup = NioEventLoopGroup(serverSettings.threads)
private val workerGroup = NioEventLoopGroup(serverSettings.threads)
private val masterGroup = NioEventLoopGroup(clientSettings.threads)
private val workerGroup = NioEventLoopGroup(clientSettings.threads)
private lateinit var closeFuture: ChannelFuture
private lateinit var address: InetSocketAddress
private val burstLimiter = object : GlobalTrafficShapingHandler(
workerGroup, serverSettings.maxKilobitsPerSecond * 1000L / 8L, 0, 50) {
workerGroup, clientSettings.maxKilobitsPerSecond * 1000L / 8L, 0, 50) {
override fun doAccounting(counter: TrafficCounter) {
statistics.getAndUpdate {
it.copy(bytesSent = it.bytesSent + counter.cumulativeWrittenBytes())
@ -76,7 +77,7 @@ class Netty(private val tls: TlsCert, private val serverSettings: ServerSettings
}
override fun start(): Http4kServer = apply {
LOGGER.info { "Starting Netty with ${serverSettings.threads} threads" }
LOGGER.info { "Starting Netty with ${clientSettings.threads} threads" }
val certs = getX509Certs(tls.certificate)
val sslContext = SslContextBuilder
@ -103,7 +104,7 @@ class Netty(private val tls: TlsCert, private val serverSettings: ServerSettings
ch.pipeline().addLast("streamer", ChunkedWriteHandler())
ch.pipeline().addLast("handler", Http4kChannelHandler(httpHandler))
ch.pipeline().addLast("exceptions", object : ChannelInboundHandlerAdapter() {
ch.pipeline().addLast("handle_ssl", object : ChannelInboundHandlerAdapter() {
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
if (cause is SSLException || (cause is DecoderException && cause.cause is SSLException)) {
LOGGER.trace { "Ignored invalid SSL connection" }
@ -120,18 +121,18 @@ class Netty(private val tls: TlsCert, private val serverSettings: ServerSettings
.option(ChannelOption.SO_BACKLOG, 1000)
.childOption(ChannelOption.SO_KEEPALIVE, true)
val channel = bootstrap.bind(InetSocketAddress(serverSettings.clientHostname, serverSettings.clientPort)).sync().channel()
val channel = bootstrap.bind(InetSocketAddress(clientSettings.clientHostname, clientSettings.clientPort)).sync().channel()
address = channel.localAddress() as InetSocketAddress
closeFuture = channel.closeFuture()
}
override fun stop() = apply {
closeFuture.cancel(false)
workerGroup.shutdownGracefully()
masterGroup.shutdownGracefully()
masterGroup.shutdownGracefully(1, 15, TimeUnit.SECONDS).sync()
workerGroup.shutdownGracefully(1, 15, TimeUnit.SECONDS).sync()
closeFuture.sync()
}
override fun port(): Int = if (serverSettings.clientPort > 0) serverSettings.clientPort else address.port
override fun port(): Int = if (clientSettings.clientPort > 0) clientSettings.clientPort else address.port
}
}

View file

@ -32,6 +32,7 @@ import io.netty.handler.codec.http.HttpServerCodec
import io.netty.handler.codec.http.HttpServerKeepAliveHandler
import io.netty.handler.stream.ChunkedWriteHandler
import java.net.InetSocketAddress
import java.util.concurrent.TimeUnit
import org.http4k.core.HttpHandler
import org.http4k.server.Http4kChannelHandler
import org.http4k.server.Http4kServer
@ -66,9 +67,9 @@ class WebUiNetty(private val hostname: String, private val port: Int) : ServerCo
}
override fun stop() = apply {
closeFuture.cancel(false)
workerGroup.shutdownGracefully()
masterGroup.shutdownGracefully()
masterGroup.shutdownGracefully(5, 15, TimeUnit.SECONDS).sync()
workerGroup.shutdownGracefully(5, 15, TimeUnit.SECONDS).sync()
closeFuture.sync()
}
override fun port(): Int = address.port

View file

@ -0,0 +1,121 @@
/*
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.server
import java.net.InetAddress
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import mdnet.base.data.Statistics
import mdnet.base.info
import mdnet.base.netty.Netty
import mdnet.base.settings.ClientSettings
import mdnet.base.settings.ServerSettings
import mdnet.cache.DiskLruCache
import org.apache.http.client.config.CookieSpecs
import org.apache.http.client.config.RequestConfig
import org.apache.http.impl.client.HttpClients
import org.http4k.client.ApacheClient
import org.http4k.core.*
import org.http4k.filter.ServerFilters
import org.http4k.routing.bind
import org.http4k.routing.routes
import org.http4k.server.Http4kServer
import org.http4k.server.asServer
import org.jetbrains.exposed.sql.Database
import org.slf4j.LoggerFactory
private val LOGGER = LoggerFactory.getLogger("Application")
fun getServer(cache: DiskLruCache, serverSettings: ServerSettings, clientSettings: ClientSettings, statistics: AtomicReference<Statistics>, isHandled: AtomicBoolean): Http4kServer {
val database = Database.connect("jdbc:sqlite:cache/data.db", "org.sqlite.JDBC")
val client = ApacheClient(responseBodyMode = BodyMode.Stream, client = HttpClients.custom()
.disableConnectionState()
.setDefaultRequestConfig(
RequestConfig.custom()
.setCookieSpec(CookieSpecs.IGNORE_COOKIES)
.setConnectTimeout(3000)
.setSocketTimeout(3000)
.setConnectionRequestTimeout(3000)
.apply {
if (clientSettings.clientHostname != "0.0.0.0") {
setLocalAddress(InetAddress.getByName(clientSettings.clientHostname))
}
}
.build())
.setMaxConnTotal(3000)
.setMaxConnPerRoute(3000)
.build())
val imageServer = ImageServer(cache, database, statistics, serverSettings, client)
return timeRequest()
.then(catchAllHideDetails())
.then(ServerFilters.CatchLensFailure)
.then(setHandled(isHandled))
.then(addCommonHeaders())
.then(
routes(
"/data/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(dataSaver = false),
"/data-saver/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(dataSaver = true),
"/{token}/data/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(
dataSaver = false,
tokenized = true
),
"/{token}/data-saver/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(
dataSaver = true,
tokenized = true
)
)
)
.asServer(Netty(serverSettings.tls!!, clientSettings, statistics))
}
fun setHandled(isHandled: AtomicBoolean): Filter {
return Filter { next: HttpHandler ->
{
isHandled.set(true)
next(it)
}
}
}
fun timeRequest(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
val cleanedUri = request.uri.path.let {
if (it.startsWith("/data")) {
it
} else {
it.replaceBefore("/data", "/{token}")
}
}
LOGGER.info { "Request for $cleanedUri received from ${request.source?.address}" }
val start = System.currentTimeMillis()
val response = next(request)
val latency = System.currentTimeMillis() - start
LOGGER.info { "Request for $cleanedUri completed (TTFB) in ${latency}ms" }
response.header("X-Time-Taken", latency.toString())
}
}
}

View file

@ -25,71 +25,59 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.goterl.lazycode.lazysodium.LazySodiumJava
import com.goterl.lazycode.lazysodium.SodiumJava
import com.goterl.lazycode.lazysodium.exceptions.SodiumException
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.InputStream
import java.security.MessageDigest
import java.time.Clock
import java.time.OffsetDateTime
import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.CipherOutputStream
import javax.crypto.spec.SecretKeySpec
import mdnet.base.Constants
import mdnet.base.data.ImageData
import mdnet.base.data.ImageDatum
import mdnet.base.data.Statistics
import mdnet.base.data.Token
import mdnet.base.info
import mdnet.base.netty.Netty
import mdnet.base.settings.RemoteSettings
import mdnet.base.settings.ServerSettings
import mdnet.base.trace
import mdnet.base.warn
import mdnet.cache.CachingInputStream
import mdnet.cache.DiskLruCache
import mdnet.security.TweetNaclFast
import org.apache.http.client.config.CookieSpecs
import org.apache.http.client.config.RequestConfig
import org.apache.http.impl.client.HttpClients
import org.http4k.client.Apache4Client
import org.http4k.core.*
import org.http4k.filter.CachingFilters
import org.http4k.filter.ServerFilters
import org.http4k.lens.Path
import org.http4k.routing.bind
import org.http4k.routing.routes
import org.http4k.server.Http4kServer
import org.http4k.server.asServer
import org.jetbrains.exposed.exceptions.ExposedSQLException
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
private val LOGGER = LoggerFactory.getLogger(ImageServer::class.java)
class ImageServer(
private val cache: DiskLruCache,
private val database: Database,
private val statistics: AtomicReference<Statistics>,
private val remoteSettings: RemoteSettings,
private val serverSettings: ServerSettings,
private val client: HttpHandler
) {
init {
synchronized(database) {
transaction(database) {
SchemaUtils.create(ImageData)
}
transaction(database) {
SchemaUtils.create(ImageData)
}
}
private val executor = Executors.newCachedThreadPool()
fun handler(dataSaver: Boolean, tokenized: Boolean = false): HttpHandler {
val box = TweetNaclFast.SecretBox(remoteSettings.tokenKey)
val sodium = LazySodiumJava(SodiumJava())
return baseHandler().then { request ->
val chapterHash = Path.of("chapterHash")(request)
val fileName = Path.of("fileName")(request)
@ -105,23 +93,21 @@ class ImageServer(
return@then Response(Status.FORBIDDEN)
}
if (tokenized || remoteSettings.forceTokens) {
if (tokenized || serverSettings.forceTokens) {
val tokenArr = Base64.getUrlDecoder().decode(Path.of("token")(request))
if (tokenArr.size < 24) {
LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
return@then Response(Status.FORBIDDEN)
}
val token = try {
JACKSON.readValue<Token>(
box.open(tokenArr.sliceArray(24 until tokenArr.size), tokenArr.sliceArray(0 until 24)).apply {
if (this == null) {
LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
return@then Response(Status.FORBIDDEN)
}
try {
sodium.cryptoBoxOpenEasyAfterNm(
tokenArr.sliceArray(24 until tokenArr.size), tokenArr.sliceArray(0 until 24), serverSettings.tokenKey
)
} catch (_: SodiumException) {
LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
return@then Response(Status.FORBIDDEN)
}
)
} catch (e: JsonProcessingException) {
LOGGER.info(e) { "Request for $sanitizedUri rejected for invalid token" }
LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
return@then Response(Status.FORBIDDEN)
}
@ -154,14 +140,13 @@ class ImageServer(
}
}
if (snapshot != null && imageDatum != null && imageDatum.contentType.isImageMimetype()) {
if (snapshot != null && imageDatum != null) {
request.handleCacheHit(sanitizedUri, getRc4(rc4Bytes), snapshot, imageDatum)
} else {
if (snapshot != null) {
snapshot.close()
LOGGER.warn { "Removing broken cache file for $sanitizedUri" }
LOGGER.warn { "Removing cache file for $sanitizedUri without corresponding DB entry" }
cache.removeUnsafe(imageId.toCacheId())
cache.flush()
}
request.handleCacheMiss(sanitizedUri, getRc4(rc4Bytes), imageId, imageDatum)
@ -220,7 +205,7 @@ class ImageServer(
it.copy(cacheMisses = it.cacheMisses + 1)
}
val mdResponse = client(Request(Method.GET, "${remoteSettings.imageServer}$sanitizedUri"))
val mdResponse = client(Request(Method.GET, "${serverSettings.imageServer}$sanitizedUri"))
if (mdResponse.status != Status.OK) {
LOGGER.trace { "Upstream query for $sanitizedUri errored with status ${mdResponse.status}" }
@ -229,18 +214,12 @@ class ImageServer(
return Response(mdResponse.status)
}
LOGGER.trace { "Upstream query for $sanitizedUri succeeded" }
val contentType = mdResponse.header("Content-Type")!!
val contentLength = mdResponse.header("Content-Length")
val lastModified = mdResponse.header("Last-Modified")
if (!contentType.isImageMimetype()) {
LOGGER.warn { "Upstream query for $sanitizedUri returned bad mimetype $contentType" }
mdResponse.close()
return Response(Status.INTERNAL_SERVER_ERROR)
}
LOGGER.trace { "Upstream query for $sanitizedUri succeeded" }
val editor = cache.editUnsafe(imageId.toCacheId())
// A null editor means that this file is being written to
@ -249,20 +228,13 @@ class ImageServer(
LOGGER.trace { "Request for $sanitizedUri is being cached and served" }
if (imageDatum == null) {
try {
synchronized(database) {
transaction(database) {
ImageDatum.new(imageId) {
this.contentType = contentType
this.lastModified = lastModified
}
synchronized(database) {
transaction(database) {
ImageDatum.new(imageId) {
this.contentType = contentType
this.lastModified = lastModified
}
}
} catch (_: ExposedSQLException) {
// some other code got to the database first, fall back to just serving
editor.abort()
LOGGER.trace { "Request for $sanitizedUri is being served" }
respondWithImage(mdResponse.body.stream, contentLength, contentType, lastModified, false)
}
}
@ -274,7 +246,6 @@ class ImageServer(
if (editor.getLength(0) == contentLength.toLong()) {
LOGGER.info { "Cache download for $sanitizedUri committed" }
editor.commit()
cache.flush()
} else {
LOGGER.warn { "Cache download for $sanitizedUri aborted" }
editor.abort()
@ -316,6 +287,7 @@ class ImageServer(
.header("X-Cache", if (cached) "HIT" else "MISS")
companion object {
private val LOGGER = LoggerFactory.getLogger(ImageServer::class.java)
private val JACKSON: ObjectMapper = jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(JavaTimeModule())
@ -335,75 +307,21 @@ class ImageServer(
}
}
private fun String.isImageMimetype() = this.toLowerCase().startsWith("image/")
fun getServer(cache: DiskLruCache, database: Database, remoteSettings: RemoteSettings, serverSettings: ServerSettings, statistics: AtomicReference<Statistics>, isHandled: AtomicBoolean): Http4kServer {
val client = Apache4Client(responseBodyMode = BodyMode.Stream, client = HttpClients.custom()
.disableConnectionState()
.setDefaultRequestConfig(
RequestConfig.custom()
.setCookieSpec(CookieSpecs.IGNORE_COOKIES)
.setConnectTimeout(3000)
.setSocketTimeout(3000)
.setConnectionRequestTimeout(3000)
.build())
.setMaxConnTotal(3000)
.setMaxConnPerRoute(3000)
.build())
val imageServer = ImageServer(cache, database, statistics, remoteSettings, client)
return timeRequest()
.then(catchAllHideDetails())
.then(ServerFilters.CatchLensFailure)
.then(setHandled(isHandled))
.then(addCommonHeaders())
.then(
routes(
"/data/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(dataSaver = false),
"/data-saver/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(dataSaver = true),
"/{token}/data/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(
dataSaver = false,
tokenized = true
),
"/{token}/data-saver/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(
dataSaver = true,
tokenized = true
)
)
)
.asServer(Netty(remoteSettings.tls!!, serverSettings, statistics))
private fun getRc4(key: ByteArray): Cipher {
val rc4 = Cipher.getInstance("RC4")
rc4.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "RC4"))
return rc4
}
fun setHandled(isHandled: AtomicBoolean): Filter {
return Filter { next: HttpHandler ->
{
isHandled.set(true)
next(it)
}
}
}
fun timeRequest(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
val cleanedUri = request.uri.path.let {
if (it.startsWith("/data")) {
it
} else {
it.replaceBefore("/data", "/{token}")
}
}
LOGGER.info { "Request for $cleanedUri received from ${request.source?.address}" }
val start = System.currentTimeMillis()
val response = next(request)
val latency = System.currentTimeMillis() - start
LOGGER.info { "Request for $cleanedUri completed (TTFB) in ${latency}ms" }
response.header("X-Time-Taken", latency.toString())
}
private fun md5Bytes(stringToHash: String): ByteArray {
val digest = MessageDigest.getInstance("MD5")
return digest.digest(stringToHash.toByteArray())
}
private fun printHexString(bytes: ByteArray): String {
val sb = StringBuilder()
for (b in bytes) {
sb.append(String.format("%02x", b))
}
return sb.toString()
}

View file

@ -1,43 +0,0 @@
/*
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.server
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
fun getRc4(key: ByteArray): Cipher {
val rc4 = Cipher.getInstance("RC4")
rc4.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "RC4"))
return rc4
}
fun md5Bytes(stringToHash: String): ByteArray {
val digest = MessageDigest.getInstance("MD5")
return digest.digest(stringToHash.toByteArray())
}
fun printHexString(bytes: ByteArray): String {
val sb = StringBuilder()
for (b in bytes) {
sb.append(String.format("%02x", b))
}
return sb.toString()
}

View file

@ -0,0 +1,42 @@
/*
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.server
import com.goterl.lazycode.lazysodium.LazySodiumJava
import com.goterl.lazycode.lazysodium.exceptions.SodiumException
import com.goterl.lazycode.lazysodium.interfaces.Box
@Throws(SodiumException::class)
fun LazySodiumJava.cryptoBoxOpenEasyAfterNm(cipherBytes: ByteArray, nonce: ByteArray, sharedKey: ByteArray): String {
if (!Box.Checker.checkNonce(nonce.size)) {
throw SodiumException("Incorrect nonce length.")
}
if (!Box.Checker.checkBeforeNmBytes(sharedKey.size)) {
throw SodiumException("Incorrect shared secret key length.")
}
val message = ByteArray(cipherBytes.size - Box.MACBYTES)
val res: Boolean = cryptoBoxOpenEasyAfterNm(message, cipherBytes, cipherBytes.size.toLong(), nonce, sharedKey)
if (!res) {
throw SodiumException("Could not fully complete shared secret key decryption.")
}
return str(message)
}

View file

@ -18,52 +18,14 @@ along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
*/
package mdnet.base.settings
import com.fasterxml.jackson.annotation.JsonUnwrapped
import com.fasterxml.jackson.databind.PropertyNamingStrategy
import com.fasterxml.jackson.databind.annotation.JsonNaming
import dev.afanasev.sekret.Secret
// client settings are verified correct in Main.kt
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
class ClientSettings(
data class ClientSettings(
val maxCacheSizeInMebibytes: Long = 20480,
val webSettings: WebSettings? = null,
val devSettings: DevSettings = DevSettings(isDev = false)
) {
// FIXME: jackson doesn't work with data classes and JsonUnwrapped
// fix this in 2.0 when we can break the settings file
// and remove the `@JsonUnwrapped`
@field:JsonUnwrapped
lateinit var serverSettings: ServerSettings
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ClientSettings
if (maxCacheSizeInMebibytes != other.maxCacheSizeInMebibytes) return false
if (webSettings != other.webSettings) return false
if (devSettings != other.devSettings) return false
if (serverSettings != other.serverSettings) return false
return true
}
override fun hashCode(): Int {
var result = maxCacheSizeInMebibytes.hashCode()
result = 31 * result + (webSettings?.hashCode() ?: 0)
result = 31 * result + devSettings.hashCode()
result = 31 * result + serverSettings.hashCode()
return result
}
override fun toString(): String {
return "ClientSettings(maxCacheSizeInMebibytes=$maxCacheSizeInMebibytes, webSettings=$webSettings, devSettings=$devSettings, serverSettings=$serverSettings)"
}
}
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class ServerSettings(
val maxMebibytesPerHour: Long = 0,
val maxKilobitsPerSecond: Long = 0,
val clientHostname: String = "0.0.0.0",
@ -71,7 +33,9 @@ data class ServerSettings(
val clientExternalPort: Int = 0,
@field:Secret val clientSecret: String = "PASTE-YOUR-SECRET-HERE",
val threads: Int = 4,
val gracefulShutdownWaitSeconds: Int = 60
val gracefulShutdownWaitSeconds: Int = 60,
val webSettings: WebSettings? = null,
val devSettings: DevSettings? = null
)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)

View file

@ -23,11 +23,11 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming
import dev.afanasev.sekret.Secret
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class RemoteSettings(
data class ServerSettings(
val imageServer: String,
val latestBuild: Int,
val url: String,
@field:Secret val tokenKey: ByteArray,
val tokenKey: ByteArray,
val compromised: Boolean,
val paused: Boolean,
val forceTokens: Boolean = false,
@ -37,7 +37,7 @@ data class RemoteSettings(
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as RemoteSettings
other as ServerSettings
if (imageServer != other.imageServer) return false
if (latestBuild != other.latestBuild) return false
@ -60,6 +60,10 @@ data class RemoteSettings(
result = 31 * result + (tls?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "ServerSettings(imageServer='$imageServer', latestBuild=$latestBuild, url='$url', tokenKey=$tokenKey, compromised=$compromised, paused=$paused, forceTokens=$forceTokens, tls=$tls)"
}
}
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)

View file

@ -1,16 +1,15 @@
<configuration>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${file-level:-WARN}</level>
<level>${file-level:-TRACE}</level>
</filter>
<file>log/latest.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>log/logFile.%d{yyyy-MM-dd_HH}.%i.log</fileNamePattern>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>log/logFile.%d{yyyy-MM-dd_HH}.log</fileNamePattern>
<maxHistory>12</maxHistory>
<maxFileSize>32MB</maxFileSize>
<totalSizeCap>256MB</totalSizeCap>
</rollingPolicy>-->
<totalSizeCap>5MB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
@ -37,6 +36,5 @@
<appender-ref ref="ASYNC"/>
</root>
<logger name="Exposed" level="ERROR"/>
<logger name="io.netty" level="INFO"/>
</configuration>

View file

@ -1 +1,49 @@
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="favicon.ico"><![endif]--><title>MD@H Client</title><link rel=stylesheet href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900"><link rel=stylesheet href=https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css><script src=https://cdn.jsdelivr.net/npm/echarts@4.1.0/dist/echarts.js></script><script src=https://cdn.jsdelivr.net/npm/vue-echarts@4.0.2></script><script src=https://unpkg.com/xterm@4.0.0/lib/xterm.js></script><link rel=text/css href=node_modules/xterm/css/xterm.css><link href=css/chunk-7577183e.6dc57fe0.css rel=prefetch><link href=js/chunk-7577183e.d6d29bcc.js rel=prefetch><link href=css/app.14a6e628.css rel=preload as=style><link href=css/chunk-vendors.b02cf67a.css rel=preload as=style><link href=js/app.ede7edb7.js rel=preload as=script><link href=js/chunk-vendors.1256013f.js rel=preload as=script><link href=css/chunk-vendors.b02cf67a.css rel=stylesheet><link href=css/app.14a6e628.css rel=stylesheet><link rel=icon type=image/png sizes=32x32 href=img/icons/favicon-32x32.png><link rel=icon type=image/png sizes=16x16 href=img/icons/favicon-16x16.png><link rel=manifest href=manifest.json><meta name=theme-color content=#f79421><meta name=apple-mobile-web-app-capable content=yes><meta name=apple-mobile-web-app-status-bar-style content=black><meta name=apple-mobile-web-app-title content="MD@H Client Interface"><link rel=apple-touch-icon href=img/icons/apple-touch-icon-152x152.png><link rel=mask-icon href=img/icons/safari-pinned-tab.svg color=#f79421><meta name=msapplication-TileImage content=img/icons/msapplication-icon-144x144.png><meta name=msapplication-TileColor content=#000000></head><body style="overflow: hidden"><noscript><div style="background-color: #0980e8; position: absolute; top: 0; left: 0; width: 100%; height: 100%; user-select: none"><div style="position: absolute; top: 15%; left: 20%; width: 60%; font-family: Segoe UI; color: white;"><p style="font-size: 180px; margin: 0">:(</p><p style="font-size: 30px; margin-top: 50px">It appears that you don't have javascript enabled.<br>This isn't a big deal, but it just means that you've killed my wonderful web UI.<br>How evil of you...</p><p style="font-size: 10px; margin-top: 10px">Really though ;-;<br>I put in a lot of work and I'm very sad that you choose to disable the one thing that I needed :/</p></div></div></noscript><div id=app></div><script src=js/chunk-vendors.1256013f.js></script><script src=js/app.ede7edb7.js></script></body></html>
<!DOCTYPE html>
<html lang=en>
<head>
<meta charset=utf-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<!--[if IE]><link rel="icon" href="favicon.ico"><![endif]--><title>MD@H Client</title>
<link rel=stylesheet href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel=stylesheet href=https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css>
<script src=https://cdn.jsdelivr.net/npm/echarts@4.1.0/dist/echarts.js></script>
<script src=https://cdn.jsdelivr.net/npm/vue-echarts@4.0.2></script>
<script src=https://unpkg.com/xterm@4.0.0/lib/xterm.js></script>
<link rel=text/css href=node_modules/xterm/css/xterm.css>
<link href=css/chunk-7577183e.6dc57fe0.css rel=prefetch>
<link href=js/chunk-7577183e.d6d29bcc.js rel=prefetch>
<link href=css/app.14a6e628.css rel=preload as=style>
<link href=css/chunk-vendors.b02cf67a.css rel=preload as=style>
<link href=js/app.64ed2e63.js rel=preload as=script>
<link href=js/chunk-vendors.1256013f.js rel=preload as=script>
<link href=css/chunk-vendors.b02cf67a.css rel=stylesheet>
<link href=css/app.14a6e628.css rel=stylesheet>
<link rel=icon type=image/png sizes=32x32 href=img/icons/favicon-32x32.png>
<link rel=icon type=image/png sizes=16x16 href=img/icons/favicon-16x16.png>
<link rel=manifest href=manifest.json>
<meta name=theme-color content=#f79421>
<meta name=apple-mobile-web-app-capable content=yes>
<meta name=apple-mobile-web-app-status-bar-style content=black>
<meta name=apple-mobile-web-app-title content="MD@H Client Interface">
<link rel=apple-touch-icon href=img/icons/apple-touch-icon-152x152.png>
<link rel=mask-icon href=img/icons/safari-pinned-tab.svg color=#f79421>
<meta name=msapplication-TileImage content=img/icons/msapplication-icon-144x144.png>
<meta name=msapplication-TileColor content=#000000>
</head>
<body style="overflow: hidden">
<noscript>
<div style="background-color: #0980e8; position: absolute; top: 0; left: 0; width: 100%; height: 100%; user-select: none">
<div style="position: absolute; top: 15%; left: 20%; width: 60%; font-family: Segoe UI; color: white;"><p
style="font-size: 180px; margin: 0">:(</p>
<p style="font-size: 30px; margin-top: 50px">It appears that you don't have javascript enabled.<br>This
isn't a big deal, but it just means that you've killed my wonderful web UI.<br>How evil of you...</p>
<p style="font-size: 10px; margin-top: 10px">Really though ;-;<br>I put in a lot of work and I'm very sad
that you choose to disable the one thing that I needed :/</p></div>
</div>
</noscript>
<div id=app></div>
<script src=js/chunk-vendors.1256013f.js></script>
<script src=js/app.64ed2e63.js></script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
self.__precacheManifest = (self.__precacheManifest || []).concat([
{
"revision": "bb309837f2cf709edac5",
"revision": "6809e6ef82872711aaa4",
"url": "css/app.14a6e628.css"
},
{
@ -12,12 +12,12 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "css/chunk-vendors.b02cf67a.css"
},
{
"revision": "7968686572fffa22fa9bdf28cc308706",
"revision": "7e936af0b22244e9cbea551705cdf582",
"url": "index.html"
},
{
"revision": "bb309837f2cf709edac5",
"url": "js/app.ede7edb7.js"
"revision": "6809e6ef82872711aaa4",
"url": "js/app.64ed2e63.js"
},
{
"revision": "f9df31735412a9a525ef",

View file

@ -1,3 +1,3 @@
importScripts("precache-manifest.9917f0a006705c9b6b6c1abfab436c1f.js", "https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");
importScripts("precache-manifest.d4ec6234a4e099198b47797ccf7d3595.js", "https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");