Compare commits
43 commits
fix-4xx-ca
...
master
Author | SHA1 | Date | |
---|---|---|---|
Kegan Myers | 3b8c09f8e8 | ||
59fd85c628 | |||
21699acc25 | |||
9ce7eccb47 | |||
35567bba1d | |||
dfd6777b45 | |||
b889ce9afe | |||
602a43d62e | |||
591e28ca6e | |||
b7b4ec7566 | |||
bf750427cd | |||
ba7539ddb7 | |||
2315a07601 | |||
c64ae4e339 | |||
89d9a9386f | |||
af73354d4f | |||
535d21ec8e | |||
9d07c18de1 | |||
398ab05788 | |||
bf4192d584 | |||
cbf509fced | |||
3dc6a5d8ae | |||
13b80cf02f | |||
33fea67a44 | |||
ef481f12ca | |||
4c18c9c288 | |||
9dcfe3b51f | |||
4a2e55270f | |||
2e14430d3d | |||
2b910edf20 | |||
a4bd6ef121 | |||
002a328f2d | |||
eab29ba2a3 | |||
6b23564f75 | |||
1b9e032282 | |||
1371ab75f2 | |||
77c92e58fb | |||
e0a4b04c70 | |||
4b1db7c705 | |||
ec6bc11403 | |||
5d8fe5b272 | |||
9c213715a5 | |||
9cf990501c |
|
@ -1,6 +1,7 @@
|
||||||
stages:
|
stages:
|
||||||
- build
|
- build
|
||||||
- publish
|
- publish
|
||||||
|
- publish_latest
|
||||||
- publish_docker
|
- publish_docker
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
|
@ -26,8 +27,22 @@ 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
|
||||||
|
|
39
CHANGELOG.md
39
CHANGELOG.md
|
@ -17,6 +17,40 @@ 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
|
### Added
|
||||||
- [2020-07-05] Added basic graph creation interface to dash [@RedMatriz].
|
- [2020-07-05] Added basic graph creation interface to dash [@RedMatriz].
|
||||||
|
@ -208,7 +242,10 @@ 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.1.5...HEAD
|
[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
|
||||||
[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
|
||||||
|
|
|
@ -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 && apk add --no-cache libsodium
|
RUN apk update
|
||||||
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
|
63
build.gradle
63
build.gradle
|
@ -1,10 +1,11 @@
|
||||||
plugins {
|
plugins {
|
||||||
id "java"
|
id "java"
|
||||||
id "org.jetbrains.kotlin.jvm" version "1.3.72"
|
id "org.jetbrains.kotlin.jvm" version "1.4.0"
|
||||||
|
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.gradle.spotless" version "4.4.0"
|
id "com.diffplug.spotless" version "5.2.0"
|
||||||
id "dev.afanasev.sekret" version "0.0.3"
|
id "dev.afanasev.sekret" version "0.0.7"
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "com.mangadex"
|
group = "com.mangadex"
|
||||||
|
@ -22,7 +23,7 @@ configurations {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly group: "dev.afanasev", name: "sekret-annotation", version: "0.0.3"
|
compileOnly group: "dev.afanasev", name: "sekret-annotation", version: "0.0.7"
|
||||||
|
|
||||||
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"
|
||||||
|
@ -32,19 +33,25 @@ 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-apache", version: "$http_4k_version"
|
implementation group: "org.http4k", name: "http4k-client-apache4", 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.30.Final"
|
runtimeOnly group: "io.netty", name: "netty-tcnative-boringssl-static", version: "2.0.34.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.30.1"
|
implementation group: "org.xerial", name: "sqlite-jdbc", version: "3.32.3.2"
|
||||||
|
|
||||||
implementation "com.goterl.lazycode:lazysodium-java:4.3.0"
|
implementation "info.picocli:picocli:4.5.0"
|
||||||
implementation "net.java.dev.jna:jna:5.5.0"
|
kapt "info.picocli:picocli-codegen:4.5.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
kapt {
|
||||||
|
arguments {
|
||||||
|
arg("project", "${project.group}/${project.name}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
java {
|
java {
|
||||||
|
@ -77,3 +84,41 @@ 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)
|
||||||
|
}
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
http_4k_version=3.251.0
|
http_4k_version=3.258.0
|
||||||
exposed_version=0.24.1
|
exposed_version=0.26.2
|
8
src/main/java/mdnet/cache/DiskLruCache.java
vendored
8
src/main/java/mdnet/cache/DiskLruCache.java
vendored
|
@ -254,7 +254,9 @@ public final class DiskLruCache implements Closeable {
|
||||||
try {
|
try {
|
||||||
readJournalLine(reader.readLine());
|
readJournalLine(reader.readLine());
|
||||||
lineCount++;
|
lineCount++;
|
||||||
} catch (EOFException endOfJournal) {
|
} catch (UnexpectedJournalLineException ignored) {
|
||||||
|
// just continue and hope nothing breaks
|
||||||
|
} catch (EOFException e) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -273,7 +275,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 IOException("unexpected journal line: " + line);
|
throw new UnexpectedJournalLineException(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
int keyBegin = firstSpace + 1;
|
int keyBegin = firstSpace + 1;
|
||||||
|
@ -305,7 +307,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 IOException("unexpected journal line: " + line);
|
throw new UnexpectedJournalLineException(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
9
src/main/java/mdnet/cache/UnexpectedJournalLineException.java
vendored
Normal file
9
src/main/java/mdnet/cache/UnexpectedJournalLineException.java
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
package mdnet.cache;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public class UnexpectedJournalLineException extends IOException {
|
||||||
|
public UnexpectedJournalLineException(String unexpectedLine) {
|
||||||
|
super("unexpected journal line: " + unexpectedLine);
|
||||||
|
}
|
||||||
|
}
|
3425
src/main/java/mdnet/security/TweetNaclFast.java
Normal file
3425
src/main/java/mdnet/security/TweetNaclFast.java
Normal file
File diff suppressed because it is too large
Load diff
|
@ -21,9 +21,84 @@ package mdnet.base
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
|
||||||
object Constants {
|
object Constants {
|
||||||
const val CLIENT_BUILD = 16
|
const val CLIENT_BUILD = 19
|
||||||
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,28 +19,48 @@ 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 com.fasterxml.jackson.core.JsonParser
|
import java.io.File
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
@ -61,78 +81,11 @@ object Main {
|
||||||
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())
|
||||||
|
|
||||||
var file = "settings.json"
|
val client = MangaDexClient(settingsFile, databaseFile, cacheFolder)
|
||||||
if (args.size == 1) {
|
Runtime.getRuntime().addShutdownHook(Thread {
|
||||||
file = args[0]
|
client.shutdown()
|
||||||
} else if (args.isNotEmpty()) {
|
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,285 +19,259 @@ 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 ch.qos.logback.classic.LoggerContext
|
import com.fasterxml.jackson.core.JsonParser
|
||||||
|
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.concurrent.atomic.AtomicBoolean
|
import java.util.regex.Pattern
|
||||||
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.ClientSettings
|
import mdnet.base.settings.*
|
||||||
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
|
||||||
|
|
||||||
sealed class State
|
// Exception class to handle when Client Settings have invalid values
|
||||||
// server is not running
|
class ClientSettingsException(message: String) : Exception(message)
|
||||||
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 clientSettings: ClientSettings) {
|
class MangaDexClient(private val settingsFile: File, databaseFile: File, cacheFolder: File) {
|
||||||
// this must remain singlethreaded because of how the state mechanism works
|
// this must remain single-threaded because of how the state mechanism works
|
||||||
private val executorService = Executors.newSingleThreadScheduledExecutor()
|
private val executor = Executors.newSingleThreadScheduledExecutor()
|
||||||
// state must only be accessed from the thread on the executorService
|
private lateinit var scheduledFuture: ScheduledFuture<*>
|
||||||
private var state: State = Uninitialized
|
|
||||||
|
|
||||||
private val serverHandler: ServerHandler = ServerHandler(clientSettings)
|
private val database: Database
|
||||||
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
|
|
||||||
private val cache: DiskLruCache
|
private val cache: DiskLruCache
|
||||||
|
private var settings: ClientSettings
|
||||||
|
|
||||||
|
// state that must only be accessed from the thread on the executor
|
||||||
|
private var imageServer: ServerManager? = null
|
||||||
|
private var webUi: Http4kServer? = null
|
||||||
|
// end protected state
|
||||||
|
|
||||||
init {
|
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(
|
||||||
File("cache"), 1, 1,
|
cacheFolder, 1, 1,
|
||||||
clientSettings.maxCacheSizeInMebibytes * 1024 * 1024 /* MiB to bytes */
|
(settings.maxCacheSizeInMebibytes * 1024 * 1024 * 0.8).toLong() /* 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() {
|
||||||
loginAndStartServer()
|
LOGGER.info { "Mangadex@Home Client initialized - starting normal operation." }
|
||||||
statsMap[Instant.now()] = statistics.get()
|
|
||||||
|
|
||||||
if (clientSettings.webSettings != null) {
|
scheduledFuture = executor.scheduleWithFixedDelay({
|
||||||
webUi = getUiServer(clientSettings.webSettings, statistics, statsMap)
|
|
||||||
webUi!!.start()
|
|
||||||
}
|
|
||||||
LOGGER.info { "Mangadex@Home Client initialized. Starting normal operation." }
|
|
||||||
|
|
||||||
executorService.scheduleAtFixedRate({
|
|
||||||
try {
|
try {
|
||||||
if (state is Running || state is GracefulShutdown || state is Uninitialized) {
|
// this blocks the executor, so no worries about concurrency
|
||||||
statistics.updateAndGet {
|
reloadClientSettings()
|
||||||
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) { "Statistics update failed" }
|
LOGGER.warn(e) { "Reload of ClientSettings failed" }
|
||||||
}
|
}
|
||||||
}, 15, 15, TimeUnit.SECONDS)
|
}, 1, 1, TimeUnit.MINUTES)
|
||||||
|
|
||||||
var lastBytesSent = statistics.get().bytesSent
|
startImageServer()
|
||||||
executorService.scheduleAtFixedRate({
|
startWebUi()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pingControl() {
|
// Precondition: settings must be filled with up-to-date settings and `imageServer` must not be null
|
||||||
val state = this.state as Running
|
private fun startWebUi() {
|
||||||
|
settings.webSettings?.let { webSettings ->
|
||||||
|
val imageServer = requireNotNull(imageServer)
|
||||||
|
|
||||||
val newSettings = serverHandler.pingControl(state.settings)
|
if (webUi != null) {
|
||||||
if (newSettings != null) {
|
throw AssertionError()
|
||||||
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) {
|
LOGGER.info { "WebUI starting" }
|
||||||
// certificates or upstream url must have changed, restart webserver
|
webUi = getUiServer(webSettings, imageServer.statistics, imageServer.statsMap).also {
|
||||||
LOGGER.info { "Doing internal restart of HTTP server to refresh certs/upstream URL" }
|
it.start()
|
||||||
|
|
||||||
this.state = GracefulShutdown(lastRunning = state) {
|
|
||||||
loginAndStartServer()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
LOGGER.info { "WebUI started" }
|
||||||
LOGGER.info { "Server ping failed - ignoring" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loginAndStartServer() {
|
// Precondition: settings must be filled with up-to-date settings
|
||||||
this.state as Uninitialized
|
private fun startImageServer() {
|
||||||
|
if (imageServer != null) {
|
||||||
val serverSettings = serverHandler.loginToControl()
|
throw AssertionError()
|
||||||
?: 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" }
|
||||||
state = Running(server, serverSettings)
|
imageServer = ServerManager(settings.serverSettings, settings.devSettings, settings.maxCacheSizeInMebibytes, cache, database).also {
|
||||||
LOGGER.info { "Internal HTTP server was successfully started" }
|
it.start()
|
||||||
|
}
|
||||||
|
LOGGER.info { "Server manager started" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logout() {
|
private fun stopImageServer() {
|
||||||
serverHandler.logoutFromControl()
|
LOGGER.info { "Server manager stopping" }
|
||||||
|
requireNotNull(imageServer).shutdown()
|
||||||
|
imageServer = null
|
||||||
|
LOGGER.info { "Server manager stopped" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopServer(nextState: State = Uninitialized) {
|
private fun stopWebUi() {
|
||||||
val state = this.state.let {
|
LOGGER.info { "WebUI stopping" }
|
||||||
when (it) {
|
requireNotNull(webUi).stop()
|
||||||
is Running ->
|
webUi = null
|
||||||
it
|
LOGGER.info { "WebUI stopped" }
|
||||||
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() {
|
fun shutdown() {
|
||||||
LOGGER.info { "Mangadex@Home Client stopping" }
|
LOGGER.info { "Mangadex@Home Client shutting down" }
|
||||||
|
|
||||||
val latch = CountDownLatch(1)
|
val latch = CountDownLatch(1)
|
||||||
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()
|
|
||||||
|
|
||||||
webUi?.close()
|
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 {
|
try {
|
||||||
cache.close()
|
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) {
|
} catch (e: IOException) {
|
||||||
LOGGER.error(e) { "Cache failed to close" }
|
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)")
|
||||||
}
|
}
|
||||||
|
|
||||||
executorService.shutdown()
|
fun isSecretValid(clientSecret: String): Boolean {
|
||||||
LOGGER.info { "Mangadex@Home Client stopped" }
|
return Pattern.matches("^[a-zA-Z0-9]{$CLIENT_KEY_LENGTH}$", clientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
|
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()
|
private val JACKSON: ObjectMapper = jacksonObjectMapper().configure(JsonParser.Feature.ALLOW_COMMENTS, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,10 +20,14 @@ 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.ClientSettings
|
import mdnet.base.settings.DevSettings
|
||||||
|
import mdnet.base.settings.RemoteSettings
|
||||||
import mdnet.base.settings.ServerSettings
|
import mdnet.base.settings.ServerSettings
|
||||||
import org.http4k.client.ApacheClient
|
import org.apache.http.client.config.RequestConfig
|
||||||
|
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
|
||||||
|
@ -40,13 +44,22 @@ object ServerHandlerJackson : ConfigurableJackson(
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||||
)
|
)
|
||||||
|
|
||||||
class ServerHandler(private val settings: ClientSettings) {
|
class ServerHandler(private val serverSettings: ServerSettings, private val devSettings: DevSettings, private val maxCacheSizeInMebibytes: Long) {
|
||||||
private val client = ApacheClient()
|
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())
|
||||||
|
|
||||||
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 settings.clientSecret
|
"secret" to serverSettings.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"))
|
||||||
|
@ -57,16 +70,16 @@ class ServerHandler(private val settings: ClientSettings) {
|
||||||
|
|
||||||
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 settings.clientSecret,
|
"secret" to serverSettings.clientSecret,
|
||||||
"port" to let {
|
"port" to let {
|
||||||
if (settings.clientExternalPort != 0) {
|
if (serverSettings.clientExternalPort != 0) {
|
||||||
settings.clientExternalPort
|
serverSettings.clientExternalPort
|
||||||
} else {
|
} else {
|
||||||
settings.clientPort
|
serverSettings.clientPort
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"disk_space" to settings.maxCacheSizeInMebibytes * 1024 * 1024,
|
"disk_space" to maxCacheSizeInMebibytes * 1024 * 1024,
|
||||||
"network_speed" to settings.maxKilobitsPerSecond * 1000 / 8,
|
"network_speed" to serverSettings.maxKilobitsPerSecond * 1000 / 8,
|
||||||
"build_version" to Constants.CLIENT_BUILD
|
"build_version" to Constants.CLIENT_BUILD
|
||||||
).let {
|
).let {
|
||||||
if (tlsCreatedAt != null) {
|
if (tlsCreatedAt != null) {
|
||||||
|
@ -76,7 +89,7 @@ class ServerHandler(private val settings: ClientSettings) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loginToControl(): ServerSettings? {
|
fun loginToControl(): RemoteSettings? {
|
||||||
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"))
|
||||||
|
@ -89,7 +102,7 @@ class ServerHandler(private val settings: ClientSettings) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pingControl(old: ServerSettings): ServerSettings? {
|
fun pingControl(old: RemoteSettings): RemoteSettings? {
|
||||||
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"))
|
||||||
|
@ -103,7 +116,7 @@ class ServerHandler(private val settings: ClientSettings) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getServerAddress(): String {
|
private fun getServerAddress(): String {
|
||||||
return if (settings.devSettings?.isDev != true)
|
return if (!devSettings.isDev)
|
||||||
SERVER_ADDRESS
|
SERVER_ADDRESS
|
||||||
else
|
else
|
||||||
SERVER_ADDRESS_DEV
|
SERVER_ADDRESS_DEV
|
||||||
|
@ -112,8 +125,8 @@ class ServerHandler(private val settings: ClientSettings) {
|
||||||
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<ServerSettings>().toLens()
|
private val SERVER_SETTINGS_LENS = Body.auto<RemoteSettings>().toLens()
|
||||||
private const val SERVER_ADDRESS = "https://api.mangadex.network/"
|
private const val SERVER_ADDRESS = "http://fakemdnet/"
|
||||||
private const val SERVER_ADDRESS_DEV = "https://mangadex-test.net/"
|
private const val SERVER_ADDRESS_DEV = "https://mangadex-test.net/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
269
src/main/kotlin/mdnet/base/ServerManager.kt
Normal file
269
src/main/kotlin/mdnet/base/ServerManager.kt
Normal file
|
@ -0,0 +1,269 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,13 +42,12 @@ 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.ClientSettings
|
import mdnet.base.settings.ServerSettings
|
||||||
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
|
||||||
|
@ -57,17 +56,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("Application")
|
private val LOGGER = LoggerFactory.getLogger("AppNetty")
|
||||||
|
|
||||||
class Netty(private val tls: TlsCert, private val clientSettings: ClientSettings, private val statistics: AtomicReference<Statistics>) : ServerConfig {
|
class Netty(private val tls: TlsCert, private val serverSettings: ServerSettings, 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(clientSettings.threads)
|
private val masterGroup = NioEventLoopGroup(serverSettings.threads)
|
||||||
private val workerGroup = NioEventLoopGroup(clientSettings.threads)
|
private val workerGroup = NioEventLoopGroup(serverSettings.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, clientSettings.maxKilobitsPerSecond * 1000L / 8L, 0, 50) {
|
workerGroup, serverSettings.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())
|
||||||
|
@ -77,7 +76,7 @@ class Netty(private val tls: TlsCert, private val clientSettings: ClientSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun start(): Http4kServer = apply {
|
override fun start(): Http4kServer = apply {
|
||||||
LOGGER.info { "Starting Netty with ${clientSettings.threads} threads" }
|
LOGGER.info { "Starting Netty with ${serverSettings.threads} threads" }
|
||||||
|
|
||||||
val certs = getX509Certs(tls.certificate)
|
val certs = getX509Certs(tls.certificate)
|
||||||
val sslContext = SslContextBuilder
|
val sslContext = SslContextBuilder
|
||||||
|
@ -104,7 +103,7 @@ class Netty(private val tls: TlsCert, private val clientSettings: ClientSettings
|
||||||
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("handle_ssl", object : ChannelInboundHandlerAdapter() {
|
ch.pipeline().addLast("exceptions", 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" }
|
||||||
|
@ -121,18 +120,18 @@ class Netty(private val tls: TlsCert, private val clientSettings: ClientSettings
|
||||||
.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(clientSettings.clientHostname, clientSettings.clientPort)).sync().channel()
|
val channel = bootstrap.bind(InetSocketAddress(serverSettings.clientHostname, serverSettings.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 {
|
||||||
masterGroup.shutdownGracefully(1, 15, TimeUnit.SECONDS).sync()
|
closeFuture.cancel(false)
|
||||||
workerGroup.shutdownGracefully(1, 15, TimeUnit.SECONDS).sync()
|
workerGroup.shutdownGracefully()
|
||||||
closeFuture.sync()
|
masterGroup.shutdownGracefully()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun port(): Int = if (clientSettings.clientPort > 0) clientSettings.clientPort else address.port
|
override fun port(): Int = if (serverSettings.clientPort > 0) serverSettings.clientPort else address.port
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,7 +32,6 @@ 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
|
||||||
|
@ -67,9 +66,9 @@ class WebUiNetty(private val hostname: String, private val port: Int) : ServerCo
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stop() = apply {
|
override fun stop() = apply {
|
||||||
masterGroup.shutdownGracefully(5, 15, TimeUnit.SECONDS).sync()
|
closeFuture.cancel(false)
|
||||||
workerGroup.shutdownGracefully(5, 15, TimeUnit.SECONDS).sync()
|
workerGroup.shutdownGracefully()
|
||||||
closeFuture.sync()
|
masterGroup.shutdownGracefully()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun port(): Int = address.port
|
override fun port(): Int = address.port
|
||||||
|
|
|
@ -1,121 +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.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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -25,59 +25,71 @@ 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 serverSettings: ServerSettings,
|
private val remoteSettings: RemoteSettings,
|
||||||
private val client: HttpHandler
|
private val client: HttpHandler
|
||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
transaction(database) {
|
synchronized(database) {
|
||||||
SchemaUtils.create(ImageData)
|
transaction(database) {
|
||||||
|
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 sodium = LazySodiumJava(SodiumJava())
|
val box = TweetNaclFast.SecretBox(remoteSettings.tokenKey)
|
||||||
|
|
||||||
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)
|
||||||
|
@ -93,21 +105,23 @@ class ImageServer(
|
||||||
return@then Response(Status.FORBIDDEN)
|
return@then Response(Status.FORBIDDEN)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenized || serverSettings.forceTokens) {
|
if (tokenized || remoteSettings.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>(
|
||||||
try {
|
box.open(tokenArr.sliceArray(24 until tokenArr.size), tokenArr.sliceArray(0 until 24)).apply {
|
||||||
sodium.cryptoBoxOpenEasyAfterNm(
|
if (this == null) {
|
||||||
tokenArr.sliceArray(24 until tokenArr.size), tokenArr.sliceArray(0 until 24), serverSettings.tokenKey
|
LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
|
||||||
)
|
return@then Response(Status.FORBIDDEN)
|
||||||
} catch (_: SodiumException) {
|
}
|
||||||
LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
|
|
||||||
return@then Response(Status.FORBIDDEN)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: JsonProcessingException) {
|
} catch (e: JsonProcessingException) {
|
||||||
LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
|
LOGGER.info(e) { "Request for $sanitizedUri rejected for invalid token" }
|
||||||
return@then Response(Status.FORBIDDEN)
|
return@then Response(Status.FORBIDDEN)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -140,13 +154,14 @@ class ImageServer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot != null && imageDatum != null) {
|
if (snapshot != null && imageDatum != null && imageDatum.contentType.isImageMimetype()) {
|
||||||
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 cache file for $sanitizedUri without corresponding DB entry" }
|
LOGGER.warn { "Removing broken cache file for $sanitizedUri" }
|
||||||
cache.removeUnsafe(imageId.toCacheId())
|
cache.removeUnsafe(imageId.toCacheId())
|
||||||
|
cache.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
request.handleCacheMiss(sanitizedUri, getRc4(rc4Bytes), imageId, imageDatum)
|
request.handleCacheMiss(sanitizedUri, getRc4(rc4Bytes), imageId, imageDatum)
|
||||||
|
@ -205,7 +220,7 @@ class ImageServer(
|
||||||
it.copy(cacheMisses = it.cacheMisses + 1)
|
it.copy(cacheMisses = it.cacheMisses + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
val mdResponse = client(Request(Method.GET, "${serverSettings.imageServer}$sanitizedUri"))
|
val mdResponse = client(Request(Method.GET, "${remoteSettings.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}" }
|
||||||
|
@ -214,12 +229,18 @@ 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
|
||||||
|
@ -228,13 +249,20 @@ 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) {
|
||||||
synchronized(database) {
|
try {
|
||||||
transaction(database) {
|
synchronized(database) {
|
||||||
ImageDatum.new(imageId) {
|
transaction(database) {
|
||||||
this.contentType = contentType
|
ImageDatum.new(imageId) {
|
||||||
this.lastModified = lastModified
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -246,6 +274,7 @@ 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()
|
||||||
|
@ -287,7 +316,6 @@ 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())
|
||||||
|
@ -307,21 +335,75 @@ class ImageServer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRc4(key: ByteArray): Cipher {
|
private fun String.isImageMimetype() = this.toLowerCase().startsWith("image/")
|
||||||
val rc4 = Cipher.getInstance("RC4")
|
|
||||||
rc4.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "RC4"))
|
fun getServer(cache: DiskLruCache, database: Database, remoteSettings: RemoteSettings, serverSettings: ServerSettings, statistics: AtomicReference<Statistics>, isHandled: AtomicBoolean): Http4kServer {
|
||||||
return rc4
|
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 md5Bytes(stringToHash: String): ByteArray {
|
fun setHandled(isHandled: AtomicBoolean): Filter {
|
||||||
val digest = MessageDigest.getInstance("MD5")
|
return Filter { next: HttpHandler ->
|
||||||
return digest.digest(stringToHash.toByteArray())
|
{
|
||||||
}
|
isHandled.set(true)
|
||||||
|
next(it)
|
||||||
private fun printHexString(bytes: ByteArray): String {
|
}
|
||||||
val sb = StringBuilder()
|
}
|
||||||
for (b in bytes) {
|
}
|
||||||
sb.append(String.format("%02x", b))
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return sb.toString()
|
|
||||||
}
|
}
|
||||||
|
|
43
src/main/kotlin/mdnet/base/server/crypto.kt
Normal file
43
src/main/kotlin/mdnet/base/server/crypto.kt
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
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()
|
||||||
|
}
|
|
@ -1,42 +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 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)
|
|
||||||
}
|
|
|
@ -18,14 +18,52 @@ 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)
|
||||||
data class ClientSettings(
|
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",
|
||||||
|
@ -33,9 +71,7 @@ data class ClientSettings(
|
||||||
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)
|
||||||
|
|
|
@ -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 ServerSettings(
|
data class RemoteSettings(
|
||||||
val imageServer: String,
|
val imageServer: String,
|
||||||
val latestBuild: Int,
|
val latestBuild: Int,
|
||||||
val url: String,
|
val url: String,
|
||||||
val tokenKey: ByteArray,
|
@field:Secret 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 ServerSettings(
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
if (javaClass != other?.javaClass) return false
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
other as ServerSettings
|
other as RemoteSettings
|
||||||
|
|
||||||
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,10 +60,6 @@ data class ServerSettings(
|
||||||
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)
|
|
@ -1,15 +1,16 @@
|
||||||
<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:-TRACE}</level>
|
<level>${file-level:-WARN}</level>
|
||||||
</filter>
|
</filter>
|
||||||
|
|
||||||
<file>log/latest.log</file>
|
<file>log/latest.log</file>
|
||||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
<fileNamePattern>log/logFile.%d{yyyy-MM-dd_HH}.log</fileNamePattern>
|
<fileNamePattern>log/logFile.%d{yyyy-MM-dd_HH}.%i.log</fileNamePattern>
|
||||||
<maxHistory>12</maxHistory>
|
<maxHistory>12</maxHistory>
|
||||||
<totalSizeCap>5MB</totalSizeCap>
|
<maxFileSize>32MB</maxFileSize>
|
||||||
</rollingPolicy>
|
<totalSizeCap>256MB</totalSizeCap>
|
||||||
|
</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>
|
||||||
|
@ -36,5 +37,6 @@
|
||||||
<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>
|
||||||
|
|
Loading…
Reference in a new issue