Page MenuHome GnuPG

No OneTemporary

diff --git a/broker/CMakeLists.txt b/broker/CMakeLists.txt
index 89c91d0..e7473a7 100644
--- a/broker/CMakeLists.txt
+++ b/broker/CMakeLists.txt
@@ -1,7 +1,22 @@
# SPDX-FileCopyrightText: 2023 g10 code GmbH
# SPDX-Contributor: Carl Schwan <carl.schwan@gnupg.com>
# SPDX-License-Identifier: BSD-2-Clauses
add_executable(gpgol-broker)
-target_sources(gpgol-broker PRIVATE main.cpp resources.qrc)
+target_sources(gpgol-broker PRIVATE
+ controllers/abstractcontroller.cpp
+ controllers/abstractcontroller.h
+ controllers/registrationcontroller.cpp
+ controllers/registrationcontroller.h
+ controllers/verificationcontroller.cpp
+ controllers/verificationcontroller.h
+ controllers/staticcontroller.h
+ controllers/staticcontroller.cpp
+
+ model/serverstate.cpp
+ model/serverstate.h
+
+ main.cpp
+ resources.qrc
+)
target_link_libraries(gpgol-broker PRIVATE Qt6::HttpServer Qt6::Core)
diff --git a/broker/controllers/abstractcontroller.cpp b/broker/controllers/abstractcontroller.cpp
new file mode 100644
index 0000000..22079a1
--- /dev/null
+++ b/broker/controllers/abstractcontroller.cpp
@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: 2023 g10 code GmbH
+// SPDX-Contributor: Carl Schwan <carl.schwan@gnupg.com>
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "abstractcontroller.h"
+
+QHttpServerResponse AbstractController::badRequest(const QString &reason)
+{
+ if (reason.isEmpty()) {
+ return QHttpServerResponse("text/plain", "Invalid request\n", QHttpServerResponse::StatusCode::BadRequest);
+ } else {
+ return QHttpServerResponse("text/plain", "Invalid request: " + reason.toUtf8() + "\n", QHttpServerResponse::StatusCode::BadRequest);
+ }
+}
diff --git a/broker/controllers/abstractcontroller.h b/broker/controllers/abstractcontroller.h
new file mode 100644
index 0000000..e78981f
--- /dev/null
+++ b/broker/controllers/abstractcontroller.h
@@ -0,0 +1,15 @@
+// SPDX-FileCopyrightText: 2023 g10 code GmbH
+// SPDX-Contributor: Carl Schwan <carl.schwan@gnupg.com>
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <QHttpServerResponse>
+#include <QString>
+
+class AbstractController
+{
+protected:
+ Q_REQUIRED_RESULT
+ static QHttpServerResponse badRequest(const QString &reason = {});
+};
diff --git a/broker/controllers/registrationcontroller.cpp b/broker/controllers/registrationcontroller.cpp
new file mode 100644
index 0000000..57c2a2f
--- /dev/null
+++ b/broker/controllers/registrationcontroller.cpp
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: 2023 g10 code GmbH
+// SPDX-Contributor: Carl Schwan <carl.schwan@gnupg.com>
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "registrationcontroller.h"
+
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QString>
+
+#include "../model/serverstate.h"
+
+using namespace Qt::Literals::StringLiterals;
+
+QHttpServerResponse RegistrationController::registerAction(const QHttpServerRequest &request)
+{
+ const auto json = QJsonDocument::fromJson(request.body());
+ if (json.isEmpty() || !json.isObject()) {
+ return badRequest();
+ }
+
+ const auto object = json.object();
+
+ if (object.isEmpty() || !object.contains("port"_L1) || !object.contains("email"_L1)) {
+ return badRequest();
+ }
+
+ ServerState::instance().servers[object["email"_L1].toString()] = Server {
+ object["serverId"_L1].toString(),
+ object["port"_L1].toInt(),
+ };
+
+ return QHttpServerResponse("text/plain", "Server added\n");
+}
diff --git a/broker/controllers/registrationcontroller.h b/broker/controllers/registrationcontroller.h
new file mode 100644
index 0000000..c91c9ee
--- /dev/null
+++ b/broker/controllers/registrationcontroller.h
@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: 2023 g10 code GmbH
+// SPDX-Contributor: Carl Schwan <carl.schwan@gnupg.com>
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include "abstractcontroller.h"
+
+#include <QHttpServerResponse>
+#include <QHttpServerRequest>
+
+class RegistrationController : public AbstractController
+{
+public:
+ static QHttpServerResponse registerAction(const QHttpServerRequest &request);
+};
diff --git a/broker/controllers/staticcontroller.cpp b/broker/controllers/staticcontroller.cpp
new file mode 100644
index 0000000..e855715
--- /dev/null
+++ b/broker/controllers/staticcontroller.cpp
@@ -0,0 +1,20 @@
+// SPDX-FileCopyrightText: 2023 g10 code GmbH
+// SPDX-Contributor: Carl Schwan <carl.schwan@gnupg.com>
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "staticcontroller.h"
+
+#include <QFile>
+#include <QDebug>
+
+using namespace Qt::Literals::StringLiterals;
+
+QHttpServerResponse StaticController::homeAction(const QHttpServerRequest &request)
+{
+ QFile file(u":/web/index.html"_s);
+ if (!file.open(QIODeviceBase::ReadOnly)) {
+ qWarning() << file.errorString();
+ }
+
+ return QHttpServerResponse("text/html", file.readAll());
+}
diff --git a/broker/controllers/staticcontroller.h b/broker/controllers/staticcontroller.h
new file mode 100644
index 0000000..6448a02
--- /dev/null
+++ b/broker/controllers/staticcontroller.h
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: 2023 g10 code GmbH
+// SPDX-Contributor: Carl Schwan <carl.schwan@gnupg.com>
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include "abstractcontroller.h"
+
+class StaticController : public AbstractController
+{
+public:
+ static QHttpServerResponse homeAction(const QHttpServerRequest &request);
+};
diff --git a/broker/controllers/verificationcontroller.cpp b/broker/controllers/verificationcontroller.cpp
new file mode 100644
index 0000000..5c78edd
--- /dev/null
+++ b/broker/controllers/verificationcontroller.cpp
@@ -0,0 +1,143 @@
+// SPDX-FileCopyrightText: 2023 g10 code GmbH
+// SPDX-Contributor: Carl Schwan <carl.schwan@gnupg.com>
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "verificationcontroller.h"
+
+#include "../model/serverstate.h"
+
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QNetworkRequest>
+#include <QNetworkAccessManager>
+
+using namespace Qt::Literals::StringLiterals;
+
+QHttpServerResponse VerificationController::verifyAction(const QHttpServerRequest &request) {
+ auto &state = ServerState::instance();
+
+ const auto doc = QJsonDocument::fromJson(request.body());
+ if (doc.isEmpty() || !doc.isObject() || !doc.object().contains("email"_L1) || !doc.object().contains("clientId"_L1)) {
+ return badRequest("Missing email or client id key in body");
+ }
+
+ const auto object = doc.object();
+ const auto email = object["email"_L1].toString();
+ const auto clientId = object["clientId"_L1].toString();
+
+ if (!state.servers.contains(email)) {
+ return badRequest("No kleopatra client registered with this email");
+ }
+ const auto port = state.servers[email].port;
+ const auto serverId = state.servers[email].id;
+
+ if (clientId.isEmpty()) {
+ return badRequest("Missing clientid in body");
+ }
+
+ // TODO use gpgme random generation
+ const auto token = QUuid::createUuid().toString(QUuid::WithoutBraces);
+ const auto jsonToken = QJsonDocument(QJsonObject{
+ { "token"_L1, token },
+ }).toJson();
+
+ // Trigger verification request in the kleopatra client
+ QNetworkRequest verifyRequest(QUrl(u"http://127.0.0.1:"_s + QString::number(port) + u"/client-verify"_s));
+ verifyRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ auto verifyReply = ServerState::instance().qnam.post(verifyRequest, jsonToken);
+
+ state.currentVerifications.insert(token, Verification(clientId, serverId));
+
+ return QHttpServerResponse("application/json", jsonToken);
+}
+
+QHttpServerResponse VerificationController::verifyConfirmWebAction(const QHttpServerRequest &request) {
+ auto &state = ServerState::instance();
+
+ const auto doc = QJsonDocument::fromJson(request.body());
+ if (doc.isEmpty() || !doc.isObject() || !doc.object().contains("email"_L1)
+ || !doc.object().contains("clientId"_L1)
+ || !doc.object().contains("token"_L1)) {
+ return badRequest("Missing email, token or client id key in body");
+ }
+
+ const auto object = doc.object();
+ const auto email = object["email"_L1].toString();
+ const auto clientId = object["clientId"_L1].toString();
+ const auto token = object["token"_L1].toString();
+
+ if (!state.servers.contains(email)) {
+ return badRequest("No kleopatra client registered with this email");
+ }
+ const auto port = state.servers[email].port;
+
+ if (clientId.isEmpty()) {
+ return badRequest("Missing clientid in body");
+ }
+
+ if (!state.currentVerifications.contains(token)) {
+ return badRequest("No current validation in progress with the given token");
+ }
+
+ auto &verification = state.currentVerifications[token];
+
+ if (verification.webclient) {
+ return badRequest("Already verified");
+ }
+
+ verification.webclient = true;
+
+ return QHttpServerResponse("application/json", QJsonDocument(QJsonObject{
+ { "confirmed"_L1, verification.isConfirmed(), },
+ }).toJson());
+}
+
+QHttpServerResponse VerificationController::verifyConfirmServerAction(const QHttpServerRequest &request) {
+ auto &state = ServerState::instance();
+
+ const auto doc = QJsonDocument::fromJson(request.body());
+ if (doc.isEmpty() || !doc.isObject() || !doc.object().contains("serverId"_L1)
+ || !doc.object().contains("token"_L1)) {
+ return badRequest("Missing email, token or client id key in body");
+ }
+
+ const auto object = doc.object();
+ const auto serverId = object["serverId"_L1].toString();
+ const auto token = object["token"_L1].toString();
+
+ if (!state.currentVerifications.contains(token)) {
+ return badRequest("No current validation in progress with the given token");
+ }
+
+ auto &verification = state.currentVerifications[token];
+
+ if (verification.serverId != serverId) {
+ return badRequest("Wrong server id:" + verification.serverId + " --- " + serverId);
+ }
+
+ if (verification.desktopserver) {
+ return badRequest("Already verified");
+ }
+
+ verification.desktopserver = true;
+
+ return QHttpServerResponse("application/json", QJsonDocument(QJsonObject{
+ { "confirmed"_L1, verification.isConfirmed(), },
+ }).toJson());
+}
+
+
+QHttpServerResponse VerificationController::verifyStatusAction(const QString token, const QHttpServerRequest &request)
+{
+ const auto &state = ServerState::instance();
+
+ if (!state.currentVerifications.contains(token)) {
+ return badRequest("No current validation in progress with the given token");
+ }
+
+ const auto &verification = state.currentVerifications[token];
+
+ return QHttpServerResponse("application/json", QJsonDocument(QJsonObject{
+ { "confirmed"_L1, verification.isConfirmed(), },
+ }).toJson());
+}
diff --git a/broker/controllers/verificationcontroller.h b/broker/controllers/verificationcontroller.h
new file mode 100644
index 0000000..ce597a2
--- /dev/null
+++ b/broker/controllers/verificationcontroller.h
@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: 2023 g10 code GmbH
+// SPDX-Contributor: Carl Schwan <carl.schwan@gnupg.com>
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include "abstractcontroller.h"
+
+#include <QHttpServerResponse>
+#include <QHttpServerRequest>
+
+class VerificationController : public AbstractController
+{
+public:
+ static QHttpServerResponse verifyAction(const QHttpServerRequest &request);
+ static QHttpServerResponse verifyConfirmWebAction(const QHttpServerRequest &request);
+ static QHttpServerResponse verifyConfirmServerAction(const QHttpServerRequest &request);
+ static QHttpServerResponse verifyStatusAction(const QString token, const QHttpServerRequest &request);
+};
diff --git a/broker/main.cpp b/broker/main.cpp
index ae19942..7743494 100644
--- a/broker/main.cpp
+++ b/broker/main.cpp
@@ -1,277 +1,93 @@
-// SPDX-FileCopyrightText: 2023 g10 code Gmbh
+// SPDX-FileCopyrightText: 2023 g10 code GmbH
// SPDX-Contributor: Carl Schwan <carl.schwan@gnupg.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QHttpServer>
#include <QHttpServerResponse>
#include <QCoreApplication>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QUuid>
#include <QFile>
+#include "controllers/registrationcontroller.h"
+#include "controllers/verificationcontroller.h"
+#include "controllers/staticcontroller.h"
+
using namespace Qt::Literals::StringLiterals;
namespace
{
QByteArray findHeader(QList<QPair<QByteArray, QByteArray>> headers, const QByteArray &key)
{
const auto it = std::find_if(std::cbegin(headers), std::cend(headers), [&key](auto header) {
return header.first == key;
});
if (it == std::cend(headers)) {
return {};
}
return it->second;
}
-
-Q_REQUIRED_RESULT
-QHttpServerResponse badRequest(const QString &reason = {})
-{
- if (reason.isEmpty()) {
- return QHttpServerResponse("text/plain", "Invalid request\n", QHttpServerResponse::StatusCode::BadRequest);
- } else {
- return QHttpServerResponse("text/plain", "Invalid request: " + reason.toUtf8() + "\n", QHttpServerResponse::StatusCode::BadRequest);
- }
-}
}
-using Token = QString;
-using ClientId = QString;
-using Email = QString;
-using Port = int;
-
-struct Server {
- using Id = QString;
- Id id;
- Port port;
-};
-
-struct Verification {
- Verification()
- {}
-
- Verification(const QString &_clientId, Server::Id _serverId)
- : clientId(_clientId)
- , serverId(_serverId)
- {}
-
- bool isEmpty() const {
- return clientId.isEmpty();
- }
-
- bool isConfirmed() const
- {
- return webclient && desktopserver;
- }
-
- QString clientId;
- Server::Id serverId;
- bool webclient = false;
- bool desktopserver = false;
-};
-
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QHttpServer server;
- QHash<Email, Server> servers;
- QHash<Email, QHash<Server::Id, ClientId>> verified;
-
- QHash<Token, Verification> currentVerifications;
-
QNetworkAccessManager qnam;
- server.route(u"/"_s, [](const QHttpServerRequest &request) {
- QFile file(u":/web/index.html"_s);
- if (!file.open(QIODeviceBase::ReadOnly)) {
- qWarning() << file.errorString();
- }
-
- return QHttpServerResponse("text/html", file.readAll());
- });
-
- server.route(u"/register"_s, [&servers](const QHttpServerRequest &request) {
- const auto json = QJsonDocument::fromJson(request.body());
- if (json.isEmpty() || !json.isObject()) {
- return badRequest();
- }
-
- const auto object = json.object();
-
- if (object.isEmpty() || !object.contains("port"_L1) || !object.contains("email"_L1)) {
- return badRequest();
- }
-
- servers[object["email"_L1].toString()] = Server {
- object["serverId"_L1].toString(),
- object["port"_L1].toInt(),
- };
-
- return QHttpServerResponse("text/plain", "Server added\n");
- });
+ server.route(u"/"_s, &StaticController::homeAction);
+ server.route(u"/register"_s, &RegistrationController::registerAction);
+ /*
server.route(u"/command/<arg>/"_s, [&servers, &verified](const QString arg, const QHttpServerRequest &request) {
const auto email = findHeader(request.headers(), "EMAIL");
if (email.isEmpty()) {
return badRequest("Missing EMAIL header");
}
if (!servers.contains(email)) {
return badRequest("No kleopatra client registered with this email");
}
const auto port = servers[email].port;
const auto serverId = servers[email].id;
const auto clientId = findHeader(request.headers(), "CLIENT_ID");
if (clientId.isEmpty()) {
return badRequest("Missing CLIENT_ID header");
}
if (!verified.contains(clientId) || !verified[clientId].contains(serverId)) {
return badRequest("Not verified client");
}
return QHttpServerResponse("text/plain", email + "\n" + QString::number(port).toUtf8() + "\n");
});
+ */
- server.route(u"/verify"_s, [&qnam, &currentVerifications, &verified, &servers](const QHttpServerRequest &request) {
- const auto doc = QJsonDocument::fromJson(request.body());
- if (doc.isEmpty() || !doc.isObject() || !doc.object().contains("email"_L1) || !doc.object().contains("clientId"_L1)) {
- return badRequest("Missing email or client id key in body");
- }
-
- const auto object = doc.object();
- const auto email = object["email"_L1].toString();
- const auto clientId = object["clientId"_L1].toString();
-
- if (!servers.contains(email)) {
- return badRequest("No kleopatra client registered with this email");
- }
- const auto port = servers[email].port;
- const auto serverId = servers[email].id;
-
- if (clientId.isEmpty()) {
- return badRequest("Missing clientid in body");
- }
-
- // TODO use gpgme random generation
- const auto token = QUuid::createUuid().toString(QUuid::WithoutBraces);
- const auto jsonToken = QJsonDocument(QJsonObject{
- { "token"_L1, token },
- }).toJson();
-
- // Trigger verification request in the kleopatra client
- QNetworkRequest verifyRequest(QUrl(u"http://127.0.0.1:"_s + QString::number(port) + u"/client-verify"_s));
- verifyRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
- auto verifyReply = qnam.post(verifyRequest, jsonToken);
-
- currentVerifications.insert(token, Verification(clientId, serverId));
-
- return QHttpServerResponse("application/json", jsonToken);
- });
-
- server.route(u"/verify/confirm/web"_s, [&qnam, &currentVerifications, &verified, &servers](const QHttpServerRequest &request) {
- const auto doc = QJsonDocument::fromJson(request.body());
- if (doc.isEmpty() || !doc.isObject() || !doc.object().contains("email"_L1)
- || !doc.object().contains("clientId"_L1)
- || !doc.object().contains("token"_L1)) {
- return badRequest("Missing email, token or client id key in body");
- }
-
- const auto object = doc.object();
- const auto email = object["email"_L1].toString();
- const auto clientId = object["clientId"_L1].toString();
- const auto token = object["token"_L1].toString();
-
- if (!servers.contains(email)) {
- return badRequest("No kleopatra client registered with this email");
- }
- const auto port = servers[email].port;
-
- if (clientId.isEmpty()) {
- return badRequest("Missing clientid in body");
- }
-
- if (!currentVerifications.contains(token)) {
- return badRequest("No current validation in progress with the given token");
- }
-
- auto &verification = currentVerifications[token];
-
- if (verification.webclient) {
- return badRequest("Already verified");
- }
-
- verification.webclient = true;
-
- return QHttpServerResponse("application/json", QJsonDocument(QJsonObject{
- { "confirmed"_L1, verification.isConfirmed(), },
- }).toJson());
- });
-
- server.route(u"/verify/confirm/server"_s, [&qnam, &currentVerifications, &verified, &servers](const QHttpServerRequest &request) {
- const auto doc = QJsonDocument::fromJson(request.body());
- if (doc.isEmpty() || !doc.isObject() || !doc.object().contains("serverId"_L1)
- || !doc.object().contains("token"_L1)) {
- return badRequest("Missing email, token or client id key in body");
- }
-
- const auto object = doc.object();
- const auto serverId = object["serverId"_L1].toString();
- const auto token = object["token"_L1].toString();
-
- if (!currentVerifications.contains(token)) {
- return badRequest("No current validation in progress with the given token");
- }
-
- auto &verification = currentVerifications[token];
-
- if (verification.serverId != serverId) {
- return badRequest("Wrong server id:" + verification.serverId + " --- " + serverId);
- }
-
- if (verification.desktopserver) {
- return badRequest("Already verified");
- }
-
- verification.desktopserver = true;
-
- return QHttpServerResponse("application/json", QJsonDocument(QJsonObject{
- { "confirmed"_L1, verification.isConfirmed(), },
- }).toJson());
- });
-
- server.route(u"/verify/status/"_s, [&qnam, &currentVerifications, &verified, &servers](const QString token, const QHttpServerRequest &request) {
- if (!currentVerifications.contains(token)) {
- return badRequest("No current validation in progress with the given token");
- }
-
- const auto &verification = currentVerifications[token];
-
- return QHttpServerResponse("application/json", QJsonDocument(QJsonObject{
- { "confirmed"_L1, verification.isConfirmed(), },
- }).toJson());
- });
+ server.route(u"/verify"_s, &VerificationController::verifyAction);
+ server.route(u"/verify/confirm/web"_s, &VerificationController::verifyConfirmWebAction);
+ server.route(u"/verify/confirm/server"_s, &VerificationController::verifyConfirmServerAction);
+ server.route(u"/verify/status"_s, &VerificationController::verifyStatusAction);
server.afterRequest([](QHttpServerResponse &&resp) {
resp.setHeader("Access-Control-Allow-Origin", "*");
return std::move(resp);
});
const auto port = server.listen(QHostAddress::Any, 5656);
if (!port) {
qWarning() << "Server failed to listen on a port.";
return 1;
}
qWarning() << u"Running on http://127.0.0.1:%1/ (Press CTRL+C to quit)"_s.arg(port);
return app.exec();
}
diff --git a/broker/model/serverstate.cpp b/broker/model/serverstate.cpp
new file mode 100644
index 0000000..f3601fa
--- /dev/null
+++ b/broker/model/serverstate.cpp
@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: 2023 g10 code Gmbh
+// SPDX-Contributor: Carl Schwan <carl.schwan@gnupg.com>
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "serverstate.h"
+
+ServerState::ServerState()
+{}
+
+ServerState &ServerState::instance()
+{
+ static ServerState s;
+ return s;
+}
diff --git a/broker/model/serverstate.h b/broker/model/serverstate.h
new file mode 100644
index 0000000..ecbed92
--- /dev/null
+++ b/broker/model/serverstate.h
@@ -0,0 +1,60 @@
+// SPDX-FileCopyrightText: 2023 g10 code Gmbh
+// SPDX-Contributor: Carl Schwan <carl.schwan@gnupg.com>
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <QString>
+#include <QHash>
+#include <QNetworkAccessManager>
+
+using Token = QString;
+using ClientId = QString;
+using Email = QString;
+using Port = int;
+
+struct Server {
+ using Id = QString;
+ Id id;
+ Port port;
+};
+
+struct Verification {
+ Verification()
+ {}
+
+ Verification(const QString &_clientId, Server::Id _serverId)
+ : clientId(_clientId)
+ , serverId(_serverId)
+ {}
+
+ bool isEmpty() const {
+ return clientId.isEmpty();
+ }
+
+ bool isConfirmed() const
+ {
+ return webclient && desktopserver;
+ }
+
+ QString clientId;
+ Server::Id serverId;
+ bool webclient = false;
+ bool desktopserver = false;
+};
+
+class ServerState
+{
+public:
+ static ServerState &instance();
+
+ QHash<Email, Server> servers;
+ QHash<Email, QHash<Server::Id, ClientId>> verified;
+
+ QHash<Token, Verification> currentVerifications;
+
+ QNetworkAccessManager qnam;
+
+private:
+ ServerState();
+};

File Metadata

Mime Type
text/x-diff
Expires
Sat, May 10, 8:53 AM (1 d, 7 h)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
c0/9f/416d813fe77cfd1a628782b83b2b

Event Timeline