diff --git a/QLog.pro b/QLog.pro index d52c63d2..694d9615 100644 --- a/QLog.pro +++ b/QLog.pro @@ -96,6 +96,9 @@ SOURCES += \ core/PlatformParameterManager.cpp \ core/PotaQE.cpp \ core/PropConditions.cpp \ + core/QSOApiKeyQuery.cpp \ + core/QSOApiKeySender.cpp \ + core/QSOApiKeySenderCredentials.cpp \ core/QSLPrintLabelRenderer.cpp \ core/QSLStorage.cpp \ core/QSOFilterManager.cpp \ @@ -283,6 +286,8 @@ HEADERS += \ core/PropConditions.h \ core/QSLPrintLabelRenderer.h \ core/QSLStorage.h \ + core/QSOApiKeyQuery.h \ + core/QSOApiKeySender.h \ core/QSOFilterManager.h \ core/QuadKeyCache.h \ core/WsjtxUDPReceiver.h \ diff --git a/core/LogParam.cpp b/core/LogParam.cpp index 2e4421f9..b835f945 100644 --- a/core/LogParam.cpp +++ b/core/LogParam.cpp @@ -737,6 +737,26 @@ void LogParam::setNetworkNotifRigStateAddrs(int port) setParam("network/listener/wsjtx/port", port); } +bool LogParam::getNetworkQSOApiEnabled() +{ + return getParam("network/qsoapi/enabled", false).toBool(); +} + +void LogParam::setNetworkQSOApiEnabled(bool enabled) +{ + setParam("network/qsoapi/enabled", enabled); +} + +QString LogParam::getNetworkQSOApiURL() +{ + return getParam("network/qsoapi/url").toString(); +} + +void LogParam::setNetworkQSOApiURL(const QString &url) +{ + setParam("network/qsoapi/url", url); +} + QString LogParam::getNetworkWsjtxForwardAddrs() { return getParam("network/forwarder/wsjtx/addrs").toString(); diff --git a/core/LogParam.h b/core/LogParam.h index a1f5bacc..de332a17 100644 --- a/core/LogParam.h +++ b/core/LogParam.h @@ -239,6 +239,14 @@ class LogParam : public QObject static void setNetworkWsjtxForwardAddrs(const QString &addrs); static bool getNetworkWsjtxListenerJoinMulticast(); static void setNetworkWsjtxListenerJoinMulticast(bool state); + + /************************ + * QSO API key sender + ************************/ + static bool getNetworkQSOApiEnabled(); + static void setNetworkQSOApiEnabled(bool enabled); + static QString getNetworkQSOApiURL(); + static void setNetworkQSOApiURL(const QString &url); static QString getNetworkWsjtxListenerMulticastAddr(); static void setNetworkWsjtxListenerMulticastAddr(const QString &addr); static int getNetworkWsjtxListenerMulticastTTL(); diff --git a/core/QSOApiKeyQuery.cpp b/core/QSOApiKeyQuery.cpp new file mode 100644 index 00000000..a3a4bb7f --- /dev/null +++ b/core/QSOApiKeyQuery.cpp @@ -0,0 +1,37 @@ +#include +#include +#include + +#include "QSOApiKeyQuery.h" +#include "logformat/AdiFormat.h" + +namespace QSOApiKeyQuery { + +QList> buildParams(const QSqlRecord &record) +{ + QList> params; + + QString adifText; + QTextStream writeStream(&adifText, QIODevice::ReadWrite); + AdiFormat writer(writeStream); + writer.exportContact(record); + writeStream.flush(); + + QTextStream readStream(&adifText, QIODevice::ReadOnly); + AdiFormat reader(readStream); + + QVariantMap fields; + if (!reader.readContact(fields)) + return params; + + for (auto it = fields.constBegin(); it != fields.constEnd(); ++it) + { + const QString value = it.value().toString(); + if (!value.isEmpty()) + params.append({it.key(), value}); + } + + return params; +} + +} // namespace QSOApiKeyQuery diff --git a/core/QSOApiKeyQuery.h b/core/QSOApiKeyQuery.h new file mode 100644 index 00000000..60480521 --- /dev/null +++ b/core/QSOApiKeyQuery.h @@ -0,0 +1,28 @@ +#ifndef QLOG_CORE_QSOAPIKEYQUERY_H +#define QLOG_CORE_QSOAPIKEYQUERY_H + +#include +#include +#include + +class QSqlRecord; + +/*! + * Pure, network-free building block for QSOApiKeySender. + * + * Flattens a logged QSO into GET query parameters by reusing QLog's own + * ADIF export (QSqlRecord -> ADIF text -> flat field map) rather than + * inventing a second field-name mapping - the same round trip + * CustomCallbook already relies on for the read direction + * (AdiFormat::readContact()). + * + * Empty fields are omitted. The api key is deliberately never added here - + * see QSOApiKeySender for why it travels separately. + */ +namespace QSOApiKeyQuery { + +QList> buildParams(const QSqlRecord &record); + +} // namespace QSOApiKeyQuery + +#endif // QLOG_CORE_QSOAPIKEYQUERY_H diff --git a/core/QSOApiKeySender.cpp b/core/QSOApiKeySender.cpp new file mode 100644 index 00000000..26a08a40 --- /dev/null +++ b/core/QSOApiKeySender.cpp @@ -0,0 +1,103 @@ +#include +#include +#include + +#include "QSOApiKeySender.h" +#include "QSOApiKeyQuery.h" +#include "core/debug.h" + +MODULE_IDENTIFICATION("qlog.core.qsoapikeysender"); + +QSOApiKeySender::QSOApiKeySender(QObject *parent) : + QObject(parent) +{ + FCT_IDENTIFICATION; + + connect(&nam, &QNetworkAccessManager::finished, this, &QSOApiKeySender::onNetworkReply); +} + +void QSOApiKeySender::QSOInserted(const QSqlRecord &record) +{ + FCT_IDENTIFICATION; + + if (!getEnabled()) + return; + + const QString url = getURL(); + const QString apiKey = getAPIKey(); + + if (url.isEmpty() || apiKey.isEmpty()) + return; + + sendQSO(url, apiKey, record); +} + +void QSOApiKeySender::sendQSO(const QString &url, const QString &apiKey, const QSqlRecord &record) +{ + FCT_IDENTIFICATION; + + qCDebug(function_parameters) << url << record; + + const QUrl endpoint(url); + + if (!endpoint.isValid() || endpoint.scheme().isEmpty()) + { + emit sendFinished(false, tr("Invalid QSO API URL")); + return; + } + + pendingUrl = endpoint; + pendingRecord = record; + currentStage = Stage::Login; + + // Step 1 - login: the api key travels in the POST body only, never in + // a URL/query string, so it cannot end up in a server access log. + QNetworkRequest request(endpoint); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("application/x-www-form-urlencoded")); + + QUrlQuery loginBody; + loginBody.addQueryItem(QStringLiteral("apikey"), apiKey); + + nam.post(request, loginBody.query(QUrl::FullyEncoded).toUtf8()); +} + +void QSOApiKeySender::onNetworkReply(QNetworkReply *reply) +{ + FCT_IDENTIFICATION; + + reply->deleteLater(); + + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const bool ok = (reply->error() == QNetworkReply::NoError && httpStatus >= 200 && httpStatus < 300); + + if (currentStage == Stage::Login) + { + if (!ok) + { + qCDebug(runtime) << "QSO API login failed" << httpStatus << reply->errorString(); + emit sendFinished(false, tr("API key login failed: %1").arg(reply->errorString())); + return; + } + + // Step 2 - upload: the QSO itself, as a plain GET. No api key here + // - if the server answered step 1 with a session cookie, + // QNetworkAccessManager's default cookie jar attaches it + // automatically, exactly like a browser would after a login form. + currentStage = Stage::Upload; + + QUrl uploadUrl(pendingUrl); + QUrlQuery query; + const auto params = QSOApiKeyQuery::buildParams(pendingRecord); + for (const auto &p : params) + query.addQueryItem(p.first, p.second); + uploadUrl.setQuery(query); + + nam.get(QNetworkRequest(uploadUrl)); + return; + } + + qCDebug(runtime) << "QSO API upload finished" << ok << httpStatus << reply->errorString(); + emit sendFinished(ok, ok ? tr("QSO sent") + : tr("QSO upload failed: %1").arg(reply->errorString())); +} diff --git a/core/QSOApiKeySender.h b/core/QSOApiKeySender.h new file mode 100644 index 00000000..0ed0d36b --- /dev/null +++ b/core/QSOApiKeySender.h @@ -0,0 +1,87 @@ +#ifndef QLOG_CORE_QSOAPIKEYSENDER_H +#define QLOG_CORE_QSOAPIKEYSENDER_H + +#include +#include +#include +#include +#include "core/CredentialStore.h" + +class QNetworkReply; + +class QSOApiKeySenderBase : public SecureServiceBase +{ +protected: + const static QString SECURE_STORAGE_KEY; + const static QString CONFIG_USERNAME_CONST; + +public: + explicit QSOApiKeySenderBase() {}; + virtual ~QSOApiKeySenderBase() {}; + + DECLARE_SECURE_SERVICE(QSOApiKeySenderBase); + + static QString getUsername() {return CONFIG_USERNAME_CONST;} + static QString getAPIKey(); + static void saveAPIKey(const QString &newKey); + + static bool getEnabled(); + static void setEnabled(bool enabled); + static QString getURL(); + static void setURL(const QString &url); +}; + +/*! + * Settings -> Network -> "Send QSO via API key". + * + * Unlike the existing UDP Notifications (core/NetworkNotification), this + * talks HTTP(S) to a single configured endpoint, for services that + * authenticate with a login-style API key rather than accept it as a + * plain query parameter (the radiodyplom.pl live-log concept). + * + * Contract - two separate requests per logged QSO: + * 1. POST {url} body: apikey= (application/x-www-form-urlencoded) + * The secret never appears in a URL/access log, and - over https:// + * - stays encrypted end-to-end. + * 2. GET {url}? + * The QSO itself, as flat ADIF-named query parameters (see + * QSOApiKeyQuery). No apikey here - authentication already happened + * in step 1. If the server sets a session cookie on the POST + * response, QNetworkAccessManager's default cookie jar carries it + * into the GET automatically - the same login-then-act flow a + * browser would do, just performed by QLog instead of a human. + * The server decides how (or whether) to correlate the two requests; + * QLog does not send any shared token/session id of its own beyond the + * above. + */ +class QSOApiKeySender : public QObject, private QSOApiKeySenderBase +{ + Q_OBJECT + +public: + explicit QSOApiKeySender(QObject *parent = nullptr); + +public slots: + void QSOInserted(const QSqlRecord &record); + + // The actual network mechanism, deliberately free of LogParam/ + // CredentialStore reads so it can be unit-tested (fake HTTP server) + // without touching either - see tests/QSOApiKeySenderTest. + void sendQSO(const QString &url, const QString &apiKey, const QSqlRecord &record); + +signals: + void sendFinished(bool ok, const QString &message); + +private slots: + void onNetworkReply(QNetworkReply *reply); + +private: + enum class Stage { Login, Upload }; + + QNetworkAccessManager nam; + Stage currentStage = Stage::Login; + QUrl pendingUrl; + QSqlRecord pendingRecord; +}; + +#endif // QLOG_CORE_QSOAPIKEYSENDER_H diff --git a/core/QSOApiKeySenderCredentials.cpp b/core/QSOApiKeySenderCredentials.cpp new file mode 100644 index 00000000..99fd8c91 --- /dev/null +++ b/core/QSOApiKeySenderCredentials.cpp @@ -0,0 +1,68 @@ +#include "QSOApiKeySender.h" +#include "core/debug.h" +#include "core/LogParam.h" + +MODULE_IDENTIFICATION("qlog.core.qsoapikeysenderbase"); + +const QString QSOApiKeySenderBase::SECURE_STORAGE_KEY = "QSOApiKey"; +const QString QSOApiKeySenderBase::CONFIG_USERNAME_CONST = "qsoapikey"; + +REGISTRATION_SECURE_SERVICE(QSOApiKeySenderBase); + +void QSOApiKeySenderBase::registerCredentials() +{ + CredentialRegistry::instance().add(SECURE_STORAGE_KEY, []() + { + return QList + { + { SECURE_STORAGE_KEY, [](){ return getUsername(); } } + }; + }); +} + +QString QSOApiKeySenderBase::getAPIKey() +{ + FCT_IDENTIFICATION; + + return getPassword(SECURE_STORAGE_KEY, getUsername()); +} + +void QSOApiKeySenderBase::saveAPIKey(const QString &newKey) +{ + FCT_IDENTIFICATION; + + deletePassword(SECURE_STORAGE_KEY, getUsername()); + + if (newKey.isEmpty()) + return; + + savePassword(SECURE_STORAGE_KEY, getUsername(), newKey); +} + +bool QSOApiKeySenderBase::getEnabled() +{ + FCT_IDENTIFICATION; + + return LogParam::getNetworkQSOApiEnabled(); +} + +void QSOApiKeySenderBase::setEnabled(bool enabled) +{ + FCT_IDENTIFICATION; + + LogParam::setNetworkQSOApiEnabled(enabled); +} + +QString QSOApiKeySenderBase::getURL() +{ + FCT_IDENTIFICATION; + + return LogParam::getNetworkQSOApiURL(); +} + +void QSOApiKeySenderBase::setURL(const QString &url) +{ + FCT_IDENTIFICATION; + + LogParam::setNetworkQSOApiURL(url); +} diff --git a/logformat/AdiFormat.h b/logformat/AdiFormat.h index 50fbe52f..78793d35 100644 --- a/logformat/AdiFormat.h +++ b/logformat/AdiFormat.h @@ -31,6 +31,14 @@ class AdiFormat : public LogFormat } } + // Reads exactly one ADIF record (fields up to and including ) into + // a flat map - the reusable primitive behind importNext(QSqlRecord&). + // Public because it is also the right building block for anything that + // needs to parse a single ADIF record without going through the full + // QSO-import/QSqlRecord pipeline - e.g. QSOApiKeyQuery, which flattens + // a logged QSO into GET query parameters via the same round trip. + virtual bool readContact(QVariantMap &); + protected: virtual bool importNextDXCCCredit(DXCCCreditRecord&) override; virtual void importStart() override; @@ -40,7 +48,6 @@ class AdiFormat : public LogFormat const QString &type=""); virtual void writeSQLRecord(const QSqlRecord& record, QMap *applTags); - virtual bool readContact(QVariantMap &); void mapContact2SQLRecord(QMap &contact, QSqlRecord &record); void contactFields2SQLRecord(QMap &contact, diff --git a/tests/QSOApiKeyQueryTest/QSOApiKeyQueryTest.pro b/tests/QSOApiKeyQueryTest/QSOApiKeyQueryTest.pro new file mode 100644 index 00000000..cdc2af29 --- /dev/null +++ b/tests/QSOApiKeyQueryTest/QSOApiKeyQueryTest.pro @@ -0,0 +1,23 @@ +QT += testlib core sql +CONFIG += console testcase c++11 +TEMPLATE = app +TARGET = tst_qsoapikeyquery + +DEFINES += VERSION=\\\"test\\\" + +INCLUDEPATH += $$PWD/../.. + +SOURCES += \ + tst_qsoapikeyquery.cpp \ + test_stubs.cpp \ + ../../core/LogLocale.cpp \ + ../../data/Accents.cpp \ + ../../logformat/AdiFormat.cpp \ + ../../core/QSOApiKeyQuery.cpp + +HEADERS += \ + ../../core/LogLocale.h \ + ../../data/Data.h \ + ../../logformat/AdiFormat.h \ + ../../logformat/LogFormat.h \ + ../../core/QSOApiKeyQuery.h diff --git a/tests/QSOApiKeyQueryTest/test_stubs.cpp b/tests/QSOApiKeyQueryTest/test_stubs.cpp new file mode 100644 index 00000000..f017633e --- /dev/null +++ b/tests/QSOApiKeyQueryTest/test_stubs.cpp @@ -0,0 +1,46 @@ +// Stubs for LogFormat/Data - QSOApiKeyQuery only needs AdiFormat's +// export/import round trip, not the full application. Mirrors +// tests/AdiFormatTest/test_stubs.cpp. + +#include "data/Data.h" +#include "logformat/LogFormat.h" + +LogFormat::LogFormat(QTextStream &stream) : + QObject(nullptr), + stream(stream), + exportedFields(QStringLiteral("*")), + duplicateQSOFunc(nullptr) +{ + defaults = nullptr; +} + +LogFormat::~LogFormat() = default; + +void LogFormat::setDefaults(QMap &defaults) +{ + this->defaults = &defaults; +} + +Data::Data(QObject *parent) : + QObject(parent) +{ +} + +Data::~Data() = default; + +QPair Data::legacyMode(const QString &) +{ + return {}; +} + +void Data::invalidateDXCCStatusCache(const QSqlRecord &) +{ +} + +void Data::invalidateSetOfDXCCStatusCache(const QSet &) +{ +} + +void Data::clearDXCCStatusCache() +{ +} diff --git a/tests/QSOApiKeyQueryTest/tst_qsoapikeyquery.cpp b/tests/QSOApiKeyQueryTest/tst_qsoapikeyquery.cpp new file mode 100644 index 00000000..112b05be --- /dev/null +++ b/tests/QSOApiKeyQueryTest/tst_qsoapikeyquery.cpp @@ -0,0 +1,84 @@ +#include +#include +#include +#include +#include + +#include "core/QSOApiKeyQuery.h" + +class QSOApiKeyQueryTest : public QObject +{ + Q_OBJECT + +private slots: + void buildParams_flattensCoreQSOFieldsAndDerivesQsoDateTimeOn(); + void buildParams_omitsFieldsNotPresentInRecord(); + +private: + static void appendField(QSqlRecord &record, const QString &name, const QVariant &value); + static QString valueOf(const QList> ¶ms, const QString &key); +}; + +void QSOApiKeyQueryTest::appendField(QSqlRecord &record, const QString &name, const QVariant &value) +{ + QSqlField field(name, value.type()); + field.setValue(value); + record.append(field); +} + +QString QSOApiKeyQueryTest::valueOf(const QList> ¶ms, const QString &key) +{ + for (const auto &p : params) + { + if (p.first == key) + return p.second; + } + return QString(); +} + +void QSOApiKeyQueryTest::buildParams_flattensCoreQSOFieldsAndDerivesQsoDateTimeOn() +{ + QSqlRecord record; + appendField(record, QStringLiteral("callsign"), QStringLiteral("OK1AA")); + appendField(record, QStringLiteral("band"), QStringLiteral("20M")); + appendField(record, QStringLiteral("mode"), QStringLiteral("FT8")); + appendField(record, QStringLiteral("rst_sent"), QStringLiteral("599")); + appendField(record, QStringLiteral("rst_rcvd"), QStringLiteral("599")); + appendField(record, QStringLiteral("gridsquare"), QStringLiteral("JO70AA")); + appendField(record, QStringLiteral("comment"), QString()); + appendField(record, QStringLiteral("start_time"), + QDateTime(QDate(2026, 8, 14), QTime(12, 0, 0), QTimeZone::utc())); + + const auto params = QSOApiKeyQuery::buildParams(record); + + QCOMPARE(valueOf(params, QStringLiteral("call")), QStringLiteral("OK1AA")); + // AdiFormat's export lower-cases band ("20M" -> "20m") + QCOMPARE(valueOf(params, QStringLiteral("band")), QStringLiteral("20m")); + QCOMPARE(valueOf(params, QStringLiteral("mode")), QStringLiteral("FT8")); + QCOMPARE(valueOf(params, QStringLiteral("rst_sent")), QStringLiteral("599")); + QCOMPARE(valueOf(params, QStringLiteral("rst_rcvd")), QStringLiteral("599")); + QCOMPARE(valueOf(params, QStringLiteral("gridsquare")), QStringLiteral("JO70AA")); + QCOMPARE(valueOf(params, QStringLiteral("qso_date")), QStringLiteral("20260814")); + QCOMPARE(valueOf(params, QStringLiteral("time_on")), QStringLiteral("120000")); + + // empty ADIF fields (comment here) must never show up as an empty-value pair + for (const auto &p : params) + QVERIFY(!p.second.isEmpty()); +} + +void QSOApiKeyQueryTest::buildParams_omitsFieldsNotPresentInRecord() +{ + QSqlRecord record; + appendField(record, QStringLiteral("callsign"), QStringLiteral("OK1AA")); + appendField(record, QStringLiteral("band"), QStringLiteral("40m")); + + const auto params = QSOApiKeyQuery::buildParams(record); + + QCOMPARE(params.size(), 2); + QCOMPARE(valueOf(params, QStringLiteral("call")), QStringLiteral("OK1AA")); + QCOMPARE(valueOf(params, QStringLiteral("band")), QStringLiteral("40m")); +} + +QTEST_APPLESS_MAIN(QSOApiKeyQueryTest) + +#include "tst_qsoapikeyquery.moc" diff --git a/tests/QSOApiKeySenderTest/QSOApiKeySenderTest.pro b/tests/QSOApiKeySenderTest/QSOApiKeySenderTest.pro new file mode 100644 index 00000000..b8f9f7a7 --- /dev/null +++ b/tests/QSOApiKeySenderTest/QSOApiKeySenderTest.pro @@ -0,0 +1,26 @@ +QT += testlib core network sql +CONFIG += console testcase c++11 +TEMPLATE = app +TARGET = tst_qsoapikeysender + +DEFINES += VERSION=\\\"test\\\" + +INCLUDEPATH += $$PWD/../.. + +SOURCES += \ + tst_qsoapikeysender.cpp \ + test_stubs.cpp \ + ../../core/QSOApiKeySender.cpp \ + ../../core/QSOApiKeyQuery.cpp \ + ../../logformat/AdiFormat.cpp \ + ../../core/LogLocale.cpp \ + ../../data/Accents.cpp + +HEADERS += \ + ../../core/QSOApiKeySender.h \ + ../../core/QSOApiKeyQuery.h \ + ../../core/CredentialStore.h \ + ../../logformat/AdiFormat.h \ + ../../logformat/LogFormat.h \ + ../../data/Data.h \ + ../../core/LogLocale.h diff --git a/tests/QSOApiKeySenderTest/test_stubs.cpp b/tests/QSOApiKeySenderTest/test_stubs.cpp new file mode 100644 index 00000000..f4ba443b --- /dev/null +++ b/tests/QSOApiKeySenderTest/test_stubs.cpp @@ -0,0 +1,71 @@ +// Stubs for QSOApiKeySenderTest. +// +// sendQSO() (the thing under test) never touches LogParam/CredentialStore - +// that's the whole point of the split (see core/QSOApiKeySender.h). But +// QSOInserted() lives in the same translation unit and does call the +// QSOApiKeySenderBase statics, so the linker still needs *some* definition +// for them. Stubbing those here keeps this test target free of +// CredentialStore.cpp/PasswordCipher.cpp/QtKeychain, exactly like +// tests/AdiFormatTest/test_stubs.cpp keeps AdiFormat's tests free of the +// real Data.cpp/LogFormat.cpp. + +#include "core/QSOApiKeySender.h" +#include "data/Data.h" +#include "logformat/LogFormat.h" + +const QString QSOApiKeySenderBase::SECURE_STORAGE_KEY = QStringLiteral("QSOApiKeySenderTestStub"); +const QString QSOApiKeySenderBase::CONFIG_USERNAME_CONST = QStringLiteral("test"); + +void QSOApiKeySenderBase::registerCredentials() +{ +} + +int QSOApiKeySenderBase::QSOApiKeySenderBaseRegistrationDummy = + QSOApiKeySenderBase::QSOApiKeySenderBaseForceRegistration(); + +bool QSOApiKeySenderBase::getEnabled() { return true; } +void QSOApiKeySenderBase::setEnabled(bool) {} +QString QSOApiKeySenderBase::getURL() { return QString(); } +void QSOApiKeySenderBase::setURL(const QString &) {} +QString QSOApiKeySenderBase::getAPIKey() { return QString(); } +void QSOApiKeySenderBase::saveAPIKey(const QString &) {} + +LogFormat::LogFormat(QTextStream &stream) : + QObject(nullptr), + stream(stream), + exportedFields(QStringLiteral("*")), + duplicateQSOFunc(nullptr) +{ + defaults = nullptr; +} + +LogFormat::~LogFormat() = default; + +void LogFormat::setDefaults(QMap &defaults) +{ + this->defaults = &defaults; +} + +Data::Data(QObject *parent) : + QObject(parent) +{ +} + +Data::~Data() = default; + +QPair Data::legacyMode(const QString &) +{ + return {}; +} + +void Data::invalidateDXCCStatusCache(const QSqlRecord &) +{ +} + +void Data::invalidateSetOfDXCCStatusCache(const QSet &) +{ +} + +void Data::clearDXCCStatusCache() +{ +} diff --git a/tests/QSOApiKeySenderTest/tst_qsoapikeysender.cpp b/tests/QSOApiKeySenderTest/tst_qsoapikeysender.cpp new file mode 100644 index 00000000..70b186bf --- /dev/null +++ b/tests/QSOApiKeySenderTest/tst_qsoapikeysender.cpp @@ -0,0 +1,246 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "core/QSOApiKeySender.h" + +namespace { + +struct ReceivedRequest +{ + QString method; + QString target; // path + query string, as sent on the request line + QString body; + QString cookieHeader; +}; + +// Minimal fake HTTP/1.1 server used to observe what QSOApiKeySender::sendQSO +// actually puts on the wire, without depending on any real service. +class FakeHttpServer : public QObject +{ + Q_OBJECT + +public: + explicit FakeHttpServer(QObject *parent = nullptr) : QObject(parent) + { + connect(&server, &QTcpServer::newConnection, this, &FakeHttpServer::onNewConnection); + server.listen(QHostAddress::LocalHost); + } + + quint16 port() const { return server.serverPort(); } + + // Canned response for the Nth request received (0-based). Defaults to + // "200 OK" with an empty body if never set. + void setResponse(int index, int statusCode, const QByteArray &extraHeaders = {}) + { + Response r; + r.statusCode = statusCode; + r.extraHeaders = extraHeaders; + responses[index] = r; + } + + QList requests; + +private slots: + void onNewConnection() + { + while (server.hasPendingConnections()) + { + QTcpSocket *socket = server.nextPendingConnection(); + connect(socket, &QTcpSocket::readyRead, this, [this, socket]() { onReadyRead(socket); }); + } + } + +private: + struct Response { int statusCode = 200; QByteArray extraHeaders; }; + + void onReadyRead(QTcpSocket *socket) + { + QByteArray &buf = buffers[socket]; + buf += socket->readAll(); + + const int headerEnd = buf.indexOf("\r\n\r\n"); + if (headerEnd < 0) + return; + + const QByteArray headerPart = buf.left(headerEnd); + const QList headerLines = headerPart.split('\n'); + const QByteArray requestLine = headerLines.value(0).trimmed(); + + int contentLength = 0; + QByteArray cookieHeader; + for (const QByteArray &line : headerLines) + { + if (line.startsWith("Content-Length:")) + contentLength = line.mid(15).trimmed().toInt(); + if (line.startsWith("Cookie:")) + cookieHeader = line.mid(7).trimmed(); + } + + const int bodyStart = headerEnd + 4; + if (buf.size() < bodyStart + contentLength) + return; + + const QByteArray body = buf.mid(bodyStart, contentLength); + + const QList parts = requestLine.split(' '); + + ReceivedRequest req; + req.method = QString::fromLatin1(parts.value(0)); + req.target = QString::fromLatin1(parts.value(1)); + req.body = QString::fromLatin1(body); + req.cookieHeader = QString::fromLatin1(cookieHeader); + + const int index = requests.size(); + requests.append(req); + + const Response resp = responses.value(index); + + QByteArray out = "HTTP/1.1 " + QByteArray::number(resp.statusCode) + " Status\r\n"; + out += "Content-Length: 0\r\n"; + out += "Connection: close\r\n"; + out += resp.extraHeaders; + out += "\r\n"; + + socket->write(out); + socket->flush(); + socket->disconnectFromHost(); + + buffers.remove(socket); + } + + QTcpServer server; + QMap buffers; + QMap responses; +}; + +} // namespace + +class QSOApiKeySenderTest : public QObject +{ + Q_OBJECT + +private slots: + void sendQSO_postsApiKeyThenGetsQSOFieldsWithoutApiKey(); + void sendQSO_carriesLoginSessionCookieIntoUpload(); + void sendQSO_stopsAfterFailedLogin(); + void sendQSO_reportsFailedUpload(); + +private: + static void appendField(QSqlRecord &record, const QString &name, const QVariant &value); +}; + +void QSOApiKeySenderTest::appendField(QSqlRecord &record, const QString &name, const QVariant &value) +{ + QSqlField field(name, value.type()); + field.setValue(value); + record.append(field); +} + +void QSOApiKeySenderTest::sendQSO_postsApiKeyThenGetsQSOFieldsWithoutApiKey() +{ + FakeHttpServer server; + QVERIFY(server.port() != 0); + + QSqlRecord record; + appendField(record, QStringLiteral("callsign"), QStringLiteral("OK1AA")); + appendField(record, QStringLiteral("band"), QStringLiteral("20m")); + + QSOApiKeySender sender; + QSignalSpy finishedSpy(&sender, &QSOApiKeySender::sendFinished); + + const QString url = QStringLiteral("http://127.0.0.1:%1/upload_qso").arg(server.port()); + sender.sendQSO(url, QStringLiteral("secret-key-123"), record); + + QVERIFY(finishedSpy.wait(2000)); + QCOMPARE(server.requests.size(), 2); + + const ReceivedRequest &login = server.requests.at(0); + QCOMPARE(login.method, QStringLiteral("POST")); + QVERIFY(login.target.startsWith(QStringLiteral("/upload_qso"))); + QCOMPARE(login.body, QStringLiteral("apikey=secret-key-123")); + // the secret must never leak into the URL/target of the login request + QVERIFY(!login.target.contains(QStringLiteral("secret-key-123"))); + + const ReceivedRequest &upload = server.requests.at(1); + QCOMPARE(upload.method, QStringLiteral("GET")); + QVERIFY(upload.target.contains(QStringLiteral("call=OK1AA"))); + QVERIFY(upload.target.contains(QStringLiteral("band=20m"))); + // the api key must not be repeated in the QSO upload request + QVERIFY(!upload.target.contains(QStringLiteral("secret-key-123"))); + QVERIFY(upload.body.isEmpty()); + + QCOMPARE(finishedSpy.count(), 1); + QCOMPARE(finishedSpy.at(0).at(0).toBool(), true); +} + +void QSOApiKeySenderTest::sendQSO_carriesLoginSessionCookieIntoUpload() +{ + FakeHttpServer server; + server.setResponse(0, 200, "Set-Cookie: sessid=abc123; Path=/\r\n"); + + QSqlRecord record; + appendField(record, QStringLiteral("callsign"), QStringLiteral("OK1AA")); + + QSOApiKeySender sender; + QSignalSpy finishedSpy(&sender, &QSOApiKeySender::sendFinished); + + const QString url = QStringLiteral("http://127.0.0.1:%1/upload_qso").arg(server.port()); + sender.sendQSO(url, QStringLiteral("secret-key-123"), record); + + QVERIFY(finishedSpy.wait(2000)); + QCOMPARE(server.requests.size(), 2); + + // the cookie set on the POST (login) response must come back on the + // following GET (upload) - QNetworkAccessManager's default cookie jar + // is what makes the "apikey as login" concept work at all. + QCOMPARE(server.requests.at(1).cookieHeader, QStringLiteral("sessid=abc123")); +} + +void QSOApiKeySenderTest::sendQSO_stopsAfterFailedLogin() +{ + FakeHttpServer server; + server.setResponse(0, 403); + + QSqlRecord record; + appendField(record, QStringLiteral("callsign"), QStringLiteral("OK1AA")); + + QSOApiKeySender sender; + QSignalSpy finishedSpy(&sender, &QSOApiKeySender::sendFinished); + + const QString url = QStringLiteral("http://127.0.0.1:%1/upload_qso").arg(server.port()); + sender.sendQSO(url, QStringLiteral("wrong-key"), record); + + QVERIFY(finishedSpy.wait(2000)); + + // a rejected login must never be followed by the QSO upload request + QCOMPARE(server.requests.size(), 1); + QCOMPARE(finishedSpy.at(0).at(0).toBool(), false); +} + +void QSOApiKeySenderTest::sendQSO_reportsFailedUpload() +{ + FakeHttpServer server; + server.setResponse(1, 500); + + QSqlRecord record; + appendField(record, QStringLiteral("callsign"), QStringLiteral("OK1AA")); + + QSOApiKeySender sender; + QSignalSpy finishedSpy(&sender, &QSOApiKeySender::sendFinished); + + const QString url = QStringLiteral("http://127.0.0.1:%1/upload_qso").arg(server.port()); + sender.sendQSO(url, QStringLiteral("secret-key-123"), record); + + QVERIFY(finishedSpy.wait(2000)); + QCOMPARE(server.requests.size(), 2); + QCOMPARE(finishedSpy.at(0).at(0).toBool(), false); +} + +QTEST_MAIN(QSOApiKeySenderTest) + +#include "tst_qsoapikeysender.moc" diff --git a/tests/tests.pro b/tests/tests.pro index 23998a1f..7883d4b7 100644 --- a/tests/tests.pro +++ b/tests/tests.pro @@ -17,4 +17,6 @@ SUBDIRS += CallsignTest \ MigrationTest \ PasswordCipherTest \ QuadKeyCacheTest \ - RigctldManagerTest + RigctldManagerTest \ + QSOApiKeyQueryTest \ + QSOApiKeySenderTest diff --git a/ui/MainWindow.cpp b/ui/MainWindow.cpp index b3b4120c..1875110d 100644 --- a/ui/MainWindow.cpp +++ b/ui/MainWindow.cpp @@ -374,6 +374,7 @@ MainWindow::MainWindow(QWidget* parent) : connect(ui->newContactWidget, &NewContactWidget::contactAdded, ui->logbookWidget, &LogbookWidget::updateTable); connect(ui->newContactWidget, &NewContactWidget::contactAdded, ui->logbookWidget, &LogbookWidget::setDefaultSort); connect(ui->newContactWidget, &NewContactWidget::contactAdded, &networknotification, &NetworkNotification::QSOInserted); + connect(ui->newContactWidget, &NewContactWidget::contactAdded, &qsoApiKeySender, &QSOApiKeySender::QSOInserted); connect(ui->newContactWidget, &NewContactWidget::contactAdded, ui->bandmapWidget, &BandmapWidget::updateSpotsStatusWhenQSOAdded); connect(ui->newContactWidget, &NewContactWidget::contactAdded, ui->alertsWidget, &AlertWidget::updateSpotsStatusWhenQSOAdded); connect(ui->newContactWidget, &NewContactWidget::contactAdded, ui->chatWidget, &ChatWidget::updateSpotsStatusWhenQSOAdded); @@ -445,6 +446,15 @@ MainWindow::MainWindow(QWidget* parent) : connect(clublogRT, &ClubLogUploader::uploadedQSO, ui->logbookWidget, &LogbookWidget::updateTable); + connect(&qsoApiKeySender, &QSOApiKeySender::sendFinished, this, [this](bool ok, const QString &msg) + { + if (ok) + return; + + qCInfo(runtime) << "QSO API Send Error: " << msg; + QMessageBox::warning(this, tr("QSO API Send Error"), msg); + }); + if ( StationProfilesManager::instance()->profileNameList().isEmpty() ) firstRun = true; else diff --git a/ui/MainWindow.h b/ui/MainWindow.h index 143664da..71b9963b 100644 --- a/ui/MainWindow.h +++ b/ui/MainWindow.h @@ -6,6 +6,7 @@ #include #include "ui/StatisticsWidget.h" #include "core/NetworkNotification.h" +#include "core/QSOApiKeySender.h" #include "core/AlertEvaluator.h" #include "core/PropConditions.h" #include "service/clublog/ClubLog.h" @@ -115,6 +116,7 @@ private slots: QPushButton *themeButton; StatisticsWidget* stats; NetworkNotification networknotification; + QSOApiKeySender qsoApiKeySender; AlertEvaluator alertEvaluator; PropConditions *conditions; bool isFusionStyle; diff --git a/ui/SettingsDialog.cpp b/ui/SettingsDialog.cpp index 9d2cf0b2..bf582b75 100644 --- a/ui/SettingsDialog.cpp +++ b/ui/SettingsDialog.cpp @@ -35,6 +35,7 @@ #include "data/Gridsquare.h" #include "core/WsjtxUDPReceiver.h" #include "core/NetworkNotification.h" +#include "core/QSOApiKeySender.h" #include "rig/Rig.h" #include "rig/RigCaps.h" #include "rotator/Rotator.h" @@ -2762,6 +2763,10 @@ void SettingsDialog::readSettings() ui->notifSpotAlertEdit->setText(NetworkNotification::getNotifSpotAlertAddrs()); ui->notifRigEdit->setText(NetworkNotification::getNotifRigStateAddrs()); + ui->qsoApiEnabledCheckbox->setChecked(QSOApiKeySenderBase::getEnabled()); + ui->qsoApiUrlEdit->setText(QSOApiKeySenderBase::getURL()); + ui->qsoApiKeyEdit->setText(QSOApiKeySenderBase::getAPIKey()); + /*******/ /* GUI */ /*******/ @@ -2899,6 +2904,10 @@ void SettingsDialog::writeSettings() NetworkNotification::saveNotifSpotAlertAddrs(ui->notifSpotAlertEdit->text()); NetworkNotification::saveNotifRigStateAddrs(ui->notifRigEdit->text()); + QSOApiKeySenderBase::setEnabled(ui->qsoApiEnabledCheckbox->isChecked()); + QSOApiKeySenderBase::setURL(ui->qsoApiUrlEdit->text()); + QSOApiKeySenderBase::saveAPIKey(ui->qsoApiKeyEdit->text()); + /*******/ /* GUI */ /*******/ diff --git a/ui/SettingsDialog.ui b/ui/SettingsDialog.ui index a5486d68..3295df8c 100644 --- a/ui/SettingsDialog.ui +++ b/ui/SettingsDialog.ui @@ -4771,6 +4771,63 @@ + + + + Send QSO via API key + + + + + + Enabled + + + + + + + <p>When enabled, every logged QSO is sent to the URL below.</p>First a POST request carries the API key (never in the URL/logs), then a GET request carries the QSO fields. + + + + + + + + + + URL + + + + + + + Endpoint URL of the third-party service. Use an https:// URL so that both the API-key POST and the QSO GET are encrypted in transit. + + + ex. https://example.com/upload_qso.php + + + + + + + API key + + + + + + + QLineEdit::Password + + + + + +