diff --git a/broker/assets/script.js b/broker/assets/script.js index a4cddf4..3861b30 100644 --- a/broker/assets/script.js +++ b/broker/assets/script.js @@ -1,278 +1,278 @@ // SPDX-FileCopyrightText: 2023 g10 code GmbH // SPDX-Contributor: Carl Schwan // SPDX-License-Identifier: GPL-2.0-or-later function downloadViaRest(callback) { const context = { isRest: true }; Office.context.mailbox.getCallbackTokenAsync(context, (tokenResults) => { if (tokenResults.status === Office.AsyncResultStatus.Failed) { console.error('Failed to get rest api auth token'); return; } const request = '' + '' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' IdOnly' + ' true' + ' ' + ' ' + ' ' + ' ' + ''; Office.context.mailbox.makeEwsRequestAsync(request, (asyncResult) => { const parser = new DOMParser(); xmlDoc = parser.parseFromString(asyncResult.value, "text/xml"); const mimeContent = xmlDoc.getElementsByTagName('t:MimeContent')[0].innerHTML; callback(atob(mimeContent)); }); }); } async function view(content) { const response = await fetch('https://localhost:5656/view', { method: 'POST', body: content, headers: { 'X-EMAIL': Office.context.mailbox.userProfile.emailAddress, 'X-NAME': Office.context.mailbox.userProfile.displayName, }, }); const json = await response.json(); return json; } async function info(content) { const response = await fetch('https://localhost:5656/info', { method: 'POST', body: content, headers: { 'X-EMAIL': Office.context.mailbox.userProfile.emailAddress, 'X-NAME': Office.context.mailbox.userProfile.displayName, }, }); const json = await response.json(); return json; } async function newEmail(content) { const response = await fetch('https://localhost:5656/new', { method: 'POST', headers: { 'X-EMAIL': Office.context.mailbox.userProfile.emailAddress, 'X-NAME': Office.context.mailbox.userProfile.displayName, }, }); const json = await response.json(); return json; } async function reply(content) { const response = await fetch('https://localhost:5656/reply', { method: 'POST', body: content, headers: { 'X-EMAIL': Office.context.mailbox.userProfile.emailAddress, 'X-NAME': Office.context.mailbox.userProfile.displayName, }, }); const json = await response.json(); return json; } async function forward(content) { const response = await fetch('https://localhost:5656/forward', { method: 'POST', body: content, headers: { 'X-EMAIL': Office.context.mailbox.userProfile.emailAddress, 'X-NAME': Office.context.mailbox.userProfile.displayName, }, }); const json = await response.json(); return json; } async function openDraft(id) { const response = await fetch(`https://localhost:5656/draft/${id}`, { method: 'POST', headers: { 'X-EMAIL': Office.context.mailbox.userProfile.emailAddress, 'X-NAME': Office.context.mailbox.userProfile.displayName, }, }); const json = await response.json(); return json; } async function deleteDraft(id) { const response = await fetch(`https://localhost:5656/draft/${id}`, { method: 'DELETE', headers: { 'X-EMAIL': Office.context.mailbox.userProfile.emailAddress, 'X-NAME': Office.context.mailbox.userProfile.displayName, }, }); const json = await response.json(); return json; } function showError(errorMessage) { const errorElement = document.getElementById('error'); errorElement.innerHTML = errorMessage; errorElement.classList.remove('d-none'); } function hideError() { const errorElement = document.getElementById('error'); errorElement.classList.add('d-none'); } Office.onReady(). then(()=> { downloadViaRest(async (content) => { const status = await info(content) const statusText = document.getElementById('status-text'); if (status.encrypted || status.signed) { const decryptButton = document.getElementById('decrypt-button'); decryptButton.classList.remove('d-none'); if (status.encrypted) { decryptButton.innerText = "Decrypt"; statusText.innerText = status.signed ? "This mail is encrypted and signed." : "This mail is encrypted."; } else if (status.signed) { decryptButton.innerText = "Show signature"; statusText.innerText = "This mail is signed"; } decryptButton.addEventListener('click', (event) => { view(content); }); } document.getElementById('reply-button').addEventListener('click', (event) => { reply(content); }); document.getElementById('forward-button').addEventListener('click', (event) => { forward(content); }); document.getElementById('new-button').addEventListener('click', (event) => { newEmail(); }); if (status.drafts.length === 0) { document.getElementById('no-draft').classList.remove('d-none'); } else { const draftsContainer = document.getElementById('drafts'); status.drafts.forEach(draft => { const draftElementContainer = document.createElement('li'); const draftElement = document.createElement('button'); draftElement.classList.add('btn', 'w-100', 'd-flex', 'flex-row', 'align-items-center'); draftElement.addEventListener('click', (event) => { openDraft(draft.id); }); const date = new Date(draft.last_modification * 1000); let todaysDate = new Date(); let lastModification = ''; if ((new Date(date)).setHours(0, 0, 0, 0) == todaysDate.setHours(0, 0, 0, 0)) { lastModification = date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric', }); } else { lastModification = date.toLocaleDateString(); } const content = document.createTextNode('Last Modified: ' + lastModification); draftElement.appendChild(content); const deleteDraftButton = document.createElement('button'); deleteDraftButton.classList.add('btn', 'btn-danger', 'ms-auto', 'py-1'); deleteDraftButton.addEventListener('click', (event) => { deleteDraft(draft.id); draftElement.remove(); }); const deleteDraftButtonContent = document.createTextNode('X'); deleteDraftButton.appendChild(deleteDraftButtonContent); draftElement.appendChild(deleteDraftButton); draftElementContainer.appendChild(draftElement); draftsContainer.appendChild(draftElementContainer); }); } function webSocketConnect() { // Create WebSocket connection. - const socket = new WebSocket("wss://localhost:5656"); + const socket = new WebSocket("wss://localhost:5657"); // Connection opened socket.addEventListener("open", (event) => { hideError(); socket.send(JSON.stringify({ command: "register", arguments: { emails: [Office.context.mailbox.userProfile.emailAddress], type: 'webclient', }, })); }); socket.addEventListener("close", (event) => { showError('Native client was disconnected'); setTimeout(function() { webSocketConnect(); }, 1000); }); socket.addEventListener("error", (event) => { showError('Native client received an error'); setTimeout(function() { webSocketConnect(); }, 1000); }); // Listen for messages socket.addEventListener("message", ({ data }) => { const message = JSON.parse(data); console.log("Message from server ", message); switch (message.type) { case 'ews': Office.context.mailbox.makeEwsRequestAsync(message.payload, (asyncResult) => { console.log('Email sent') // let the client known that the email was sent socket.send(JSON.stringify({ command: 'email-sent', arguments: { id: message.id, email: Office.context.mailbox.userProfile.emailAddress, } })); }); break; case 'disconnection': showError('Native client was disconnected (disconnection)'); break; case 'connection': hideError(); break; } }); } webSocketConnect(); }); }); diff --git a/broker/webserver.cpp b/broker/webserver.cpp index 56b946e..4e7da5a 100644 --- a/broker/webserver.cpp +++ b/broker/webserver.cpp @@ -1,316 +1,300 @@ // SPDX-FileCopyrightText: 2023 g10 code GmbH // SPDX-Contributor: Carl Schwan // SPDX-License-Identifier: GPL-2.0-or-later #include "webserver.h" #include #include #include #include #include #include #include #include #include #include #include #include #include "controllers/registrationcontroller.h" #include "controllers/staticcontroller.h" #include "controllers/emailcontroller.h" using namespace Qt::Literals::StringLiterals; WebServer WebServer::s_instance = WebServer(); WebServer &WebServer::self() { return s_instance; } WebServer::WebServer() : QObject(nullptr) , m_httpServer(new QHttpServer(this)) , m_webSocketServer(new QWebSocketServer(u"GPGOL"_s, QWebSocketServer::SslMode::SecureMode, this)) { } WebServer::~WebServer() = default; bool WebServer::run() { QFile privateKeyFile(QStringLiteral(":/assets/private.key")); if (!privateKeyFile.open(QIODevice::ReadOnly)) { qWarning() << u"Couldn't open file for reading: %1"_s.arg(privateKeyFile.errorString()); return false; } const QSslKey sslKey(&privateKeyFile, QSsl::Rsa); privateKeyFile.close(); const auto sslCertificateChain = QSslCertificate::fromPath(QStringLiteral(":/assets/certificate.crt")); if (sslCertificateChain.isEmpty()) { qWarning() << u"Couldn't retrieve SSL certificate from file."_s; return false; } - m_httpServer->route(u"/"_s, [](const QHttpServerRequest &request) ->auto { - // Upgrade http connection to websocket - auto findHeader = [](QList> headers, const QByteArray &key) -> QByteArray - { - 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; - }; - - QByteArray webSocketKey = findHeader(request.headers(), "Sec-WebSocket-Key") + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; - - QCryptographicHash hash(QCryptographicHash::Algorithm::Sha1); - hash.addData(webSocketKey); - - auto response = QHttpServerResponse("", QHttpServerResponder::StatusCode::SwitchingProtocols); - response.addHeader("Upgrade", "websocket"); - response.addHeader("Connection", "Upgrade"); - response.addHeader("Sec-WebSocket-Accept", hash.result().toBase64()); - return response; - }); - // Static assets controller m_httpServer->route(u"/home"_s, &StaticController::homeAction); m_httpServer->route(u"/assets/"_s, &StaticController::assetsAction); // Registration controller m_httpServer->route(u"/register"_s, &RegistrationController::registerAction); // Email controller m_httpServer->route(u"/view"_s, &EmailController::viewEmailAction); m_httpServer->route(u"/info"_s, &EmailController::infoEmailAction); m_httpServer->route(u"/reply"_s, &EmailController::replyEmailAction); m_httpServer->route(u"/forward"_s, &EmailController::forwardEmailAction); m_httpServer->route(u"/new"_s, &EmailController::newEmailAction); m_httpServer->route(u"/socket-web"_s, &EmailController::socketWebAction); m_httpServer->route(u"/draft/"_s, &EmailController::draftAction); m_httpServer->afterRequest([](QHttpServerResponse &&resp) { resp.setHeader("Access-Control-Allow-Origin", "*"); return std::move(resp); }); m_httpServer->sslSetup(sslCertificateChain.front(), sslKey); const auto port = m_httpServer->listen(QHostAddress::Any, WebServer::Port); if (!port) { qWarning() << "Server failed to listen on a port."; return false; } - qWarning() << u"Running on https://127.0.0.1:%1/ (Press CTRL+C to quit)"_s.arg(port); + qWarning() << u"Running http server on https://127.0.0.1:%1/ (Press CTRL+C to quit)"_s.arg(port); + + + QSslConfiguration sslConfiguration; + sslConfiguration.setPeerVerifyMode(QSslSocket::VerifyNone); + sslConfiguration.setLocalCertificate(sslCertificateChain.front()); + sslConfiguration.setPrivateKey(sslKey); + m_webSocketServer->setSslConfiguration(sslConfiguration); + + if (m_webSocketServer->listen(QHostAddress::Any, WebServer::WebSocketPort)) { + qWarning() << u"Running websocket server on wss://127.0.0.1:%1/ (Press CTRL+C to quit)"_s.arg(WebServer::Port + 1); + connect(m_webSocketServer, &QWebSocketServer::newConnection, this, &WebServer::onNewConnection); + } - connect(m_webSocketServer, &QWebSocketServer::newConnection, this, &WebServer::onNewConnection); return true; } void WebServer::onNewConnection() { auto pSocket = m_webSocketServer->nextPendingConnection(); if (!pSocket) { return; } qDebug() << "Client connected:" << pSocket->peerName() << pSocket->origin(); connect(pSocket, &QWebSocket::textMessageReceived, this, &WebServer::processTextMessage); connect(pSocket, &QWebSocket::binaryMessageReceived, this, &WebServer::processBinaryMessage); connect(pSocket, &QWebSocket::disconnected, this, &WebServer::socketDisconnected); m_clients << pSocket; } void WebServer::processTextMessage(QString message) { auto webClient = qobject_cast(sender()); if (webClient) { QJsonParseError error; const auto doc = QJsonDocument::fromJson(message.toUtf8(), &error); if (error.error != QJsonParseError::NoError) { qWarning() << "Error parsing json" << error.errorString(); return; } if (!doc.isObject()) { qWarning() << "Invalid json received"; return; } const auto object = doc.object(); if (!object.contains("command"_L1) || !object["command"_L1].isString() || !object.contains("arguments"_L1) || !object["arguments"_L1].isObject()) { qWarning() << "Invalid json received: no command or arguments set" ; return; } static QHash commandMapping { { "register"_L1, WebServer::Command::Register }, { "email-sent"_L1, WebServer::Command::EmailSent }, }; const auto command = commandMapping[doc["command"_L1].toString()]; processCommand(command, object["arguments"_L1].toObject(), webClient); } } void WebServer::processCommand(Command command, const QJsonObject &arguments, QWebSocket *socket) { switch (command) { case Command::Register: { const auto type = arguments["type"_L1].toString(); qDebug() << "Register" << arguments; if (type.isEmpty()) { qWarning() << "empty client type given when registering"; return; } const auto emails = arguments["emails"_L1].toArray(); if (type == "webclient"_L1) { if (emails.isEmpty()) { qWarning() << "empty email given"; } for (const auto &email : emails) { m_webClientsMappingToEmail[email.toString()] = socket; qWarning() << "email" << email.toString() << "mapped to a web client"; const auto nativeClient = m_nativeClientsMappingToEmail[email.toString()]; if (nativeClient) { QJsonDocument doc(QJsonObject{ { "type"_L1, "connection"_L1 }, { "payload"_L1, QJsonObject{ { "client_type"_L1, "web_client"_L1 } }} }); nativeClient->sendTextMessage(QString::fromUtf8(doc.toJson())); } } } else { if (emails.isEmpty()) { qWarning() << "empty email given"; } for (const auto &email : emails) { m_nativeClientsMappingToEmail[email.toString()] = socket; qWarning() << "email" << email.toString() << "mapped to a native client"; const auto webClient = m_webClientsMappingToEmail[email.toString()]; if (webClient) { QJsonDocument doc(QJsonObject{ { "type"_L1, "connection"_L1 }, { "payload"_L1, QJsonObject{ { "client_type"_L1, "native_client"_L1 } }} }); webClient->sendTextMessage(QString::fromUtf8(doc.toJson())); } } } return; } case Command::EmailSent: { const auto email = arguments["email"_L1].toString(); const auto socket = m_nativeClientsMappingToEmail[email]; if (!socket) { return; } QJsonDocument doc(QJsonObject{ { "type"_L1, "email-sent"_L1 }, { "arguments"_L1, arguments }, }); socket->sendTextMessage(QString::fromUtf8(doc.toJson())); return; } case Command::Undefined: qWarning() << "Invalid json received: invalid command" ; return; } } bool WebServer::sendMessageToWebClient(const QString &email, const QByteArray &payload) { auto socket = m_webClientsMappingToEmail[email]; if (!socket) { return false; } socket->sendTextMessage(QString::fromUtf8(payload)); return true; } void WebServer::processBinaryMessage(QByteArray message) { qWarning() << "got binary message" << message; QWebSocket *pClient = qobject_cast(sender()); if (pClient) { pClient->sendBinaryMessage(message); } } void WebServer::socketDisconnected() { QWebSocket *pClient = qobject_cast(sender()); if (pClient) { qDebug() << "Client disconnected" << pClient; // Web client was disconnected { const auto it = std::find_if(m_webClientsMappingToEmail.cbegin(), m_webClientsMappingToEmail.cend(), [pClient](QWebSocket *webSocket) { return pClient == webSocket; }); if (it != m_webClientsMappingToEmail.cend()) { const auto email = it.key(); const auto nativeClient = m_nativeClientsMappingToEmail[email]; qDebug() << "webclient with email disconnected" << email << nativeClient; if (nativeClient) { QJsonDocument doc(QJsonObject{ { "type"_L1, "disconnection"_L1 }, }); nativeClient->sendTextMessage(QString::fromUtf8(doc.toJson())); } m_webClientsMappingToEmail.removeIf([pClient](auto socket) { return pClient == socket.value(); }); } } // Native client was disconnected const auto emails = m_nativeClientsMappingToEmail.keys(); for (const auto &email : emails) { const auto webSocket = m_nativeClientsMappingToEmail[email]; if (webSocket != pClient) { qDebug() << "webSocket not equal" << email << webSocket << pClient; continue; } qDebug() << "native client for" << email << "was disconnected."; QJsonDocument doc(QJsonObject{ { "type"_L1, "disconnection"_L1 }, }); sendMessageToWebClient(email, doc.toJson()); } m_nativeClientsMappingToEmail.removeIf([pClient](auto socket) { return pClient == socket.value(); }); m_clients.removeAll(pClient); } } diff --git a/broker/webserver.h b/broker/webserver.h index dc17e60..98db50e 100644 --- a/broker/webserver.h +++ b/broker/webserver.h @@ -1,66 +1,67 @@ // SPDX-FileCopyrightText: 2023 g10 code GmbH // SPDX-Contributor: Carl Schwan // SPDX-License-Identifier: GPL-2.0-or-later #pragma once #include #include #include #include QT_FORWARD_DECLARE_CLASS(QWebSocketServer) QT_FORWARD_DECLARE_CLASS(QWebSocket) class WebsocketRequestBackend; class QHttpServer; class WebServer : public QObject { Q_OBJECT public: /// Get singleton instance of WebServer static WebServer &self(); /// WebServer destructor. ~WebServer() override; /// Start web server. bool run(); /// is a valid WebServer instance bool isValid() const; bool sendMessageToWebClient(const QString &email, const QByteArray &payload); bool sendMessageToNativeClient(const QString &email, const QByteArray &payload); private Q_SLOTS: void onNewConnection(); void processTextMessage(QString message); void processBinaryMessage(QByteArray message); void socketDisconnected(); private: WebServer(); enum class Command { Undefined, ///< Undefined command. Register, ///< Registration of a client (native or web). EmailSent, ///< Confirmation that an email was sent. }; enum SpecialValues { Port = 5656, + WebSocketPort = 5657, }; void processCommand(Command command, const QJsonObject &arguments, QWebSocket *socket); QHttpServer * const m_httpServer; QWebSocketServer * const m_webSocketServer; QList m_clients; QHash m_webClientsMappingToEmail; QHash m_nativeClientsMappingToEmail; static WebServer s_instance; }; diff --git a/server/autotests/emailcontrollertest.cpp b/server/autotests/emailcontrollertest.cpp index 38e1bec..8227d5b 100644 --- a/server/autotests/emailcontrollertest.cpp +++ b/server/autotests/emailcontrollertest.cpp @@ -1,157 +1,155 @@ // SPDX-FileCopyrightText: 2023 g10 code GmbH // SPDX-Contributor: Carl Schwan // SPDX-License-Identifier: GPL-2.0-or-later #include #include #include #include #include #include #include #include #include #include #include #include #include #include "../webserver.h" #include "../websocketclient.h" #include "../draft/draftmanager.h" #include "../editor/composerwindow.h" #include "../editor/recipientseditor.h" #include #include #include using namespace Qt::Literals::StringLiterals; class EmailControllerTest : public QObject { Q_OBJECT private Q_SLOTS: void initTestCase() { DraftManager::self(true); QCoreApplication::setApplicationName(u"gpgol-server"_s); KLocalizedString::setApplicationDomain(QByteArrayLiteral("gpgol")); m_webServer = new WebServer; m_webServer->run(); auto webSocketServer = new QWebSocketServer(QStringLiteral("SSL Server"), QWebSocketServer::NonSecureMode); - if (webSocketServer->listen(QHostAddress::Any, 5656)) { + if (webSocketServer->listen(QHostAddress::Any, 5657)) { } } void testInfoEmailAction() { QFile file(QStringLiteral(DATA_DIR) + u"/encrypted.mbox"_s); QVERIFY(file.open(QIODeviceBase::ReadOnly)); QNetworkRequest request(QUrl(u"http://127.0.0.1:%1/info"_s.arg(m_webServer->port()))); auto reply = m_qnam.post(request, file.readAll()); QSignalSpy spy(reply, &QNetworkReply::finished); spy.wait(); QVERIFY(reply->error() == QNetworkReply::NoError); const auto doc = QJsonDocument::fromJson(reply->readAll()); QVERIFY(!doc.isNull() && doc.isObject()); const auto object = doc.object(); QVERIFY(object["drafts"_L1].toArray().isEmpty()); QVERIFY(object["encrypted"_L1].toBool()); QVERIFY(!object["signed"_L1].toBool()); } void testViewEmailAction() { QFile file(QStringLiteral(DATA_DIR) + u"/plaintext.mbox"_s); QVERIFY(file.open(QIODeviceBase::ReadOnly)); QNetworkRequest request(QUrl(u"http://127.0.0.1:%1/view"_s.arg(m_webServer->port()))); auto reply = m_qnam.post(request, file.readAll()); QSignalSpy spy(reply, &QNetworkReply::finished); spy.wait(); QVERIFY(reply->error() == QNetworkReply::NoError); const auto widgets = qApp->topLevelWidgets(); QVERIFY(!widgets.isEmpty()); MimeTreeParser::Widgets::MessageViewerDialog *dialog = nullptr; for (auto widget : widgets) { if (!widget->isHidden()) { if (const auto messageViewer = qobject_cast(widget)) { dialog = messageViewer; break; } } } QVERIFY(dialog); WebsocketClient::self(QUrl(u"ws://127.0.0.1"_s), 5656); const auto toolBar = dialog->toolBar(); QVERIFY(toolBar->isVisible()); const auto actions = toolBar->actions(); QCOMPARE(actions.count(), 3); qWarning() << actions; QCOMPARE(actions[1]->icon().name(), u"mail-reply-sender-symbolic"_s); actions[1]->trigger(); const auto widgets2 = qApp->topLevelWidgets(); QVERIFY(!widgets2.isEmpty()); ComposerWindow *composer = nullptr; for (auto widget : widgets2) { if (!widget->isHidden()) { if (const auto composerWindow = qobject_cast(widget)) { composer = composerWindow; break; } } } QVERIFY(composer); QSignalSpy spyInit(composer, &ComposerWindow::initialized); spyInit.wait(); QCOMPARE(composer->subject(), u"RE: A random subject with alternative contenttype"_s); const auto recipients = composer->recipientsEditor()->recipients(); QCOMPARE(recipients.count(), 2); QCOMPARE(recipients[0]->email(), u"konqi@example.org"_s); QCOMPARE(recipients[0]->name(), u"Konqi"_s); QCOMPARE(recipients[1]->email(), u"konqi@kde.org"_s); QVERIFY(recipients[1]->name().isEmpty()); - - QVERIFY(false); } void cleanupTestCase() { m_webServer->deleteLater(); } private: QThread *m_thread = nullptr; WebServer *m_webServer = nullptr; QWebSocketServer *m_webSocketServer = nullptr; QNetworkAccessManager m_qnam; }; QTEST_MAIN(EmailControllerTest) #include "emailcontrollertest.moc" diff --git a/server/main.cpp b/server/main.cpp index 263ab00..ee12c5c 100644 --- a/server/main.cpp +++ b/server/main.cpp @@ -1,46 +1,46 @@ // SPDX-FileCopyrightText: 2023 g10 code Gmbh // SPDX-Contributor: Carl Schwan // SPDX-License-Identifier: GPL-2.0-or-later #include "controllers/emailcontroller.h" #include #include #include #include #include #include #include #include #include #include #include "websocketclient.h" #include "webserver.h" #include "qnam.h" using namespace Qt::Literals::StringLiterals; using namespace std::chrono; int main(int argc, char *argv[]) { QApplication app(argc, argv); app.setQuitOnLastWindowClosed(false); KLocalizedString::setApplicationDomain(QByteArrayLiteral("gpgol")); QObject::connect(qnam, &QNetworkAccessManager::sslErrors, qnam, [](QNetworkReply *reply, const QList &) { reply->ignoreSslErrors(); }); WebServer server; server.run(); if (!server.running()) { qWarning() << "Server failed to listen on a port."; return 1; } const auto port = server.port(); - WebsocketClient::self(QUrl(u"wss://localhost:5656/"_s), port); + WebsocketClient::self(QUrl(u"wss://localhost:5657/"_s), port); return app.exec(); }