Files
sqlitebrowser/src/RemoteNetwork.cpp
2021-01-22 14:38:35 +01:00

551 lines
21 KiB
C++

#include <QApplication>
#include <QtNetwork/QNetworkAccessManager>
#include <QMessageBox>
#include <QtNetwork/QNetworkReply>
#include <QFile>
#include <QtNetwork/QSslKey>
#include <QProgressDialog>
#include <QInputDialog>
#include <QDir>
#include <QUrlQuery>
#include <QtNetwork/QHttpMultiPart>
#include <QtNetwork/QNetworkProxyFactory>
#include <QTimeZone>
#include <QtNetwork/QNetworkProxy>
#include <json.hpp>
#include <iterator>
#include "FileDialog.h"
#include "RemoteNetwork.h"
#include "Settings.h"
#include "sqlite.h"
#include "version.h"
using json = nlohmann::json;
RemoteNetwork::RemoteNetwork() :
m_manager(new QNetworkAccessManager),
m_progress(nullptr),
m_sslConfiguration(QSslConfiguration::defaultConfiguration())
{
// Set up SSL configuration
m_sslConfiguration.setPeerVerifyMode(QSslSocket::VerifyPeer);
// Load CA certs from resource file
QDir dirCaCerts(":/certs");
QStringList caCertsList = dirCaCerts.entryList();
QList<QSslCertificate> caCerts;
for(const QString& caCertName : caCertsList)
caCerts += QSslCertificate::fromPath(":/certs/" + caCertName);
m_sslConfiguration.setCaCertificates(caCerts);
// Load settings and set up some more stuff while doing so
reloadSettings();
// Set up signals
connect(m_manager, &QNetworkAccessManager::encrypted, this, &RemoteNetwork::gotEncrypted);
connect(m_manager, &QNetworkAccessManager::sslErrors, this, &RemoteNetwork::gotError);
}
RemoteNetwork::~RemoteNetwork()
{
delete m_manager;
delete m_progress;
}
void RemoteNetwork::reloadSettings()
{
// Load all configured client certificates
m_clientCertFiles.clear();
auto client_certs = Settings::getValue("remote", "client_certificates").toStringList();
for(const QString& path : client_certs)
{
QFile file(path);
file.open(QFile::ReadOnly);
QSslCertificate cert(&file);
file.close();
m_clientCertFiles.insert({path, cert});
}
// Always add the default certificate for anonymous access to dbhub.io
{
QFile file(":/user_certs/public.cert.pem");
file.open(QFile::ReadOnly);
QSslCertificate cert(&file);
file.close();
m_clientCertFiles.insert({":/user_certs/public.cert.pem", cert});
}
// Configure proxy to use
{
QString type = Settings::getValue("proxy", "type").toString();
QNetworkProxy proxy;
if(type == "system")
{
// For system settings we have to get the system-wide proxy and use that
// Get list of proxies for accessing dbhub.io via HTTPS and use the first one
auto list = QNetworkProxyFactory::systemProxyForQuery(QNetworkProxyQuery(QUrl("https://db4s.dbhub.io/")));
proxy = list.front();
} else {
// For any other type we have to set up our own proxy configuration
// Retrieve the required settings
QString host = Settings::getValue("proxy", "host").toString();
unsigned short port = static_cast<unsigned short>(Settings::getValue("proxy", "port").toUInt());
bool authentication = Settings::getValue("proxy", "authentication").toBool();
if(type == "http")
proxy.setType(QNetworkProxy::HttpProxy);
else if(type == "socks5")
proxy.setType(QNetworkProxy::Socks5Proxy);
else
proxy.setType(QNetworkProxy::NoProxy);
proxy.setHostName(host);
proxy.setPort(port);
// Only set authentication details when authentication is required
if(authentication)
{
QString user = Settings::getValue("proxy", "user").toString();
QString password = Settings::getValue("proxy", "password").toString();
proxy.setUser(user);
proxy.setPassword(password);
}
}
// Start using the new proxy configuration
QNetworkProxy::setApplicationProxy(proxy);
}
}
void RemoteNetwork::gotEncrypted(QNetworkReply* reply)
{
#ifdef Q_OS_MAC
// Temporary workaround for now, as Qt 5.8 and below doesn't support
// verifying certificates on OSX: https://bugreports.qt.io/browse/QTBUG-56973
// Hopefully this is fixed in Qt 5.9
return;
#else
// Verify the server's certificate using our CA certs
auto verificationErrors = reply->sslConfiguration().peerCertificate().verify(m_sslConfiguration.caCertificates());
bool good = false;
if(verificationErrors.size() == 0)
{
good = true;
} else if(verificationErrors.size() == 1) {
// Ignore any self signed certificate errors
if(verificationErrors.at(0).error() == QSslError::SelfSignedCertificate || verificationErrors.at(0).error() == QSslError::SelfSignedCertificateInChain)
good = true;
}
// If the server certificate didn't turn out to be good, abort the reply here
if(!good)
reply->abort();
#endif
}
void RemoteNetwork::gotReply(QNetworkReply* reply)
{
// What type of data is this?
RequestType type = static_cast<RequestType>(reply->property("type").toInt());
// Hide progress dialog before opening a file dialog to make sure the progress dialog doesn't interfer with the file dialog
if(type == RequestTypeDatabase || type == RequestTypePush)
m_progress->reset();
// Handle the reply data
switch(type)
{
case RequestTypeDatabase:
{
// It's a database file.
// Get last modified date as provided by the server
QDateTime last_modified;
QString content_disposition = reply->rawHeader("Content-Disposition");
QRegExp regex("^.*modification-date=\"(.+)\";.*$");
regex.setMinimal(true); // Set to non-greedy matching
if(regex.indexIn(content_disposition) != -1)
last_modified = QDateTime::fromString(regex.cap(1), Qt::ISODate);
// Extract all other information from reply and send it to slots
emit fetchFinished(reply->url().fileName(),
reply->property("certfile").toString(),
reply->url(),
QUrlQuery(reply->url()).queryItemValue("commit").toStdString(),
QUrlQuery(reply->url()).queryItemValue("branch").toStdString(),
last_modified,
reply);
}
break;
case RequestTypePush:
{
// Read and check results
json obj = json::parse(reply->readAll(), nullptr, false);
if(obj.is_discarded() || !obj.is_object())
break;
// Extract all information from reply and send it to slots
emit pushFinished(reply->url().fileName(),
reply->property("certfile").toString(),
QString::fromStdString(obj["url"]),
obj["commit_id"],
QUrlQuery(QUrl(QString::fromStdString(obj["url"]))).queryItemValue("branch").toStdString(),
reply->property("source_file").toString());
break;
}
case RequestTypeDownload:
{
// It's a download
// Where should we save it?
QString path = FileDialog::getSaveFileName(FileDialogTypes::CreateDatabaseFile,
nullptr,
tr("Choose a location to save the file"),
QString(),
reply->url().fileName() + "_" + QUrlQuery(reply->url()).queryItemValue("commit") + ".db");
if(path.isEmpty())
break;
// Save the downloaded data in that file
QFile file(path);
file.open(QIODevice::WriteOnly);
file.write(reply->readAll());
file.close();
}
break;
case RequestTypeCustom:
break;
}
// Delete reply later, i.e. after returning from this slot function
reply->deleteLater();
}
void RemoteNetwork::gotError(QNetworkReply* reply, const QList<QSslError>& errors)
{
// Are there any errors in here that aren't about self-signed certificates and non-matching hostnames?
bool serious_errors = std::any_of(errors.begin(), errors.end(), [](const QSslError& error) { return error.error() != QSslError::SelfSignedCertificate; });
// Just stop the error checking here and accept the reply if there were no 'serious' errors
if(!serious_errors)
{
reply->ignoreSslErrors(errors);
return;
}
// Build an error message and short it to the user
QString message = tr("Error opening remote file at %1.\n%2").arg(reply->url().toString(), errors.at(0).errorString());
QMessageBox::warning(nullptr, qApp->applicationName(), message);
// Delete reply later, i.e. after returning from this slot function
if(m_progress)
m_progress->reset();
reply->deleteLater();
}
void RemoteNetwork::updateProgress(qint64 bytesTransmitted, qint64 bytesTotal)
{
// Find out to which pending reply this progress update belongs
QNetworkReply* reply = qobject_cast<QNetworkReply*>(QObject::sender());
// Update progress dialog
if(bytesTotal == -1)
{
// We don't know anything about the current progress, but it's still downloading
m_progress->setMinimum(0);
m_progress->setMaximum(0);
m_progress->setValue(0);
} else if(bytesTransmitted == bytesTotal) {
// The download has finished
m_progress->reset();
} else {
// It's still downloading and we know the current progress
// Were using a range 0 to 10000 here, the progress dialog will calculate 0% to 100% values from that. The reason we're not using
// the byte counts as-is is that they're 64bit wide while the progress dialog takes only 32bit values, so for large files the values
// would lose precision. The reason why we're not using a range 0 to 100 is that our range increases the precision a bit and this way
// we're prepared if the progress dialog will show decimal numbers one day on one platform.
m_progress->setMinimum(0);
m_progress->setMaximum(10000);
m_progress->setValue(static_cast<int>((static_cast<float>(bytesTransmitted) / static_cast<float>(bytesTotal)) * 10000.0f));
}
// Check if the Cancel button has been pressed
if(reply && m_progress->wasCanceled())
{
reply->abort();
m_progress->reset();
}
}
const QList<QSslCertificate>& RemoteNetwork::caCertificates() const
{
static QList<QSslCertificate> certs = m_sslConfiguration.caCertificates();
return certs;
}
QString RemoteNetwork::getInfoFromClientCert(const QString& cert, CertInfo info) const
{
// Get the common name of the certificate and split it into user name and server address
QString cn = m_clientCertFiles.at(cert).subjectInfo(QSslCertificate::CommonName).at(0);
QStringList cn_parts = cn.split("@");
if(cn_parts.size() < 2)
return QString();
// Return requested part of the CN
if(info == CertInfoUser)
{
return cn_parts.first();
} else if(info == CertInfoServer) {
// Assemble the full URL from the host name. We use port 443 by default but for
// local development purposes we use 5550 instead.
QString host = cn_parts.last();
host = QString("https://%1%2/").arg(host).arg(host.contains("docker-dev") ? ":5550" : "");
return host;
}
return QString();
}
bool RemoteNetwork::prepareSsl(QNetworkRequest* request, const QString& clientCert)
{
// Check if client cert exists
const QSslCertificate& cert = m_clientCertFiles[clientCert];
if(cert.isNull())
{
QMessageBox::warning(nullptr, qApp->applicationName(), tr("Error: Invalid client certificate specified."));
return false;
}
// Load private key for the client certificate
QFile fileClientCert(clientCert);
fileClientCert.open(QFile::ReadOnly);
QSslKey clientKey(&fileClientCert, QSsl::Rsa, QSsl::Pem, QSsl::PrivateKey);
while(clientKey.isNull())
{
// If the private key couldn't be read, we assume it's password protected. So ask the user for the correct password and try reading it
// again. If the user cancels the password dialog, abort the whole process.
QString password = QInputDialog::getText(nullptr, qApp->applicationName(), tr("Please enter the passphrase for this client certificate in order to authenticate."));
if(password.isEmpty())
return false;
clientKey = QSslKey(&fileClientCert, QSsl::Rsa, QSsl::Pem, QSsl::PrivateKey, password.toUtf8());
}
fileClientCert.close();
// Set client certificate (from the cache) and private key (just loaded)
m_sslConfiguration.setLocalCertificate(cert);
m_sslConfiguration.setPrivateKey(clientKey);
// Apply SSL configuration
request->setSslConfiguration(m_sslConfiguration);
return true;
}
void RemoteNetwork::prepareProgressDialog(QNetworkReply* reply, bool upload, const QUrl& url)
{
// Instantiate progress dialog and apply some basic settings
if(!m_progress)
m_progress = new QProgressDialog();
m_progress->reset();
m_progress->setWindowModality(Qt::NonModal);
m_progress->setCancelButtonText(tr("Cancel"));
// Set dialog text
QString url_for_display = url.toString(QUrl::PrettyDecoded | QUrl::RemoveQuery);
if(upload)
m_progress->setLabelText(tr("Uploading remote database to\n%1").arg(url_for_display));
else
m_progress->setLabelText(tr("Downloading remote database from\n%1").arg(url_for_display));
// Show dialog
m_progress->show();
// Make sure the dialog is updated
if(upload)
connect(reply, &QNetworkReply::uploadProgress, this, &RemoteNetwork::updateProgress);
else
connect(reply, &QNetworkReply::downloadProgress, this, &RemoteNetwork::updateProgress);
}
void RemoteNetwork::fetch(const QUrl& url, RequestType type, const QString& clientCert,
std::function<void(QByteArray)> when_finished, bool synchronous, bool ignore_errors)
{
// Build network request
QNetworkRequest request;
request.setUrl(url);
request.setRawHeader("User-Agent", QString("%1 %2").arg(qApp->organizationName(), APP_VERSION).toUtf8());
#if QT_VERSION >= QT_VERSION_CHECK(5, 6, 0)
request.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true);
#endif
// Set SSL configuration when trying to access a file via the HTTPS protocol.
// Skip this step when no client certificate was specified. In this case the default HTTPS configuration is used.
bool https = url.scheme().compare("https", Qt::CaseInsensitive) == 0;
if(https && !clientCert.isNull())
{
// If configuring the SSL connection fails, abort the request here
if(!prepareSsl(&request, clientCert))
return;
}
// Clear access cache if necessary
clearAccessCache(clientCert);
// Fetch database and prepare pending reply for future processing
QNetworkReply* reply = m_manager->get(request);
reply->setProperty("type", type);
reply->setProperty("certfile", clientCert);
reply->setProperty("ignore_errors", ignore_errors);
// Hook up custom handler when there is one and global handler otherwise
if(when_finished)
{
connect(reply, &QNetworkReply::finished, reply, [this, when_finished, reply]() {
if(handleReply(reply))
when_finished(reply->readAll());
});
} else {
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
if(handleReply(reply))
gotReply(reply);
});
}
// When the synchrounous flag is set we wait for the request to finish before continuing
if(synchronous)
{
QEventLoop loop;
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
}
// Initialise the progress dialog for this request, but only if this is a database file or a download.
// Directory listing and similar are small enough to be loaded without progress dialog.
if(type == RequestTypeDatabase || type == RequestTypeDownload)
prepareProgressDialog(reply, false, url);
}
void RemoteNetwork::push(const QString& filename, const QUrl& url, const QString& clientCert, const QString& remotename,
const QString& commitMessage, const QString& licence, bool isPublic, const QString& branch,
bool forcePush, const QString& last_commit)
{
// Open the file to send and check if it exists
QFile* file = new QFile(filename);
if(!file->open(QFile::ReadOnly))
{
delete file;
QMessageBox::warning(nullptr, qApp->applicationName(), tr("Error: Cannot open the file for sending."));
return;
}
// Build network request
QNetworkRequest request;
request.setUrl(url);
request.setRawHeader("User-Agent", QString("%1 %2").arg(qApp->organizationName(), APP_VERSION).toUtf8());
// Get the last modified date of the file and prepare it for conversion into the ISO date format
QDateTime last_modified = QFileInfo(filename).lastModified().toOffsetFromUtc(0);
// Prepare HTTP multi part data containing all the information about the commit we're about to push
QHttpMultiPart* multipart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addPart(multipart, "file", file, remotename);
addPart(multipart, "commitmsg", commitMessage);
addPart(multipart, "licence", licence);
addPart(multipart, "public", isPublic ? "true" : "false");
addPart(multipart, "branch", branch);
addPart(multipart, "force", forcePush ? "true" : "false");
addPart(multipart, "lastmodified", last_modified.toString("yyyy-MM-dd'T'HH:mm:ss'Z'"));
// Only add commit id if one was provided
if(!last_commit.isEmpty())
addPart(multipart, "commit", last_commit);
// Set SSL configuration when trying to access a file via the HTTPS protocol
bool https = url.scheme().compare("https", Qt::CaseInsensitive) == 0;
if(https)
{
// If configuring the SSL connection fails, abort the request here
if(!prepareSsl(&request, clientCert))
{
delete file;
return;
}
}
// Clear access cache if necessary
clearAccessCache(clientCert);
// Put database to remote server and save pending reply for future processing
QNetworkReply* reply = m_manager->post(request, multipart);
reply->setProperty("type", RequestTypePush);
reply->setProperty("certfile", clientCert);
reply->setProperty("source_file", filename);
multipart->setParent(reply); // Delete the multi-part object along with the reply
// Connect reply handler
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
if(handleReply(reply))
gotReply(reply);
});
// Initialise the progress dialog for this request
prepareProgressDialog(reply, true, url);
}
void RemoteNetwork::addPart(QHttpMultiPart* multipart, const QString& name, const QString& value) const
{
QHttpPart part;
part.setHeader(QNetworkRequest::ContentDispositionHeader, QString("form-data; name=\"%1\"").arg(name));
part.setBody(value.toUtf8());
multipart->append(part);
}
void RemoteNetwork::addPart(QHttpMultiPart* multipart, const QString& name, QFile* file, const QString& filename) const
{
QHttpPart part;
part.setHeader(QNetworkRequest::ContentDispositionHeader, QString("form-data; name=\"%1\"; filename=\"%2\"").arg(name, filename));
part.setBodyDevice(file);
file->setParent(multipart); // Close the file and delete the file object as soon as the multi-part object is destroyed
multipart->append(part);
}
void RemoteNetwork::clearAccessCache(const QString& clientCert)
{
// When the client certificate is different from the one before, clear the access and authentication cache.
// Otherwise Qt might use the old certificate again.
static QString lastClientCert;
if(lastClientCert != clientCert)
{
lastClientCert = clientCert;
m_manager->clearAccessCache();
}
}
bool RemoteNetwork::handleReply(QNetworkReply* reply)
{
// Check if request was successful
if(reply->error() != QNetworkReply::NoError)
{
// Do not show error message when operation was cancelled on purpose
if(reply->error() != QNetworkReply::OperationCanceledError && !reply->property("ignore_errors").toBool())
{
QMessageBox::warning(nullptr, qApp->applicationName(),
reply->errorString() + "\n" + reply->readAll());
}
reply->deleteLater();
return false;
}
return true;
}