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: stages:
- build - build
- publish - publish
- publish_latest
- publish_docker - publish_docker
cache: cache:
@ -27,22 +26,8 @@ publish:
name: "mangadex_at_home" name: "mangadex_at_home"
paths: paths:
- "*.jar" - "*.jar"
- "mangadex_at_home-*.zip"
- settings.sample.json - 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: publish_docker:
image: docker:git image: docker:git
stage: publish stage: publish

View file

@ -6,8 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- [2020-07-05] Added basic graph creation interface to dash [@RedMatriz].
### Changed ### Changed
- [2020-07-05] Changed mobile dash to allow for graph creation [@RedMatriz].
- [2020-07-05] Minor improvements to graph load [@RedMatriz].
### Deprecated ### Deprecated
@ -17,53 +20,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security ### 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 ## [1.1.5] - 2020-07-05
### Added
- [2020-07-05] Added basic graph creation interface to dash [@RedMatriz].
### Changed ### Changed
- [2020-07-04] Changed GitLab CI to build on every push irregardless of tagging by [@carbotaniuman]. - [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] 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 ### Fixed
- [2020-07-04] Fixed typo on `access-control-allow-methods` by [@carbotaniuman]. - [2020-07-04] Fixed typo on `access-control-allow-methods` by [@carbotaniuman].
- [2020-07-05] Fixed minor graph update issues [@RedMatriz].
### Security ### Security
- [2020-07-05] Prevent `Referer` matching on subdomains such as `mangadex.org.example.com` by [@AviKav]. - [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 ### Fixed
- [2020-06-11] Tweaked logging configuration to reduce log file sizes by [@carbotaniuman]. - [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 [Unreleased]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.1.5...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
[1.1.5]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.1.4...1.1.5 [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.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 [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 FROM openjdk:15-alpine
WORKDIR /mangahome WORKDIR /mangahome
COPY /build/libs/mangadex_at_home.jar . COPY /build/libs/mangadex_at_home.jar .
RUN apk update RUN apk update && apk add --no-cache libsodium
VOLUME "/mangahome/cache" VOLUME "/mangahome/cache"
EXPOSE 443 8080 EXPOSE 443 8080
CMD java -Dfile-level=off -Dstdout-level=trace -jar mangadex_at_home.jar CMD java -Dfile-level=off -Dstdout-level=trace -jar mangadex_at_home.jar

View file

@ -1,11 +1,10 @@
plugins { plugins {
id "java" id "java"
id "org.jetbrains.kotlin.jvm" version "1.4.0" id "org.jetbrains.kotlin.jvm" version "1.3.72"
id "org.jetbrains.kotlin.kapt" version "1.4.0"
id "application" id "application"
id "com.github.johnrengelman.shadow" version "5.2.0" id "com.github.johnrengelman.shadow" version "5.2.0"
id "com.diffplug.spotless" version "5.2.0" id "com.diffplug.gradle.spotless" version "4.4.0"
id "dev.afanasev.sekret" version "0.0.7" id "dev.afanasev.sekret" version "0.0.3"
} }
group = "com.mangadex" group = "com.mangadex"
@ -23,7 +22,7 @@ configurations {
} }
dependencies { 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-stdlib-jdk8"
implementation "org.jetbrains.kotlin:kotlin-reflect" 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-core", version: "$http_4k_version"
implementation group: "org.http4k", name: "http4k-format-jackson", 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: "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" 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: "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-core", version: "$exposed_version"
implementation group: "org.jetbrains.exposed", name: "exposed-dao", 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.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" implementation "com.goterl.lazycode:lazysodium-java:4.2.6"
kapt "info.picocli:picocli-codegen:4.5.0" implementation "net.java.dev.jna:jna:5.5.0"
}
kapt {
arguments {
arg("project", "${project.group}/${project.name}")
}
} }
java { java {
@ -60,7 +53,6 @@ java {
} }
spotless { spotless {
lineEndings 'UNIX'
java { java {
targetExclude("build/generated/**/*") targetExclude("build/generated/**/*")
eclipse() eclipse()
@ -84,41 +76,3 @@ tasks.register("generateVersion", Copy) {
} }
sourceSets.main.java.srcDir generateVersion.outputs.files 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 http_4k_version=3.251.0
exposed_version=0.26.2 exposed_version=0.24.1

View file

@ -254,9 +254,7 @@ public final class DiskLruCache implements Closeable {
try { try {
readJournalLine(reader.readLine()); readJournalLine(reader.readLine());
lineCount++; lineCount++;
} catch (UnexpectedJournalLineException ignored) { } catch (EOFException endOfJournal) {
// just continue and hope nothing breaks
} catch (EOFException e) {
break; break;
} }
} }
@ -275,7 +273,7 @@ public final class DiskLruCache implements Closeable {
private void readJournalLine(String line) throws IOException { private void readJournalLine(String line) throws IOException {
int firstSpace = line.indexOf(' '); int firstSpace = line.indexOf(' ');
if (firstSpace == -1) { if (firstSpace == -1) {
throw new UnexpectedJournalLineException(line); throw new IOException("unexpected journal line: " + line);
} }
int keyBegin = firstSpace + 1; int keyBegin = firstSpace + 1;
@ -307,7 +305,7 @@ public final class DiskLruCache implements Closeable {
} else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) { } else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) {
// This work was already done by calling lruEntries.get(). // This work was already done by calling lruEntries.get().
} else { } 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 import java.time.Duration
object Constants { object Constants {
const val CLIENT_BUILD = 19 const val CLIENT_BUILD = 16
val MAX_AGE_CACHE: Duration = Duration.ofDays(14)
@JvmField val MAX_AGE_CACHE: Duration = Duration.ofDays(14)
const val MAX_READ_TIME_SECONDS = 300 const val MAX_READ_TIME_SECONDS = 300
const val MAX_WRITE_TIME_SECONDS = 60 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 package mdnet.base
import ch.qos.logback.classic.LoggerContext 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 kotlin.system.exitProcess
import mdnet.BuildInfo import mdnet.BuildInfo
import mdnet.base.settings.ClientSettings
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import picocli.CommandLine
object Main { object Main {
private val LOGGER = LoggerFactory.getLogger(Main::class.java) private val LOGGER = LoggerFactory.getLogger(Main::class.java)
private val JACKSON: ObjectMapper = jacksonObjectMapper().enable(SerializationFeature.INDENT_OUTPUT).configure(JsonParser.Feature.ALLOW_COMMENTS, true)
@JvmStatic @JvmStatic
fun main(args: Array<String>) { 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( println(
"Mangadex@Home Client Version ${BuildInfo.VERSION} (Build ${Constants.CLIENT_BUILD}) initializing" "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/>. along with Mangadex@Home. If not, see <https://www.gnu.org/licenses/>.
""".trimIndent()) """.trimIndent())
val client = MangaDexClient(settingsFile, databaseFile, cacheFolder) var file = "settings.json"
Runtime.getRuntime().addShutdownHook(Thread { if (args.size == 1) {
client.shutdown() file = args[0]
(LoggerFactory.getILoggerFactory() as LoggerContext).stop() } 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() 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 */ /* ktlint-disable no-wildcard-imports */
package mdnet.base package mdnet.base
import com.fasterxml.jackson.core.JsonParser import ch.qos.logback.classic.LoggerContext
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper 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.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import java.io.File import java.io.File
import java.io.FileReader
import java.io.IOException import java.io.IOException
import java.time.Instant
import java.util.*
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit 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.Main.dieWithError
import mdnet.base.data.Statistics
import mdnet.base.server.getServer
import mdnet.base.server.getUiServer 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.DiskLruCache
import mdnet.cache.HeaderMismatchException import mdnet.cache.HeaderMismatchException
import org.http4k.server.Http4kServer import org.http4k.server.Http4kServer
import org.jetbrains.exposed.sql.Database
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
// Exception class to handle when Client Settings have invalid values sealed class State
class ClientSettingsException(message: String) : Exception(message) // 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) { class MangaDexClient(private val clientSettings: ClientSettings) {
// this must remain single-threaded because of how the state mechanism works // this must remain singlethreaded because of how the state mechanism works
private val executor = Executors.newSingleThreadScheduledExecutor() private val executorService = Executors.newSingleThreadScheduledExecutor()
private lateinit var scheduledFuture: ScheduledFuture<*> // state must only be accessed from the thread on the executorService
private var state: State = Uninitialized
private val database: Database private val serverHandler: ServerHandler = ServerHandler(clientSettings)
private val cache: DiskLruCache private val statsMap: MutableMap<Instant, Statistics> = Collections
private var settings: ClientSettings .synchronizedMap(object : LinkedHashMap<Instant, Statistics>(240) {
override fun removeEldestEntry(eldest: Map.Entry<Instant, Statistics>): Boolean {
// state that must only be accessed from the thread on the executor return this.size > 240
private var imageServer: ServerManager? = null }
})
private val statistics: AtomicReference<Statistics> = AtomicReference(
Statistics()
)
private val isHandled: AtomicBoolean = AtomicBoolean(false)
private var webUi: Http4kServer? = null private var webUi: Http4kServer? = null
// end protected state private val cache: DiskLruCache
init { 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 { try {
cache = DiskLruCache.open( cache = DiskLruCache.open(
cacheFolder, 1, 1, File("cache"), 1, 1,
(settings.maxCacheSizeInMebibytes * 1024 * 1024 * 0.8).toLong() /* MiB to bytes */ clientSettings.maxCacheSizeInMebibytes * 1024 * 1024 /* MiB to bytes */
) )
cache.get("statistics")?.use {
statistics.set(JACKSON.readValue<Statistics>(it.getInputStream(0)))
}
} catch (e: HeaderMismatchException) { } catch (e: HeaderMismatchException) {
LOGGER.warn { "Cache version may be outdated - remove if necessary" } LOGGER.warn { "Cache version may be outdated - remove if necessary" }
dieWithError(e) dieWithError(e)
} catch (e: IOException) { } catch (e: IOException) {
LOGGER.warn { "Cache may be corrupt - remove if necessary" }
dieWithError(e) dieWithError(e)
} }
} }
fun runLoop() { 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 { try {
// this blocks the executor, so no worries about concurrency if (state is Running || state is GracefulShutdown || state is Uninitialized) {
reloadClientSettings() 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) { } 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() var lastBytesSent = statistics.get().bytesSent
startWebUi() 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" }
} }
// Precondition: settings must be filled with up-to-date settings and `imageServer` must not be null stopServer(state.nextState)
private fun startWebUi() { state.action()
settings.webSettings?.let { webSettings -> }
val imageServer = requireNotNull(imageServer) else -> {
LOGGER.info {
"Waiting another 15 seconds for graceful shutdown (${state.counts} out of $timesToWait)"
}
if (webUi != null) { 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)
}
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 = GracefulShutdown(lastRunning = state) {
loginAndStartServer()
}
}
} else {
LOGGER.info { "Server ping failed - ignoring" }
}
}
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}"
}
}
state = Running(server, serverSettings)
LOGGER.info { "Internal HTTP server was successfully started" }
}
private fun logout() {
serverHandler.logoutFromControl()
}
private fun stopServer(nextState: State = Uninitialized) {
val state = this.state.let {
when (it) {
is Running ->
it
is GracefulShutdown ->
it.lastRunning
else ->
throw AssertionError() 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 LOGGER.info { "Shutting down HTTP server" }
private fun startImageServer() { state.server.stop()
if (imageServer != null) { LOGGER.info { "Internal HTTP server has shut down" }
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() { this.state = nextState
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() { fun shutdown() {
LOGGER.info { "Mangadex@Home Client shutting down" } LOGGER.info { "Mangadex@Home Client stopping" }
val latch = CountDownLatch(1) val latch = CountDownLatch(1)
executorService.schedule({
scheduledFuture.cancel(false) val state = this.state
if (state is Running) {
executor.schedule({ this.state = GracefulShutdown(state, nextState = Shutdown) {
if (webUi != null) { latch.countDown()
stopWebUi()
} }
if (imageServer != null) { } else if (state is GracefulShutdown) {
stopImageServer() 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()
webUi?.close()
try { try {
cache.close() cache.close()
} catch (e: IOException) { } catch (e: IOException) {
LOGGER.error(e) { "Cache failed to close" } LOGGER.error(e) { "Cache failed to close" }
} }
latch.countDown() executorService.shutdown()
}, 0, TimeUnit.SECONDS) LOGGER.info { "Mangadex@Home Client stopped" }
latch.await() (LoggerFactory.getILoggerFactory() as LoggerContext).stop()
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()
}
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" }
} 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 { companion object {
private const val CLIENT_KEY_LENGTH = 52
private val LOGGER = LoggerFactory.getLogger(MangaDexClient::class.java) 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.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.KotlinModule
import java.net.InetAddress
import mdnet.base.ServerHandlerJackson.auto import mdnet.base.ServerHandlerJackson.auto
import mdnet.base.settings.DevSettings import mdnet.base.settings.ClientSettings
import mdnet.base.settings.RemoteSettings
import mdnet.base.settings.ServerSettings import mdnet.base.settings.ServerSettings
import org.apache.http.client.config.RequestConfig import org.http4k.client.ApacheClient
import org.apache.http.impl.client.HttpClients
import org.http4k.client.Apache4Client
import org.http4k.core.Body import org.http4k.core.Body
import org.http4k.core.Method import org.http4k.core.Method
import org.http4k.core.Request import org.http4k.core.Request
@ -44,22 +40,13 @@ object ServerHandlerJackson : ConfigurableJackson(
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
) )
class ServerHandler(private val serverSettings: ServerSettings, private val devSettings: DevSettings, private val maxCacheSizeInMebibytes: Long) { class ServerHandler(private val settings: ClientSettings) {
private val client = Apache4Client(client = HttpClients.custom() private val client = ApacheClient()
.setDefaultRequestConfig(
RequestConfig.custom()
.apply {
if (serverSettings.clientHostname != "0.0.0.0") {
setLocalAddress(InetAddress.getByName(serverSettings.clientHostname))
}
}
.build())
.build())
fun logoutFromControl(): Boolean { fun logoutFromControl(): Boolean {
LOGGER.info { "Disconnecting from the control server" } LOGGER.info { "Disconnecting from the control server" }
val params = mapOf<String, Any>( 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")) 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> = private fun getPingParams(tlsCreatedAt: String? = null): Map<String, Any> =
mapOf<String, Any>( mapOf<String, Any>(
"secret" to serverSettings.clientSecret, "secret" to settings.clientSecret,
"port" to let { "port" to let {
if (serverSettings.clientExternalPort != 0) { if (settings.clientExternalPort != 0) {
serverSettings.clientExternalPort settings.clientExternalPort
} else { } else {
serverSettings.clientPort settings.clientPort
} }
}, },
"disk_space" to maxCacheSizeInMebibytes * 1024 * 1024, "disk_space" to settings.maxCacheSizeInMebibytes * 1024 * 1024,
"network_speed" to serverSettings.maxKilobitsPerSecond * 1000 / 8, "network_speed" to settings.maxKilobitsPerSecond * 1000 / 8,
"build_version" to Constants.CLIENT_BUILD "build_version" to Constants.CLIENT_BUILD
).let { ).let {
if (tlsCreatedAt != null) { 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" } LOGGER.info { "Connecting to the control server" }
val request = STRING_ANY_MAP_LENS(getPingParams(), Request(Method.POST, getServerAddress() + "ping")) 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" } LOGGER.info { "Pinging the control server" }
val request = STRING_ANY_MAP_LENS(getPingParams(old.tls!!.createdAt), Request(Method.POST, getServerAddress() + "ping")) 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 { private fun getServerAddress(): String {
return if (!devSettings.isDev) return if (settings.devSettings?.isDev != true)
SERVER_ADDRESS SERVER_ADDRESS
else else
SERVER_ADDRESS_DEV SERVER_ADDRESS_DEV
@ -125,8 +112,8 @@ class ServerHandler(private val serverSettings: ServerSettings, private val devS
companion object { companion object {
private val LOGGER = LoggerFactory.getLogger(ServerHandler::class.java) private val LOGGER = LoggerFactory.getLogger(ServerHandler::class.java)
private val STRING_ANY_MAP_LENS = Body.auto<Map<String, Any>>().toLens() private val STRING_ANY_MAP_LENS = Body.auto<Map<String, Any>>().toLens()
private val SERVER_SETTINGS_LENS = Body.auto<RemoteSettings>().toLens() private val SERVER_SETTINGS_LENS = Body.auto<ServerSettings>().toLens()
private const val SERVER_ADDRESS = "http://fakemdnet/" private const val SERVER_ADDRESS = "https://api.mangadex.network/"
private const val SERVER_ADDRESS_DEV = "https://mangadex-test.net/" 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.PrivateKey
import java.security.cert.CertificateFactory import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import javax.net.ssl.SSLException import javax.net.ssl.SSLException
import mdnet.base.Constants import mdnet.base.Constants
import mdnet.base.data.Statistics import mdnet.base.data.Statistics
import mdnet.base.info import mdnet.base.info
import mdnet.base.settings.ServerSettings import mdnet.base.settings.ClientSettings
import mdnet.base.settings.TlsCert import mdnet.base.settings.TlsCert
import mdnet.base.trace import mdnet.base.trace
import org.http4k.core.HttpHandler import org.http4k.core.HttpHandler
@ -56,17 +57,17 @@ import org.http4k.server.Http4kServer
import org.http4k.server.ServerConfig import org.http4k.server.ServerConfig
import org.slf4j.LoggerFactory 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 { override fun toServer(httpHandler: HttpHandler): Http4kServer = object : Http4kServer {
private val masterGroup = NioEventLoopGroup(serverSettings.threads) private val masterGroup = NioEventLoopGroup(clientSettings.threads)
private val workerGroup = NioEventLoopGroup(serverSettings.threads) private val workerGroup = NioEventLoopGroup(clientSettings.threads)
private lateinit var closeFuture: ChannelFuture private lateinit var closeFuture: ChannelFuture
private lateinit var address: InetSocketAddress private lateinit var address: InetSocketAddress
private val burstLimiter = object : GlobalTrafficShapingHandler( private val burstLimiter = object : GlobalTrafficShapingHandler(
workerGroup, serverSettings.maxKilobitsPerSecond * 1000L / 8L, 0, 50) { workerGroup, clientSettings.maxKilobitsPerSecond * 1000L / 8L, 0, 50) {
override fun doAccounting(counter: TrafficCounter) { override fun doAccounting(counter: TrafficCounter) {
statistics.getAndUpdate { statistics.getAndUpdate {
it.copy(bytesSent = it.bytesSent + counter.cumulativeWrittenBytes()) 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 { 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 certs = getX509Certs(tls.certificate)
val sslContext = SslContextBuilder 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("streamer", ChunkedWriteHandler())
ch.pipeline().addLast("handler", Http4kChannelHandler(httpHandler)) 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) { override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
if (cause is SSLException || (cause is DecoderException && cause.cause is SSLException)) { if (cause is SSLException || (cause is DecoderException && cause.cause is SSLException)) {
LOGGER.trace { "Ignored invalid SSL connection" } 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) .option(ChannelOption.SO_BACKLOG, 1000)
.childOption(ChannelOption.SO_KEEPALIVE, true) .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 address = channel.localAddress() as InetSocketAddress
closeFuture = channel.closeFuture() closeFuture = channel.closeFuture()
} }
override fun stop() = apply { override fun stop() = apply {
closeFuture.cancel(false) masterGroup.shutdownGracefully(1, 15, TimeUnit.SECONDS).sync()
workerGroup.shutdownGracefully() workerGroup.shutdownGracefully(1, 15, TimeUnit.SECONDS).sync()
masterGroup.shutdownGracefully() 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.codec.http.HttpServerKeepAliveHandler
import io.netty.handler.stream.ChunkedWriteHandler import io.netty.handler.stream.ChunkedWriteHandler
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.util.concurrent.TimeUnit
import org.http4k.core.HttpHandler import org.http4k.core.HttpHandler
import org.http4k.server.Http4kChannelHandler import org.http4k.server.Http4kChannelHandler
import org.http4k.server.Http4kServer import org.http4k.server.Http4kServer
@ -66,9 +67,9 @@ class WebUiNetty(private val hostname: String, private val port: Int) : ServerCo
} }
override fun stop() = apply { override fun stop() = apply {
closeFuture.cancel(false) masterGroup.shutdownGracefully(5, 15, TimeUnit.SECONDS).sync()
workerGroup.shutdownGracefully() workerGroup.shutdownGracefully(5, 15, TimeUnit.SECONDS).sync()
masterGroup.shutdownGracefully() closeFuture.sync()
} }
override fun port(): Int = address.port 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.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue 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.BufferedInputStream
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.security.MessageDigest
import java.time.Clock import java.time.Clock
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.* import java.util.*
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.CipherInputStream import javax.crypto.CipherInputStream
import javax.crypto.CipherOutputStream import javax.crypto.CipherOutputStream
import javax.crypto.spec.SecretKeySpec
import mdnet.base.Constants import mdnet.base.Constants
import mdnet.base.data.ImageData import mdnet.base.data.ImageData
import mdnet.base.data.ImageDatum import mdnet.base.data.ImageDatum
import mdnet.base.data.Statistics import mdnet.base.data.Statistics
import mdnet.base.data.Token import mdnet.base.data.Token
import mdnet.base.info import mdnet.base.info
import mdnet.base.netty.Netty
import mdnet.base.settings.RemoteSettings
import mdnet.base.settings.ServerSettings import mdnet.base.settings.ServerSettings
import mdnet.base.trace import mdnet.base.trace
import mdnet.base.warn import mdnet.base.warn
import mdnet.cache.CachingInputStream import mdnet.cache.CachingInputStream
import mdnet.cache.DiskLruCache 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.core.*
import org.http4k.filter.CachingFilters import org.http4k.filter.CachingFilters
import org.http4k.filter.ServerFilters
import org.http4k.lens.Path 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.Database
import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
private val LOGGER = LoggerFactory.getLogger(ImageServer::class.java)
class ImageServer( class ImageServer(
private val cache: DiskLruCache, private val cache: DiskLruCache,
private val database: Database, private val database: Database,
private val statistics: AtomicReference<Statistics>, private val statistics: AtomicReference<Statistics>,
private val remoteSettings: RemoteSettings, private val serverSettings: ServerSettings,
private val client: HttpHandler private val client: HttpHandler
) { ) {
init { init {
synchronized(database) {
transaction(database) { transaction(database) {
SchemaUtils.create(ImageData) SchemaUtils.create(ImageData)
} }
} }
}
private val executor = Executors.newCachedThreadPool() private val executor = Executors.newCachedThreadPool()
fun handler(dataSaver: Boolean, tokenized: Boolean = false): HttpHandler { fun handler(dataSaver: Boolean, tokenized: Boolean = false): HttpHandler {
val box = TweetNaclFast.SecretBox(remoteSettings.tokenKey) val sodium = LazySodiumJava(SodiumJava())
return baseHandler().then { request -> return baseHandler().then { request ->
val chapterHash = Path.of("chapterHash")(request) val chapterHash = Path.of("chapterHash")(request)
val fileName = Path.of("fileName")(request) val fileName = Path.of("fileName")(request)
@ -105,23 +93,21 @@ class ImageServer(
return@then Response(Status.FORBIDDEN) return@then Response(Status.FORBIDDEN)
} }
if (tokenized || remoteSettings.forceTokens) { if (tokenized || serverSettings.forceTokens) {
val tokenArr = Base64.getUrlDecoder().decode(Path.of("token")(request)) 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 { val token = try {
JACKSON.readValue<Token>( JACKSON.readValue<Token>(
box.open(tokenArr.sliceArray(24 until tokenArr.size), tokenArr.sliceArray(0 until 24)).apply { try {
if (this == null) { 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" } LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
return@then Response(Status.FORBIDDEN) return@then Response(Status.FORBIDDEN)
} }
}
) )
} catch (e: JsonProcessingException) { } 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) 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) request.handleCacheHit(sanitizedUri, getRc4(rc4Bytes), snapshot, imageDatum)
} else { } else {
if (snapshot != null) { if (snapshot != null) {
snapshot.close() 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.removeUnsafe(imageId.toCacheId())
cache.flush()
} }
request.handleCacheMiss(sanitizedUri, getRc4(rc4Bytes), imageId, imageDatum) request.handleCacheMiss(sanitizedUri, getRc4(rc4Bytes), imageId, imageDatum)
@ -220,7 +205,7 @@ class ImageServer(
it.copy(cacheMisses = it.cacheMisses + 1) 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) { if (mdResponse.status != Status.OK) {
LOGGER.trace { "Upstream query for $sanitizedUri errored with status ${mdResponse.status}" } LOGGER.trace { "Upstream query for $sanitizedUri errored with status ${mdResponse.status}" }
@ -229,18 +214,12 @@ class ImageServer(
return Response(mdResponse.status) return Response(mdResponse.status)
} }
LOGGER.trace { "Upstream query for $sanitizedUri succeeded" }
val contentType = mdResponse.header("Content-Type")!! val contentType = mdResponse.header("Content-Type")!!
val contentLength = mdResponse.header("Content-Length") val contentLength = mdResponse.header("Content-Length")
val lastModified = mdResponse.header("Last-Modified") 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()) val editor = cache.editUnsafe(imageId.toCacheId())
// A null editor means that this file is being written to // A null editor means that this file is being written to
@ -249,7 +228,6 @@ class ImageServer(
LOGGER.trace { "Request for $sanitizedUri is being cached and served" } LOGGER.trace { "Request for $sanitizedUri is being cached and served" }
if (imageDatum == null) { if (imageDatum == null) {
try {
synchronized(database) { synchronized(database) {
transaction(database) { transaction(database) {
ImageDatum.new(imageId) { ImageDatum.new(imageId) {
@ -258,12 +236,6 @@ class ImageServer(
} }
} }
} }
} 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)
}
} }
val tee = CachingInputStream( val tee = CachingInputStream(
@ -274,7 +246,6 @@ class ImageServer(
if (editor.getLength(0) == contentLength.toLong()) { if (editor.getLength(0) == contentLength.toLong()) {
LOGGER.info { "Cache download for $sanitizedUri committed" } LOGGER.info { "Cache download for $sanitizedUri committed" }
editor.commit() editor.commit()
cache.flush()
} else { } else {
LOGGER.warn { "Cache download for $sanitizedUri aborted" } LOGGER.warn { "Cache download for $sanitizedUri aborted" }
editor.abort() editor.abort()
@ -316,6 +287,7 @@ class ImageServer(
.header("X-Cache", if (cached) "HIT" else "MISS") .header("X-Cache", if (cached) "HIT" else "MISS")
companion object { companion object {
private val LOGGER = LoggerFactory.getLogger(ImageServer::class.java)
private val JACKSON: ObjectMapper = jacksonObjectMapper() private val JACKSON: ObjectMapper = jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(JavaTimeModule()) .registerModule(JavaTimeModule())
@ -335,75 +307,21 @@ class ImageServer(
} }
} }
private fun String.isImageMimetype() = this.toLowerCase().startsWith("image/") private fun getRc4(key: ByteArray): Cipher {
val rc4 = Cipher.getInstance("RC4")
fun getServer(cache: DiskLruCache, database: Database, remoteSettings: RemoteSettings, serverSettings: ServerSettings, statistics: AtomicReference<Statistics>, isHandled: AtomicBoolean): Http4kServer { rc4.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "RC4"))
val client = Apache4Client(responseBodyMode = BodyMode.Stream, client = HttpClients.custom() return rc4
.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))
} }
fun setHandled(isHandled: AtomicBoolean): Filter { private fun md5Bytes(stringToHash: String): ByteArray {
return Filter { next: HttpHandler -> val digest = MessageDigest.getInstance("MD5")
{ return digest.digest(stringToHash.toByteArray())
isHandled.set(true)
next(it)
}
}
} }
fun timeRequest(): Filter { private fun printHexString(bytes: ByteArray): String {
return Filter { next: HttpHandler -> val sb = StringBuilder()
{ request: Request -> for (b in bytes) {
val cleanedUri = request.uri.path.let { sb.append(String.format("%02x", b))
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())
}
} }
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 package mdnet.base.settings
import com.fasterxml.jackson.annotation.JsonUnwrapped
import com.fasterxml.jackson.databind.PropertyNamingStrategy import com.fasterxml.jackson.databind.PropertyNamingStrategy
import com.fasterxml.jackson.databind.annotation.JsonNaming import com.fasterxml.jackson.databind.annotation.JsonNaming
import dev.afanasev.sekret.Secret import dev.afanasev.sekret.Secret
// client settings are verified correct in Main.kt
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
class ClientSettings( data class ClientSettings(
val maxCacheSizeInMebibytes: Long = 20480, 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 maxMebibytesPerHour: Long = 0,
val maxKilobitsPerSecond: Long = 0, val maxKilobitsPerSecond: Long = 0,
val clientHostname: String = "0.0.0.0", val clientHostname: String = "0.0.0.0",
@ -71,7 +33,9 @@ data class ServerSettings(
val clientExternalPort: Int = 0, val clientExternalPort: Int = 0,
@field:Secret val clientSecret: String = "PASTE-YOUR-SECRET-HERE", @field:Secret val clientSecret: String = "PASTE-YOUR-SECRET-HERE",
val threads: Int = 4, val threads: Int = 4,
val gracefulShutdownWaitSeconds: Int = 60 val gracefulShutdownWaitSeconds: Int = 60,
val webSettings: WebSettings? = null,
val devSettings: DevSettings? = null
) )
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)

View file

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

View file

@ -1,16 +1,15 @@
<configuration> <configuration>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${file-level:-WARN}</level> <level>${file-level:-TRACE}</level>
</filter> </filter>
<file>log/latest.log</file> <file>log/latest.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>log/logFile.%d{yyyy-MM-dd_HH}.%i.log</fileNamePattern> <fileNamePattern>log/logFile.%d{yyyy-MM-dd_HH}.log</fileNamePattern>
<maxHistory>12</maxHistory> <maxHistory>12</maxHistory>
<maxFileSize>32MB</maxFileSize> <totalSizeCap>5MB</totalSizeCap>
<totalSizeCap>256MB</totalSizeCap> </rollingPolicy>
</rollingPolicy>-->
<encoder> <encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern> <pattern>%d{YYYY-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
@ -37,6 +36,5 @@
<appender-ref ref="ASYNC"/> <appender-ref ref="ASYNC"/>
</root> </root>
<logger name="Exposed" level="ERROR"/>
<logger name="io.netty" level="INFO"/> <logger name="io.netty" level="INFO"/>
</configuration> </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([ self.__precacheManifest = (self.__precacheManifest || []).concat([
{ {
"revision": "bb309837f2cf709edac5", "revision": "6809e6ef82872711aaa4",
"url": "css/app.14a6e628.css" "url": "css/app.14a6e628.css"
}, },
{ {
@ -12,12 +12,12 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "css/chunk-vendors.b02cf67a.css" "url": "css/chunk-vendors.b02cf67a.css"
}, },
{ {
"revision": "7968686572fffa22fa9bdf28cc308706", "revision": "7e936af0b22244e9cbea551705cdf582",
"url": "index.html" "url": "index.html"
}, },
{ {
"revision": "bb309837f2cf709edac5", "revision": "6809e6ef82872711aaa4",
"url": "js/app.ede7edb7.js" "url": "js/app.64ed2e63.js"
}, },
{ {
"revision": "f9df31735412a9a525ef", "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");