From 9c60bc8ce4a0990c228d2cd746791b88c41532b8 Mon Sep 17 00:00:00 2001 From: ConfuSomu Date: Thu, 24 Aug 2023 18:43:15 -0400 Subject: Implement basic Mastodon API support Implement support for OAuth 2.0 code entry, which is then used to retrieve a token that is stored in the application's settings. Authentification allowed the implementation of a basic "get post from URL" feature mostly made for testing. --- CMakeLists.txt | 7 +++ src/mainwindow.cpp | 19 ++++++ src/mainwindow.h | 1 + src/mainwindow.ui | 6 ++ src/net/instance.cpp | 24 ++++++++ src/net/instance.h | 24 ++++++++ src/net/mastodon_instance.cpp | 134 ++++++++++++++++++++++++++++++++++++++++++ src/net/mastodon_instance.h | 19 ++++++ src/settingsdialog.cpp | 27 ++++++++- 9 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 src/net/instance.cpp create mode 100644 src/net/instance.h create mode 100644 src/net/mastodon_instance.cpp create mode 100644 src/net/mastodon_instance.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f5e6f18..15486f5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,8 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # For Kate's LSP find_package(QT NAMES Qt5 REQUIRED COMPONENTS Widgets Concurrent) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets Concurrent) +find_package(mastodonpp REQUIRED) + # add_subdirectory will happen later… set(PROJECT_SOURCES src/main.cpp @@ -38,6 +40,10 @@ set(PROJECT_SOURCES src/list_item.h src/command_line.cpp src/command_line.h + src/net/instance.cpp + src/net/instance.h + src/net/mastodon_instance.cpp + src/net/mastodon_instance.h src/activitypub/fields.h src/activitypub/apactivity.h src/activitypub/apactor.h @@ -81,6 +87,7 @@ else() endif() target_link_libraries(ActorViewer PRIVATE Qt${QT_VERSION_MAJOR}::Widgets) +target_link_libraries(ActorViewer PUBLIC mastodonpp) set_target_properties(ActorViewer PROPERTIES MACOSX_BUNDLE_GUI_IDENTIFIER space.twilightsparkle.ActorViewer diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index c6203d4..4ab0bb2 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1,12 +1,15 @@ #include "mainwindow.h" #include "./ui_mainwindow.h" +#include "src/activitypub/appost.h" #include "src/archive/base_archive.h" #include "src/finddialog.h" #include "src/list_item.h" #include "src/command_line.h" #include "src/settingsdialog.h" +#include "src/net/instance.h" #include +#include #include #include #include @@ -148,6 +151,22 @@ void MainWindow::set_search_text(const QString &text) { ui->textInputSearch->setText(text); } +void MainWindow::on_actionOpen_URL_triggered(bool checked) { + bool ok; + QString url = QInputDialog::getText(this, tr("Open status from URL"), tr("Status URL:"), QLineEdit::Normal, "https://…", &ok); + + // TODO: Move all of this to another thread + // TODO: Reuse the Instance object + // Really hacky code but works as a PoC and allows testing + if (ok and not url.isEmpty()) { + Instance* instance = Instance::create_instance(); + APPost* post = instance->get_post_from_url(url); + QString html = post->get_html_render({ui->statusInfoText->width(), &locale_context}); + ui->statusInfoText->setHtml(html); + delete instance; instance = nullptr; + } else return; +} + void MainWindow::relist_statuses() { if (data_archive) { ui->listWidget->clear(); diff --git a/src/mainwindow.h b/src/mainwindow.h index 4c7e034..24bc970 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -49,6 +49,7 @@ private slots: void on_actionRandom_status_triggered(bool checked); void on_actionCopy_status_triggered(bool checked); void on_actionFind_triggered(bool checked); + void on_actionOpen_URL_triggered(bool checked); void on_textInputSearch_textEdited(const QString &text); diff --git a/src/mainwindow.ui b/src/mainwindow.ui index 292930d..2d7cf30 100644 --- a/src/mainwindow.ui +++ b/src/mainwindow.ui @@ -123,6 +123,7 @@ p, li { white-space: pre-wrap; } Go + @@ -271,6 +272,11 @@ p, li { white-space: pre-wrap; } QAction::PreferencesRole + + + Open URL… + + listWidget diff --git a/src/net/instance.cpp b/src/net/instance.cpp new file mode 100644 index 0000000..c3af86c --- /dev/null +++ b/src/net/instance.cpp @@ -0,0 +1,24 @@ +#include "instance.h" +#include "mastodon_instance.h" +#include "src/settings_interface.h" + +Instance* Instance::create_instance() { + return create_instance(SettingsInterface::quick_read_setting("net/instance/type")); +} + +Instance* Instance::create_instance(AppSettingsTypes::InstanceType type) { + switch (type) { + case AppSettingsTypes::InstanceType::MASTODON: + return new MastodonInstance; + default: + return nullptr; + } +} + +QString Instance::oauth2_step1() { + return ""; +} + +Instance::OAuth2Step2 Instance::oauth2_step2(const QString &auth_code) { + return {false}; +} diff --git a/src/net/instance.h b/src/net/instance.h new file mode 100644 index 0000000..2b7eec7 --- /dev/null +++ b/src/net/instance.h @@ -0,0 +1,24 @@ +#pragma once +#include "src/activitypub/appost.h" +#include "src/settings_interface.h" + +class Instance { +public: + struct OAuth2Step2 { + bool ok; + QString token; + }; + + virtual ~Instance() {}; + virtual APPost* get_post_from_url(const QString &url) = 0; + // Returns the URI the user has to visit in their browser + virtual QString oauth2_step1(); + // auth_code is the code given by the user through the URI + // Returns true if authenfication was successful. + virtual OAuth2Step2 oauth2_step2(const QString &auth_code); + bool supports_oauth2 = false; + // Creates an instance by reading the type from settings + static Instance* create_instance(); + // Creates a specific type of instance, for use with SettingsDialog + static Instance* create_instance(AppSettingsTypes::InstanceType type); +}; diff --git a/src/net/mastodon_instance.cpp b/src/net/mastodon_instance.cpp new file mode 100644 index 0000000..8b88221 --- /dev/null +++ b/src/net/mastodon_instance.cpp @@ -0,0 +1,134 @@ +#include "mastodon_instance.h" +#include "src/activitypub/appost.h" +#include "src/activitypub/apquestion.h" +#include "src/settings_interface.h" +#include "src/types.h" + +#include +#include +#include +#include +#include + +APPost* post_from_json(const QJsonObject &status); + +MastodonInstance::MastodonInstance() : instance(SettingsInterface::quick_read_setting("net/instance/address").toStdString(), SettingsInterface::quick_read_setting("net/instance/token").toStdString()), connection(instance) { + +} + +MastodonInstance::~MastodonInstance() { + if (obtain_token) + delete obtain_token; +} + +QString MastodonInstance::oauth2_step1() { + obtain_token = new mastodonpp::Instance::ObtainToken{instance}; + auto answer{obtain_token->step_1("ActorViewer", "read", "https://twilightsparkle.space")}; + if (answer) + return answer.body.c_str(); + else + return ""; +} + +Instance::OAuth2Step2 MastodonInstance::oauth2_step2(const QString &auth_code) { + if (!obtain_token) return {false}; + auto answer{obtain_token->step_2(auth_code.toStdString())}; + QString token; + + if (answer) { + token = answer.body.c_str(); + } + + delete obtain_token; obtain_token = nullptr; + return {(bool)answer, token}; +} + +APPost* MastodonInstance::get_post_from_url(const QString &url) { + auto answer{connection.get(mastodonpp::API::v2::search, { + {"q", QUrl::toPercentEncoding(url).toStdString()}, + {"type", "statuses"}, + {"limit", "1"}, + {"resolve", "true"} + })}; + + if (answer) { + QJsonDocument doc = QJsonDocument::fromJson(answer.body.c_str()); + QJsonObject obj = doc.object(); + qDebug() << doc; + if (obj.contains("statuses")) { + QJsonObject status = obj["statuses"].toArray().first().toObject(); + if (status.isEmpty()) return new APPost({.content="invalid"}); // Invalid + return post_from_json(status); + } + } + + return new APPost({.content="connection error"}); +} + +// Create a filled APPost object from a Status entity (https://docs.joinmastodon.org/entities/Status/) +// We have to return a pointer as else I can't get derived classes of APPost to render properly: the additional methods are ignored. +// Furthermore, using pointers removes unecessary copying, even if that can be reduced by return value optimization. +APPost* post_from_json(const QJsonObject &status) { + APObjectFields fields; + bool is_question = status.contains("poll"); + + fields.by_actor = status["account"]["url"].toString(); + fields.reply_to_url = status["in_reply_to_id"].toString(); // id ≠ url + + fields.languages.append(status["language"].toString()); + fields.published = status["created_at"].toString(); + + fields.object_url = status["uri"].toString(); + fields.web_url = status["url"].toString(); + + if (status["sensitive"].toBool(true)) + fields.summary = status["spoiler_text"].toString(); + if (status.contains("content")) + fields.content = status["content"].toString(); + + if (status.contains("visibility")) { + QString visibility = status["visibility"].toString(); + if (visibility == "public") fields.visibility = StatusType::PUBLIC; + else if (visibility == "unlisted") fields.visibility = StatusType::UNLISTED; + else if (visibility == "private") fields.visibility = StatusType::PRIVATE; + else if (visibility == "direct") fields.visibility = StatusType::DIRECT; + else fields.visibility = StatusType::UNKNOWN; + } + + if (QJsonArray medias = status["media_attachments"].toArray(); not medias.isEmpty()) { + for (QJsonValueRef media : medias) { + if (not media.isObject()) continue; + QJsonObject attrib = media.toObject(); + fields.attachments.push_back({ + // FIXME: we use the preview to lower bandwidth but it would be nicer to have APAttachment fields for differencing between qualities. + .path = attrib["preview_url"].toString(), + // FIXME: we don't have any better attribute to work with + .filename = attrib["id"].toString(), + .media_type = attrib["type"].toString(), + .name = attrib["description"].toString() + // TODO: we don't use the blurhash yet… + }); + } + } + + if (is_question) { + QJsonObject poll = status["poll"].toObject(); + + fields.question.end_time = poll["expires_at"].toString(); + fields.question.closed_time = poll["expires_at"].toString(); + fields.question.total_votes = poll["votes_count"].toInt(); + + for (QJsonValueRef option : poll["options"].toArray()) { + QJsonObject element = option.toObject(); + fields.question.poll_options.push_back({element["title"].toString(), element["votes_count"].toInt()}); + } + + + // TODO: have an boolean attribute for defining if the poll is multiple choice or not, int for the voters_count and bool/int for `voted` and `own_votes` + } + + if (is_question) + return new APQuestion(fields); + else + return new APPost(fields); +} diff --git a/src/net/mastodon_instance.h b/src/net/mastodon_instance.h new file mode 100644 index 0000000..983bef6 --- /dev/null +++ b/src/net/mastodon_instance.h @@ -0,0 +1,19 @@ +#pragma once +#include "instance.h" +#include "mastodonpp/instance.hpp" +#include "mastodonpp/connection.hpp" + +class MastodonInstance : public Instance { +public: + MastodonInstance(); + ~MastodonInstance(); + APPost* get_post_from_url(const QString &url); + QString oauth2_step1(); + OAuth2Step2 oauth2_step2(const QString &auth_code); + bool supports_oauth2 = true; + +private: + mastodonpp::Instance instance; + mastodonpp::Connection connection; + mastodonpp::Instance::ObtainToken* obtain_token = nullptr; +}; diff --git a/src/settingsdialog.cpp b/src/settingsdialog.cpp index 213859a..051091f 100644 --- a/src/settingsdialog.cpp +++ b/src/settingsdialog.cpp @@ -1,6 +1,14 @@ #include "settingsdialog.h" +#include "src/net/instance.h" +#include "src/settings_interface.h" + #include #include +#include +#include +#include +#include +#include SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent, Qt::Dialog), ui(new Ui::SettingsDialog) @@ -64,7 +72,24 @@ void SettingsDialog::on_instanceActionsLabel_linkActivated(const QString& link) ui->instanceAddressLineEdit->setText(instance_address); update_ui_in_progress = false; } else if (link == "action:request-token") { - // TODO: open browser with token request URL + Instance* instance = Instance::create_instance((AppSettingsTypes::InstanceType)ui->instanceTypeComboBox->currentIndex()); + + QString url = instance->oauth2_step1(); + if (not QDesktopServices::openUrl(url)) + QMessageBox::information(this, tr("Navigate to this URL"), tr("Please open the following URL in your browser: %1").arg(1)); + + Instance::OAuth2Step2 step2 = {.ok = false}; + while (not step2.ok) { + bool ok; + QString code = QInputDialog::getText(this, tr("Enter authorization code"), tr("Enter the code given during the authentification flow:"), QLineEdit::Normal, "", &ok); + + if (code.isEmpty()) return; // User canceled + if (ok) step2 = instance->oauth2_step2(code); + } + + // Do not set update_ui_in_progress as we want the new text to be seen as new text by settings_interface (through on_tokenLineEdit_editingFinished()) + ui->tokenLineEdit->setText(step2.token); + on_tokenLineEdit_editingFinished(); // Make sure that the change has been noticed as sometimes it isn't } } -- cgit v1.2.3-54-g00ecf