mirror of
https://gitlab.com/mangadex-pub/mangadex_at_home.git
synced 2024-01-19 02:48:37 +00:00
Merge branch 'jackson' into 'master'
Switch from Gson to Jackson for better null checks and invalid property support See merge request mangadex/mangadex_at_home!35
This commit is contained in:
commit
7a278c0386
|
@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
|
- [2020-06-19] Errored out on invalid settings.json tokens [@carbotaniuman]
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- [2020-06-19] Changed default CPU thread count to `4` by [@lflare].
|
- [2020-06-19] Changed default CPU thread count to `4` by [@lflare].
|
||||||
|
|
|
@ -20,16 +20,16 @@ dependencies {
|
||||||
compileOnly group:"dev.afanasev", name: "sekret-annotation", version: "0.0.3"
|
compileOnly group:"dev.afanasev", name: "sekret-annotation", version: "0.0.3"
|
||||||
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-reflect"
|
||||||
|
|
||||||
implementation group: "commons-io", name: "commons-io", version: "2.7"
|
implementation group: "commons-io", name: "commons-io", version: "2.7"
|
||||||
implementation group: "com.konghq", name: "unirest-java", version: "3.7.02"
|
|
||||||
|
|
||||||
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-gson", version: "$http_4k_version"
|
implementation group: "org.http4k", name: "http4k-format-jackson", version: "$http_4k_version"
|
||||||
implementation group: "org.http4k", name: "http4k-client-apache", version: "$http_4k_version"
|
implementation group: "org.http4k", name: "http4k-client-apache", version: "$http_4k_version"
|
||||||
implementation group: "org.http4k", name: "http4k-server-netty", version: "$http_4k_version"
|
implementation group: "org.http4k", name: "http4k-server-netty", version: "$http_4k_version"
|
||||||
runtimeOnly group:"io.netty", name: "netty-tcnative-boringssl-static", version: "2.0.30.Final"
|
runtimeOnly group:"io.netty", name: "netty-tcnative-boringssl-static", version: "2.0.30.Final"
|
||||||
|
|
||||||
|
|
||||||
implementation group:"ch.qos.logback", name: "logback-classic", version: "1.2.1"
|
implementation group:"ch.qos.logback", name: "logback-classic", version: "1.2.1"
|
||||||
|
|
||||||
implementation group: "org.jetbrains.exposed", name: "exposed-core", version: "$exposed_version"
|
implementation group: "org.jetbrains.exposed", name: "exposed-core", version: "$exposed_version"
|
||||||
|
|
|
@ -1,104 +0,0 @@
|
||||||
package mdnet.base;
|
|
||||||
|
|
||||||
import mdnet.base.settings.ClientSettings;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.io.FileNotFoundException;
|
|
||||||
import java.io.FileReader;
|
|
||||||
import java.io.FileWriter;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import static mdnet.base.Constants.GSON;
|
|
||||||
|
|
||||||
public class Main {
|
|
||||||
private final static Logger LOGGER = LoggerFactory.getLogger(Main.class);
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
System.out.println("Mangadex@Home Client " + Constants.CLIENT_VERSION + " (Build " + Constants.CLIENT_BUILD
|
|
||||||
+ ") initializing\n");
|
|
||||||
System.out.println("Copyright (c) 2020, MangaDex Network");
|
|
||||||
|
|
||||||
String file = "settings.json";
|
|
||||||
if (args.length == 1) {
|
|
||||||
file = args[0];
|
|
||||||
} else if (args.length != 0) {
|
|
||||||
dieWithError("Expected one argument: path to config file, or nothing");
|
|
||||||
}
|
|
||||||
|
|
||||||
ClientSettings settings;
|
|
||||||
|
|
||||||
try {
|
|
||||||
settings = GSON.fromJson(new FileReader(file), ClientSettings.class);
|
|
||||||
} catch (FileNotFoundException ignored) {
|
|
||||||
settings = new ClientSettings();
|
|
||||||
LOGGER.warn("Settings file {} not found, generating file", file);
|
|
||||||
try (FileWriter writer = new FileWriter(file)) {
|
|
||||||
writer.write(GSON.toJson(settings));
|
|
||||||
} catch (IOException e) {
|
|
||||||
dieWithError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
validateSettings(settings);
|
|
||||||
|
|
||||||
if (LOGGER.isInfoEnabled()) {
|
|
||||||
LOGGER.info("Client settings loaded: {}", settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
MangaDexClient client = new MangaDexClient(settings);
|
|
||||||
Runtime.getRuntime().addShutdownHook(new Thread(client::shutdown));
|
|
||||||
client.runLoop();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void dieWithError(Throwable e) {
|
|
||||||
if (LOGGER.isErrorEnabled()) {
|
|
||||||
LOGGER.error("Critical Error", e);
|
|
||||||
}
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void dieWithError(String error) {
|
|
||||||
if (LOGGER.isErrorEnabled()) {
|
|
||||||
LOGGER.error("Critical Error: {}", error);
|
|
||||||
}
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void validateSettings(ClientSettings settings) {
|
|
||||||
if (!isSecretValid(settings.getClientSecret()))
|
|
||||||
dieWithError("Config Error: API Secret is invalid, must be 52 alphanumeric characters");
|
|
||||||
|
|
||||||
if (settings.getClientPort() == 0) {
|
|
||||||
dieWithError("Config Error: Invalid port number");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.getMaxCacheSizeInMebibytes() < 1024) {
|
|
||||||
dieWithError("Config Error: Invalid max cache size, must be >= 1024 MiB (1GiB)");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.getThreads() < 4) {
|
|
||||||
dieWithError("Config Error: Invalid number of threads, must be >= 4");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.getMaxMebibytesPerHour() < 0) {
|
|
||||||
dieWithError("Config Error: Max bandwidth must be >= 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.getMaxKilobitsPerSecond() < 0) {
|
|
||||||
dieWithError("Config Error: Max burst rate must be >= 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.getWebSettings() != null) {
|
|
||||||
if (settings.getWebSettings().getUiPort() == 0) {
|
|
||||||
dieWithError("Config Error: Invalid UI port number");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean isSecretValid(String clientSecret) {
|
|
||||||
final int CLIENT_KEY_LENGTH = 52;
|
|
||||||
return Pattern.matches("^[a-zA-Z0-9]{" + CLIENT_KEY_LENGTH + "}$", clientSecret);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,6 +3,7 @@ package mdnet.base;
|
||||||
import mdnet.base.settings.ClientSettings;
|
import mdnet.base.settings.ClientSettings;
|
||||||
import mdnet.base.server.ApplicationKt;
|
import mdnet.base.server.ApplicationKt;
|
||||||
import mdnet.base.server.WebUiKt;
|
import mdnet.base.server.WebUiKt;
|
||||||
|
import mdnet.base.settings.ServerSettings;
|
||||||
import mdnet.cache.DiskLruCache;
|
import mdnet.cache.DiskLruCache;
|
||||||
import org.http4k.server.Http4kServer;
|
import org.http4k.server.Http4kServer;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
@ -19,7 +20,7 @@ import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static mdnet.base.Constants.GSON;
|
import static mdnet.base.Constants.JACKSON;
|
||||||
|
|
||||||
public class MangaDexClient {
|
public class MangaDexClient {
|
||||||
private final static Logger LOGGER = LoggerFactory.getLogger(MangaDexClient.class);
|
private final static Logger LOGGER = LoggerFactory.getLogger(MangaDexClient.class);
|
||||||
|
@ -62,8 +63,8 @@ public class MangaDexClient {
|
||||||
|
|
||||||
DiskLruCache.Snapshot snapshot = cache.get("statistics");
|
DiskLruCache.Snapshot snapshot = cache.get("statistics");
|
||||||
if (snapshot != null) {
|
if (snapshot != null) {
|
||||||
String json = snapshot.getString(0);
|
statistics.set(JACKSON.readValue(snapshot.getInputStream(0), Statistics.class));
|
||||||
statistics.set(GSON.fromJson(json, Statistics.class));
|
snapshot.close();
|
||||||
} else {
|
} else {
|
||||||
statistics.set(new Statistics());
|
statistics.set(new Statistics());
|
||||||
}
|
}
|
||||||
|
@ -215,8 +216,7 @@ public class MangaDexClient {
|
||||||
|
|
||||||
DiskLruCache.Editor editor = cache.edit("statistics");
|
DiskLruCache.Editor editor = cache.edit("statistics");
|
||||||
if (editor != null) {
|
if (editor != null) {
|
||||||
String json = GSON.toJson(statistics.get(), Statistics.class);
|
JACKSON.writeValue(editor.newOutputStream(0), Statistics.class);
|
||||||
editor.setString(0, json);
|
|
||||||
editor.commit();
|
editor.commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,84 +0,0 @@
|
||||||
package mdnet.base;
|
|
||||||
|
|
||||||
import kong.unirest.HttpResponse;
|
|
||||||
import kong.unirest.Unirest;
|
|
||||||
import kong.unirest.json.JSONObject;
|
|
||||||
import mdnet.base.settings.ClientSettings;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
|
|
||||||
public class ServerHandler {
|
|
||||||
private final static Logger LOGGER = LoggerFactory.getLogger(ServerHandler.class);
|
|
||||||
private static final String SERVER_ADDRESS = "https://api.mangadex.network/";
|
|
||||||
|
|
||||||
private final ClientSettings settings;
|
|
||||||
|
|
||||||
public ServerHandler(ClientSettings settings) {
|
|
||||||
this.settings = settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean logoutFromControl() {
|
|
||||||
if (LOGGER.isInfoEnabled()) {
|
|
||||||
LOGGER.info("Disconnecting from the control server");
|
|
||||||
}
|
|
||||||
|
|
||||||
HashMap<String, Object> params = new HashMap<>();
|
|
||||||
params.put("secret", settings.getClientSecret());
|
|
||||||
|
|
||||||
HttpResponse<?> json = Unirest.post(SERVER_ADDRESS + "stop").header("Content-Type", "application/json")
|
|
||||||
.body(new JSONObject(params)).asEmpty();
|
|
||||||
|
|
||||||
return json.isSuccess();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServerSettings loginToControl() {
|
|
||||||
if (LOGGER.isInfoEnabled()) {
|
|
||||||
LOGGER.info("Connecting to the control server");
|
|
||||||
}
|
|
||||||
|
|
||||||
HashMap<String, Object> params = new HashMap<>();
|
|
||||||
params.put("secret", settings.getClientSecret());
|
|
||||||
params.put("port", settings.getClientPort());
|
|
||||||
params.put("disk_space", settings.getMaxCacheSizeInMebibytes() * 1024 * 1024 /* MiB to bytes */);
|
|
||||||
params.put("network_speed", settings.getMaxKilobitsPerSecond() * 1000 / 8 /* Kbps to bytes */);
|
|
||||||
params.put("build_version", Constants.CLIENT_BUILD);
|
|
||||||
|
|
||||||
HttpResponse<ServerSettings> response = Unirest.post(SERVER_ADDRESS + "ping")
|
|
||||||
.header("Content-Type", "application/json").body(new JSONObject(params)).asObject(ServerSettings.class);
|
|
||||||
|
|
||||||
if (response.isSuccess()) {
|
|
||||||
return response.getBody();
|
|
||||||
} else {
|
|
||||||
// unirest deserializes errors into an object with all null fields instead of a
|
|
||||||
// null object
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServerSettings pingControl(ServerSettings old) {
|
|
||||||
if (LOGGER.isInfoEnabled()) {
|
|
||||||
LOGGER.info("Pinging the control server");
|
|
||||||
}
|
|
||||||
|
|
||||||
HashMap<String, Object> params = new HashMap<>();
|
|
||||||
params.put("secret", settings.getClientSecret());
|
|
||||||
params.put("port", settings.getClientPort());
|
|
||||||
params.put("disk_space", settings.getMaxCacheSizeInMebibytes() * 1024 * 1024 /* MiB to bytes */);
|
|
||||||
params.put("network_speed", settings.getMaxKilobitsPerSecond() * 1000 / 8 /* Kbps to bytes */);
|
|
||||||
params.put("build_version", Constants.CLIENT_BUILD);
|
|
||||||
params.put("tls_created_at", old.getTls().getCreatedAt());
|
|
||||||
|
|
||||||
HttpResponse<ServerSettings> response = Unirest.post(SERVER_ADDRESS + "ping")
|
|
||||||
.header("Content-Type", "application/json").body(new JSONObject(params)).asObject(ServerSettings.class);
|
|
||||||
|
|
||||||
if (response.isSuccess()) {
|
|
||||||
return response.getBody();
|
|
||||||
} else {
|
|
||||||
// unirest deserializes errors into an object with all null fields instead of a
|
|
||||||
// null object
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,118 +0,0 @@
|
||||||
package mdnet.base;
|
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName;
|
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public final class ServerSettings {
|
|
||||||
@SerializedName("image_server")
|
|
||||||
private final String imageServer;
|
|
||||||
private final TlsCert tls;
|
|
||||||
@SerializedName("latest_build")
|
|
||||||
private final int latestBuild;
|
|
||||||
private final String url;
|
|
||||||
private final boolean compromised;
|
|
||||||
|
|
||||||
public ServerSettings(String imageServer, TlsCert tls, int latestBuild, String url, boolean compromised) {
|
|
||||||
this.imageServer = Objects.requireNonNull(imageServer);
|
|
||||||
this.tls = tls;
|
|
||||||
this.latestBuild = latestBuild;
|
|
||||||
this.url = url;
|
|
||||||
this.compromised = compromised;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getImageServer() {
|
|
||||||
return imageServer;
|
|
||||||
}
|
|
||||||
|
|
||||||
public TlsCert getTls() {
|
|
||||||
return tls;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getLatestBuild() {
|
|
||||||
return latestBuild;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return "ServerSettings{" + "imageServer='" + imageServer + '\'' + ", tls=" + tls + ", latestBuild="
|
|
||||||
+ latestBuild + ", url='" + url + "', compromised=" + compromised + '}';
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean equals(Object o) {
|
|
||||||
if (this == o)
|
|
||||||
return true;
|
|
||||||
if (o == null || getClass() != o.getClass())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
ServerSettings that = (ServerSettings) o;
|
|
||||||
|
|
||||||
if (!imageServer.equals(that.imageServer))
|
|
||||||
return false;
|
|
||||||
return Objects.equals(tls, that.tls);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int hashCode() {
|
|
||||||
int result = imageServer.hashCode();
|
|
||||||
result = 31 * result + (tls != null ? tls.hashCode() : 0);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static final class TlsCert {
|
|
||||||
@SerializedName("created_at")
|
|
||||||
private final String createdAt;
|
|
||||||
@SerializedName("private_key")
|
|
||||||
private final String privateKey;
|
|
||||||
private final String certificate;
|
|
||||||
|
|
||||||
public TlsCert(String createdAt, String privateKey, String certificate) {
|
|
||||||
this.createdAt = Objects.requireNonNull(createdAt);
|
|
||||||
this.privateKey = Objects.requireNonNull(privateKey);
|
|
||||||
this.certificate = Objects.requireNonNull(certificate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getCreatedAt() {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getPrivateKey() {
|
|
||||||
return privateKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getCertificate() {
|
|
||||||
return certificate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return "TlsCert{" + "createdAt='" + createdAt + '\'' + ", privateKey='" + "<hidden>" + '\''
|
|
||||||
+ ", certificate='" + "<hidden>" + '\'' + '}';
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean equals(Object o) {
|
|
||||||
if (this == o)
|
|
||||||
return true;
|
|
||||||
if (o == null || getClass() != o.getClass())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
TlsCert tlsCert = (TlsCert) o;
|
|
||||||
|
|
||||||
if (!createdAt.equals(tlsCert.createdAt))
|
|
||||||
return false;
|
|
||||||
if (!privateKey.equals(tlsCert.privateKey))
|
|
||||||
return false;
|
|
||||||
return certificate.equals(tlsCert.certificate);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int hashCode() {
|
|
||||||
int result = createdAt.hashCode();
|
|
||||||
result = 31 * result + privateKey.hashCode();
|
|
||||||
result = 31 * result + certificate.hashCode();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +1,6 @@
|
||||||
package mdnet.base
|
package mdnet.base
|
||||||
|
|
||||||
import com.google.gson.Gson
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.google.gson.GsonBuilder
|
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
|
||||||
object Constants {
|
object Constants {
|
||||||
|
@ -10,5 +9,5 @@ object Constants {
|
||||||
const val WEBUI_VERSION = "0.1.1"
|
const val WEBUI_VERSION = "0.1.1"
|
||||||
val MAX_AGE_CACHE: Duration = Duration.ofDays(14)
|
val MAX_AGE_CACHE: Duration = Duration.ofDays(14)
|
||||||
@JvmField
|
@JvmField
|
||||||
val GSON: Gson = GsonBuilder().setPrettyPrinting().create()
|
val JACKSON = jacksonObjectMapper()
|
||||||
}
|
}
|
||||||
|
|
103
src/main/kotlin/mdnet/base/Main.kt
Normal file
103
src/main/kotlin/mdnet/base/Main.kt
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
package mdnet.base
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonParseException
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException
|
||||||
|
import com.fasterxml.jackson.databind.JsonMappingException
|
||||||
|
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import mdnet.base.Constants.JACKSON
|
||||||
|
import mdnet.base.settings.ClientSettings
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.io.FileReader
|
||||||
|
import java.io.FileWriter
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
object Main {
|
||||||
|
private val LOGGER = LoggerFactory.getLogger(Main::class.java)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
println(
|
||||||
|
"Mangadex@Home Client ${Constants.CLIENT_VERSION} (Build ${Constants.CLIENT_BUILD}) initializing"
|
||||||
|
)
|
||||||
|
println("Copyright (c) 2020, MangaDex Network")
|
||||||
|
|
||||||
|
var file = "settings.json"
|
||||||
|
if (args.size == 1) {
|
||||||
|
file = args[0]
|
||||||
|
} else if (args.isNotEmpty()) {
|
||||||
|
dieWithError("Expected one argument: path to config file, or nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
val settings: ClientSettings = 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)
|
||||||
|
|
||||||
|
if (LOGGER.isInfoEnabled) {
|
||||||
|
LOGGER.info("Client settings loaded: {}", settings)
|
||||||
|
}
|
||||||
|
val client = MangaDexClient(settings)
|
||||||
|
Runtime.getRuntime().addShutdownHook(Thread { client.shutdown() })
|
||||||
|
client.runLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun dieWithError(e: Throwable): Nothing {
|
||||||
|
if (LOGGER.isErrorEnabled) {
|
||||||
|
LOGGER.error("Critical Error", e)
|
||||||
|
}
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun dieWithError(error: String): Nothing {
|
||||||
|
if (LOGGER.isErrorEnabled) {
|
||||||
|
LOGGER.error("Critical Error: {}", error)
|
||||||
|
}
|
||||||
|
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.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)
|
||||||
|
}
|
||||||
|
}
|
93
src/main/kotlin/mdnet/base/ServerHandler.kt
Normal file
93
src/main/kotlin/mdnet/base/ServerHandler.kt
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
package mdnet.base
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||||
|
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||||
|
import mdnet.base.settings.ClientSettings
|
||||||
|
import mdnet.base.settings.ServerSettings
|
||||||
|
import org.http4k.client.ApacheClient
|
||||||
|
import org.http4k.core.Body
|
||||||
|
import org.http4k.core.Method
|
||||||
|
import org.http4k.core.Request
|
||||||
|
import org.http4k.format.ConfigurableJackson
|
||||||
|
import org.http4k.format.asConfigurable
|
||||||
|
import org.http4k.format.withStandardMappings
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
|
import mdnet.base.ServerHandlerJackson.auto
|
||||||
|
object ServerHandlerJackson : ConfigurableJackson(
|
||||||
|
KotlinModule()
|
||||||
|
.asConfigurable()
|
||||||
|
.withStandardMappings()
|
||||||
|
.done()
|
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
class ServerHandler(private val settings: ClientSettings) {
|
||||||
|
private val client = ApacheClient()
|
||||||
|
|
||||||
|
fun logoutFromControl(): Boolean {
|
||||||
|
if (LOGGER.isInfoEnabled) {
|
||||||
|
LOGGER.info("Disconnecting from the control server")
|
||||||
|
}
|
||||||
|
val params = mapOf<String, Any>(
|
||||||
|
"secret" to settings.clientSecret
|
||||||
|
)
|
||||||
|
|
||||||
|
val request = STRING_ANY_MAP_LENS(params, Request(Method.POST, SERVER_ADDRESS + "stop"))
|
||||||
|
val response = client(request)
|
||||||
|
|
||||||
|
return response.status.successful
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPingParams(tlsCreatedAt: String? = null): Map<String, Any> =
|
||||||
|
mapOf<String, Any>(
|
||||||
|
"secret" to settings.clientSecret,
|
||||||
|
"port" to settings.clientPort,
|
||||||
|
"disk_space" to settings.maxCacheSizeInMebibytes * 1024 * 1024,
|
||||||
|
"network_speed" to settings.maxKilobitsPerSecond * 1000 / 8,
|
||||||
|
"build_version" to Constants.CLIENT_BUILD
|
||||||
|
).let {
|
||||||
|
if (tlsCreatedAt != null) {
|
||||||
|
it.plus("tls_created_at" to tlsCreatedAt)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loginToControl(): ServerSettings? {
|
||||||
|
if (LOGGER.isInfoEnabled) {
|
||||||
|
LOGGER.info("Connecting to the control server")
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = STRING_ANY_MAP_LENS(getPingParams(), Request(Method.POST, SERVER_ADDRESS + "ping"))
|
||||||
|
val response = client(request)
|
||||||
|
|
||||||
|
return if (response.status.successful) {
|
||||||
|
SERVER_SETTINGS_LENS(response)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pingControl(old: ServerSettings): ServerSettings? {
|
||||||
|
if (LOGGER.isInfoEnabled) {
|
||||||
|
LOGGER.info("Pinging the control server")
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = STRING_ANY_MAP_LENS(getPingParams(old.tls!!.createdAt), Request(Method.POST, SERVER_ADDRESS + "ping"))
|
||||||
|
val response = client(request)
|
||||||
|
|
||||||
|
return if (response.status.successful) {
|
||||||
|
SERVER_SETTINGS_LENS(response)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LOGGER = LoggerFactory.getLogger(ServerHandler::class.java)
|
||||||
|
private val STRING_ANY_MAP_LENS = Body.auto<Map<String, Any>>().toLens()
|
||||||
|
private val SERVER_SETTINGS_LENS = Body.auto<ServerSettings>().toLens()
|
||||||
|
private const val SERVER_ADDRESS = "https://api.mangadex.network/"
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,14 @@
|
||||||
package mdnet.base
|
package mdnet.base
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.fasterxml.jackson.databind.PropertyNamingStrategy
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonNaming
|
||||||
|
|
||||||
|
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
|
||||||
data class Statistics(
|
data class Statistics(
|
||||||
@field:SerializedName("requests_served") val requestsServed: Int = 0,
|
val requestsServed: Int = 0,
|
||||||
@field:SerializedName("cache_hits") val cacheHits: Int = 0,
|
val cacheHits: Int = 0,
|
||||||
@field:SerializedName("cache_misses") val cacheMisses: Int = 0,
|
val cacheMisses: Int = 0,
|
||||||
@field:SerializedName("browser_cached") val browserCached: Int = 0,
|
val browserCached: Int = 0,
|
||||||
@field:SerializedName("bytes_sent") val bytesSent: Long = 0,
|
val bytesSent: Long = 0,
|
||||||
@field:SerializedName("bytes_on_disk") val bytesOnDisk: Long = 0
|
val bytesOnDisk: Long = 0
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
package mdnet.base
|
package mdnet.base.netty
|
||||||
|
|
||||||
import io.netty.bootstrap.ServerBootstrap
|
import io.netty.bootstrap.ServerBootstrap
|
||||||
import io.netty.channel.ChannelFactory
|
import io.netty.channel.ChannelFactory
|
||||||
|
@ -19,7 +19,9 @@ import io.netty.handler.ssl.SslContextBuilder
|
||||||
import io.netty.handler.stream.ChunkedWriteHandler
|
import io.netty.handler.stream.ChunkedWriteHandler
|
||||||
import io.netty.handler.traffic.GlobalTrafficShapingHandler
|
import io.netty.handler.traffic.GlobalTrafficShapingHandler
|
||||||
import io.netty.handler.traffic.TrafficCounter
|
import io.netty.handler.traffic.TrafficCounter
|
||||||
|
import mdnet.base.Statistics
|
||||||
import mdnet.base.settings.ClientSettings
|
import mdnet.base.settings.ClientSettings
|
||||||
|
import mdnet.base.settings.TlsCert
|
||||||
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
|
||||||
|
@ -38,7 +40,7 @@ import javax.net.ssl.SSLException
|
||||||
|
|
||||||
private val LOGGER = LoggerFactory.getLogger("Application")
|
private val LOGGER = LoggerFactory.getLogger("Application")
|
||||||
|
|
||||||
class Netty(private val tls: ServerSettings.TlsCert, private val clientSettings: ClientSettings, private val statistics: AtomicReference<Statistics>) : ServerConfig {
|
class Netty(private val tls: TlsCert, private val clientSettings: ClientSettings, private val statistics: AtomicReference<Statistics>) : ServerConfig {
|
||||||
override fun toServer(httpHandler: HttpHandler): Http4kServer = object : Http4kServer {
|
override fun toServer(httpHandler: HttpHandler): Http4kServer = object : Http4kServer {
|
||||||
private val masterGroup = NioEventLoopGroup(clientSettings.threads)
|
private val masterGroup = NioEventLoopGroup(clientSettings.threads)
|
||||||
private val workerGroup = NioEventLoopGroup(clientSettings.threads)
|
private val workerGroup = NioEventLoopGroup(clientSettings.threads)
|
|
@ -19,7 +19,7 @@
|
||||||
//LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
//LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
//OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
//OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
//SOFTWARE.
|
//SOFTWARE.
|
||||||
package mdnet.base
|
package mdnet.base.netty
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.security.KeyFactory
|
import java.security.KeyFactory
|
||||||
|
@ -35,13 +35,23 @@ private const val PKCS_8_PEM_FOOTER = "-----END PRIVATE KEY-----"
|
||||||
fun loadKey(keyDataString: String): PrivateKey? {
|
fun loadKey(keyDataString: String): PrivateKey? {
|
||||||
if (keyDataString.contains(PKCS_1_PEM_HEADER)) {
|
if (keyDataString.contains(PKCS_1_PEM_HEADER)) {
|
||||||
// OpenSSL / PKCS#1 Base64 PEM encoded file
|
// OpenSSL / PKCS#1 Base64 PEM encoded file
|
||||||
val fixedString = keyDataString.replace(PKCS_1_PEM_HEADER, "").replace(PKCS_1_PEM_FOOTER, "")
|
val fixedString = keyDataString.replace(PKCS_1_PEM_HEADER, "").replace(
|
||||||
return readPkcs1PrivateKey(base64Decode(fixedString))
|
PKCS_1_PEM_FOOTER, "")
|
||||||
|
return readPkcs1PrivateKey(
|
||||||
|
base64Decode(
|
||||||
|
fixedString
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (keyDataString.contains(PKCS_8_PEM_HEADER)) {
|
if (keyDataString.contains(PKCS_8_PEM_HEADER)) {
|
||||||
// PKCS#8 Base64 PEM encoded file
|
// PKCS#8 Base64 PEM encoded file
|
||||||
val fixedString = keyDataString.replace(PKCS_8_PEM_HEADER, "").replace(PKCS_8_PEM_FOOTER, "")
|
val fixedString = keyDataString.replace(PKCS_8_PEM_HEADER, "").replace(
|
||||||
return readPkcs1PrivateKey(base64Decode(fixedString))
|
PKCS_8_PEM_FOOTER, "")
|
||||||
|
return readPkcs1PrivateKey(
|
||||||
|
base64Decode(
|
||||||
|
fixedString
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
2
src/main/kotlin/mdnet/base/WebUiNetty.kt → src/main/kotlin/mdnet/base/netty/WebUiNetty.kt
Executable file → Normal file
2
src/main/kotlin/mdnet/base/WebUiNetty.kt → src/main/kotlin/mdnet/base/netty/WebUiNetty.kt
Executable file → Normal file
|
@ -1,4 +1,4 @@
|
||||||
package mdnet.base
|
package mdnet.base.netty
|
||||||
|
|
||||||
import io.netty.bootstrap.ServerBootstrap
|
import io.netty.bootstrap.ServerBootstrap
|
||||||
import io.netty.channel.ChannelFactory
|
import io.netty.channel.ChannelFactory
|
|
@ -1,8 +1,8 @@
|
||||||
/* ktlint-disable no-wildcard-imports */
|
/* ktlint-disable no-wildcard-imports */
|
||||||
package mdnet.base.server
|
package mdnet.base.server
|
||||||
|
|
||||||
import mdnet.base.Netty
|
import mdnet.base.netty.Netty
|
||||||
import mdnet.base.ServerSettings
|
import mdnet.base.settings.ServerSettings
|
||||||
import mdnet.base.Statistics
|
import mdnet.base.Statistics
|
||||||
import mdnet.base.settings.ClientSettings
|
import mdnet.base.settings.ClientSettings
|
||||||
import mdnet.cache.DiskLruCache
|
import mdnet.cache.DiskLruCache
|
||||||
|
@ -21,7 +21,7 @@ fun getServer(cache: DiskLruCache, serverSettings: ServerSettings, clientSetting
|
||||||
val database = Database.connect("jdbc:sqlite:cache/data.db", "org.sqlite.JDBC")
|
val database = Database.connect("jdbc:sqlite:cache/data.db", "org.sqlite.JDBC")
|
||||||
val imageServer = ImageServer(cache, statistics, serverSettings.imageServer, database, isHandled)
|
val imageServer = ImageServer(cache, statistics, serverSettings.imageServer, database, isHandled)
|
||||||
|
|
||||||
return Timer
|
return timeRequest()
|
||||||
.then(catchAllHideDetails())
|
.then(catchAllHideDetails())
|
||||||
.then(ServerFilters.CatchLensFailure)
|
.then(ServerFilters.CatchLensFailure)
|
||||||
.then(addCommonHeaders())
|
.then(addCommonHeaders())
|
||||||
|
@ -39,5 +39,5 @@ fun getServer(cache: DiskLruCache, serverSettings: ServerSettings, clientSetting
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.asServer(Netty(serverSettings.tls, clientSettings, statistics))
|
.asServer(Netty(serverSettings.tls!!, clientSettings, statistics))
|
||||||
}
|
}
|
||||||
|
|
|
@ -200,8 +200,8 @@ class ImageServer(private val cache: DiskLruCache, private val statistics: Atomi
|
||||||
}
|
}
|
||||||
editor.commit()
|
editor.commit()
|
||||||
} else {
|
} else {
|
||||||
if (LOGGER.isInfoEnabled) {
|
if (LOGGER.isWarnEnabled) {
|
||||||
LOGGER.info("Cache download for $sanitizedUri aborted")
|
LOGGER.warn("Cache download for $sanitizedUri aborted")
|
||||||
}
|
}
|
||||||
editor.abort()
|
editor.abort()
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ package mdnet.base.server
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
import mdnet.base.Statistics
|
import mdnet.base.Statistics
|
||||||
import mdnet.base.WebUiNetty
|
import mdnet.base.netty.WebUiNetty
|
||||||
import mdnet.base.settings.WebSettings
|
import mdnet.base.settings.WebSettings
|
||||||
import org.http4k.core.Body
|
import org.http4k.core.Body
|
||||||
import org.http4k.core.Method
|
import org.http4k.core.Method
|
||||||
|
@ -12,7 +12,7 @@ import org.http4k.core.Response
|
||||||
import org.http4k.core.Status
|
import org.http4k.core.Status
|
||||||
import org.http4k.core.then
|
import org.http4k.core.then
|
||||||
import org.http4k.filter.ServerFilters
|
import org.http4k.filter.ServerFilters
|
||||||
import org.http4k.format.Gson.auto
|
import org.http4k.format.Jackson.auto
|
||||||
import org.http4k.routing.ResourceLoader
|
import org.http4k.routing.ResourceLoader
|
||||||
import org.http4k.routing.bind
|
import org.http4k.routing.bind
|
||||||
import org.http4k.routing.routes
|
import org.http4k.routing.routes
|
||||||
|
|
|
@ -41,25 +41,24 @@ fun catchAllHideDetails(): Filter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val Timer = Filter {
|
fun timeRequest(): Filter {
|
||||||
next: HttpHandler -> {
|
return Filter { next: HttpHandler ->
|
||||||
request: Request ->
|
{ request: Request ->
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
val response = next(request)
|
val response = next(request)
|
||||||
val latency = System.currentTimeMillis() - start
|
val latency = System.currentTimeMillis() - start
|
||||||
if (LOGGER.isTraceEnabled && response.header("X-Uri") != null) {
|
if (LOGGER.isTraceEnabled && response.header("X-Uri") != null) {
|
||||||
// Dirty hack to get sanitizedUri from ImageServer
|
// Dirty hack to get sanitizedUri from ImageServer
|
||||||
val sanitizedUri = response.header("X-Uri")
|
val sanitizedUri = response.header("X-Uri")
|
||||||
|
|
||||||
// Log in TRACE
|
// Log in TRACE
|
||||||
if (LOGGER.isInfoEnabled) {
|
if (LOGGER.isInfoEnabled) {
|
||||||
LOGGER.info("Request for $sanitizedUri completed in ${latency}ms")
|
LOGGER.info("Request for $sanitizedUri completed in ${latency}ms")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete response header entirely
|
// Delete response header entirely
|
||||||
response.header("X-Uri", null)
|
response.header("X-Uri", null)
|
||||||
}
|
}
|
||||||
// Set response header with processing times
|
// Set response header with processing times
|
||||||
response.header("X-Time-Taken", latency.toString())
|
response.header("X-Time-Taken", latency.toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,23 @@
|
||||||
package mdnet.base.settings
|
package mdnet.base.settings
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.fasterxml.jackson.databind.PropertyNamingStrategy
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonNaming
|
||||||
import dev.afanasev.sekret.Secret
|
import dev.afanasev.sekret.Secret
|
||||||
|
|
||||||
|
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
|
||||||
data class ClientSettings(
|
data class ClientSettings(
|
||||||
@field:SerializedName("max_cache_size_in_mebibytes") val maxCacheSizeInMebibytes: Long = 20480,
|
val maxCacheSizeInMebibytes: Long = 20480,
|
||||||
@field:SerializedName("max_mebibytes_per_hour") val maxMebibytesPerHour: Long = 0,
|
val maxMebibytesPerHour: Long = 0,
|
||||||
@field:SerializedName("max_kilobits_per_second") val maxKilobitsPerSecond: Long = 0,
|
val maxKilobitsPerSecond: Long = 0,
|
||||||
@field:SerializedName("client_hostname") val clientHostname: String = "0.0.0.0",
|
val clientHostname: String = "0.0.0.0",
|
||||||
@field:SerializedName("client_port") val clientPort: Int = 443,
|
val clientPort: Int = 443,
|
||||||
@field:Secret @field:SerializedName("client_secret") val clientSecret: String = "PASTE-YOUR-SECRET-HERE",
|
@field:Secret val clientSecret: String = "PASTE-YOUR-SECRET-HERE",
|
||||||
@field:SerializedName("threads") val threads: Int = 4,
|
val threads: Int = 4,
|
||||||
@field:SerializedName("web_settings") val webSettings: WebSettings? = null
|
val webSettings: WebSettings? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
|
||||||
data class WebSettings(
|
data class WebSettings(
|
||||||
@field:SerializedName("ui_hostname") val uiHostname: String = "127.0.0.1",
|
val uiHostname: String = "127.0.0.1",
|
||||||
@field:SerializedName("ui_port") val uiPort: Int = 8080
|
val uiPort: Int = 8080
|
||||||
)
|
)
|
||||||
|
|
21
src/main/kotlin/mdnet/base/settings/ServerSettings.kt
Normal file
21
src/main/kotlin/mdnet/base/settings/ServerSettings.kt
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
package mdnet.base.settings
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.PropertyNamingStrategy
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonNaming
|
||||||
|
import dev.afanasev.sekret.Secret
|
||||||
|
|
||||||
|
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
|
||||||
|
data class ServerSettings (
|
||||||
|
val imageServer: String,
|
||||||
|
val latestBuild: Int,
|
||||||
|
val url: String,
|
||||||
|
val compromised: Boolean,
|
||||||
|
val tls: TlsCert?
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
|
||||||
|
data class TlsCert (
|
||||||
|
val createdAt: String,
|
||||||
|
@field:Secret val privateKey: String,
|
||||||
|
@field:Secret val certificate: String
|
||||||
|
)
|
Loading…
Reference in a new issue