File indexing completed on 2025-01-05 03:53:32

0001 /* ============================================================
0002  *
0003  * This file is a part of digiKam project
0004  * https://www.digikam.org
0005  *
0006  * Date        : 2016-05-27
0007  * Description : Implementation of v3 of the Imgur API
0008  *
0009  * SPDX-FileCopyrightText: 2016      by Fabian Vogt <fabian at ritter dash vogt dot de>
0010  * SPDX-FileCopyrightText: 2016-2020 by Caulier Gilles <caulier dot gilles at gmail dot com>
0011  *
0012  * SPDX-License-Identifier: GPL-2.0-or-later
0013  *
0014  * ============================================================ */
0015 
0016 #include "imgurtalker.h"
0017 
0018 // Qt includes
0019 
0020 #include <QHttpMultiPart>
0021 #include <QJsonDocument>
0022 #include <QJsonObject>
0023 #include <QTimerEvent>
0024 #include <QUrlQuery>
0025 #include <QFileInfo>
0026 #include <QQueue>
0027 
0028 // KDE includes
0029 
0030 #include <klocalizedstring.h>
0031 
0032 // Local includes
0033 
0034 #include "dinfointerface.h"
0035 #include "digikam_debug.h"
0036 #include "wstoolutils.h"
0037 #include "networkmanager.h"
0038 #include "o0settingsstore.h"
0039 #include "o0globals.h"
0040 
0041 
0042 using namespace Digikam;
0043 
0044 namespace DigikamGenericImgUrPlugin
0045 {
0046 
0047 static const QString imgur_auth_url       = QLatin1String("https://api.imgur.com/oauth2/authorize"),
0048 imgur_token_url                           = QLatin1String("https://api.imgur.com/oauth2/token");
0049 static const uint16_t imgur_redirect_port = 8000; // Redirect URI is http://127.0.0.1:8000      // krazy:exclude=insecurenet
0050 
0051 class Q_DECL_HIDDEN ImgurTalker::Private
0052 {
0053 public:
0054 
0055     explicit Private()
0056       : client_id       (QLatin1String("bd2572bce74b73d")),
0057         client_secret   (QLatin1String("300988683e99cb7b203a5889cf71de9ac891c1c1")),
0058         workTimer       (0),
0059         reply           (nullptr),
0060         image           (nullptr),
0061         netMngr         (nullptr)
0062     {
0063     }
0064 
0065     /// API key and secret
0066     QString                   client_id;
0067     QString                   client_secret;
0068 
0069     /// Handler for OAuth 2 related requests.
0070     O2                        auth;
0071 
0072     /// Work queue.
0073     QQueue<ImgurTalkerAction> workQueue;
0074 
0075     /// ID of timer triggering on idle (0ms).
0076     int                       workTimer;
0077 
0078     /// Current QNetworkReply instance.
0079     QNetworkReply*            reply;
0080 
0081     /// Current image being uploaded.
0082     QFile*                    image;
0083 
0084     /// The QNetworkAccessManager instance used for connections.
0085     QNetworkAccessManager*    netMngr;
0086 };
0087 
0088 ImgurTalker::ImgurTalker(QObject* const parent)
0089     : QObject(parent),
0090       d      (new Private)
0091 {
0092     d->netMngr = NetworkManager::instance()->getNetworkManager(this);
0093 
0094     d->auth.setClientId(d->client_id);
0095     d->auth.setClientSecret(d->client_secret);
0096     d->auth.setRequestUrl(imgur_auth_url);
0097     d->auth.setTokenUrl(imgur_token_url);
0098     d->auth.setRefreshTokenUrl(imgur_token_url);
0099     d->auth.setLocalPort(imgur_redirect_port);
0100     d->auth.setLocalhostPolicy(QString());
0101 
0102     QSettings* const settings    = WSToolUtils::getOauthSettings(this);
0103     O0SettingsStore* const store = new O0SettingsStore(settings,
0104                                                        QLatin1String(O2_ENCRYPTION_KEY), this);
0105     store->setGroupKey(QLatin1String("Imgur"));
0106     d->auth.setStore(store);
0107 
0108     connect(&d->auth, &O2::linkedChanged,
0109             this, &ImgurTalker::slotOauthAuthorized);
0110 
0111     connect(&d->auth, &O2::openBrowser,
0112             this, &ImgurTalker::slotOauthRequestPin);
0113 
0114     connect(&d->auth, &O2::linkingFailed,
0115             this, &ImgurTalker::slotOauthFailed);
0116 }
0117 
0118 ImgurTalker::~ImgurTalker()
0119 {
0120     // Disconnect all signals as cancelAllWork may emit.
0121 
0122     disconnect(this, nullptr, nullptr, nullptr);
0123     cancelAllWork();
0124 
0125     delete d;
0126 }
0127 
0128 O2& ImgurTalker::getAuth()
0129 {
0130     return d->auth;
0131 }
0132 
0133 unsigned int ImgurTalker::workQueueLength()
0134 {
0135     return d->workQueue.size();
0136 }
0137 
0138 void ImgurTalker::queueWork(const ImgurTalkerAction& action)
0139 {
0140     d->workQueue.enqueue(action);
0141     startWorkTimer();
0142 }
0143 
0144 void ImgurTalker::cancelAllWork()
0145 {
0146     stopWorkTimer();
0147 
0148     if (d->reply)
0149     {
0150         d->reply->abort();
0151     }
0152 
0153     // Should signalError be emitted for those actions?
0154 
0155     while (!d->workQueue.isEmpty())
0156     {
0157         d->workQueue.dequeue();
0158     }
0159 }
0160 
0161 QUrl ImgurTalker::urlForDeletehash(const QString& deletehash)
0162 {
0163     return QUrl{QLatin1String("https://imgur.com/delete/") + deletehash};
0164 }
0165 
0166 void ImgurTalker::slotOauthAuthorized()
0167 {
0168     bool success = d->auth.linked();
0169 
0170     if (success)
0171     {
0172         startWorkTimer();
0173     }
0174     else
0175     {
0176         Q_EMIT signalBusy(false);
0177     }
0178 
0179     Q_EMIT signalAuthorized(success,
0180                           d->auth.extraTokens()[QLatin1String("account_username")].toString());
0181 }
0182 
0183 void ImgurTalker::slotOauthRequestPin(const QUrl& url)
0184 {
0185     Q_EMIT signalBusy(false);
0186     Q_EMIT signalRequestPin(url);
0187 }
0188 
0189 void ImgurTalker::slotOauthFailed()
0190 {
0191     cancelAllWork();
0192     Q_EMIT signalAuthError(i18n("Could not authorize"));
0193 }
0194 
0195 void ImgurTalker::slotUploadProgress(qint64 sent, qint64 total)
0196 {
0197     // Don't divide by 0
0198 
0199     if (total > 0)
0200     {
0201         Q_EMIT signalProgress((sent * 100) / total, d->workQueue.first());
0202     }
0203 }
0204 
0205 void ImgurTalker::slotReplyFinished()
0206 {
0207     auto* const reply = d->reply;
0208     reply->deleteLater();
0209     d->reply          = nullptr;
0210 
0211     if (d->image)
0212     {
0213         delete d->image;
0214         d->image = nullptr;
0215     }
0216 
0217     if (d->workQueue.isEmpty())
0218     {
0219         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Received result without request";
0220         return;
0221     }
0222 
0223     // NOTE: toInt() returns 0 if conversion fails. That fits nicely already.
0224 
0225     int netcode   = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
0226     auto response = QJsonDocument::fromJson(reply->readAll());
0227 
0228     if ((netcode == 200) && !response.isEmpty())
0229     {
0230         // Success!
0231 
0232         ImgurTalkerResult result;
0233         result.action = &d->workQueue.first();
0234         auto data     = response.object()[QLatin1String("data")].toObject();
0235 
0236         switch (result.action->type)
0237         {
0238             case ImgurTalkerActionType::IMG_UPLOAD:
0239             case ImgurTalkerActionType::ANON_IMG_UPLOAD:
0240                 result.image.animated    = data[QLatin1String("animated")].toBool();
0241                 result.image.bandwidth   = data[QLatin1String("bandwidth")].toInt();
0242                 result.image.datetime    = data[QLatin1String("datetime")].toInt();
0243                 result.image.deletehash  = data[QLatin1String("deletehash")].toString();
0244                 result.image.description = data[QLatin1String("description")].toString();
0245                 result.image.height      = data[QLatin1String("height")].toInt();
0246                 result.image.hash        = data[QLatin1String("id")].toString();
0247                 result.image.name        = data[QLatin1String("name")].toString();
0248                 result.image.size        = data[QLatin1String("size")].toInt();
0249                 result.image.title       = data[QLatin1String("title")].toString();
0250                 result.image.type        = data[QLatin1String("type")].toString();
0251                 result.image.url         = data[QLatin1String("link")].toString();
0252                 result.image.views       = data[QLatin1String("views")].toInt();
0253                 result.image.width       = data[QLatin1String("width")].toInt();
0254                 break;
0255 
0256             case ImgurTalkerActionType::ACCT_INFO:
0257                 result.account.username = data[QLatin1String("url")].toString();
0258                 // TODO: Other fields.
0259                 break;
0260 
0261             default:
0262                 qCWarning(DIGIKAM_WEBSERVICES_LOG) << "Unexpected action";
0263                 qCDebug(DIGIKAM_WEBSERVICES_LOG) << response.toJson();
0264                 break;
0265         }
0266 
0267         Q_EMIT signalSuccess(result);
0268     }
0269     else
0270     {
0271         if (netcode == 403)
0272         {
0273             /**
0274              * HTTP 403 Forbidden -> Invalid token?
0275              * That needs to be handled internally, so don't Q_EMIT signalProgress
0276              * and keep the action in the queue for later retries.
0277              */
0278             d->auth.refresh();
0279             return;
0280         }
0281         else
0282         {
0283             // Failed.
0284 
0285             auto msg = response.object()[QLatin1String("data")]
0286                        .toObject()[QLatin1String("error")]
0287                        .toString(QLatin1String("Could not read response."));
0288 
0289             Q_EMIT signalError(msg, d->workQueue.first());
0290         }
0291     }
0292 
0293     // Next work item.
0294 
0295     d->workQueue.dequeue();
0296     startWorkTimer();
0297 }
0298 
0299 void ImgurTalker::timerEvent(QTimerEvent* event)
0300 {
0301     if (event->timerId() != d->workTimer)
0302     {
0303         QObject::timerEvent(event);
0304         return;
0305     }
0306 
0307     event->accept();
0308 
0309     // One-shot only.
0310 
0311     QObject::killTimer(event->timerId());
0312     d->workTimer = 0;
0313 
0314     doWork();
0315 }
0316 
0317 void ImgurTalker::startWorkTimer()
0318 {
0319     if (!d->workQueue.isEmpty() && d->workTimer == 0)
0320     {
0321         d->workTimer = QObject::startTimer(0);
0322         Q_EMIT signalBusy(true);
0323     }
0324     else
0325     {
0326         Q_EMIT signalBusy(false);
0327     }
0328 }
0329 
0330 void ImgurTalker::stopWorkTimer()
0331 {
0332     if (d->workTimer != 0)
0333     {
0334         QObject::killTimer(d->workTimer);
0335         d->workTimer = 0;
0336     }
0337 }
0338 
0339 void ImgurTalker::addAuthToken(QNetworkRequest* request)
0340 {
0341     request->setRawHeader(QByteArray("Authorization"),
0342                           QString::fromLatin1("Bearer %1").arg(d->auth.token()).toUtf8());
0343 }
0344 
0345 void ImgurTalker::addAnonToken(QNetworkRequest* request)
0346 {
0347     request->setRawHeader(QByteArray("Authorization"),
0348                           QString::fromLatin1("Client-ID %1").arg(d->auth.clientId()).toUtf8());
0349 }
0350 
0351 void ImgurTalker::doWork()
0352 {
0353     if (d->workQueue.isEmpty() || (d->reply != nullptr))
0354     {
0355         return;
0356     }
0357 
0358     auto &work = d->workQueue.first();
0359 
0360     if ((work.type != ImgurTalkerActionType::ANON_IMG_UPLOAD) && !d->auth.linked())
0361     {
0362         d->auth.link();
0363         return; // Wait for the signalAuthorized() signal.
0364     }
0365 
0366     switch (work.type)
0367     {
0368         case ImgurTalkerActionType::ACCT_INFO:
0369         {
0370             QNetworkRequest request(QUrl(QString::fromLatin1("https://api.imgur.com/3/account/%1")
0371                                         .arg(QLatin1String(work.account.username.toUtf8().toPercentEncoding()))));
0372             addAuthToken(&request);
0373 
0374             d->reply = d->netMngr->get(request);
0375             break;
0376         }
0377 
0378         case ImgurTalkerActionType::ANON_IMG_UPLOAD:
0379         case ImgurTalkerActionType::IMG_UPLOAD:
0380         {
0381             d->image = new QFile(work.upload.imgpath);
0382 
0383             if (!d->image->open(QIODevice::ReadOnly))
0384             {
0385                 delete d->image;
0386                 d->image = nullptr;
0387 
0388                 // Failed.
0389                 Q_EMIT signalError(i18n("Could not open file"), d->workQueue.first());
0390 
0391                 d->workQueue.dequeue();
0392                 doWork();
0393                 return;
0394             }
0395 
0396             // Set ownership to d->image to delete that as well.
0397 
0398             auto* const multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType, d->image);
0399             QHttpPart title;
0400             title.setHeader(QNetworkRequest::ContentDispositionHeader,
0401                             QLatin1String("form-data; name=\"title\""));
0402             title.setBody(work.upload.title.toUtf8().toPercentEncoding());
0403             multiPart->append(title);
0404 
0405             QHttpPart description;
0406             description.setHeader(QNetworkRequest::ContentDispositionHeader,
0407                                   QLatin1String("form-data; name=\"description\""));
0408             description.setBody(work.upload.description.toUtf8().toPercentEncoding());
0409             multiPart->append(description);
0410 
0411             QHttpPart imagePart;
0412             imagePart.setHeader(QNetworkRequest::ContentDispositionHeader,
0413                             QVariant(QString::fromLatin1("form-data; name=\"image\"; filename=\"%1\"")
0414                             .arg(QLatin1String(QFileInfo(work.upload.imgpath).fileName().toUtf8().toPercentEncoding()))));
0415             imagePart.setHeader(QNetworkRequest::ContentTypeHeader, QLatin1String("application/octet-stream"));
0416             imagePart.setBodyDevice(d->image);
0417             multiPart->append(imagePart);
0418 
0419             QNetworkRequest request(QUrl(QLatin1String("https://api.imgur.com/3/image")));
0420 
0421             if (work.type == ImgurTalkerActionType::IMG_UPLOAD)
0422             {
0423                 addAuthToken(&request);
0424             }
0425             else
0426             {
0427                 addAnonToken(&request);
0428             }
0429 
0430             d->reply = d->netMngr->post(request, multiPart);
0431 
0432             // delete the multiPart with the reply
0433 
0434             multiPart->setParent(d->reply);
0435 
0436             break;
0437         }
0438     }
0439 
0440     if (d->reply)
0441     {
0442         connect(d->reply, &QNetworkReply::uploadProgress,
0443                 this, &ImgurTalker::slotUploadProgress);
0444 
0445         connect(d->reply, &QNetworkReply::finished,
0446                 this, &ImgurTalker::slotReplyFinished);
0447     }
0448 }
0449 
0450 } // namespace DigikamGenericImgUrPlugin
0451 
0452 #include "moc_imgurtalker.cpp"