Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions QLog.pro
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down
20 changes: 20 additions & 0 deletions core/LogParam.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions core/LogParam.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
37 changes: 37 additions & 0 deletions core/QSOApiKeyQuery.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#include <QSqlRecord>
#include <QTextStream>
#include <QVariantMap>

#include "QSOApiKeyQuery.h"
#include "logformat/AdiFormat.h"

namespace QSOApiKeyQuery {

QList<QPair<QString, QString>> buildParams(const QSqlRecord &record)
{
QList<QPair<QString, QString>> 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
28 changes: 28 additions & 0 deletions core/QSOApiKeyQuery.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#ifndef QLOG_CORE_QSOAPIKEYQUERY_H
#define QLOG_CORE_QSOAPIKEYQUERY_H

#include <QList>
#include <QPair>
#include <QString>

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<QPair<QString, QString>> buildParams(const QSqlRecord &record);

} // namespace QSOApiKeyQuery

#endif // QLOG_CORE_QSOAPIKEYQUERY_H
103 changes: 103 additions & 0 deletions core/QSOApiKeySender.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QUrlQuery>

#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()));
}
87 changes: 87 additions & 0 deletions core/QSOApiKeySender.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#ifndef QLOG_CORE_QSOAPIKEYSENDER_H
#define QLOG_CORE_QSOAPIKEYSENDER_H

#include <QObject>
#include <QUrl>
#include <QSqlRecord>
#include <QNetworkAccessManager>
#include "core/CredentialStore.h"

class QNetworkReply;

class QSOApiKeySenderBase : public SecureServiceBase<QSOApiKeySenderBase>
{
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=<key> (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}?<qso fields>
* 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
68 changes: 68 additions & 0 deletions core/QSOApiKeySenderCredentials.cpp
Original file line number Diff line number Diff line change
@@ -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<CredentialDescriptor>
{
{ 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);
}
Loading