1 /*
2  * Copyright (C) by Klaas Freitag <freitag@owncloud.com>
3  * Copyright (C) by Daniel Molkentin <danimo@owncloud.com>
4  *
5  * This program is free software; you can redistribute it and/or modify
6  * it under the terms of the GNU General Public License as published by
7  * the Free Software Foundation; either version 2 of the License, or
8  * (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful, but
11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
12  * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13  * for more details.
14  */
15 
16 #include <QJsonDocument>
17 #include <QLoggingCategory>
18 #include <QNetworkRequest>
19 #include <QNetworkAccessManager>
20 #include <QNetworkReply>
21 #include <QNetworkRequest>
22 #include <QSslConfiguration>
23 #include <QSslCipher>
24 #include <QBuffer>
25 #include <QXmlStreamReader>
26 #include <QStringList>
27 #include <QStack>
28 #include <QTimer>
29 #include <QMutex>
30 #include <QCoreApplication>
31 #include <QJsonDocument>
32 #include <QJsonObject>
33 #include <qloggingcategory.h>
34 #ifndef TOKEN_AUTH_ONLY
35 #include <QPainter>
36 #include <QPainterPath>
37 #endif
38 
39 #include "networkjobs.h"
40 #include "account.h"
41 #include "owncloudpropagator.h"
42 #include "clientsideencryption.h"
43 
44 #include "creds/abstractcredentials.h"
45 #include "creds/httpcredentials.h"
46 
47 namespace OCC {
48 
49 Q_LOGGING_CATEGORY(lcEtagJob, "nextcloud.sync.networkjob.etag", QtInfoMsg)
50 Q_LOGGING_CATEGORY(lcLsColJob, "nextcloud.sync.networkjob.lscol", QtInfoMsg)
51 Q_LOGGING_CATEGORY(lcCheckServerJob, "nextcloud.sync.networkjob.checkserver", QtInfoMsg)
52 Q_LOGGING_CATEGORY(lcPropfindJob, "nextcloud.sync.networkjob.propfind", QtInfoMsg)
53 Q_LOGGING_CATEGORY(lcAvatarJob, "nextcloud.sync.networkjob.avatar", QtInfoMsg)
54 Q_LOGGING_CATEGORY(lcMkColJob, "nextcloud.sync.networkjob.mkcol", QtInfoMsg)
55 Q_LOGGING_CATEGORY(lcProppatchJob, "nextcloud.sync.networkjob.proppatch", QtInfoMsg)
56 Q_LOGGING_CATEGORY(lcJsonApiJob, "nextcloud.sync.networkjob.jsonapi", QtInfoMsg)
57 Q_LOGGING_CATEGORY(lcDetermineAuthTypeJob, "nextcloud.sync.networkjob.determineauthtype", QtInfoMsg)
58 const int notModifiedStatusCode = 304;
59 
parseEtag(const char * header)60 QByteArray parseEtag(const char *header)
61 {
62     if (!header)
63         return QByteArray();
64     QByteArray arr = header;
65 
66     // Weak E-Tags can appear when gzip compression is on, see #3946
67     if (arr.startsWith("W/"))
68         arr = arr.mid(2);
69 
70     // https://github.com/owncloud/client/issues/1195
71     arr.replace("-gzip", "");
72 
73     if (arr.length() >= 2 && arr.startsWith('"') && arr.endsWith('"')) {
74         arr = arr.mid(1, arr.length() - 2);
75     }
76     return arr;
77 }
78 
RequestEtagJob(AccountPtr account,const QString & path,QObject * parent)79 RequestEtagJob::RequestEtagJob(AccountPtr account, const QString &path, QObject *parent)
80     : AbstractNetworkJob(account, path, parent)
81 {
82 }
83 
start()84 void RequestEtagJob::start()
85 {
86     QNetworkRequest req;
87     req.setRawHeader("Depth", "0");
88 
89     QByteArray xml("<?xml version=\"1.0\" ?>\n"
90                    "<d:propfind xmlns:d=\"DAV:\">\n"
91                    "  <d:prop>\n"
92                    "    <d:getetag/>\n"
93                    "  </d:prop>\n"
94                    "</d:propfind>\n");
95     auto *buf = new QBuffer(this);
96     buf->setData(xml);
97     buf->open(QIODevice::ReadOnly);
98     // assumes ownership
99     sendRequest("PROPFIND", makeDavUrl(path()), req, buf);
100 
101     if (reply()->error() != QNetworkReply::NoError) {
102         qCWarning(lcEtagJob) << "request network error: " << reply()->errorString();
103     }
104     AbstractNetworkJob::start();
105 }
106 
finished()107 bool RequestEtagJob::finished()
108 {
109     qCInfo(lcEtagJob) << "Request Etag of" << reply()->request().url() << "FINISHED WITH STATUS"
110                       <<  replyStatusString();
111 
112     auto httpCode = reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
113     if (httpCode == 207) {
114         // Parse DAV response
115         QXmlStreamReader reader(reply());
116         reader.addExtraNamespaceDeclaration(QXmlStreamNamespaceDeclaration(QStringLiteral("d"), QStringLiteral("DAV:")));
117         QByteArray etag;
118         while (!reader.atEnd()) {
119             QXmlStreamReader::TokenType type = reader.readNext();
120             if (type == QXmlStreamReader::StartElement && reader.namespaceUri() == QLatin1String("DAV:")) {
121                 QString name = reader.name().toString();
122                 if (name == QLatin1String("getetag")) {
123                     auto etagText = reader.readElementText();
124                     auto parsedTag = parseEtag(etagText.toUtf8());
125                     if (!parsedTag.isEmpty()) {
126                         etag += parsedTag;
127                     } else {
128                         etag += etagText.toUtf8();
129                     }
130                 }
131             }
132         }
133         emit etagRetrieved(etag, QDateTime::fromString(QString::fromUtf8(_responseTimestamp), Qt::RFC2822Date));
134         emit finishedWithResult(etag);
135     } else {
136         emit finishedWithResult(HttpError{ httpCode, errorString() });
137     }
138     return true;
139 }
140 
141 /*********************************************************************************************/
142 
MkColJob(AccountPtr account,const QString & path,QObject * parent)143 MkColJob::MkColJob(AccountPtr account, const QString &path, QObject *parent)
144     : AbstractNetworkJob(account, path, parent)
145 {
146 }
147 
MkColJob(AccountPtr account,const QString & path,const QMap<QByteArray,QByteArray> & extraHeaders,QObject * parent)148 MkColJob::MkColJob(AccountPtr account, const QString &path, const QMap<QByteArray, QByteArray> &extraHeaders, QObject *parent)
149     : AbstractNetworkJob(account, path, parent)
150     , _extraHeaders(extraHeaders)
151 {
152 }
153 
MkColJob(AccountPtr account,const QUrl & url,const QMap<QByteArray,QByteArray> & extraHeaders,QObject * parent)154 MkColJob::MkColJob(AccountPtr account, const QUrl &url,
155     const QMap<QByteArray, QByteArray> &extraHeaders, QObject *parent)
156     : AbstractNetworkJob(account, QString(), parent)
157     , _url(url)
158     , _extraHeaders(extraHeaders)
159 {
160 }
161 
start()162 void MkColJob::start()
163 {
164     // add 'Content-Length: 0' header (see https://github.com/owncloud/client/issues/3256)
165     QNetworkRequest req;
166     req.setRawHeader("Content-Length", "0");
167     for (auto it = _extraHeaders.constBegin(); it != _extraHeaders.constEnd(); ++it) {
168         req.setRawHeader(it.key(), it.value());
169     }
170 
171     // assumes ownership
172     if (_url.isValid()) {
173         sendRequest("MKCOL", _url, req);
174     } else {
175         sendRequest("MKCOL", makeDavUrl(path()), req);
176     }
177     AbstractNetworkJob::start();
178 }
179 
finished()180 bool MkColJob::finished()
181 {
182     qCInfo(lcMkColJob) << "MKCOL of" << reply()->request().url() << "FINISHED WITH STATUS"
183                        << replyStatusString();
184 
185     if (reply()->error() != QNetworkReply::NoError) {
186         Q_EMIT finishedWithError(reply());
187     } else {
188         Q_EMIT finishedWithoutError();
189     }
190     return true;
191 }
192 
193 /*********************************************************************************************/
194 // supposed to read <D:collection> when pointing to <D:resourcetype><D:collection></D:resourcetype>..
readContentsAsString(QXmlStreamReader & reader)195 static QString readContentsAsString(QXmlStreamReader &reader)
196 {
197     QString result;
198     int level = 0;
199     do {
200         QXmlStreamReader::TokenType type = reader.readNext();
201         if (type == QXmlStreamReader::StartElement) {
202             level++;
203             result += "<" + reader.name().toString() + ">";
204         } else if (type == QXmlStreamReader::Characters) {
205             result += reader.text();
206         } else if (type == QXmlStreamReader::EndElement) {
207             level--;
208             if (level < 0) {
209                 break;
210             }
211             result += "</" + reader.name().toString() + ">";
212         }
213 
214     } while (!reader.atEnd());
215     return result;
216 }
217 
218 
219 LsColXMLParser::LsColXMLParser() = default;
220 
parse(const QByteArray & xml,QHash<QString,ExtraFolderInfo> * fileInfo,const QString & expectedPath)221 bool LsColXMLParser::parse(const QByteArray &xml, QHash<QString, ExtraFolderInfo> *fileInfo, const QString &expectedPath)
222 {
223     // Parse DAV response
224     QXmlStreamReader reader(xml);
225     reader.addExtraNamespaceDeclaration(QXmlStreamNamespaceDeclaration("d", "DAV:"));
226 
227     QStringList folders;
228     QString currentHref;
229     QMap<QString, QString> currentTmpProperties;
230     QMap<QString, QString> currentHttp200Properties;
231     bool currentPropsHaveHttp200 = false;
232     bool insidePropstat = false;
233     bool insideProp = false;
234     bool insideMultiStatus = false;
235 
236     while (!reader.atEnd()) {
237         QXmlStreamReader::TokenType type = reader.readNext();
238         QString name = reader.name().toString();
239         // Start elements with DAV:
240         if (type == QXmlStreamReader::StartElement && reader.namespaceUri() == QLatin1String("DAV:")) {
241             if (name == QLatin1String("href")) {
242                 // We don't use URL encoding in our request URL (which is the expected path) (QNAM will do it for us)
243                 // but the result will have URL encoding..
244                 QString hrefString = QUrl::fromLocalFile(QUrl::fromPercentEncoding(reader.readElementText().toUtf8()))
245                         .adjusted(QUrl::NormalizePathSegments)
246                         .path();
247                 if (!hrefString.startsWith(expectedPath)) {
248                     qCWarning(lcLsColJob) << "Invalid href" << hrefString << "expected starting with" << expectedPath;
249                     return false;
250                 }
251                 currentHref = hrefString;
252             } else if (name == QLatin1String("response")) {
253             } else if (name == QLatin1String("propstat")) {
254                 insidePropstat = true;
255             } else if (name == QLatin1String("status") && insidePropstat) {
256                 QString httpStatus = reader.readElementText();
257                 if (httpStatus.startsWith("HTTP/1.1 200")) {
258                     currentPropsHaveHttp200 = true;
259                 } else {
260                     currentPropsHaveHttp200 = false;
261                 }
262             } else if (name == QLatin1String("prop")) {
263                 insideProp = true;
264                 continue;
265             } else if (name == QLatin1String("multistatus")) {
266                 insideMultiStatus = true;
267                 continue;
268             }
269         }
270 
271         if (type == QXmlStreamReader::StartElement && insidePropstat && insideProp) {
272             // All those elements are properties
273             QString propertyContent = readContentsAsString(reader);
274             if (name == QLatin1String("resourcetype") && propertyContent.contains("collection")) {
275                 folders.append(currentHref);
276             } else if (name == QLatin1String("size")) {
277                 bool ok = false;
278                 auto s = propertyContent.toLongLong(&ok);
279                 if (ok && fileInfo) {
280                     (*fileInfo)[currentHref].size = s;
281                 }
282             } else if (name == QLatin1String("fileid")) {
283                 (*fileInfo)[currentHref].fileId = propertyContent.toUtf8();
284             }
285             currentTmpProperties.insert(reader.name().toString(), propertyContent);
286         }
287 
288         // End elements with DAV:
289         if (type == QXmlStreamReader::EndElement) {
290             if (reader.namespaceUri() == QLatin1String("DAV:")) {
291                 if (reader.name() == "response") {
292                     if (currentHref.endsWith('/')) {
293                         currentHref.chop(1);
294                     }
295                     emit directoryListingIterated(currentHref, currentHttp200Properties);
296                     currentHref.clear();
297                     currentHttp200Properties.clear();
298                 } else if (reader.name() == "propstat") {
299                     insidePropstat = false;
300                     if (currentPropsHaveHttp200) {
301                         currentHttp200Properties = QMap<QString, QString>(currentTmpProperties);
302                     }
303                     currentTmpProperties.clear();
304                     currentPropsHaveHttp200 = false;
305                 } else if (reader.name() == "prop") {
306                     insideProp = false;
307                 }
308             }
309         }
310     }
311 
312     if (reader.hasError()) {
313         // XML Parser error? Whatever had been emitted before will come as directoryListingIterated
314         qCWarning(lcLsColJob) << "ERROR" << reader.errorString() << xml;
315         return false;
316     } else if (!insideMultiStatus) {
317         qCWarning(lcLsColJob) << "ERROR no WebDAV response?" << xml;
318         return false;
319     } else {
320         emit directoryListingSubfolders(folders);
321         emit finishedWithoutError();
322     }
323     return true;
324 }
325 
326 /*********************************************************************************************/
327 
LsColJob(AccountPtr account,const QString & path,QObject * parent)328 LsColJob::LsColJob(AccountPtr account, const QString &path, QObject *parent)
329     : AbstractNetworkJob(account, path, parent)
330 {
331 }
332 
LsColJob(AccountPtr account,const QUrl & url,QObject * parent)333 LsColJob::LsColJob(AccountPtr account, const QUrl &url, QObject *parent)
334     : AbstractNetworkJob(account, QString(), parent)
335     , _url(url)
336 {
337 }
338 
setProperties(QList<QByteArray> properties)339 void LsColJob::setProperties(QList<QByteArray> properties)
340 {
341     _properties = properties;
342 }
343 
properties() const344 QList<QByteArray> LsColJob::properties() const
345 {
346     return _properties;
347 }
348 
start()349 void LsColJob::start()
350 {
351     QList<QByteArray> properties = _properties;
352 
353     if (properties.isEmpty()) {
354         qCWarning(lcLsColJob) << "Propfind with no properties!";
355     }
356     QByteArray propStr;
357     foreach (const QByteArray &prop, properties) {
358         if (prop.contains(':')) {
359             int colIdx = prop.lastIndexOf(":");
360             auto ns = prop.left(colIdx);
361             if (ns == "http://owncloud.org/ns") {
362                 propStr += "    <oc:" + prop.mid(colIdx + 1) + " />\n";
363             } else {
364                 propStr += "    <" + prop.mid(colIdx + 1) + " xmlns=\"" + ns + "\" />\n";
365             }
366         } else {
367             propStr += "    <d:" + prop + " />\n";
368         }
369     }
370 
371     QNetworkRequest req;
372     req.setRawHeader("Depth", "1");
373     QByteArray xml("<?xml version=\"1.0\" ?>\n"
374                    "<d:propfind xmlns:d=\"DAV:\" xmlns:oc=\"http://owncloud.org/ns\">\n"
375                    "  <d:prop>\n"
376         + propStr + "  </d:prop>\n"
377                     "</d:propfind>\n");
378     auto *buf = new QBuffer(this);
379     buf->setData(xml);
380     buf->open(QIODevice::ReadOnly);
381     if (_url.isValid()) {
382         sendRequest("PROPFIND", _url, req, buf);
383     } else {
384         sendRequest("PROPFIND", makeDavUrl(path()), req, buf);
385     }
386     AbstractNetworkJob::start();
387 }
388 
389 // TODO: Instead of doing all in this slot, we should iteratively parse in readyRead(). This
390 // would allow us to be more asynchronous in processing while data is coming from the network,
391 // not all in one big blob at the end.
finished()392 bool LsColJob::finished()
393 {
394     qCInfo(lcLsColJob) << "LSCOL of" << reply()->request().url() << "FINISHED WITH STATUS"
395                        << replyStatusString();
396 
397     QString contentType = reply()->header(QNetworkRequest::ContentTypeHeader).toString();
398     int httpCode = reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
399     if (httpCode == 207 && contentType.contains("application/xml; charset=utf-8")) {
400         LsColXMLParser parser;
401         connect(&parser, &LsColXMLParser::directoryListingSubfolders,
402             this, &LsColJob::directoryListingSubfolders);
403         connect(&parser, &LsColXMLParser::directoryListingIterated,
404             this, &LsColJob::directoryListingIterated);
405         connect(&parser, &LsColXMLParser::finishedWithError,
406             this, &LsColJob::finishedWithError);
407         connect(&parser, &LsColXMLParser::finishedWithoutError,
408             this, &LsColJob::finishedWithoutError);
409 
410         QString expectedPath = reply()->request().url().path(); // something like "/owncloud/remote.php/dav/folder"
411         if (!parser.parse(reply()->readAll(), &_folderInfos, expectedPath)) {
412             // XML parse error
413             emit finishedWithError(reply());
414         }
415     } else {
416         // wrong content type, wrong HTTP code or any other network error
417         emit finishedWithError(reply());
418     }
419 
420     return true;
421 }
422 
423 /*********************************************************************************************/
424 
425 namespace {
426     const char statusphpC[] = "status.php";
427     const char nextcloudDirC[] = "nextcloud/";
428 }
429 
CheckServerJob(AccountPtr account,QObject * parent)430 CheckServerJob::CheckServerJob(AccountPtr account, QObject *parent)
431     : AbstractNetworkJob(account, QLatin1String(statusphpC), parent)
432     , _subdirFallback(false)
433     , _permanentRedirects(0)
434 {
435     setIgnoreCredentialFailure(true);
436     connect(this, &AbstractNetworkJob::redirected,
437         this, &CheckServerJob::slotRedirected);
438 }
439 
start()440 void CheckServerJob::start()
441 {
442     _serverUrl = account()->url();
443     sendRequest("GET", Utility::concatUrlPath(_serverUrl, path()));
444     connect(reply(), &QNetworkReply::metaDataChanged, this, &CheckServerJob::metaDataChangedSlot);
445     connect(reply(), &QNetworkReply::encrypted, this, &CheckServerJob::encryptedSlot);
446     AbstractNetworkJob::start();
447 }
448 
onTimedOut()449 void CheckServerJob::onTimedOut()
450 {
451     qCWarning(lcCheckServerJob) << "TIMEOUT";
452     if (reply() && reply()->isRunning()) {
453         emit timeout(reply()->url());
454     } else if (!reply()) {
455         qCWarning(lcCheckServerJob) << "Timeout even there was no reply?";
456     }
457     deleteLater();
458 }
459 
version(const QJsonObject & info)460 QString CheckServerJob::version(const QJsonObject &info)
461 {
462     return info.value(QLatin1String("version")).toString();
463 }
464 
versionString(const QJsonObject & info)465 QString CheckServerJob::versionString(const QJsonObject &info)
466 {
467     return info.value(QLatin1String("versionstring")).toString();
468 }
469 
installed(const QJsonObject & info)470 bool CheckServerJob::installed(const QJsonObject &info)
471 {
472     return info.value(QLatin1String("installed")).toBool();
473 }
474 
mergeSslConfigurationForSslButton(const QSslConfiguration & config,AccountPtr account)475 static void mergeSslConfigurationForSslButton(const QSslConfiguration &config, AccountPtr account)
476 {
477     if (config.peerCertificateChain().length() > 0) {
478         account->_peerCertificateChain = config.peerCertificateChain();
479     }
480     if (!config.sessionCipher().isNull()) {
481         account->_sessionCipher = config.sessionCipher();
482     }
483     if (config.sessionTicket().length() > 0) {
484         account->_sessionTicket = config.sessionTicket();
485     }
486 }
487 
encryptedSlot()488 void CheckServerJob::encryptedSlot()
489 {
490     mergeSslConfigurationForSslButton(reply()->sslConfiguration(), account());
491 }
492 
slotRedirected(QNetworkReply * reply,const QUrl & targetUrl,int redirectCount)493 void CheckServerJob::slotRedirected(QNetworkReply *reply, const QUrl &targetUrl, int redirectCount)
494 {
495     QByteArray slashStatusPhp("/");
496     slashStatusPhp.append(statusphpC);
497 
498     int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
499     QString path = targetUrl.path();
500     if ((httpCode == 301 || httpCode == 308) // permanent redirection
501         && redirectCount == _permanentRedirects // don't apply permanent redirects after a temporary one
502         && path.endsWith(slashStatusPhp)) {
503         _serverUrl = targetUrl;
504         _serverUrl.setPath(path.left(path.size() - slashStatusPhp.size()));
505         qCInfo(lcCheckServerJob) << "status.php was permanently redirected to"
506                                  << targetUrl << "new server url is" << _serverUrl;
507         ++_permanentRedirects;
508     }
509 }
510 
metaDataChangedSlot()511 void CheckServerJob::metaDataChangedSlot()
512 {
513     account()->setSslConfiguration(reply()->sslConfiguration());
514     mergeSslConfigurationForSslButton(reply()->sslConfiguration(), account());
515 }
516 
517 
finished()518 bool CheckServerJob::finished()
519 {
520     if (reply()->request().url().scheme() == QLatin1String("https")
521         && reply()->sslConfiguration().sessionTicket().isEmpty()
522         && reply()->error() == QNetworkReply::NoError) {
523         qCWarning(lcCheckServerJob) << "No SSL session identifier / session ticket is used, this might impact sync performance negatively.";
524     }
525 
526     mergeSslConfigurationForSslButton(reply()->sslConfiguration(), account());
527 
528     // The server installs to /owncloud. Let's try that if the file wasn't found
529     // at the original location
530     if ((reply()->error() == QNetworkReply::ContentNotFoundError) && (!_subdirFallback)) {
531         _subdirFallback = true;
532         setPath(QLatin1String(nextcloudDirC) + QLatin1String(statusphpC));
533         start();
534         qCInfo(lcCheckServerJob) << "Retrying with" << reply()->url();
535         return false;
536     }
537 
538     QByteArray body = reply()->peek(4 * 1024);
539     int httpStatus = reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
540     if (body.isEmpty() || httpStatus != 200) {
541         qCWarning(lcCheckServerJob) << "error: status.php replied " << httpStatus << body;
542         emit instanceNotFound(reply());
543     } else {
544         QJsonParseError error;
545         auto status = QJsonDocument::fromJson(body, &error);
546         // empty or invalid response
547         if (error.error != QJsonParseError::NoError || status.isNull()) {
548             qCWarning(lcCheckServerJob) << "status.php from server is not valid JSON!" << body << reply()->request().url() << error.errorString();
549         }
550 
551         qCInfo(lcCheckServerJob) << "status.php returns: " << status << " " << reply()->error() << " Reply: " << reply();
552         if (status.object().contains("installed")) {
553             emit instanceFound(_serverUrl, status.object());
554         } else {
555             qCWarning(lcCheckServerJob) << "No proper answer on " << reply()->url();
556             emit instanceNotFound(reply());
557         }
558     }
559     return true;
560 }
561 
562 /*********************************************************************************************/
563 
PropfindJob(AccountPtr account,const QString & path,QObject * parent)564 PropfindJob::PropfindJob(AccountPtr account, const QString &path, QObject *parent)
565     : AbstractNetworkJob(account, path, parent)
566 {
567 }
568 
start()569 void PropfindJob::start()
570 {
571     QList<QByteArray> properties = _properties;
572 
573     if (properties.isEmpty()) {
574         qCWarning(lcLsColJob) << "Propfind with no properties!";
575     }
576     QNetworkRequest req;
577     // Always have a higher priority than the propagator because we use this from the UI
578     // and really want this to be done first (no matter what internal scheduling QNAM uses).
579     // Also possibly useful for avoiding false timeouts.
580     req.setPriority(QNetworkRequest::HighPriority);
581     req.setRawHeader("Depth", "0");
582     QByteArray propStr;
583     foreach (const QByteArray &prop, properties) {
584         if (prop.contains(':')) {
585             int colIdx = prop.lastIndexOf(":");
586             propStr += "    <" + prop.mid(colIdx + 1) + " xmlns=\"" + prop.left(colIdx) + "\" />\n";
587         } else {
588             propStr += "    <d:" + prop + " />\n";
589         }
590     }
591     QByteArray xml = "<?xml version=\"1.0\" ?>\n"
592                      "<d:propfind xmlns:d=\"DAV:\">\n"
593                      "  <d:prop>\n"
594         + propStr + "  </d:prop>\n"
595                     "</d:propfind>\n";
596 
597     auto *buf = new QBuffer(this);
598     buf->setData(xml);
599     buf->open(QIODevice::ReadOnly);
600     sendRequest("PROPFIND", makeDavUrl(path()), req, buf);
601 
602     AbstractNetworkJob::start();
603 }
604 
setProperties(QList<QByteArray> properties)605 void PropfindJob::setProperties(QList<QByteArray> properties)
606 {
607     _properties = properties;
608 }
609 
properties() const610 QList<QByteArray> PropfindJob::properties() const
611 {
612     return _properties;
613 }
614 
finished()615 bool PropfindJob::finished()
616 {
617     qCInfo(lcPropfindJob) << "PROPFIND of" << reply()->request().url() << "FINISHED WITH STATUS"
618                           << replyStatusString();
619 
620     int http_result_code = reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
621 
622     if (http_result_code == 207) {
623         // Parse DAV response
624         QXmlStreamReader reader(reply());
625         reader.addExtraNamespaceDeclaration(QXmlStreamNamespaceDeclaration("d", "DAV:"));
626 
627         QVariantMap items;
628         // introduced to nesting is ignored
629         QStack<QString> curElement;
630 
631         while (!reader.atEnd()) {
632             QXmlStreamReader::TokenType type = reader.readNext();
633             if (type == QXmlStreamReader::StartElement) {
634                 if (!curElement.isEmpty() && curElement.top() == QLatin1String("prop")) {
635                     items.insert(reader.name().toString(), reader.readElementText(QXmlStreamReader::SkipChildElements));
636                 } else {
637                     curElement.push(reader.name().toString());
638                 }
639             }
640             if (type == QXmlStreamReader::EndElement) {
641                 if (curElement.top() == reader.name()) {
642                     curElement.pop();
643                 }
644             }
645         }
646         if (reader.hasError()) {
647             qCWarning(lcPropfindJob) << "XML parser error: " << reader.errorString();
648             emit finishedWithError(reply());
649         } else {
650             emit result(items);
651         }
652     } else {
653         qCWarning(lcPropfindJob) << "*not* successful, http result code is" << http_result_code
654                                  << (http_result_code == 302 ? reply()->header(QNetworkRequest::LocationHeader).toString() : QLatin1String(""));
655         emit finishedWithError(reply());
656     }
657     return true;
658 }
659 
660 /*********************************************************************************************/
661 
662 #ifndef TOKEN_AUTH_ONLY
AvatarJob(AccountPtr account,const QString & userId,int size,QObject * parent)663 AvatarJob::AvatarJob(AccountPtr account, const QString &userId, int size, QObject *parent)
664     : AbstractNetworkJob(account, QString(), parent)
665 {
666     if (account->serverVersionInt() >= Account::makeServerVersion(10, 0, 0)) {
667         _avatarUrl = Utility::concatUrlPath(account->url(), QString("remote.php/dav/avatars/%1/%2.png").arg(userId, QString::number(size)));
668     } else {
669         _avatarUrl = Utility::concatUrlPath(account->url(), QString("index.php/avatar/%1/%2").arg(userId, QString::number(size)));
670     }
671 }
672 
start()673 void AvatarJob::start()
674 {
675     QNetworkRequest req;
676     sendRequest("GET", _avatarUrl, req);
677     AbstractNetworkJob::start();
678 }
679 
makeCircularAvatar(const QImage & baseAvatar)680 QImage AvatarJob::makeCircularAvatar(const QImage &baseAvatar)
681 {
682     if (baseAvatar.isNull()) {
683         return {};
684     }
685 
686     int dim = baseAvatar.width();
687 
688     QImage avatar(dim, dim, QImage::Format_ARGB32);
689     avatar.fill(Qt::transparent);
690 
691     QPainter painter(&avatar);
692     painter.setRenderHint(QPainter::Antialiasing);
693 
694     QPainterPath path;
695     path.addEllipse(0, 0, dim, dim);
696     painter.setClipPath(path);
697 
698     painter.drawImage(0, 0, baseAvatar);
699     painter.end();
700 
701     return avatar;
702 }
703 
finished()704 bool AvatarJob::finished()
705 {
706     int http_result_code = reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
707 
708     QImage avImage;
709 
710     if (http_result_code == 200) {
711         QByteArray pngData = reply()->readAll();
712         if (pngData.size()) {
713             if (avImage.loadFromData(pngData)) {
714                 qCDebug(lcAvatarJob) << "Retrieved Avatar pixmap!";
715             }
716         }
717     }
718     emit(avatarPixmap(avImage));
719     return true;
720 }
721 #endif
722 
723 /*********************************************************************************************/
724 
ProppatchJob(AccountPtr account,const QString & path,QObject * parent)725 ProppatchJob::ProppatchJob(AccountPtr account, const QString &path, QObject *parent)
726     : AbstractNetworkJob(account, path, parent)
727 {
728 }
729 
start()730 void ProppatchJob::start()
731 {
732     if (_properties.isEmpty()) {
733         qCWarning(lcProppatchJob) << "Proppatch with no properties!";
734     }
735     QNetworkRequest req;
736 
737     QByteArray propStr;
738     QMapIterator<QByteArray, QByteArray> it(_properties);
739     while (it.hasNext()) {
740         it.next();
741         QByteArray keyName = it.key();
742         QByteArray keyNs;
743         if (keyName.contains(':')) {
744             int colIdx = keyName.lastIndexOf(":");
745             keyNs = keyName.left(colIdx);
746             keyName = keyName.mid(colIdx + 1);
747         }
748 
749         propStr += "    <" + keyName;
750         if (!keyNs.isEmpty()) {
751             propStr += " xmlns=\"" + keyNs + "\" ";
752         }
753         propStr += ">";
754         propStr += it.value();
755         propStr += "</" + keyName + ">\n";
756     }
757     QByteArray xml = "<?xml version=\"1.0\" ?>\n"
758                      "<d:propertyupdate xmlns:d=\"DAV:\">\n"
759                      "  <d:set><d:prop>\n"
760         + propStr + "  </d:prop></d:set>\n"
761                     "</d:propertyupdate>\n";
762 
763     auto *buf = new QBuffer(this);
764     buf->setData(xml);
765     buf->open(QIODevice::ReadOnly);
766     sendRequest("PROPPATCH", makeDavUrl(path()), req, buf);
767     AbstractNetworkJob::start();
768 }
769 
setProperties(QMap<QByteArray,QByteArray> properties)770 void ProppatchJob::setProperties(QMap<QByteArray, QByteArray> properties)
771 {
772     _properties = properties;
773 }
774 
properties() const775 QMap<QByteArray, QByteArray> ProppatchJob::properties() const
776 {
777     return _properties;
778 }
779 
finished()780 bool ProppatchJob::finished()
781 {
782     qCInfo(lcProppatchJob) << "PROPPATCH of" << reply()->request().url() << "FINISHED WITH STATUS"
783                            << replyStatusString();
784 
785     int http_result_code = reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
786 
787     if (http_result_code == 207) {
788         emit success();
789     } else {
790         qCWarning(lcProppatchJob) << "*not* successful, http result code is" << http_result_code
791                                   << (http_result_code == 302 ? reply()->header(QNetworkRequest::LocationHeader).toString() : QLatin1String(""));
792         emit finishedWithError();
793     }
794     return true;
795 }
796 
797 /*********************************************************************************************/
798 
EntityExistsJob(AccountPtr account,const QString & path,QObject * parent)799 EntityExistsJob::EntityExistsJob(AccountPtr account, const QString &path, QObject *parent)
800     : AbstractNetworkJob(account, path, parent)
801 {
802 }
803 
start()804 void EntityExistsJob::start()
805 {
806     sendRequest("HEAD", makeAccountUrl(path()));
807     AbstractNetworkJob::start();
808 }
809 
finished()810 bool EntityExistsJob::finished()
811 {
812     emit exists(reply());
813     return true;
814 }
815 
816 /*********************************************************************************************/
817 
JsonApiJob(const AccountPtr & account,const QString & path,QObject * parent)818 JsonApiJob::JsonApiJob(const AccountPtr &account, const QString &path, QObject *parent)
819     : AbstractNetworkJob(account, path, parent)
820 {
821 }
822 
addQueryParams(const QUrlQuery & params)823 void JsonApiJob::addQueryParams(const QUrlQuery &params)
824 {
825     _additionalParams = params;
826 }
827 
addRawHeader(const QByteArray & headerName,const QByteArray & value)828 void JsonApiJob::addRawHeader(const QByteArray &headerName, const QByteArray &value)
829 {
830    _request.setRawHeader(headerName, value);
831 }
832 
setBody(const QJsonDocument & body)833 void JsonApiJob::setBody(const QJsonDocument &body)
834 {
835     _body = body.toJson();
836     qCDebug(lcJsonApiJob) << "Set body for request:" << _body;
837     if (!_body.isEmpty()) {
838         _request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
839     }
840 }
841 
842 
setVerb(Verb value)843 void JsonApiJob::setVerb(Verb value)
844 {
845     _verb = value;
846 }
847 
848 
verbToString() const849 QByteArray JsonApiJob::verbToString() const
850 {
851     switch (_verb) {
852     case Verb::Get:
853         return "GET";
854     case Verb::Post:
855         return "POST";
856     case Verb::Put:
857         return "PUT";
858     case Verb::Delete:
859         return "DELETE";
860     }
861     return "GET";
862 }
863 
start()864 void JsonApiJob::start()
865 {
866     addRawHeader("OCS-APIREQUEST", "true");
867     auto query = _additionalParams;
868     query.addQueryItem(QLatin1String("format"), QLatin1String("json"));
869     QUrl url = Utility::concatUrlPath(account()->url(), path(), query);
870     const auto httpVerb = verbToString();
871     if (!_body.isEmpty()) {
872         sendRequest(httpVerb, url, _request, _body);
873     } else {
874         sendRequest(httpVerb, url, _request);
875     }
876     AbstractNetworkJob::start();
877 }
878 
finished()879 bool JsonApiJob::finished()
880 {
881     qCInfo(lcJsonApiJob) << "JsonApiJob of" << reply()->request().url() << "FINISHED WITH STATUS"
882                          << replyStatusString();
883 
884     int statusCode = 0;
885     int httpStatusCode = reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
886     if (reply()->error() != QNetworkReply::NoError) {
887         qCWarning(lcJsonApiJob) << "Network error: " << path() << errorString() << reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute);
888         statusCode = reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
889         emit jsonReceived(QJsonDocument(), statusCode);
890         return true;
891     }
892 
893     QString jsonStr = QString::fromUtf8(reply()->readAll());
894     if (jsonStr.contains("<?xml version=\"1.0\"?>")) {
895         const QRegularExpression rex("<statuscode>(\\d+)</statuscode>");
896         const auto rexMatch = rex.match(jsonStr);
897         if (rexMatch.hasMatch()) {
898             // this is a error message coming back from ocs.
899             statusCode = rexMatch.captured(1).toInt();
900         }
901     } else if(jsonStr.isEmpty() && httpStatusCode == notModifiedStatusCode){
902         qCWarning(lcJsonApiJob) << "Nothing changed so nothing to retrieve - status code: " << httpStatusCode;
903         statusCode = httpStatusCode;
904     } else {
905         const QRegularExpression rex(R"("statuscode":(\d+))");
906         // example: "{"ocs":{"meta":{"status":"ok","statuscode":100,"message":null},"data":{"version":{"major":8,"minor":"... (504)
907         const auto rxMatch = rex.match(jsonStr);
908         if (rxMatch.hasMatch()) {
909             statusCode = rxMatch.captured(1).toInt();
910         }
911     }
912 
913     // save new ETag value
914     if(reply()->rawHeaderList().contains("ETag"))
915         emit etagResponseHeaderReceived(reply()->rawHeader("ETag"), statusCode);
916 
917     const auto desktopNotificationsAllowed = reply()->rawHeader(QByteArray("X-Nextcloud-User-Status"));
918     if(!desktopNotificationsAllowed.isEmpty()) {
919         emit allowDesktopNotificationsChanged(desktopNotificationsAllowed == "online");
920     }
921 
922     QJsonParseError error;
923     auto json = QJsonDocument::fromJson(jsonStr.toUtf8(), &error);
924     // empty or invalid response and status code is != 304 because jsonStr is expected to be empty
925     if ((error.error != QJsonParseError::NoError || json.isNull()) && httpStatusCode != notModifiedStatusCode) {
926         qCWarning(lcJsonApiJob) << "invalid JSON!" << jsonStr << error.errorString();
927         emit jsonReceived(json, statusCode);
928         return true;
929     }
930 
931     emit jsonReceived(json, statusCode);
932     return true;
933 }
934 
935 
DetermineAuthTypeJob(AccountPtr account,QObject * parent)936 DetermineAuthTypeJob::DetermineAuthTypeJob(AccountPtr account, QObject *parent)
937     : QObject(parent)
938     , _account(account)
939 {
940 }
941 
start()942 void DetermineAuthTypeJob::start()
943 {
944     qCInfo(lcDetermineAuthTypeJob) << "Determining auth type for" << _account->davUrl();
945 
946     QNetworkRequest req;
947     // Prevent HttpCredentialsAccessManager from setting an Authorization header.
948     req.setAttribute(HttpCredentials::DontAddCredentialsAttribute, true);
949     // Don't reuse previous auth credentials
950     req.setAttribute(QNetworkRequest::AuthenticationReuseAttribute, QNetworkRequest::Manual);
951 
952     // Start three parallel requests
953 
954     // 1. determines whether it's a basic auth server
955     auto get = _account->sendRequest("GET", _account->url(), req);
956 
957     // 2. checks the HTTP auth method.
958     auto propfind = _account->sendRequest("PROPFIND", _account->davUrl(), req);
959 
960     // 3. Determines if the old flow has to be used (GS for now)
961     auto oldFlowRequired = new JsonApiJob(_account, "/ocs/v2.php/cloud/capabilities", this);
962 
963     get->setTimeout(30 * 1000);
964     propfind->setTimeout(30 * 1000);
965     oldFlowRequired->setTimeout(30 * 1000);
966     get->setIgnoreCredentialFailure(true);
967     propfind->setIgnoreCredentialFailure(true);
968     oldFlowRequired->setIgnoreCredentialFailure(true);
969 
970     connect(get, &SimpleNetworkJob::finishedSignal, this, [this, get]() {
971         const auto reply = get->reply();
972         const auto wwwAuthenticateHeader = reply->rawHeader("WWW-Authenticate");
973         if (reply->error() == QNetworkReply::AuthenticationRequiredError
974             && (wwwAuthenticateHeader.startsWith("Basic") || wwwAuthenticateHeader.startsWith("Bearer"))) {
975             _resultGet = Basic;
976         } else {
977             _resultGet = LoginFlowV2;
978         }
979         _getDone = true;
980         checkAllDone();
981     });
982     connect(propfind, &SimpleNetworkJob::finishedSignal, this, [this](QNetworkReply *reply) {
983         auto authChallenge = reply->rawHeader("WWW-Authenticate").toLower();
984         if (authChallenge.contains("bearer ")) {
985             _resultPropfind = OAuth;
986         } else {
987             if (authChallenge.isEmpty()) {
988                 qCWarning(lcDetermineAuthTypeJob) << "Did not receive WWW-Authenticate reply to auth-test PROPFIND";
989             } else {
990                 qCWarning(lcDetermineAuthTypeJob) << "Unknown WWW-Authenticate reply to auth-test PROPFIND:" << authChallenge;
991             }
992             _resultPropfind = Basic;
993         }
994         _propfindDone = true;
995         checkAllDone();
996     });
997     connect(oldFlowRequired, &JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) {
998         if (statusCode == 200) {
999             _resultOldFlow = LoginFlowV2;
1000 
1001             auto data = json.object().value("ocs").toObject().value("data").toObject().value("capabilities").toObject();
1002             auto gs = data.value("globalscale");
1003             if (gs != QJsonValue::Undefined) {
1004                 auto flow = gs.toObject().value("desktoplogin");
1005                 if (flow != QJsonValue::Undefined) {
1006                     if (flow.toInt() == 1) {
1007 #ifdef WITH_WEBENGINE
1008                         _resultOldFlow = WebViewFlow;
1009 #else // WITH_WEBENGINE
1010                         qCWarning(lcDetermineAuthTypeJob) << "Server does only support flow1, but this client was compiled without support for flow1";
1011 #endif // WITH_WEBENGINE
1012                     }
1013                 }
1014             }
1015         } else {
1016             _resultOldFlow = Basic;
1017         }
1018         _oldFlowDone = true;
1019         checkAllDone();
1020     });
1021 
1022     oldFlowRequired->start();
1023 }
1024 
checkAllDone()1025 void DetermineAuthTypeJob::checkAllDone()
1026 {
1027     // Do not conitunue until eve
1028     if (!_getDone || !_propfindDone || !_oldFlowDone) {
1029         return;
1030     }
1031 
1032     Q_ASSERT(_resultGet != NoAuthType);
1033     Q_ASSERT(_resultPropfind != NoAuthType);
1034     Q_ASSERT(_resultOldFlow != NoAuthType);
1035 
1036     auto result = _resultPropfind;
1037 
1038 #ifdef WITH_WEBENGINE
1039     // WebViewFlow > OAuth > Basic
1040     if (_account->serverVersionInt() >= Account::makeServerVersion(12, 0, 0)) {
1041         result = WebViewFlow;
1042     }
1043 #endif // WITH_WEBENGINE
1044 
1045     // LoginFlowV2 > WebViewFlow > OAuth > Basic
1046     if (_account->serverVersionInt() >= Account::makeServerVersion(16, 0, 0)) {
1047         result = LoginFlowV2;
1048     }
1049 
1050 #ifdef WITH_WEBENGINE
1051     // If we determined that we need the webview flow (GS for example) then we switch to that
1052     if (_resultOldFlow == WebViewFlow) {
1053         result = WebViewFlow;
1054     }
1055 #endif // WITH_WEBENGINE
1056 
1057     // If we determined that a simple get gave us an authentication required error
1058     // then the server enforces basic auth and we got no choice but to use this
1059     if (_resultGet == Basic) {
1060         result = Basic;
1061     }
1062 
1063     qCInfo(lcDetermineAuthTypeJob) << "Auth type for" << _account->davUrl() << "is" << result;
1064     emit authType(result);
1065     deleteLater();
1066 }
1067 
SimpleNetworkJob(AccountPtr account,QObject * parent)1068 SimpleNetworkJob::SimpleNetworkJob(AccountPtr account, QObject *parent)
1069     : AbstractNetworkJob(account, QString(), parent)
1070 {
1071 }
1072 
startRequest(const QByteArray & verb,const QUrl & url,QNetworkRequest req,QIODevice * requestBody)1073 QNetworkReply *SimpleNetworkJob::startRequest(const QByteArray &verb, const QUrl &url,
1074     QNetworkRequest req, QIODevice *requestBody)
1075 {
1076     auto reply = sendRequest(verb, url, req, requestBody);
1077     start();
1078     return reply;
1079 }
1080 
finished()1081 bool SimpleNetworkJob::finished()
1082 {
1083     emit finishedSignal(reply());
1084     return true;
1085 }
1086 
1087 
DeleteApiJob(AccountPtr account,const QString & path,QObject * parent)1088 DeleteApiJob::DeleteApiJob(AccountPtr account, const QString &path, QObject *parent)
1089     : AbstractNetworkJob(account, path, parent)
1090 {
1091 
1092 }
1093 
start()1094 void DeleteApiJob::start()
1095 {
1096     QNetworkRequest req;
1097     req.setRawHeader("OCS-APIREQUEST", "true");
1098     QUrl url = Utility::concatUrlPath(account()->url(), path());
1099     sendRequest("DELETE", url, req);
1100     AbstractNetworkJob::start();
1101 }
1102 
finished()1103 bool DeleteApiJob::finished()
1104 {
1105     qCInfo(lcJsonApiJob) << "JsonApiJob of" << reply()->request().url() << "FINISHED WITH STATUS"
1106                          << reply()->error()
1107                          << (reply()->error() == QNetworkReply::NoError ? QLatin1String("") : errorString());
1108 
1109     int httpStatus = reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
1110 
1111 
1112     if (reply()->error() != QNetworkReply::NoError) {
1113         qCWarning(lcJsonApiJob) << "Network error: " << path() << errorString() << httpStatus;
1114         emit result(httpStatus);
1115         return true;
1116     }
1117 
1118     const auto replyData = QString::fromUtf8(reply()->readAll());
1119     qCInfo(lcJsonApiJob()) << "TMX Delete Job" << replyData;
1120     emit result(httpStatus);
1121     return true;
1122 }
1123 
fetchPrivateLinkUrl(AccountPtr account,const QString & remotePath,const QByteArray & numericFileId,QObject * target,std::function<void (const QString & url)> targetFun)1124 void fetchPrivateLinkUrl(AccountPtr account, const QString &remotePath,
1125     const QByteArray &numericFileId, QObject *target,
1126     std::function<void(const QString &url)> targetFun)
1127 {
1128     QString oldUrl;
1129     if (!numericFileId.isEmpty())
1130         oldUrl = account->deprecatedPrivateLinkUrl(numericFileId).toString(QUrl::FullyEncoded);
1131 
1132     // Retrieve the new link by PROPFIND
1133     auto *job = new PropfindJob(account, remotePath, target);
1134     job->setProperties(
1135         QList<QByteArray>()
1136         << "http://owncloud.org/ns:fileid" // numeric file id for fallback private link generation
1137         << "http://owncloud.org/ns:privatelink");
1138     job->setTimeout(10 * 1000);
1139     QObject::connect(job, &PropfindJob::result, target, [=](const QVariantMap &result) {
1140         auto privateLinkUrl = result["privatelink"].toString();
1141         auto numericFileId = result["fileid"].toByteArray();
1142         if (!privateLinkUrl.isEmpty()) {
1143             targetFun(privateLinkUrl);
1144         } else if (!numericFileId.isEmpty()) {
1145             targetFun(account->deprecatedPrivateLinkUrl(numericFileId).toString(QUrl::FullyEncoded));
1146         } else {
1147             targetFun(oldUrl);
1148         }
1149     });
1150     QObject::connect(job, &PropfindJob::finishedWithError, target, [=](QNetworkReply *) {
1151         targetFun(oldUrl);
1152     });
1153     job->start();
1154 }
1155 
1156 } // namespace OCC
1157