1 /* ============================================================
2  *
3  * This file is a part of digiKam project
4  * https://www.digikam.org
5  *
6  * Date        : 2018-05-20
7  * Description : a tool to export images to Pinterest web service
8  *
9  * Copyright (C) 2018      by Tarek Talaat <tarektalaat93 at gmail dot com>
10  *
11  * This program is free software; you can redistribute it
12  * and/or modify it under the terms of the GNU General
13  * Public License as published by the Free Software Foundation;
14  * either version 2, or (at your option) any later version.
15  *
16  * This program is distributed in the hope that it will be useful,
17  * but WITHOUT ANY WARRANTY; without even the implied warranty of
18  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19  * GNU General Public License for more details.
20  *
21  * ============================================================ */
22 
23 #include "ptalker.h"
24 
25 // Qt includes
26 
27 #include <QJsonDocument>
28 #include <QJsonParseError>
29 #include <QJsonObject>
30 #include <QJsonValue>
31 #include <QJsonArray>
32 #include <QByteArray>
33 #include <QList>
34 #include <QPair>
35 #include <QFileInfo>
36 #include <QWidget>
37 #include <QMessageBox>
38 #include <QApplication>
39 #include <QDesktopServices>
40 #include <QUrlQuery>
41 #include <QHttpMultiPart>
42 #include <QNetworkAccessManager>
43 #include <QScopedPointer>
44 
45 // KDE includes
46 
47 #include <klocalizedstring.h>
48 #include <kwindowconfig.h>
49 
50 // Local includes
51 
52 #include "digikam_debug.h"
53 #include "digikam_version.h"
54 #include "wstoolutils.h"
55 #include "pwindow.h"
56 #include "pitem.h"
57 #include "webbrowserdlg.h"
58 #include "previewloadthread.h"
59 
60 namespace DigikamGenericPinterestPlugin
61 {
62 
63 class Q_DECL_HIDDEN PTalker::Private
64 {
65 public:
66 
67     enum State
68     {
69         P_USERNAME = 0,
70         P_LISTBOARDS,
71         P_CREATEBOARD,
72         P_ADDPIN,
73         P_ACCESSTOKEN
74     };
75 
76 public:
77 
Private()78     explicit Private()
79       : parent  (nullptr),
80         netMngr (nullptr),
81         reply   (nullptr),
82         settings(nullptr),
83         state   (P_USERNAME),
84         browser (nullptr)
85     {
86         clientId     = QLatin1String("4983380570301022071");
87         clientSecret = QLatin1String("2a698db679125930d922a2dfb897e16b668a67c6f614593636e83fc3d8d9b47d");
88 
89         authUrl      = QLatin1String("https://api.pinterest.com/oauth/");
90         tokenUrl     = QLatin1String("https://api.pinterest.com/v1/oauth/token");
91         redirectUrl  = QLatin1String("https://login.live.com/oauth20_desktop.srf");
92         scope        = QLatin1String("read_public,write_public");
93         serviceName  = QLatin1String("Pinterest");
94         serviceKey   = QLatin1String("access_token");
95     }
96 
97 public:
98 
99     QString                clientId;
100     QString                clientSecret;
101     QString                authUrl;
102     QString                tokenUrl;
103     QString                redirectUrl;
104     QString                accessToken;
105     QString                scope;
106     QString                userName;
107     QString                serviceName;
108     QString                serviceKey;
109 
110     QWidget*               parent;
111 
112     QNetworkAccessManager* netMngr;
113     QNetworkReply*         reply;
114 
115     QSettings*             settings;
116 
117     State                  state;
118 
119     QMap<QString, QString> urlParametersMap;
120 
121     WebBrowserDlg*         browser;
122 };
123 
PTalker(QWidget * const parent)124 PTalker::PTalker(QWidget* const parent)
125     : d(new Private)
126 {
127     d->parent   = parent;
128     d->netMngr  = new QNetworkAccessManager(this);
129     d->settings = WSToolUtils::getOauthSettings(this);
130 
131     connect(d->netMngr, SIGNAL(finished(QNetworkReply*)),
132             this, SLOT(slotFinished(QNetworkReply*)));
133 
134     connect(this, SIGNAL(pinterestLinkingFailed()),
135             this, SLOT(slotLinkingFailed()));
136 
137     connect(this, SIGNAL(pinterestLinkingSucceeded()),
138             this, SLOT(slotLinkingSucceeded()));
139 }
140 
~PTalker()141 PTalker::~PTalker()
142 {
143     if (d->reply)
144     {
145         d->reply->abort();
146     }
147 
148     WSToolUtils::removeTemporaryDir("pinterest");
149 
150     delete d;
151 }
152 
link()153 void PTalker::link()
154 {
155     emit signalBusy(true);
156 
157     QUrl url(d->authUrl);
158     QUrlQuery query(url);
159     query.addQueryItem(QLatin1String("client_id"),     d->clientId);
160     query.addQueryItem(QLatin1String("scope"),         d->scope);
161     query.addQueryItem(QLatin1String("redirect_uri"),  d->redirectUrl);
162     query.addQueryItem(QLatin1String("response_type"), QLatin1String("code"));
163     url.setQuery(query);
164 
165     delete d->browser;
166     d->browser = new WebBrowserDlg(url, d->parent, true);
167     d->browser->setModal(true);
168 
169     connect(d->browser, SIGNAL(urlChanged(QUrl)),
170             this, SLOT(slotCatchUrl(QUrl)));
171 
172     connect(d->browser, SIGNAL(closeView(bool)),
173             this, SIGNAL(signalBusy(bool)));
174 
175     d->browser->show();
176 }
177 
unLink()178 void PTalker::unLink()
179 {
180     d->accessToken = QString();
181 
182     d->settings->beginGroup(d->serviceName);
183     d->settings->remove(QString());
184     d->settings->endGroup();
185 
186     emit pinterestLinkingSucceeded();
187 }
188 
slotCatchUrl(const QUrl & url)189 void PTalker::slotCatchUrl(const QUrl& url)
190 {
191     d->urlParametersMap = ParseUrlParameters(url.toString());
192     QString code        = d->urlParametersMap.value(QLatin1String("code"));
193     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Received URL from webview in link function: " << url ;
194 
195     if (!code.isEmpty())
196     {
197         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "CODE Received";
198         d->browser->close();
199         getToken(code);
200         emit signalBusy(false);
201     }
202 }
203 
getToken(const QString & code)204 void PTalker::getToken(const QString& code)
205 {
206     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Code: " << code;
207     QUrl url(d->tokenUrl);
208     QUrlQuery query(url);
209     query.addQueryItem(QLatin1String("grant_type"),    QLatin1String("authorization_code"));
210     query.addQueryItem(QLatin1String("client_id"),     d->clientId);
211     query.addQueryItem(QLatin1String("client_secret"), d->clientSecret);
212     query.addQueryItem(QLatin1String("code"),          code);
213     url.setQuery(query);
214     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Token Request URL:    " << url.toString();
215 
216     QNetworkRequest netRequest(url);
217     netRequest.setHeader(QNetworkRequest::ContentTypeHeader, QLatin1String("application/x-www-form-urlencoded"));
218     netRequest.setRawHeader("Accept", "application/json");
219 
220     d->reply = d->netMngr->post(netRequest, QByteArray());
221 
222     d->state = Private::P_ACCESSTOKEN;
223 }
224 
ParseUrlParameters(const QString & url)225 QMap<QString, QString> PTalker::ParseUrlParameters(const QString& url)
226 {
227     QMap<QString, QString> urlParameters;
228 
229     if (url.indexOf(QLatin1Char('?')) == -1)
230     {
231         return urlParameters;
232     }
233 
234     QString tmp           = url.right(url.length()-url.indexOf(QLatin1Char('?')) - 1);
235     QStringList paramlist = tmp.split(QLatin1Char('&'));
236 
237     for (int i = 0 ; i < paramlist.count() ; ++i)
238     {
239         QStringList paramarg = paramlist.at(i).split(QLatin1Char('='));
240 
241         if (paramarg.count() == 2)
242         {
243             urlParameters.insert(paramarg.at(0), paramarg.at(1));
244         }
245     }
246 
247     return urlParameters;
248 }
249 
slotLinkingFailed()250 void PTalker::slotLinkingFailed()
251 {
252     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "LINK to Pinterest fail";
253     emit signalBusy(false);
254 }
255 
slotLinkingSucceeded()256 void PTalker::slotLinkingSucceeded()
257 {
258     if (d->accessToken.isEmpty())
259     {
260         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "UNLINK to Pinterest ok";
261         emit signalBusy(false);
262         return;
263     }
264 
265     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "LINK to Pinterest ok";
266     writeSettings();
267     emit signalLinkingSucceeded();
268 }
269 
authenticated()270 bool PTalker::authenticated()
271 {
272     return (!d->accessToken.isEmpty());
273 }
274 
cancel()275 void PTalker::cancel()
276 {
277     if (d->reply)
278     {
279         d->reply->abort();
280         d->reply = nullptr;
281     }
282 
283     emit signalBusy(false);
284 }
285 
createBoard(QString & boardName)286 void PTalker::createBoard(QString& boardName)
287 {
288     QUrl url(QLatin1String("https://api.pinterest.com/v1/boards/"));
289     QNetworkRequest netRequest(url);
290     netRequest.setHeader(QNetworkRequest::ContentTypeHeader, QLatin1String("application/json"));
291     netRequest.setRawHeader("Authorization", QString::fromLatin1("Bearer %1").arg(d->accessToken).toUtf8());
292 
293     QByteArray postData = QString::fromUtf8("{\"name\": \"%1\"}").arg(boardName).toUtf8();
294 /*
295     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "createBoard:" << postData;
296 */
297     d->reply = d->netMngr->post(netRequest, postData);
298 
299     d->state = Private::P_CREATEBOARD;
300     emit signalBusy(true);
301 }
302 
getUserName()303 void PTalker::getUserName()
304 {
305     QUrl url(QLatin1String("https://api.pinterest.com/v1/me/?fields=username"));
306 
307     QNetworkRequest netRequest(url);
308     netRequest.setRawHeader("Authorization", QString::fromLatin1("Bearer %1").arg(d->accessToken).toUtf8());
309 
310     d->reply = d->netMngr->get(netRequest);
311     d->state = Private::P_USERNAME;
312     emit signalBusy(true);
313 }
314 
315 /**
316  * Get list of boards by parsing json sent by pinterest
317  */
listBoards(const QString &)318 void PTalker::listBoards(const QString& /*path*/)
319 {
320     QUrl url(QLatin1String("https://api.pinterest.com/v1/me/boards/"));;
321 
322     QNetworkRequest netRequest(url);
323     netRequest.setRawHeader("Authorization", QString::fromLatin1("Bearer %1").arg(d->accessToken).toUtf8());
324 /*
325     netRequest.setHeader(QNetworkRequest::ContentTypeHeader, QLatin1String("application/json"));
326 */
327     d->reply = d->netMngr->get(netRequest);
328 
329     d->state = Private::P_LISTBOARDS;
330     emit signalBusy(true);
331 }
332 
addPin(const QString & imgPath,const QString & uploadBoard,bool rescale,int maxDim,int imageQuality)333 bool PTalker::addPin(const QString& imgPath,
334                      const QString& uploadBoard,
335                      bool rescale,
336                      int maxDim,
337                      int imageQuality)
338 {
339     if (d->reply)
340     {
341         d->reply->abort();
342         d->reply = nullptr;
343     }
344 
345     emit signalBusy(true);
346 
347     QImage image = PreviewLoadThread::loadHighQualitySynchronously(imgPath).copyQImage();
348 
349     if (image.isNull())
350     {
351         emit signalBusy(false);
352         return false;
353     }
354 
355     QString path = WSToolUtils::makeTemporaryDir("pinterest").filePath(QFileInfo(imgPath)
356                                                  .baseName().trimmed() + QLatin1String(".jpg"));
357 
358     if (rescale && ((image.width() > maxDim) || (image.height() > maxDim)))
359     {
360         image = image.scaled(maxDim, maxDim, Qt::KeepAspectRatio, Qt::SmoothTransformation);
361     }
362 
363     image.save(path, "JPEG", imageQuality);
364 
365     QScopedPointer<DMetadata> meta(new DMetadata);
366 
367     if (meta->load(imgPath))
368     {
369         meta->setItemDimensions(image.size());
370         meta->setItemOrientation(DMetadata::ORIENTATION_NORMAL);
371         meta->setMetadataWritingMode((int)DMetadata::WRITE_TO_FILE_ONLY);
372         meta->save(path, true);
373     }
374 
375     QString boardParam              = d->userName + QLatin1Char('/') + uploadBoard;
376 
377     QUrl url(QString::fromLatin1("https://api.pinterest.com/v1/pins/?access_token=%1").arg(d->accessToken));
378 
379     QHttpMultiPart* const multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
380 
381     // Board Section
382 
383     QHttpPart board;
384     QString boardHeader = QLatin1String("form-data; name=\"board\"") ;
385     board.setHeader(QNetworkRequest::ContentDispositionHeader, boardHeader);
386 
387     QByteArray postData = boardParam.toUtf8();
388     board.setBody(postData);
389     multiPart->append(board);
390 
391     // Note section
392 
393     QHttpPart note;
394     QString noteHeader = QLatin1String("form-data; name=\"note\"") ;
395     note.setHeader(QNetworkRequest::ContentDispositionHeader, noteHeader);
396 
397     postData           = QByteArray();
398 
399     note.setBody(postData);
400     multiPart->append(note);
401 
402     // image section
403 
404     QFile* const file  = new QFile(imgPath);
405 
406     if (!file)
407     {
408         return false;
409     }
410 
411     if (!file->open(QIODevice::ReadOnly))
412     {
413         return false;
414     }
415 
416     QHttpPart imagePart;
417     QString imagePartHeader = QLatin1String("form-data; name=\"image\"; filename=\"") +
418                               QFileInfo(imgPath).fileName() + QLatin1Char('"');
419 
420     imagePart.setHeader(QNetworkRequest::ContentDispositionHeader, imagePartHeader);
421     imagePart.setHeader(QNetworkRequest::ContentTypeHeader, QLatin1String("image/jpeg"));
422 
423     imagePart.setBodyDevice(file);
424     multiPart->append(imagePart);
425 
426     QString content = QLatin1String("multipart/form-data;boundary=") + QString::fromUtf8(multiPart->boundary());
427     QNetworkRequest netRequest(url);
428     netRequest.setHeader(QNetworkRequest::ContentTypeHeader, content);
429 
430     d->reply = d->netMngr->post(netRequest, multiPart);
431 
432     // delete the multiPart and file with the reply
433 
434     multiPart->setParent(d->reply);
435     d->state = Private::P_ADDPIN;
436 
437     return true;
438 }
439 
slotFinished(QNetworkReply * reply)440 void PTalker::slotFinished(QNetworkReply* reply)
441 {
442     if (reply != d->reply)
443     {
444         return;
445     }
446 
447     d->reply = nullptr;
448 
449     if (reply->error() != QNetworkReply::NoError)
450     {
451         if (d->state != Private::P_CREATEBOARD)
452         {
453             emit signalBusy(false);
454             QMessageBox::critical(QApplication::activeWindow(),
455                                   i18n("Error"), reply->errorString());
456 /*
457             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Error content: " << QString(reply->readAll());
458 */
459             reply->deleteLater();
460             return;
461         }
462     }
463 
464     QByteArray buffer = reply->readAll();
465 /*
466     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "BUFFER" << QString(buffer);
467 */
468     switch (d->state)
469     {
470         case Private::P_LISTBOARDS:
471             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "In P_LISTBOARDS";
472             parseResponseListBoards(buffer);
473             break;
474 
475         case Private::P_CREATEBOARD:
476             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "In P_CREATEBOARD";
477             parseResponseCreateBoard(buffer);
478             break;
479 
480         case Private::P_ADDPIN:
481             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "In P_ADDPIN";
482             parseResponseAddPin(buffer);
483             break;
484 
485         case Private::P_USERNAME:
486             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "In P_USERNAME";
487             parseResponseUserName(buffer);
488             break;
489 
490         case Private::P_ACCESSTOKEN:
491             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "In P_ACCESSTOKEN";
492             parseResponseAccessToken(buffer);
493             break;
494 
495         default:
496             break;
497     }
498 
499     reply->deleteLater();
500 }
501 
parseResponseAccessToken(const QByteArray & data)502 void PTalker::parseResponseAccessToken(const QByteArray& data)
503 {
504     QJsonDocument doc      = QJsonDocument::fromJson(data);
505     QJsonObject jsonObject = doc.object();
506     d->accessToken         = jsonObject[QLatin1String("access_token")].toString();
507 
508     if (!d->accessToken.isEmpty())
509     {
510         qDebug(DIGIKAM_WEBSERVICES_LOG) << "Access token Received: " << d->accessToken;
511         emit pinterestLinkingSucceeded();
512     }
513     else
514     {
515         emit pinterestLinkingFailed();
516     }
517 
518     emit signalBusy(false);
519 }
520 
parseResponseAddPin(const QByteArray & data)521 void PTalker::parseResponseAddPin(const QByteArray& data)
522 {
523     QJsonDocument doc      = QJsonDocument::fromJson(data);
524     QJsonObject jsonObject = doc.object()[QLatin1String("data")].toObject();
525     bool success           = jsonObject.contains(QLatin1String("id"));
526     emit signalBusy(false);
527 
528     if (!success)
529     {
530         emit signalAddPinFailed(i18n("Failed to upload Pin"));
531     }
532     else
533     {
534         emit signalAddPinSucceeded();
535     }
536 }
537 
parseResponseUserName(const QByteArray & data)538 void PTalker::parseResponseUserName(const QByteArray& data)
539 {
540     QJsonDocument doc      = QJsonDocument::fromJson(data);
541     QJsonObject jsonObject = doc.object()[QLatin1String("data")].toObject();
542     d->userName            = jsonObject[QLatin1String("username")].toString();
543 
544     emit signalBusy(false);
545     emit signalSetUserName(d->userName);
546 }
547 
parseResponseListBoards(const QByteArray & data)548 void PTalker::parseResponseListBoards(const QByteArray& data)
549 {
550     QJsonParseError err;
551     QJsonDocument doc = QJsonDocument::fromJson(data, &err);
552 
553     if (err.error != QJsonParseError::NoError)
554     {
555         emit signalBusy(false);
556         emit signalListBoardsFailed(i18n("Failed to list boards"));
557         return;
558     }
559 
560     QJsonObject jsonObject = doc.object();
561 /*
562     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Json Listing Boards : " << doc;
563 */
564     QJsonArray jsonArray   = jsonObject[QLatin1String("data")].toArray();
565 
566     QList<QPair<QString, QString> > list;
567 
568     foreach (const QJsonValue& value, jsonArray)
569     {
570         QString boardID;
571         QString boardName;
572         QJsonObject obj = value.toObject();
573         boardID         = obj[QLatin1String("id")].toString();
574         boardName       = obj[QLatin1String("name")].toString();
575 
576         list.append(qMakePair(boardID, boardName));
577     }
578 
579     emit signalBusy(false);
580     emit signalListBoardsDone(list);
581 }
582 
parseResponseCreateBoard(const QByteArray & data)583 void PTalker::parseResponseCreateBoard(const QByteArray& data)
584 {
585     QJsonDocument doc1     = QJsonDocument::fromJson(data);
586     QJsonObject jsonObject = doc1.object();
587     bool fail              = jsonObject.contains(QLatin1String("error"));
588 
589     emit signalBusy(false);
590 
591     if (fail)
592     {
593         QJsonParseError err;
594         QJsonDocument doc2 = QJsonDocument::fromJson(data, &err);
595         emit signalCreateBoardFailed(jsonObject[QLatin1String("error_summary")].toString());
596     }
597     else
598     {
599         emit signalCreateBoardSucceeded();
600     }
601 }
602 
writeSettings()603 void PTalker::writeSettings()
604 {
605     d->settings->beginGroup(d->serviceName);
606     d->settings->setValue(d->serviceKey, d->accessToken);
607     d->settings->endGroup();
608 }
609 
readSettings()610 void PTalker::readSettings()
611 {
612     d->settings->beginGroup(d->serviceName);
613     d->accessToken = d->settings->value(d->serviceKey).toString();
614     d->settings->endGroup();
615 
616     if (d->accessToken.isEmpty())
617     {
618         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Linking...";
619         link();
620     }
621     else
622     {
623         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Already Linked";
624         emit pinterestLinkingSucceeded();
625     }
626 }
627 
628 } // namespace DigikamGenericPinterestPlugin
629