summaryrefslogtreecommitdiff
path: root/transferengine-plugins/mastodontransferplugin
diff options
context:
space:
mode:
authorAndrew Branson <andrew.branson@jolla.com>2026-02-10 10:41:02 +0100
committerAndrew Branson <andrew.branson@jolla.com>2026-02-10 17:09:39 +0100
commit4351f4627ba9e71775438dd26c9acddd002c7e11 (patch)
tree3c72c980c5c81507109087bda67052b7ec8216b6 /transferengine-plugins/mastodontransferplugin
Initial commit
Diffstat (limited to 'transferengine-plugins/mastodontransferplugin')
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodonapi.cpp244
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodonapi.h56
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp25
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h27
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro24
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp200
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodonuploader.h51
7 files changed, 627 insertions, 0 deletions
diff --git a/transferengine-plugins/mastodontransferplugin/mastodonapi.cpp b/transferengine-plugins/mastodontransferplugin/mastodonapi.cpp
new file mode 100644
index 0000000..a4b40a9
--- /dev/null
+++ b/transferengine-plugins/mastodontransferplugin/mastodonapi.cpp
@@ -0,0 +1,244 @@
+#include "mastodonapi.h"
+
+#include <QtCore/QFile>
+#include <QtCore/QFileInfo>
+#include <QtNetwork/QHttpMultiPart>
+#include <QtCore/QJsonDocument>
+#include <QtCore/QJsonObject>
+#include <QtCore/QUrl>
+#include <QtCore/QUrlQuery>
+
+#include <QtNetwork/QNetworkRequest>
+
+#include <QtDebug>
+
+MastodonApi::MastodonApi(QNetworkAccessManager *qnam, QObject *parent)
+ : QObject(parent)
+ , m_qnam(qnam)
+{
+}
+
+MastodonApi::~MastodonApi()
+{
+}
+
+QString MastodonApi::normalizeApiHost(const QString &rawHost)
+{
+ QString host = rawHost.trimmed();
+ if (host.isEmpty()) {
+ host = QStringLiteral("https://mastodon.social");
+ }
+
+ if (!host.startsWith(QLatin1String("https://"))
+ && !host.startsWith(QLatin1String("http://"))) {
+ host.prepend(QStringLiteral("https://"));
+ }
+
+ QUrl url(host);
+ if (!url.isValid() || url.host().isEmpty()) {
+ return QStringLiteral("https://mastodon.social");
+ }
+
+ QString normalized = QString::fromLatin1(url.toEncoded(QUrl::RemovePath
+ | QUrl::RemoveQuery
+ | QUrl::RemoveFragment));
+ if (normalized.endsWith(QLatin1Char('/'))) {
+ normalized.chop(1);
+ }
+
+ return normalized;
+}
+
+bool MastodonApi::uploadImage(const QString &filePath,
+ const QString &statusText,
+ const QString &mimeType,
+ const QString &apiHost,
+ const QString &accessToken)
+{
+ QFile file(filePath);
+ if (filePath.isEmpty() || !file.open(QIODevice::ReadOnly)) {
+ qWarning() << Q_FUNC_INFO << "error opening file:" << filePath;
+ return false;
+ }
+
+ m_apiHost = normalizeApiHost(apiHost);
+ m_accessToken = accessToken;
+ m_statusText = statusText;
+
+ if (m_accessToken.isEmpty()) {
+ qWarning() << Q_FUNC_INFO << "missing access token";
+ return false;
+ }
+
+ const QByteArray imageData = file.readAll();
+ const QFileInfo fileInfo(filePath);
+
+ QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
+
+ QHttpPart filePart;
+ filePart.setHeader(QNetworkRequest::ContentDispositionHeader,
+ QVariant(QStringLiteral("form-data; name=\"file\"; filename=\"%1\"")
+ .arg(fileInfo.fileName())));
+ if (!mimeType.isEmpty()) {
+ filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(mimeType));
+ }
+ filePart.setBody(imageData);
+ multiPart->append(filePart);
+
+ QNetworkRequest request(QUrl(m_apiHost + QStringLiteral("/api/v1/media")));
+ request.setRawHeader(QByteArrayLiteral("Authorization"),
+ QByteArrayLiteral("Bearer ") + m_accessToken.toUtf8());
+
+ QNetworkReply *reply = m_qnam->post(request, multiPart);
+ if (!reply) {
+ delete multiPart;
+ return false;
+ }
+
+ multiPart->setParent(reply);
+ m_replies.insert(reply, UPLOAD_MEDIA);
+
+ connect(reply, SIGNAL(error(QNetworkReply::NetworkError)),
+ this, SLOT(replyError(QNetworkReply::NetworkError)));
+ connect(reply, &QNetworkReply::uploadProgress,
+ this, &MastodonApi::uploadProgress);
+ connect(reply, &QNetworkReply::finished,
+ this, &MastodonApi::finished);
+
+ return true;
+}
+
+bool MastodonApi::postStatus(const QString &mediaId)
+{
+ if (mediaId.isEmpty()) {
+ qWarning() << Q_FUNC_INFO << "media id is empty";
+ return false;
+ }
+
+ QUrlQuery query;
+ if (!m_statusText.isEmpty()) {
+ query.addQueryItem(QStringLiteral("status"), m_statusText);
+ }
+ query.addQueryItem(QStringLiteral("media_ids[]"), mediaId);
+
+ const QByteArray postData = query.query(QUrl::FullyEncoded).toUtf8();
+
+ QNetworkRequest request(QUrl(m_apiHost + QStringLiteral("/api/v1/statuses")));
+ request.setRawHeader(QByteArrayLiteral("Authorization"),
+ QByteArrayLiteral("Bearer ") + m_accessToken.toUtf8());
+ request.setHeader(QNetworkRequest::ContentTypeHeader,
+ QVariant(QStringLiteral("application/x-www-form-urlencoded")));
+
+ QNetworkReply *reply = m_qnam->post(request, postData);
+ if (!reply) {
+ return false;
+ }
+
+ m_replies.insert(reply, POST_STATUS);
+ connect(reply, SIGNAL(error(QNetworkReply::NetworkError)),
+ this, SLOT(replyError(QNetworkReply::NetworkError)));
+ connect(reply, &QNetworkReply::finished,
+ this, &MastodonApi::finished);
+
+ return true;
+}
+
+void MastodonApi::cancelUpload()
+{
+ if (m_replies.isEmpty()) {
+ qWarning() << Q_FUNC_INFO << "can't cancel upload";
+ return;
+ }
+
+ const QList<QNetworkReply*> replies = m_replies.keys();
+ Q_FOREACH (QNetworkReply *reply, replies) {
+ reply->abort();
+ }
+ m_replies.clear();
+}
+
+void MastodonApi::replyError(QNetworkReply::NetworkError error)
+{
+ Q_UNUSED(error)
+}
+
+void MastodonApi::uploadProgress(qint64 sent, qint64 total)
+{
+ if (total > 0) {
+ emit transferProgressUpdated(sent / static_cast<qreal>(total));
+ }
+}
+
+void MastodonApi::finished()
+{
+ QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender());
+ if (!reply || !m_replies.contains(reply)) {
+ return;
+ }
+
+ const API_CALL apiCall = m_replies.take(reply);
+ const QByteArray data = reply->readAll();
+ const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ const QNetworkReply::NetworkError error = reply->error();
+
+ reply->deleteLater();
+
+ if (apiCall == UPLOAD_MEDIA) {
+ if (error != QNetworkReply::NoError || httpCode < 200 || httpCode >= 300) {
+ finishTransfer(error == QNetworkReply::NoError ? QNetworkReply::UnknownNetworkError : error,
+ httpCode,
+ data);
+ return;
+ }
+
+ QString mediaId;
+ const QJsonDocument doc = QJsonDocument::fromJson(data);
+ if (doc.isObject()) {
+ const QJsonValue idValue = doc.object().value(QStringLiteral("id"));
+ if (idValue.isString()) {
+ mediaId = idValue.toString();
+ } else if (idValue.isDouble()) {
+ mediaId = QString::number(static_cast<qint64>(idValue.toDouble()));
+ }
+ }
+
+ if (!postStatus(mediaId)) {
+ qWarning() << Q_FUNC_INFO << "unable to create mastodon status";
+ emit transferError();
+ }
+ return;
+ }
+
+ if (apiCall == POST_STATUS) {
+ finishTransfer(error, httpCode, data);
+ return;
+ }
+
+ emit transferError();
+}
+
+void MastodonApi::finishTransfer(QNetworkReply::NetworkError error, int httpCode, const QByteArray &data)
+{
+ if (httpCode == 401) {
+ emit credentialsExpired();
+ }
+
+ if (error != QNetworkReply::NoError) {
+ if (error == QNetworkReply::OperationCanceledError) {
+ emit transferCanceled();
+ return;
+ }
+
+ qWarning() << Q_FUNC_INFO << "network error:" << error << "httpCode:" << httpCode << "data:" << data;
+ emit transferError();
+ return;
+ }
+
+ if (httpCode < 200 || httpCode >= 300) {
+ qWarning() << Q_FUNC_INFO << "http error:" << httpCode << "data:" << data;
+ emit transferError();
+ return;
+ }
+
+ emit transferFinished();
+}
diff --git a/transferengine-plugins/mastodontransferplugin/mastodonapi.h b/transferengine-plugins/mastodontransferplugin/mastodonapi.h
new file mode 100644
index 0000000..0ec3653
--- /dev/null
+++ b/transferengine-plugins/mastodontransferplugin/mastodonapi.h
@@ -0,0 +1,56 @@
+#ifndef MASTODONAPI_H
+#define MASTODONAPI_H
+
+#include <QtCore/QMap>
+#include <QtCore/QObject>
+
+#include <QtNetwork/QNetworkAccessManager>
+#include <QtNetwork/QNetworkReply>
+
+class MastodonApi : public QObject
+{
+ Q_OBJECT
+
+public:
+ enum API_CALL {
+ NONE,
+ UPLOAD_MEDIA,
+ POST_STATUS
+ };
+
+ explicit MastodonApi(QNetworkAccessManager *qnam, QObject *parent = 0);
+ ~MastodonApi();
+
+ bool uploadImage(const QString &filePath,
+ const QString &statusText,
+ const QString &mimeType,
+ const QString &apiHost,
+ const QString &accessToken);
+
+ void cancelUpload();
+
+Q_SIGNALS:
+ void transferProgressUpdated(qreal progress);
+ void transferFinished();
+ void transferError();
+ void transferCanceled();
+ void credentialsExpired();
+
+private Q_SLOTS:
+ void replyError(QNetworkReply::NetworkError error);
+ void finished();
+ void uploadProgress(qint64 received, qint64 total);
+
+private:
+ static QString normalizeApiHost(const QString &rawHost);
+ bool postStatus(const QString &mediaId);
+ void finishTransfer(QNetworkReply::NetworkError error, int httpCode, const QByteArray &data);
+
+ QMap<QNetworkReply*, API_CALL> m_replies;
+ QNetworkAccessManager *m_qnam;
+ QString m_accessToken;
+ QString m_apiHost;
+ QString m_statusText;
+};
+
+#endif // MASTODONAPI_H
diff --git a/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp
new file mode 100644
index 0000000..2ee4cd0
--- /dev/null
+++ b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp
@@ -0,0 +1,25 @@
+#include "mastodontransferplugin.h"
+#include "mastodonuploader.h"
+
+#include <QtPlugin>
+#include <QNetworkAccessManager>
+
+MastodonTransferPlugin::MastodonTransferPlugin()
+ : QObject(), TransferPluginInterface()
+ , m_qnam(new QNetworkAccessManager(this))
+{
+}
+
+MastodonTransferPlugin::~MastodonTransferPlugin()
+{
+}
+
+MediaTransferInterface *MastodonTransferPlugin::transferObject()
+{
+ return new MastodonUploader(m_qnam, this);
+}
+
+QString MastodonTransferPlugin::pluginId() const
+{
+ return QLatin1String("Mastodon");
+}
diff --git a/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h
new file mode 100644
index 0000000..68cf188
--- /dev/null
+++ b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h
@@ -0,0 +1,27 @@
+#ifndef MASTODONTRANSFERPLUGIN_H
+#define MASTODONTRANSFERPLUGIN_H
+
+#include <QtCore/QObject>
+
+#include <transferplugininterface.h>
+
+class QNetworkAccessManager;
+
+class Q_DECL_EXPORT MastodonTransferPlugin : public QObject, public TransferPluginInterface
+{
+ Q_OBJECT
+ Q_PLUGIN_METADATA(IID "org.sailfishos.transfer.plugin.mastodon")
+ Q_INTERFACES(TransferPluginInterface)
+
+public:
+ MastodonTransferPlugin();
+ ~MastodonTransferPlugin();
+
+ MediaTransferInterface *transferObject();
+ QString pluginId() const;
+
+private:
+ QNetworkAccessManager *m_qnam;
+};
+
+#endif // MASTODONTRANSFERPLUGIN_H
diff --git a/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro
new file mode 100644
index 0000000..b37bf17
--- /dev/null
+++ b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro
@@ -0,0 +1,24 @@
+TEMPLATE = lib
+TARGET = $$qtLibraryTarget(mastodontransferplugin)
+CONFIG += plugin
+DEPENDPATH += .
+INCLUDEPATH += ..
+
+QT += network
+
+CONFIG += link_pkgconfig
+PKGCONFIG += nemotransferengine-qt5 accounts-qt5 sailfishaccounts libsignon-qt5
+
+HEADERS += mastodontransferplugin.h \
+ mastodonuploader.h \
+ ../mastodonshareservicestatus.h \
+ mastodonapi.h
+
+SOURCES += mastodontransferplugin.cpp \
+ mastodonuploader.cpp \
+ ../mastodonshareservicestatus.cpp \
+ mastodonapi.cpp
+
+target.path = $$[QT_INSTALL_LIBS]/nemo-transferengine/plugins/transfer
+
+INSTALLS += target
diff --git a/transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp b/transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp
new file mode 100644
index 0000000..9e2fa1a
--- /dev/null
+++ b/transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp
@@ -0,0 +1,200 @@
+#include "mastodonuploader.h"
+#include "mastodonapi.h"
+
+#include <imageoperation.h>
+#include <mediaitem.h>
+
+#include <QtCore/QFile>
+#include <QtCore/QMimeDatabase>
+#include <QtCore/QMimeType>
+
+#include <QtDebug>
+
+MastodonUploader::MastodonUploader(QNetworkAccessManager *qnam, QObject *parent)
+ : MediaTransferInterface(parent)
+ , m_api(0)
+ , m_mastodonShareServiceStatus(0)
+ , m_qnam(qnam)
+ , m_useTmpFile(false)
+{
+}
+
+MastodonUploader::~MastodonUploader()
+{
+}
+
+QString MastodonUploader::displayName() const
+{
+ return tr("Mastodon");
+}
+
+QUrl MastodonUploader::serviceIcon() const
+{
+ return QUrl(QStringLiteral("image://theme/graphic-s-service-mastodon"));
+}
+
+bool MastodonUploader::cancelEnabled() const
+{
+ return true;
+}
+
+bool MastodonUploader::restartEnabled() const
+{
+ return true;
+}
+
+void MastodonUploader::start()
+{
+ if (!mediaItem()) {
+ qWarning() << Q_FUNC_INFO << "NULL MediaItem. Can't continue";
+ setStatus(MediaTransferInterface::TransferInterrupted);
+ return;
+ }
+
+ if (!m_mastodonShareServiceStatus) {
+ m_mastodonShareServiceStatus = new MastodonShareServiceStatus(this);
+ connect(m_mastodonShareServiceStatus, &MastodonShareServiceStatus::serviceReady,
+ this, &MastodonUploader::startUploading);
+ connect(m_mastodonShareServiceStatus, &MastodonShareServiceStatus::serviceError,
+ this, [this] (const QString &) {
+ transferError();
+ });
+ }
+
+ m_mastodonShareServiceStatus->queryStatus();
+}
+
+void MastodonUploader::cancel()
+{
+ if (m_api) {
+ m_api->cancelUpload();
+ } else {
+ qWarning() << Q_FUNC_INFO << "Can't cancel. NULL MastodonApi object!";
+ }
+}
+
+void MastodonUploader::startUploading()
+{
+ if (!m_mastodonShareServiceStatus) {
+ qWarning() << Q_FUNC_INFO << "NULL MastodonShareServiceStatus object!";
+ return;
+ }
+
+ const quint32 accountId = mediaItem()->value(MediaItem::AccountId).toInt();
+ m_accountDetails = m_mastodonShareServiceStatus->detailsByIdentifier(accountId);
+ if (m_accountDetails.accountId <= 0 || m_accountDetails.accessToken.isEmpty()) {
+ qWarning() << Q_FUNC_INFO << "Mastodon account details missing for id" << accountId;
+ transferError();
+ return;
+ }
+
+ postImage();
+}
+
+void MastodonUploader::transferFinished()
+{
+ setStatus(MediaTransferInterface::TransferFinished);
+}
+
+void MastodonUploader::transferProgress(qreal progress)
+{
+ setProgress(progress);
+}
+
+void MastodonUploader::transferError()
+{
+ setStatus(MediaTransferInterface::TransferInterrupted);
+ qWarning() << Q_FUNC_INFO << "Transfer interrupted";
+}
+
+void MastodonUploader::transferCanceled()
+{
+ setStatus(MediaTransferInterface::TransferCanceled);
+}
+
+void MastodonUploader::credentialsExpired()
+{
+ const quint32 accountId = mediaItem()->value(MediaItem::AccountId).toInt();
+ m_mastodonShareServiceStatus->setCredentialsNeedUpdate(accountId, QStringLiteral("mastodon-sharing"));
+}
+
+void MastodonUploader::setStatus(MediaTransferInterface::TransferStatus status)
+{
+ const bool finished = (status == TransferCanceled
+ || status == TransferInterrupted
+ || status == TransferFinished);
+ if (m_useTmpFile && finished) {
+ QFile::remove(m_filePath);
+ m_useTmpFile = false;
+ m_filePath.clear();
+ }
+
+ MediaTransferInterface::setStatus(status);
+}
+
+void MastodonUploader::postImage()
+{
+ m_useTmpFile = false;
+ m_filePath.clear();
+ const QString sourceFile = mediaItem()->value(MediaItem::Url).toUrl().toLocalFile();
+ if (sourceFile.isEmpty()) {
+ qWarning() << Q_FUNC_INFO << "Empty source file";
+ setStatus(MediaTransferInterface::TransferInterrupted);
+ return;
+ }
+
+ QMimeDatabase db;
+ const QMimeType mime = db.mimeTypeForFile(sourceFile);
+ const bool isJpeg = mime.name() == QLatin1String("image/jpeg");
+
+ if (isJpeg && mediaItem()->value(MediaItem::MetadataStripped).toBool()) {
+ m_useTmpFile = true;
+ m_filePath = ImageOperation::removeImageMetadata(sourceFile);
+ if (m_filePath.isEmpty()) {
+ qWarning() << Q_FUNC_INFO << "Failed to remove metadata";
+ MediaTransferInterface::setStatus(MediaTransferInterface::TransferInterrupted);
+ return;
+ }
+ }
+
+ const qreal scale = mediaItem()->value(MediaItem::ScalePercent).toReal();
+ if (0 < scale && scale < 1) {
+ m_useTmpFile = true;
+ m_filePath = ImageOperation::scaleImage(sourceFile, scale, m_filePath);
+ if (m_filePath.isEmpty()) {
+ qWarning() << Q_FUNC_INFO << "Failed to scale image";
+ MediaTransferInterface::setStatus(MediaTransferInterface::TransferInterrupted);
+ return;
+ }
+ }
+
+ if (!m_useTmpFile) {
+ m_filePath = sourceFile;
+ }
+
+ if (!m_api) {
+ m_api = new MastodonApi(m_qnam, this);
+ connect(m_api, &MastodonApi::transferProgressUpdated,
+ this, &MastodonUploader::transferProgress);
+ connect(m_api, &MastodonApi::transferFinished,
+ this, &MastodonUploader::transferFinished);
+ connect(m_api, &MastodonApi::transferError,
+ this, &MastodonUploader::transferError);
+ connect(m_api, &MastodonApi::transferCanceled,
+ this, &MastodonUploader::transferCanceled);
+ connect(m_api, &MastodonApi::credentialsExpired,
+ this, &MastodonUploader::credentialsExpired);
+ }
+
+ const bool ok = m_api->uploadImage(m_filePath,
+ mediaItem()->value(MediaItem::Description).toString(),
+ mediaItem()->value(MediaItem::MimeType).toString(),
+ m_accountDetails.apiHost,
+ m_accountDetails.accessToken);
+ if (ok) {
+ setStatus(MediaTransferInterface::TransferStarted);
+ } else {
+ setStatus(MediaTransferInterface::TransferInterrupted);
+ qWarning() << Q_FUNC_INFO << "Failed to upload image";
+ }
+}
diff --git a/transferengine-plugins/mastodontransferplugin/mastodonuploader.h b/transferengine-plugins/mastodontransferplugin/mastodonuploader.h
new file mode 100644
index 0000000..b0ea263
--- /dev/null
+++ b/transferengine-plugins/mastodontransferplugin/mastodonuploader.h
@@ -0,0 +1,51 @@
+#ifndef MASTODONUPLOADER_H
+#define MASTODONUPLOADER_H
+
+#include <QtNetwork/QNetworkAccessManager>
+
+#include <mediatransferinterface.h>
+
+#include "mastodonshareservicestatus.h"
+
+class MastodonApi;
+
+class MastodonUploader : public MediaTransferInterface
+{
+ Q_OBJECT
+
+public:
+ MastodonUploader(QNetworkAccessManager *qnam, QObject *parent = 0);
+ ~MastodonUploader();
+
+ QString displayName() const;
+ QUrl serviceIcon() const;
+ bool cancelEnabled() const;
+ bool restartEnabled() const;
+
+public Q_SLOTS:
+ void start();
+ void cancel();
+
+private Q_SLOTS:
+ void startUploading();
+ void transferFinished();
+ void transferProgress(qreal progress);
+ void transferError();
+ void transferCanceled();
+ void credentialsExpired();
+
+protected:
+ void setStatus(MediaTransferInterface::TransferStatus status);
+
+private:
+ void postImage();
+
+ MastodonApi *m_api;
+ MastodonShareServiceStatus *m_mastodonShareServiceStatus;
+ QNetworkAccessManager *m_qnam;
+ MastodonShareServiceStatus::AccountDetails m_accountDetails;
+ bool m_useTmpFile;
+ QString m_filePath;
+};
+
+#endif // MASTODONUPLOADER_H