// SPDX-FileCopyrightText: 2021 kaniini // SPDX-FileCopyrightText: 2021 Carl Schwan // SPDX-License-Identifier: GPL-3.0-only #include "account/account.h" #include "account/notificationhandler.h" #include "network/networkcontroller.h" #include "tokodon_http_debug.h" #ifdef HAVE_KUNIFIEDPUSH #include "ecdh.h" #include "tokodon_debug.h" #endif #include using namespace Qt::Literals::StringLiterals; Account::Account(const QString &instanceUri, QNetworkAccessManager *nam, bool ignoreSslErrors, bool admin, QObject *parent) : AbstractAccount(parent, instanceUri) , m_ignoreSslErrors(ignoreSslErrors) , m_qnam(nam) { m_preferences = new Preferences(this); setInstanceUri(instanceUri); m_requestingAdmin = admin; } Account::Account(AccountConfig *settings, QNetworkAccessManager *nam, QObject *parent) : AbstractAccount(parent) , m_qnam(nam) { m_preferences = new Preferences(this); m_config = settings; connect(this, &Account::authenticated, this, &Account::checkForFollowRequests); buildFromSettings(); } Account::~Account() { m_identityCache.clear(); } void Account::get(const QUrl &url, bool authenticated, QObject *parent, std::function reply_cb, std::function errorCallback) { QNetworkRequest request = makeRequest(url, authenticated); qCDebug(TOKODON_HTTP) << "GET" << url; QNetworkReply *reply = m_qnam->get(request); reply->setParent(parent); handleReply(reply, reply_cb, errorCallback); } void Account::post(const QUrl &url, const QJsonDocument &doc, bool authenticated, QObject *parent, std::function reply_cb, std::function error_cb, QHash headers) { auto post_data = doc.toJson(); QNetworkRequest request = makeRequest(url, authenticated); request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); for (const auto [headerKey, headerValue] : headers.asKeyValueRange()) { request.setRawHeader(headerKey, headerValue); } qCDebug(TOKODON_HTTP) << "POST" << url << "[" << post_data << "]"; auto reply = m_qnam->post(request, post_data); reply->setParent(parent); handleReply(reply, reply_cb, error_cb); } void Account::put(const QUrl &url, const QJsonDocument &doc, bool authenticated, QObject *parent, std::function reply_cb) { auto post_data = doc.toJson(); QNetworkRequest request = makeRequest(url, authenticated); request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); qCDebug(TOKODON_HTTP) << "PUT" << url << "[" << post_data << "]"; QNetworkReply *reply = m_qnam->put(request, post_data); reply->setParent(parent); handleReply(reply, reply_cb); } void Account::put(const QUrl &url, const QUrlQuery &formdata, bool authenticated, QObject *parent, std::function reply_cb) { auto post_data = formdata.toString().toLatin1(); QNetworkRequest request = makeRequest(url, authenticated); request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded")); qCDebug(TOKODON_HTTP) << "PUT" << url << "[" << post_data << "]"; QNetworkReply *reply = m_qnam->put(request, post_data); reply->setParent(parent); handleReply(reply, reply_cb); } void Account::post(const QUrl &url, const QUrlQuery &formdata, bool authenticated, QObject *parent, std::function reply_cb, std::function errorCallback) { auto post_data = formdata.toString().toLatin1(); QNetworkRequest request = makeRequest(url, authenticated); request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded")); qCDebug(TOKODON_HTTP) << "POST" << url << "[" << post_data << "]"; QNetworkReply *reply = m_qnam->post(request, post_data); reply->setParent(parent); handleReply(reply, reply_cb, errorCallback); } QNetworkReply *Account::post(const QUrl &url, QHttpMultiPart *message, bool authenticated, QObject *parent, std::function reply_cb) { QNetworkRequest request = makeRequest(url, authenticated); qCDebug(TOKODON_HTTP) << "POST" << url << "(multipart-message)"; QNetworkReply *reply = m_qnam->post(request, message); reply->setParent(parent); handleReply(reply, reply_cb); return reply; } void Account::patch(const QUrl &url, QHttpMultiPart *multiPart, bool authenticated, QObject *parent, std::function callback) { QNetworkRequest request = makeRequest(url, authenticated); qCDebug(TOKODON_HTTP) << "PATCH" << url << "(multipart-message)"; QNetworkReply *reply = m_qnam->sendCustomRequest(request, "PATCH", multiPart); reply->setParent(parent); handleReply(reply, callback); } void Account::deleteResource(const QUrl &url, bool authenticated, QObject *parent, std::function callback) { QNetworkRequest request = makeRequest(url, authenticated); qCDebug(TOKODON_HTTP) << "DELETE" << url << "(multipart-message)"; QNetworkReply *reply = m_qnam->deleteResource(request); reply->setParent(parent); handleReply(reply, callback); } QNetworkRequest Account::makeRequest(const QUrl &url, bool authenticated) const { QNetworkRequest request(url); if (authenticated && haveToken()) { const QByteArray bearer = QStringLiteral("Bearer %1").arg(m_token).toLocal8Bit(); request.setRawHeader("Authorization", bearer); } return request; } void Account::handleReply(QNetworkReply *reply, std::function reply_cb, std::function errorCallback) const { connect(reply, &QNetworkReply::finished, [reply, reply_cb, errorCallback]() { reply->deleteLater(); if (200 != reply->attribute(QNetworkRequest::HttpStatusCodeAttribute) && !reply->url().toString().contains("nodeinfo"_L1)) { if (errorCallback) { errorCallback(reply); } else { qCWarning(TOKODON_HTTP) << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute) << reply->url(); Q_EMIT NetworkController::instance().networkErrorOccurred(reply->errorString()); } return; } if (reply_cb) { reply_cb(reply); } }); if (m_ignoreSslErrors) { connect(reply, &QNetworkReply::sslErrors, this, [reply](const QList &) { reply->ignoreSslErrors(); }); } } // assumes file is already opened and named QNetworkReply *Account::upload(const QUrl &filename, std::function callback) { auto file = new QFile(filename.toLocalFile()); const QFileInfo info(filename.toLocalFile()); file->open(QFile::ReadOnly); auto mp = new QHttpMultiPart(QHttpMultiPart::FormDataType); QHttpPart filePart; filePart.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/octet-stream")); filePart.setHeader(QNetworkRequest::ContentDispositionHeader, QStringLiteral("form-data; name=\"file\"; filename=\"%1\"").arg(info.fileName())); filePart.setBodyDevice(file); file->setParent(mp); mp->append(filePart); const auto uploadUrl = apiUrl(QStringLiteral("/api/v1/media")); qCDebug(TOKODON_HTTP) << "POST" << uploadUrl << "(upload)"; return post(uploadUrl, mp, true, this, callback); } void Account::requestRemoteObject(const QUrl &remoteUrl, QObject *parent, std::function callback) { auto url = apiUrl(QStringLiteral("/api/v2/search")); url.setQuery({ {QStringLiteral("q"), remoteUrl.toString()}, {QStringLiteral("resolve"), QStringLiteral("true")}, {QStringLiteral("limit"), QStringLiteral("1")}, }); get(url, true, parent, std::move(callback)); } static QMap stringToStreamingEventType = { {QStringLiteral("update"), AbstractAccount::StreamingEventType::UpdateEvent}, {QStringLiteral("delete"), AbstractAccount::StreamingEventType::DeleteEvent}, {QStringLiteral("notification"), AbstractAccount::StreamingEventType::NotificationEvent}, {QStringLiteral("filters_changed"), AbstractAccount::StreamingEventType::FiltersChangedEvent}, {QStringLiteral("conversation"), AbstractAccount::StreamingEventType::ConversationEvent}, {QStringLiteral("announcement"), AbstractAccount::StreamingEventType::AnnouncementEvent}, {QStringLiteral("announcement.reaction"), AbstractAccount::StreamingEventType::AnnouncementRedactedEvent}, {QStringLiteral("announcement.delete"), AbstractAccount::StreamingEventType::AnnouncementDeletedEvent}, {QStringLiteral("status.update"), AbstractAccount::StreamingEventType::StatusUpdatedEvent}, {QStringLiteral("encrypted_message"), AbstractAccount::StreamingEventType::EncryptedMessageChangedEvent}, }; QWebSocket *Account::streamingSocket(const QString &stream) { if (m_token.isEmpty()) { return nullptr; } if (m_websockets.contains(stream)) { return m_websockets[stream]; } auto socket = new QWebSocket(); socket->setParent(this); const auto url = streamingUrl(stream); connect(socket, &QWebSocket::textMessageReceived, this, [=](const QString &message) { const auto env = QJsonDocument::fromJson(message.toLocal8Bit()); if (env.isObject() && env.object().contains("event"_L1)) { const auto event = stringToStreamingEventType[env.object()["event"_L1].toString()]; Q_EMIT streamingEvent(event, env.object()["payload"_L1].toString().toLocal8Bit()); if (event == NotificationEvent) { const auto doc = QJsonDocument::fromJson(env.object()["payload"_L1].toString().toLocal8Bit()); handleNotification(doc); return; } } }); socket->open(url); m_websockets[stream] = socket; return socket; } void Account::validateToken(bool newAccount) { const QUrl verify_credentials = apiUrl(QStringLiteral("/api/v1/accounts/verify_credentials")); get( verify_credentials, true, this, [=](QNetworkReply *reply) { if (!reply->isFinished()) { return; } const auto doc = QJsonDocument::fromJson(reply->readAll()); if (!doc.isObject()) { return; } const auto object = doc.object(); if (!object.contains("source"_L1)) { return; } m_identity = identityLookup(object["id"_L1].toString(), object); m_name = m_identity->username(); Q_EMIT identityChanged(); Q_EMIT authenticated(true, {}); #ifdef HAVE_KUNIFIEDPUSH if (newAccount) { // We asked for the push scope, so we can safely start subscribing to notifications config()->setEnablePushNotifications(true); config()->save(); } #else Q_UNUSED(newAccount) #endif #ifdef HAVE_KUNIFIEDPUSH get( apiUrl(QStringLiteral("/api/v1/push/subscription")), true, this, [=](QNetworkReply *reply) { m_hasPushSubscription = true; const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); if (!NetworkController::instance().endpoint.isEmpty() && doc["endpoint"_L1] != NetworkController::instance().endpoint) { qWarning(TOKODON_LOG) << "KUnifiedPush endpoint has changed to" << NetworkController::instance().endpoint << ", resubscribing!"; deleteResource(apiUrl(QStringLiteral("/api/v1/push/subscription")), true, this, [=](QNetworkReply *reply) { Q_UNUSED(reply) m_hasPushSubscription = false; subscribePushNotifications(); }); } else { updatePushNotifications(); } }, [=](QNetworkReply *reply) { Q_UNUSED(reply); m_hasPushSubscription = false; updatePushNotifications(); }); #endif }, [=](QNetworkReply *reply) { const auto doc = QJsonDocument::fromJson(reply->readAll()); Q_EMIT authenticated(false, doc.isEmpty() ? reply->errorString() : doc["error"_L1].toString()); }); fetchInstanceMetadata(); // set up streaming for notifications streamingSocket(QStringLiteral("user")); } void Account::writeToSettings() { // do not write to settings if we do not have complete information yet, // or else it writes malformed and possibly duplicate accounts to settings. if (m_name.isEmpty() || m_instance_uri.isEmpty()) { return; } AccountConfig config(settingsGroupName()); config.setClientId(m_client_id); config.setInstanceUri(m_instance_uri); config.setName(m_name); config.setIgnoreSslErrors(m_ignoreSslErrors); config.save(); auto accessTokenJob = new QKeychain::WritePasswordJob{QStringLiteral("Tokodon"), this}; #ifdef SAILFISHOS accessTokenJob->setInsecureFallback(true); #endif accessTokenJob->setKey(accessTokenKey()); accessTokenJob->setTextData(m_token); accessTokenJob->start(); auto clientSecretJob = new QKeychain::WritePasswordJob{QStringLiteral("Tokodon"), this}; #ifdef SAILFISHOS clientSecretJob->setInsecureFallback(true); #endif clientSecretJob->setKey(clientSecretKey()); clientSecretJob->setTextData(m_client_secret); clientSecretJob->start(); } void Account::buildFromSettings() { m_client_id = m_config->clientId(); m_name = m_config->name(); m_instance_uri = m_config->instanceUri(); m_ignoreSslErrors = m_config->ignoreSslErrors(); auto accessTokenJob = new QKeychain::ReadPasswordJob{QStringLiteral("Tokodon"), this}; #ifdef SAILFISHOS accessTokenJob->setInsecureFallback(true); #endif accessTokenJob->setKey(accessTokenKey()); QObject::connect(accessTokenJob, &QKeychain::ReadPasswordJob::finished, [this, accessTokenJob]() { m_token = accessTokenJob->textData(); validateToken(); }); accessTokenJob->start(); auto clientSecretJob = new QKeychain::ReadPasswordJob{QStringLiteral("Tokodon"), this}; #ifdef SAILFISHOS clientSecretJob->setInsecureFallback(true); #endif clientSecretJob->setKey(clientSecretKey()); QObject::connect(clientSecretJob, &QKeychain::ReadPasswordJob::finished, [this, clientSecretJob]() { m_client_secret = clientSecretJob->textData(); }); clientSecretJob->start(); } void Account::checkForFollowRequests() { get(apiUrl(QStringLiteral("/api/v1/follow_requests")), true, this, [this](QNetworkReply *reply) { const auto followRequestResult = QJsonDocument::fromJson(reply->readAll()); if (m_followRequestCount != followRequestResult.array().size()) { m_followRequestCount = followRequestResult.array().size(); Q_EMIT followRequestCountChanged(); } }); } void Account::updatePushNotifications() { #ifdef HAVE_KUNIFIEDPUSH auto cfg = config(); // If push notifications are explicitly disabled (like if we have an account that does not have the scope) skip if (!cfg->enablePushNotifications()) { return; } if (m_hasPushSubscription && !cfg->enableNotifications()) { unsubscribePushNotifications(); } else if (!m_hasPushSubscription && cfg->enableNotifications()) { subscribePushNotifications(); } else { QUrlQuery formdata = buildNotificationFormData(); formdata.addQueryItem(QStringLiteral("policy"), QStringLiteral("all")); put(apiUrl(QStringLiteral("/api/v1/push/subscription")), formdata, true, this, [=](QNetworkReply *reply) { qCDebug(TOKODON_HTTP) << "Updated push notification rules:" << reply->readAll(); }); } #endif } void Account::unsubscribePushNotifications() { #ifdef HAVE_KUNIFIEDPUSH Q_ASSERT(m_hasPushSubscription); deleteResource(apiUrl(QStringLiteral("/api/v1/push/subscription")), true, this, [=](QNetworkReply *reply) { m_hasPushSubscription = false; qCDebug(TOKODON_HTTP) << "Unsubscribed from push notifications:" << reply->readAll(); }); #endif } void Account::subscribePushNotifications() { #ifdef HAVE_KUNIFIEDPUSH Q_ASSERT(!m_hasPushSubscription); // Generate 16 random bytes QByteArray randArray; for (int i = 0; i < 16; i++) { randArray.push_back(QRandomGenerator::global()->generate()); } QUrlQuery formdata = buildNotificationFormData(); formdata.addQueryItem(QStringLiteral("subscription[endpoint]"), QUrl(NetworkController::instance().endpoint).toString()); // TODO: save this keypair in the keychain const auto keys = generateECDHKeypair(); formdata.addQueryItem(QStringLiteral("subscription[keys][p256dh]"), QString::fromUtf8(exportPublicKey(keys).toBase64(QByteArray::Base64UrlEncoding))); formdata.addQueryItem(QStringLiteral("subscription[keys][auth]"), QString::fromUtf8(randArray.toBase64(QByteArray::Base64UrlEncoding))); formdata.addQueryItem(QStringLiteral("data[policy]"), QStringLiteral("all")); post( apiUrl(QStringLiteral("/api/v1/push/subscription")), formdata, true, this, [=](QNetworkReply *reply) { m_hasPushSubscription = true; qCDebug(TOKODON_HTTP) << "Subscribed to push notifications:" << reply->readAll(); }, [=](QNetworkReply *reply) { Q_UNUSED(reply); // to prevent a visible error }); #endif } QUrlQuery Account::buildNotificationFormData() { auto cfg = config(); QUrlQuery formdata; const auto addQuery = [&formdata](const QString key, const bool value) { formdata.addQueryItem(QStringLiteral("data[alerts][%1]").arg(key), value ? QStringLiteral("true") : QStringLiteral("false")); }; addQuery(QStringLiteral("mention"), cfg->notifyMention()); addQuery(QStringLiteral("status"), cfg->notifyStatus()); addQuery(QStringLiteral("reblog"), cfg->notifyBoost()); addQuery(QStringLiteral("follow"), cfg->notifyFollow()); addQuery(QStringLiteral("follow_request"), cfg->notifyFollowRequest()); addQuery(QStringLiteral("favourite"), cfg->notifyFavorite()); addQuery(QStringLiteral("poll"), cfg->notifyPoll()); addQuery(QStringLiteral("update"), cfg->notifyUpdate()); addQuery(QStringLiteral("admin.sign_up"), cfg->notifySignup()); addQuery(QStringLiteral("admin.report"), cfg->notifyReport()); return formdata; } void Account::registerTokodon(const bool authCode) { registerApplication(QStringLiteral("Tokodon"), QStringLiteral("https://apps.kde.org/tokodon"), m_requestingAdmin ? QStringLiteral("admin:read admin:write") : QStringLiteral(""), authCode); } #include "moc_account.cpp"