diff --git a/.gitignore b/.gitignore index 906aa8dd9..1fbbdca52 100755 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ resources/skins/*/*.map aqtinstall.log .sass-cache build-dir/ +build/ docs/build \ No newline at end of file diff --git a/resources/text/licenses.json b/resources/text/licenses.json index 741ce1450..9bffad9ca 100644 --- a/resources/text/licenses.json +++ b/resources/text/licenses.json @@ -2,7 +2,7 @@ { "title": "GNU GPL v3.0", "file": "COPYING_GNU_GPL", - "components": "RSS Guard, mimesis, Numix" + "components": "RSS Guard, mimesis, Numix, Kristall/geminiclient" }, { "title": "GNU LGPL v3.0", diff --git a/src/librssguard/CMakeLists.txt b/src/librssguard/CMakeLists.txt index 54bd82a07..7b00cf089 100644 --- a/src/librssguard/CMakeLists.txt +++ b/src/librssguard/CMakeLists.txt @@ -253,6 +253,8 @@ set(SOURCES network-web/adblock/adblockmanager.h network-web/adblock/adblockrequestinfo.cpp network-web/adblock/adblockrequestinfo.h + network-web/gemini/geminiclient.cpp + network-web/gemini/geminiclient.h network-web/apiserver.cpp network-web/apiserver.h network-web/articleparse.cpp diff --git a/src/librssguard/core/feeddownloader.cpp b/src/librssguard/core/feeddownloader.cpp index 8eaca567e..6b2299a03 100644 --- a/src/librssguard/core/feeddownloader.cpp +++ b/src/librssguard/core/feeddownloader.cpp @@ -267,6 +267,7 @@ void FeedDownloader::updateOneFeed(ServiceRoot* acc, << " microseconds."; QList read_msgs, important_msgs; + QHash loaded_filters; for (int i = 0; i < msgs.size(); i++) { Message msg_original(msgs[i]); @@ -295,7 +296,10 @@ void FeedDownloader::updateOneFeed(ServiceRoot* acc, tmr.restart(); try { - MessageObject::FilteringAction decision = msg_filter->filterMessage(&filter_engine); + MessageObject::FilteringAction decision = + msg_filter->filterMessage(&filter_engine, !loaded_filters.contains(msg_filter->id())); + + loaded_filters.insert(msg_filter->id(), true); qDebugNN << LOGSEC_FEEDDOWNLOADER << "Running filter script, it took " << tmr.nsecsElapsed() / 1000 << " microseconds."; diff --git a/src/librssguard/core/messagefilter.cpp b/src/librssguard/core/messagefilter.cpp index 8ac23a0b8..6d44bb155 100644 --- a/src/librssguard/core/messagefilter.cpp +++ b/src/librssguard/core/messagefilter.cpp @@ -8,17 +8,21 @@ MessageFilter::MessageFilter(int id, QObject* parent) : QObject(parent), m_id(id) {} -MessageObject::FilteringAction MessageFilter::filterMessage(QJSEngine* engine) { - QJSValue filter_func = engine->evaluate(qApp->replaceUserDataFolderPlaceholder(m_script)); +MessageObject::FilteringAction MessageFilter::filterMessage(QJSEngine* engine, bool evaluate_filter) { + if (evaluate_filter) { + QJSValue filter_func = + engine->evaluate(qApp->replaceUserDataFolderPlaceholder(m_script).replace(QSL("filterMessage()"), + QSL("filterMessage%1()").arg(m_id))); - if (filter_func.isError()) { - QJSValue::ErrorType error = filter_func.errorType(); - QString message = filter_func.toString(); + if (filter_func.isError()) { + QJSValue::ErrorType error = filter_func.errorType(); + QString message = filter_func.toString(); - throw FilteringException(error, message); + throw FilteringException(error, message); + } } - auto filter_output = engine->evaluate(QSL("filterMessage()")); + auto filter_output = engine->evaluate(QSL("filterMessage%1()").arg(m_id)); if (filter_output.isError()) { QJSValue::ErrorType error = filter_output.errorType(); diff --git a/src/librssguard/core/messagefilter.h b/src/librssguard/core/messagefilter.h index 90cfc5535..57d746385 100644 --- a/src/librssguard/core/messagefilter.h +++ b/src/librssguard/core/messagefilter.h @@ -16,7 +16,7 @@ class RSSGUARD_DLLSPEC MessageFilter : public QObject { public: explicit MessageFilter(int id = -1, QObject* parent = nullptr); - MessageObject::FilteringAction filterMessage(QJSEngine* engine); + MessageObject::FilteringAction filterMessage(QJSEngine* engine, bool evaluate_filter = true); int id() const; void setId(int id); diff --git a/src/librssguard/miscellaneous/application.cpp b/src/librssguard/miscellaneous/application.cpp index a1ebae54d..b8bf580dc 100644 --- a/src/librssguard/miscellaneous/application.cpp +++ b/src/librssguard/miscellaneous/application.cpp @@ -30,6 +30,7 @@ #include "miscellaneous/settings.h" #include "network-web/adblock/adblockicon.h" #include "network-web/adblock/adblockmanager.h" +#include "network-web/gemini/geminiclient.h" #include "network-web/webfactory.h" #include "services/abstract/serviceroot.h" @@ -97,20 +98,6 @@ Application::Application(const QString& id, int& argc, char** argv, const QStrin #endif #endif - /* - QString aa = "Fri, 12 Apr 2024 5:23:57 GMT"; - QDateTimeParser par(QMetaType::QDateTime, QDateTimeParser::FromString, QCalendar()); - - par.setDefaultLocale(QLocale::c()); - - QString st = "ddd, dd MMM yyyy H:m:s"; - bool parsed = par.parseFormat(st); - QDateTime dt; - par.fromString(aa, &dt); - - // QDateTime tim = QDateTime::fromString(aa, form); - QString check = dt.toString(); -*/ QString custom_ua; parseCmdArgumentsFromMyInstance(raw_cli_args, custom_ua); @@ -1390,13 +1377,20 @@ void Application::fillCmdArgumentsParser(QCommandLineParser& parser) { .arg(MAX_THREADPOOL_THREADS), QSL("count")); - parser.addOptions({ - help, version, log_file, custom_data_folder, disable_singleinstance, disable_only_debug, disable_debug, + parser.addOptions({help, + version, + log_file, + custom_data_folder, + disable_singleinstance, + disable_only_debug, + disable_debug, #if defined(NO_LITE) - force_lite, + force_lite, #endif - forced_style, adblock_port, custom_ua, custom_threads - }); + forced_style, + adblock_port, + custom_ua, + custom_threads}); parser.addPositionalArgument(QSL("urls"), QSL("List of URL addresses pointing to individual online feeds which should be added."), QSL("[url-1 ... url-n]")); diff --git a/src/librssguard/network-web/gemini/geminiclient.cpp b/src/librssguard/network-web/gemini/geminiclient.cpp new file mode 100644 index 000000000..ebedf5e84 --- /dev/null +++ b/src/librssguard/network-web/gemini/geminiclient.cpp @@ -0,0 +1,457 @@ +#include "network-web/gemini/geminiclient.h" + +#include + +#include +#include +#include +#include + +bool CryptoIdentity::isHostFiltered(const QUrl& url) const { + if (this->host_filter.isEmpty()) + return false; + + QString url_text = url.toString(QUrl::FullyEncoded); + + QRegExp pattern{this->host_filter, Qt::CaseInsensitive, QRegExp::Wildcard}; + + return not pattern.exactMatch(url_text); +} + +bool CryptoIdentity::isAutomaticallyEnabledOn(const QUrl& url) const { + if (this->host_filter.isEmpty()) + return false; + if (not this->auto_enable) + return false; + + QString url_text = url.toString(QUrl::FullyEncoded); + + QRegExp pattern{this->host_filter, Qt::CaseInsensitive, QRegExp::Wildcard}; + + return pattern.exactMatch(url_text); +} + +GeminiClient::GeminiClient(QObject* parent) : QObject(parent) { + connect(&socket, &QSslSocket::encrypted, this, &GeminiClient::socketEncrypted); + connect(&socket, &QSslSocket::readyRead, this, &GeminiClient::socketReadyRead); + connect(&socket, &QSslSocket::disconnected, this, &GeminiClient::socketDisconnected); + // connect(&socket, &QSslSocket::stateChanged, [](QSslSocket::SocketState state) { + // qDebug() << "Socket state changed to " << state; + // }); + connect(&socket, QOverload&>::of(&QSslSocket::sslErrors), this, &GeminiClient::sslErrors); + +#if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)) + connect(&socket, &QTcpSocket::errorOccurred, this, &GeminiClient::socketError); +#else + connect(&socket, QOverload::of(&QTcpSocket::error), this, &GeminiClient::socketError); +#endif + + // States + connect(&socket, &QAbstractSocket::hostFound, this, [this]() { + emit this->requestStateChange(RequestState::HostFound); + }); + connect(&socket, &QAbstractSocket::connected, this, [this]() { + emit this->requestStateChange(RequestState::Connected); + }); + connect(&socket, &QAbstractSocket::disconnected, this, [this]() { + emit this->requestStateChange(RequestState::None); + }); + emit this->requestStateChange(RequestState::None); +} + +GeminiClient::~GeminiClient() { + is_receiving_body = false; +} + +bool GeminiClient::supportsScheme(const QString& scheme) const { + return (scheme == "gemini"); +} + +bool GeminiClient::startRequest(const QUrl& url, RequestOptions options) { + if (url.scheme() != "gemini") + return false; + + // qDebug() << "start request" << url; + + if (socket.state() != QTcpSocket::UnconnectedState) { + socket.disconnectFromHost(); + socket.close(); + if (not socket.waitForDisconnected(1500)) + return false; + } + + emit this->requestStateChange(RequestState::Started); + + this->is_error_state = false; + + this->options = options; + + QSslConfiguration ssl_config = socket.sslConfiguration(); + ssl_config.setProtocol(QSsl::TlsV1_2OrLater); + /* + if (not kristall::globals().trust.gemini.enable_ca) + ssl_config.setCaCertificates(QList{}); + else + */ + ssl_config.setCaCertificates(QSslConfiguration::systemCaCertificates()); + /* + */ + + socket.setSslConfiguration(ssl_config); + + socket.connectToHostEncrypted(url.host(), url.port(1965)); + + this->buffer.clear(); + this->body.clear(); + this->is_receiving_body = false; + this->suppress_socket_tls_error = true; + + if (not socket.isOpen()) + return false; + + target_url = url; + mime_type = ""; + + return true; +} + +bool GeminiClient::isInProgress() const { + return (socket.state() != QTcpSocket::UnconnectedState); +} + +bool GeminiClient::cancelRequest() { + // qDebug() << "cancel request" << isInProgress(); + if (isInProgress()) { + this->is_receiving_body = false; + this->socket.disconnectFromHost(); + this->buffer.clear(); + this->body.clear(); + if (socket.state() != QTcpSocket::UnconnectedState) { + socket.disconnectFromHost(); + } + this->socket.waitForDisconnected(500); + this->socket.close(); + bool success = not isInProgress(); + // qDebug() << "cancel success" << success; + return success; + } + else { + return true; + } +} + +bool GeminiClient::enableClientCertificate(const CryptoIdentity& ident) { + this->socket.setLocalCertificate(ident.certificate); + this->socket.setPrivateKey(ident.private_key); + return true; +} + +void GeminiClient::disableClientCertificate() { + this->socket.setLocalCertificate(QSslCertificate{}); + this->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 this->networkError(network_error, textual_description); +} + +void GeminiClient::socketEncrypted() { + emit this->hostCertificateLoaded(this->socket.peerCertificate()); + + QString request = target_url.toString(QUrl::FormattingOptions(QUrl::FullyEncoded)) + "\r\n"; + + QByteArray request_bytes = request.toUtf8(); + + qint64 offset = 0; + while (offset < request_bytes.size()) { + const auto len = socket.write(request_bytes.constData() + offset, request_bytes.size() - offset); + if (len <= 0) { + socket.close(); + return; + } + offset += len; + } +} + +void GeminiClient::socketReadyRead() { + if (this->is_error_state) // don't do any further + return; + QByteArray response = socket.readAll(); + + if (is_receiving_body) { + body.append(response); + emit this->requestProgress(body.size()); + } + else { + for (int i = 0; i < response.size(); i++) { + if (response[i] == '\n') { + buffer.append(response.data(), i); + body.append(response.data() + i + 1, response.size() - i - 1); + + // "XY " + if (buffer.size() < 4) { // we allow an empty + socket.close(); + qDebug() << buffer; + emit networkError(ProtocolViolation, QObject::tr("Line is too short for valid protocol")); + return; + } + if (buffer.size() >= 1200) { + emit networkError(ProtocolViolation, QObject::tr("response too large!")); + socket.close(); + } + if (buffer[buffer.size() - 1] != '\r') { + socket.close(); + qDebug() << buffer; + emit networkError(ProtocolViolation, QObject::tr("Line does not end with ")); + return; + } + if (not isdigit(buffer[0])) { + socket.close(); + qDebug() << buffer; + emit networkError(ProtocolViolation, QObject::tr("First character is not a digit.")); + return; + } + if (not isdigit(buffer[1])) { + socket.close(); + qDebug() << buffer; + emit networkError(ProtocolViolation, QObject::tr("Second character is not a digit.")); + return; + } + // TODO: Implement stricter version + // if(buffer[2] != ' ') { + if (not isspace(buffer[2])) { + socket.close(); + qDebug() << buffer; + emit networkError(ProtocolViolation, QObject::tr("Third character is not a space.")); + return; + } + + QString meta = QString::fromUtf8(buffer.data() + 3, buffer.size() - 4); + + int primary_code = buffer[0] - '0'; + int secondary_code = buffer[1] - '0'; + + qDebug() << primary_code << secondary_code << meta; + + // We don't need to receive any data after that. + if (primary_code != 2) + 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 + is_receiving_body = true; + mime_type = meta; + return; + + case 3: { // redirect + QUrl new_url(meta); + if (new_url.isValid()) { + if (new_url.isRelative()) + new_url = target_url.resolved(new_url); + assert(not new_url.isRelative()); + + emit redirected(new_url, (secondary_code == 1)); + } + else { + emit networkError(ProtocolViolation, QObject::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, QObject::tr("Unspecified status code used!")); + return; + } + + assert(false and "unreachable"); + } + } + if ((buffer.size() + response.size()) >= 1200) { + emit networkError(ProtocolViolation, QObject::tr("META too large!")); + socket.close(); + } + buffer.append(response); + } +} + +void GeminiClient::socketDisconnected() { + if (this->is_receiving_body and not this->is_error_state) { + body.append(socket.readAll()); + emit requestComplete(body, mime_type); + } +} + +void GeminiClient::sslErrors(const QList& errors) { + emit this->hostCertificateLoaded(this->socket.peerCertificate()); + + if (options & IgnoreTlsErrors) { + socket.ignoreSslErrors(errors); + return; + } + + QList remaining_errors = errors; + QList 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; + } + } + + 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 this->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) { + socket.close(); + return; + } + + this->is_error_state = true; + if (not this->suppress_socket_tls_error) { + this->emitNetworkError(socketError, socket.errorString()); + } +} diff --git a/src/librssguard/network-web/gemini/geminiclient.h b/src/librssguard/network-web/gemini/geminiclient.h new file mode 100644 index 000000000..24627948f --- /dev/null +++ b/src/librssguard/network-web/gemini/geminiclient.h @@ -0,0 +1,142 @@ +#ifndef GEMINICLIENT_HPP +#define GEMINICLIENT_HPP + +#include +#include +#include +#include +#include +#include + +//! Cryptographic user identitiy consisting +//! of a key-certificate pair and some user information. +struct CryptoIdentity { + //! The certificate that is used for cryptography + QSslCertificate certificate; + + //! The actual private key that is used for cryptography + QSslKey private_key; + + //! The title with which the identity is presented to the user. + QString display_name; + + //! Notes that the user can have per identity for improved identity management + QString user_notes; + + //! True for long-lived identities + bool is_persistent = false; + + //! If not empty, Kristall will check + QString host_filter = ""; + + //! When this is set to true and the host_filter is not empty, + //! the certificate will be automatically enabled for hosts matching the filter. + bool auto_enable = false; + + bool isValid() const { + return (not this->certificate.isNull()) and (not this->private_key.isNull()); + } + + //! returns true if a host does not match the filter criterion + bool isHostFiltered(const QUrl& url) const; + + //! returns true when the identity should be enabled on url + bool isAutomaticallyEnabledOn(const QUrl& url) const; +}; + +class GeminiClient : public QObject { + Q_OBJECT + + public: + enum class RequestState { + None = 0, + Started = 1, + HostFound = 2, + Connected = 3, + + StartedWeb = 255, + }; + + enum NetworkError { + UnknownError, //!< There was an unhandled network error + ProtocolViolation, //!< The server responded with something unexpected and violated the protocol + HostNotFound, //!< The host was not found by the client + ConnectionRefused, //!< The host refused connection on that port + ResourceNotFound, //!< The requested resource was not found on the server + BadRequest, //!< Our client misbehaved and did a request the server cannot understand + ProxyRequest, //!< We requested a proxy operation, but the server does not allow that + InternalServerError, + InvalidClientCertificate, + UntrustedHost, //!< We don't know the host, and we don't trust it + MistrustedHost, //!< We know the host and it's not the server identity we've seen before + Unauthorized, //!< The requested resource could not be accessed. + TlsFailure, //!< Unspecified TLS failure + Timeout, //!< The network connection timed out. + }; + + enum RequestOptions { + Default = 0, + IgnoreTlsErrors = 1, + }; + + explicit GeminiClient(QObject* parent = nullptr); + virtual ~GeminiClient(); + + bool supportsScheme(const QString& scheme) const; + + bool startRequest(const QUrl& url, RequestOptions options); + bool isInProgress() const; + bool cancelRequest(); + + bool enableClientCertificate(const CryptoIdentity& ident); + void disableClientCertificate(); + + signals: + //! We successfully transferred some bytes from the server + void requestProgress(qint64 transferred); + + //! The request completed with the given data and mime type + void requestComplete(const QByteArray& data, const QString& mime); + + //! The state of the request has changed + void requestStateChange(RequestState state); + + //! Server redirected us to another URL + void redirected(const QUrl& uri, bool is_permanent); + + //! The server needs some information from the user to process this query. + void inputRequired(const QString& user_query, bool is_sensitive); + + //! There was an error while processing the request + void networkError(NetworkError error, const QString& reason); + + //! The server wants us to use a client certificate + void certificateRequired(const QString& info); + + //! The server uses TLS and has a certificate. + void hostCertificateLoaded(const QSslCertificate& cert); + + protected: + void emitNetworkError(QAbstractSocket::SocketError error_code, const QString& textual_description); + + private slots: + void socketEncrypted(); + void socketReadyRead(); + void socketDisconnected(); + void sslErrors(const QList& errors); + void socketError(QAbstractSocket::SocketError socketError); + + private: + bool is_receiving_body; + bool suppress_socket_tls_error; + bool is_error_state; + + QUrl target_url; + QSslSocket socket; + QByteArray buffer; + QByteArray body; + QString mime_type; + RequestOptions options; +}; + +#endif // GEMINICLIENT_HPP