547 lines
14 KiB
C++
547 lines
14 KiB
C++
// For license of this file, see <project-root-folder>/LICENSE.md.
|
|
//
|
|
// This file is heavily inspired by https://github.com/ikskuh/kristall.
|
|
|
|
#include "network-web/gemini/geminiclient.h"
|
|
|
|
#include "definitions/definitions.h"
|
|
|
|
#include <cassert>
|
|
|
|
#include <QDebug>
|
|
#include <QRegularExpression>
|
|
#include <QSslConfiguration>
|
|
#include <QUrl>
|
|
|
|
bool CryptoIdentity::isValid() const {
|
|
return !certificate.isNull() && !private_key.isNull();
|
|
}
|
|
|
|
bool CryptoIdentity::isHostFiltered(const QUrl& url) const {
|
|
if (host_filter.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
QString url_text = url.toString(QUrl::FullyEncoded);
|
|
QRegularExpression pattern(QRegularExpression::wildcardToRegularExpression(host_filter),
|
|
QRegularExpression::PatternOption::CaseInsensitiveOption);
|
|
|
|
return !pattern.match(url_text).hasMatch();
|
|
}
|
|
|
|
bool CryptoIdentity::isAutomaticallyEnabledOn(const QUrl& url) const {
|
|
if (host_filter.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
if (!auto_enable) {
|
|
return false;
|
|
}
|
|
|
|
QString url_text = url.toString(QUrl::FullyEncoded);
|
|
QRegularExpression pattern(QRegularExpression::wildcardToRegularExpression(host_filter),
|
|
QRegularExpression::PatternOption::CaseInsensitiveOption);
|
|
|
|
return pattern.match(url_text).hasMatch();
|
|
}
|
|
|
|
GeminiClient::GeminiClient(QObject* parent) : QObject(parent) {
|
|
connect(&m_socket, &QSslSocket::encrypted, this, &GeminiClient::socketEncrypted);
|
|
connect(&m_socket, &QSslSocket::readyRead, this, &GeminiClient::socketReadyRead);
|
|
connect(&m_socket, &QSslSocket::disconnected, this, &GeminiClient::socketDisconnected);
|
|
connect(&m_socket, QOverload<const QList<QSslError>&>::of(&QSslSocket::sslErrors), this, &GeminiClient::sslErrors);
|
|
|
|
// connect(&socket, &QSslSocket::stateChanged, [](QSslSocket::SocketState state) {
|
|
// qDebug() << "Socket state changed to " << state;
|
|
// });
|
|
|
|
#if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 0))
|
|
connect(&m_socket, &QTcpSocket::errorOccurred, this, &GeminiClient::socketError);
|
|
#else
|
|
connect(&m_socket, QOverload<QAbstractSocket::SocketError>::of(&QTcpSocket::error), this, &GeminiClient::socketError);
|
|
#endif
|
|
|
|
// States
|
|
connect(&m_socket, &QAbstractSocket::hostFound, this, [this]() {
|
|
emit this->requestStateChange(RequestState::HostFound);
|
|
});
|
|
|
|
connect(&m_socket, &QAbstractSocket::connected, this, [this]() {
|
|
emit this->requestStateChange(RequestState::Connected);
|
|
});
|
|
|
|
connect(&m_socket, &QAbstractSocket::disconnected, this, [this]() {
|
|
emit this->requestStateChange(RequestState::None);
|
|
});
|
|
|
|
emit requestStateChange(RequestState::None);
|
|
}
|
|
|
|
GeminiClient::~GeminiClient() {
|
|
m_isReceivingBody = false;
|
|
}
|
|
|
|
bool GeminiClient::supportsUrl(const QString& url) const {
|
|
return url.startsWith(QL1S("gemini://"));
|
|
}
|
|
|
|
bool GeminiClient::supportsUrl(const QUrl& url) const {
|
|
return url.scheme() == QL1S("gemini");
|
|
}
|
|
|
|
bool GeminiClient::startRequest(const QUrl& url, RequestOptions options) {
|
|
if (!supportsUrl(url)) {
|
|
return false;
|
|
}
|
|
|
|
// qDebug() << "start request" << url;
|
|
|
|
if (m_socket.state() != QTcpSocket::UnconnectedState) {
|
|
m_socket.disconnectFromHost();
|
|
m_socket.close();
|
|
|
|
if (!m_socket.waitForDisconnected(1500)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
emit requestStateChange(RequestState::Started);
|
|
|
|
m_inErrorState = false;
|
|
options = options;
|
|
|
|
QSslConfiguration ssl_config = m_socket.sslConfiguration();
|
|
ssl_config.setProtocol(QSsl::TlsV1_2OrLater);
|
|
|
|
/*
|
|
if (not kristall::globals().trust.gemini.enable_ca)
|
|
ssl_config.setCaCertificates(QList<QSslCertificate>{});
|
|
else
|
|
*/
|
|
|
|
ssl_config.setCaCertificates(QSslConfiguration::systemCaCertificates());
|
|
|
|
/*
|
|
*/
|
|
|
|
m_socket.setSslConfiguration(ssl_config);
|
|
m_socket.connectToHostEncrypted(url.host(), url.port(1965));
|
|
|
|
m_buffer.clear();
|
|
m_body.clear();
|
|
m_isReceivingBody = false;
|
|
m_suppressSocketTlsErrors = true;
|
|
|
|
if (!m_socket.isOpen()) {
|
|
return false;
|
|
}
|
|
|
|
m_targetUrl = url;
|
|
m_mimeType = "<invalid>";
|
|
|
|
return true;
|
|
}
|
|
|
|
bool GeminiClient::inProgress() const {
|
|
return m_socket.state() != QTcpSocket::UnconnectedState;
|
|
}
|
|
|
|
bool GeminiClient::cancelRequest() {
|
|
// qDebug() << "cancel request" << isInProgress();
|
|
if (inProgress()) {
|
|
m_isReceivingBody = false;
|
|
m_socket.disconnectFromHost();
|
|
m_buffer.clear();
|
|
m_body.clear();
|
|
|
|
if (m_socket.state() != QTcpSocket::UnconnectedState) {
|
|
m_socket.disconnectFromHost();
|
|
}
|
|
|
|
m_socket.waitForDisconnected(500);
|
|
m_socket.close();
|
|
|
|
bool success = !inProgress();
|
|
|
|
// qDebug() << "cancel success" << success;
|
|
|
|
return success;
|
|
}
|
|
else {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
bool GeminiClient::enableClientCertificate(const CryptoIdentity& ident) {
|
|
m_socket.setLocalCertificate(ident.certificate);
|
|
m_socket.setPrivateKey(ident.private_key);
|
|
|
|
return true;
|
|
}
|
|
|
|
void GeminiClient::disableClientCertificate() {
|
|
m_socket.setLocalCertificate(QSslCertificate{});
|
|
m_socket.setPrivateKey(QSslKey{});
|
|
}
|
|
|
|
void GeminiClient::emitNetworkError(QAbstractSocket::SocketError error_code, const QString& textual_description) {
|
|
NetworkError network_error = UnknownError;
|
|
|
|
switch (error_code) {
|
|
case QAbstractSocket::ConnectionRefusedError:
|
|
network_error = ConnectionRefused;
|
|
break;
|
|
|
|
case QAbstractSocket::HostNotFoundError:
|
|
network_error = HostNotFound;
|
|
break;
|
|
|
|
case QAbstractSocket::SocketTimeoutError:
|
|
network_error = Timeout;
|
|
break;
|
|
|
|
case QAbstractSocket::SslHandshakeFailedError:
|
|
network_error = TlsFailure;
|
|
break;
|
|
|
|
case QAbstractSocket::SslInternalError:
|
|
network_error = TlsFailure;
|
|
break;
|
|
|
|
case QAbstractSocket::SslInvalidUserDataError:
|
|
network_error = TlsFailure;
|
|
break;
|
|
|
|
default:
|
|
qDebug() << "unhandled network error:" << error_code;
|
|
break;
|
|
}
|
|
|
|
emit networkError(network_error, textual_description);
|
|
}
|
|
|
|
void GeminiClient::socketEncrypted() {
|
|
emit hostCertificateLoaded(m_socket.peerCertificate());
|
|
|
|
QString request = m_targetUrl.toString(QUrl::FormattingOptions(QUrl::FullyEncoded)) + QSL("\r\n");
|
|
|
|
QByteArray request_bytes = request.toUtf8();
|
|
|
|
qint64 offset = 0;
|
|
|
|
while (offset < request_bytes.size()) {
|
|
const auto len = m_socket.write(request_bytes.constData() + offset, request_bytes.size() - offset);
|
|
|
|
if (len <= 0) {
|
|
m_socket.close();
|
|
return;
|
|
}
|
|
|
|
offset += len;
|
|
}
|
|
}
|
|
|
|
void GeminiClient::socketReadyRead() {
|
|
if (m_inErrorState) {
|
|
return;
|
|
}
|
|
|
|
QByteArray response = m_socket.readAll();
|
|
|
|
if (m_isReceivingBody) {
|
|
m_body.append(response);
|
|
|
|
emit requestProgress(m_body.size());
|
|
}
|
|
else {
|
|
for (int i = 0; i < response.size(); i++) {
|
|
if (response[i] == '\n') {
|
|
m_buffer.append(response.data(), i);
|
|
m_body.append(response.data() + i + 1, response.size() - i - 1);
|
|
|
|
// "XY " <META> <CR> <LF>
|
|
if (m_buffer.size() < 4) { // we allow an empty <META>
|
|
m_socket.close();
|
|
|
|
qDebug() << m_buffer;
|
|
|
|
emit networkError(ProtocolViolation, tr("Line is too short for valid protocol"));
|
|
|
|
return;
|
|
}
|
|
if (m_buffer.size() >= 1200) {
|
|
emit networkError(ProtocolViolation, tr("response too large!"));
|
|
|
|
m_socket.close();
|
|
}
|
|
if (m_buffer[m_buffer.size() - 1] != '\r') {
|
|
m_socket.close();
|
|
|
|
qDebug() << m_buffer;
|
|
|
|
emit networkError(ProtocolViolation, tr("Line does not end with <CR> <LF>"));
|
|
|
|
return;
|
|
}
|
|
if (!isdigit(m_buffer[0])) {
|
|
m_socket.close();
|
|
|
|
qDebug() << m_buffer;
|
|
|
|
emit networkError(ProtocolViolation, tr("First character is not a digit."));
|
|
|
|
return;
|
|
}
|
|
if (not isdigit(m_buffer[1])) {
|
|
m_socket.close();
|
|
|
|
qDebug() << m_buffer;
|
|
|
|
emit networkError(ProtocolViolation, tr("Second character is not a digit."));
|
|
|
|
return;
|
|
}
|
|
|
|
// TODO: Implement stricter version
|
|
// if(buffer[2] != ' ') {
|
|
if (!isspace(m_buffer[2])) {
|
|
m_socket.close();
|
|
|
|
qDebug() << m_buffer;
|
|
|
|
emit networkError(ProtocolViolation, tr("Third character is not a space."));
|
|
|
|
return;
|
|
}
|
|
|
|
QString meta = QString::fromUtf8(m_buffer.data() + 3, m_buffer.size() - 4);
|
|
|
|
int primary_code = m_buffer[0] - '0';
|
|
int secondary_code = m_buffer[1] - '0';
|
|
|
|
qDebug() << primary_code << secondary_code << meta;
|
|
|
|
// We don't need to receive any data after that.
|
|
if (primary_code != 2) {
|
|
m_socket.close();
|
|
}
|
|
|
|
switch (primary_code) {
|
|
case 1: // requesting input
|
|
switch (secondary_code) {
|
|
case 1:
|
|
emit inputRequired(meta, true);
|
|
break;
|
|
|
|
case 0:
|
|
default:
|
|
emit inputRequired(meta, false);
|
|
}
|
|
|
|
return;
|
|
|
|
case 2: // success
|
|
m_isReceivingBody = true;
|
|
m_mimeType = meta;
|
|
|
|
return;
|
|
|
|
case 3: { // redirect
|
|
QUrl new_url(meta);
|
|
if (new_url.isValid()) {
|
|
if (new_url.isRelative()) {
|
|
new_url = m_targetUrl.resolved(new_url);
|
|
}
|
|
|
|
assert(not new_url.isRelative());
|
|
|
|
emit redirected(new_url, (secondary_code == 1));
|
|
}
|
|
else {
|
|
emit networkError(ProtocolViolation, tr("Invalid URL for redirection!"));
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
case 4: { // temporary failure
|
|
NetworkError type = UnknownError;
|
|
|
|
switch (secondary_code) {
|
|
case 1:
|
|
type = InternalServerError;
|
|
break;
|
|
|
|
case 2:
|
|
type = InternalServerError;
|
|
break;
|
|
|
|
case 3:
|
|
type = InternalServerError;
|
|
break;
|
|
|
|
case 4:
|
|
type = UnknownError;
|
|
break;
|
|
}
|
|
|
|
emit networkError(type, meta);
|
|
|
|
return;
|
|
}
|
|
|
|
case 5: { // permanent failure
|
|
NetworkError type = UnknownError;
|
|
|
|
switch (secondary_code) {
|
|
case 1:
|
|
type = ResourceNotFound;
|
|
break;
|
|
|
|
case 2:
|
|
type = ResourceNotFound;
|
|
break;
|
|
|
|
case 3:
|
|
type = ProxyRequest;
|
|
break;
|
|
|
|
case 9:
|
|
type = BadRequest;
|
|
break;
|
|
}
|
|
|
|
emit networkError(type, meta);
|
|
|
|
return;
|
|
}
|
|
|
|
case 6: // client certificate required
|
|
switch (secondary_code) {
|
|
case 0:
|
|
emit certificateRequired(meta);
|
|
return;
|
|
|
|
case 1:
|
|
emit networkError(Unauthorized, meta);
|
|
return;
|
|
|
|
default:
|
|
case 2:
|
|
emit networkError(InvalidClientCertificate, meta);
|
|
return;
|
|
}
|
|
|
|
return;
|
|
|
|
default:
|
|
emit networkError(ProtocolViolation, tr("Unspecified status code used!"));
|
|
|
|
return;
|
|
}
|
|
|
|
assert(false && "unreachable");
|
|
}
|
|
}
|
|
if ((m_buffer.size() + response.size()) >= 1200) {
|
|
emit networkError(ProtocolViolation, tr("META too large!"));
|
|
|
|
m_socket.close();
|
|
}
|
|
|
|
m_buffer.append(response);
|
|
}
|
|
}
|
|
|
|
void GeminiClient::socketDisconnected() {
|
|
if (m_isReceivingBody && !m_inErrorState) {
|
|
m_body.append(m_socket.readAll());
|
|
emit requestComplete(m_body, m_mimeType);
|
|
}
|
|
}
|
|
|
|
void GeminiClient::sslErrors(const QList<QSslError>& errors) {
|
|
emit hostCertificateLoaded(m_socket.peerCertificate());
|
|
|
|
if (m_options & IgnoreTlsErrors) {
|
|
m_socket.ignoreSslErrors(errors);
|
|
return;
|
|
}
|
|
|
|
QList<QSslError> remaining_errors = errors;
|
|
QList<QSslError> ignored_errors;
|
|
|
|
int i = 0;
|
|
while (i < remaining_errors.size()) {
|
|
const auto& err = remaining_errors.at(i);
|
|
|
|
bool ignore = false;
|
|
|
|
/*
|
|
*/
|
|
ignore = true;
|
|
/*
|
|
if (SslTrust::isTrustRelated(err.error())) {
|
|
switch (kristall::globals().trust.gemini.getTrust(target_url, socket.peerCertificate())) {
|
|
case SslTrust::Trusted:
|
|
ignore = true;
|
|
break;
|
|
case SslTrust::Untrusted:
|
|
this->is_error_state = true;
|
|
this->suppress_socket_tls_error = true;
|
|
emit this->networkError(UntrustedHost, toFingerprintString(socket.peerCertificate()));
|
|
return;
|
|
case SslTrust::Mistrusted:
|
|
this->is_error_state = true;
|
|
this->suppress_socket_tls_error = true;
|
|
emit this->networkError(MistrustedHost, toFingerprintString(socket.peerCertificate()));
|
|
return;
|
|
}
|
|
}
|
|
|
|
else */
|
|
if (err.error() == QSslError::UnableToVerifyFirstCertificate) {
|
|
ignore = true;
|
|
}
|
|
|
|
if (ignore) {
|
|
ignored_errors.append(err);
|
|
remaining_errors.removeAt(0);
|
|
}
|
|
else {
|
|
i += 1;
|
|
}
|
|
}
|
|
|
|
m_socket.ignoreSslErrors(ignored_errors);
|
|
|
|
qDebug() << "ignoring" << ignored_errors.size() << "out of" << errors.size();
|
|
|
|
for (const auto& error : remaining_errors) {
|
|
qWarning() << int(error.error()) << error.errorString();
|
|
}
|
|
|
|
if (remaining_errors.size() > 0) {
|
|
emit networkError(TlsFailure, remaining_errors.first().errorString());
|
|
}
|
|
}
|
|
|
|
void GeminiClient::socketError(QAbstractSocket::SocketError socketError) {
|
|
// When remote host closes TLS session, the client closes the socket.
|
|
// This is more sane then erroring out here as it's a perfectly legal
|
|
// state and we know the TLS connection has ended.
|
|
if (socketError == QAbstractSocket::RemoteHostClosedError) {
|
|
m_socket.close();
|
|
return;
|
|
}
|
|
|
|
m_inErrorState = true;
|
|
|
|
if (!m_suppressSocketTlsErrors) {
|
|
emitNetworkError(socketError, m_socket.errorString());
|
|
}
|
|
}
|
|
|
|
QUrl GeminiClient::targetUrl() const {
|
|
return m_targetUrl;
|
|
}
|