1// SPDX-FileCopyrightText: 2015 Felix Rohrbach <kde@fxrh.de>
2// SPDX-FileCopyrightText: 2016 Kitsune Ral <Kitsune-Ral@users.sf.net>
3// SPDX-License-Identifier: LGPL-2.1-or-later
4
5#include "basejob.h"
6
7#include "../logging_categories_p.h"
8
9#include "../connectiondata.h"
10#include "../networkaccessmanager.h"
11
12#include <QtCore/QMetaEnum>
13#include <QtCore/QPointer>
14#include <QtCore/QRegularExpression>
15#include <QtCore/QTimer>
16#include <QtNetwork/QNetworkReply>
17#include <QtNetwork/QNetworkRequest>
18
19using namespace Quotient;
20using std::chrono::seconds, std::chrono::milliseconds;
21using namespace std::chrono_literals;
22
23BaseJob::StatusCode BaseJob::Status::fromHttpCode(int httpCode)
24{
25 // Based on https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
26 if (httpCode / 10 == 41) // 41x errors
27 return httpCode == 410 ? IncorrectRequest : NotFound;
28 switch (httpCode) {
29 case 401:
30 return Unauthorised;
31 // clang-format off
32 case 403: case 407: // clang-format on
33 return ContentAccessError;
34 case 404:
35 return NotFound;
36 // clang-format off
37 case 400: case 405: case 406: case 426: case 428: case 505: // clang-format on
38 case 494: // Unofficial nginx "Request header too large"
39 case 497: // Unofficial nginx "HTTP request sent to HTTPS port"
40 return IncorrectRequest;
41 case 429:
42 return TooManyRequests;
43 case 501:
44 case 510:
45 return RequestNotImplemented;
46 case 511:
47 return NetworkAuthRequired;
48 default:
49 return NetworkError;
50 }
51}
52
53QDebug BaseJob::Status::dumpToLog(QDebug dbg) const
54{
55 QDebugStateSaver _s(dbg);
56 dbg.noquote().nospace();
57 if (auto* const k = QMetaEnum::fromType<StatusCode>().valueToKey(value: code)) {
58 const QByteArray b = k;
59 dbg << b.mid(index: b.lastIndexOf(c: ':'));
60 } else
61 dbg << code;
62 return dbg << ": " << message;
63}
64
65class Q_DECL_HIDDEN BaseJob::Private {
66public:
67 struct JobTimeoutConfig {
68 seconds jobTimeout;
69 seconds nextRetryInterval;
70 };
71
72 // Using an idiom from clang-tidy:
73 // http://clang.llvm.org/extra/clang-tidy/checks/modernize-pass-by-value.html
74 Private(HttpVerb v, QByteArray endpoint, const QUrlQuery& q,
75 RequestData&& data, bool nt)
76 : verb(v)
77 , apiEndpoint(std::move(endpoint))
78 , requestQuery(q)
79 , requestData(std::move(data))
80 , needsToken(nt)
81 {
82 timer.setSingleShot(true);
83 retryTimer.setSingleShot(true);
84 }
85
86 ~Private()
87 {
88 if (reply) {
89 if (reply->isRunning()) {
90 reply->abort();
91 }
92 delete reply;
93 }
94 }
95
96 QNetworkRequest prepareRequest() const;
97 void sendRequest(const QNetworkRequest& req);
98
99 /*! \brief Parse the response byte array into JSON
100 *
101 * This calls QJsonDocument::fromJson() on rawResponse, converts
102 * the QJsonParseError result to BaseJob::Status and stores the resulting
103 * JSON in jsonResponse.
104 */
105 Status parseJson();
106
107 ConnectionData* connection = nullptr;
108
109 // Contents for the network request
110 HttpVerb verb;
111 QByteArray apiEndpoint;
112 QHash<QByteArray, QByteArray> requestHeaders;
113 QUrlQuery requestQuery;
114 RequestData requestData;
115 bool needsToken;
116
117 bool inBackground = false;
118
119 // There's no use of QMimeType here because we don't want to match
120 // content types against the known MIME type hierarchy; and at the same
121 // type QMimeType is of little help with MIME type globs (`text/*` etc.)
122 QByteArrayList expectedContentTypes { "application/json" };
123
124 QByteArrayList expectedKeys;
125
126 // When the QNetworkAccessManager is destroyed it destroys all pending replies.
127 // Using QPointer allows us to know when that happend.
128 QPointer<QNetworkReply> reply;
129
130 Status status = Unprepared;
131 QByteArray rawResponse;
132 /// Contains a null document in case of non-JSON body (for a successful
133 /// or unsuccessful response); a document with QJsonObject or QJsonArray
134 /// in case of a successful response with JSON payload, as per the API
135 /// definition (including an empty JSON object - QJsonObject{});
136 /// and QJsonObject in case of an API error.
137 QJsonDocument jsonResponse;
138 QUrl errorUrl; //!< May contain a URL to help with some errors
139
140 QMessageLogger::CategoryFunction logCat = &JOBS;
141
142 QTimer timer;
143 QTimer retryTimer;
144
145 static constexpr auto errorStrategy = std::to_array<const JobTimeoutConfig>(
146 a: { { .jobTimeout: 90s, .nextRetryInterval: 5s }, { .jobTimeout: 90s, .nextRetryInterval: 10s }, { .jobTimeout: 120s, .nextRetryInterval: 30s } });
147 int maxRetries = int(errorStrategy.size());
148 int retriesTaken = 0;
149
150 [[nodiscard]] const JobTimeoutConfig& getCurrentTimeoutConfig() const
151 {
152 return errorStrategy[std::min(a: size_t(retriesTaken),
153 b: errorStrategy.size() - 1)];
154 }
155
156 [[nodiscard]] QString dumpRequest() const
157 {
158 static const std::array verbs { "GET"_ls, "PUT"_ls, "POST"_ls,
159 "DELETE"_ls };
160 const auto verbWord = verbs.at(n: size_t(verb));
161 return verbWord % u' '
162 % (reply ? reply->url().toString(options: QUrl::RemoveQuery)
163 : makeRequestUrl(baseUrl: connection->baseUrl(), encodedPath: apiEndpoint)
164 .toString());
165 }
166};
167
168inline bool isHex(QChar c)
169{
170 return c.isDigit() || (c >= u'A' && c <= u'F') || (c >= u'a' && c <= u'f');
171}
172
173QByteArray BaseJob::encodeIfParam(const QString& paramPart)
174{
175 const auto percentIndex = paramPart.indexOf(c: u'%');
176 if (percentIndex != -1 && paramPart.size() > percentIndex + 2
177 && isHex(c: paramPart[percentIndex + 1])
178 && isHex(c: paramPart[percentIndex + 2])) {
179 qCWarning(JOBS)
180 << "Developers, upfront percent-encoding of job parameters is "
181 "deprecated since libQuotient 0.7; the string involved is"
182 << paramPart;
183 return QUrl(paramPart, QUrl::TolerantMode).toEncoded();
184 }
185 return QUrl::toPercentEncoding(paramPart);
186}
187
188BaseJob::BaseJob(HttpVerb verb, const QString& name, QByteArray endpoint,
189 bool needsToken)
190 : BaseJob(verb, name, std::move(endpoint), QUrlQuery {}, RequestData {},
191 needsToken)
192{}
193
194BaseJob::BaseJob(HttpVerb verb, const QString& name, QByteArray endpoint,
195 const QUrlQuery& query, RequestData&& data, bool needsToken)
196 : d(makeImpl<Private>(args&: verb, args: std::move(endpoint), args: query, args: std::move(data),
197 args&: needsToken))
198{
199 setObjectName(name);
200 connect(sender: &d->timer, signal: &QTimer::timeout, context: this, slot: &BaseJob::timeout);
201 connect(sender: &d->retryTimer, signal: &QTimer::timeout, context: this, slot: [this] {
202 qCDebug(d->logCat) << "Retrying" << this;
203 d->connection->submit(job: this);
204 });
205}
206
207BaseJob::~BaseJob()
208{
209 stop();
210 d->retryTimer.stop(); // See #398
211 qCDebug(d->logCat) << this << "destroyed";
212}
213
214QUrl BaseJob::requestUrl() const { return d->reply ? d->reply->url() : QUrl(); }
215
216bool BaseJob::isBackground() const { return d->inBackground; }
217
218const BaseJob::headers_t& BaseJob::requestHeaders() const
219{
220 return d->requestHeaders;
221}
222
223void BaseJob::setRequestHeader(const headers_t::key_type& headerName,
224 const headers_t::mapped_type& headerValue)
225{
226 d->requestHeaders[headerName] = headerValue;
227}
228
229void BaseJob::setRequestHeaders(const BaseJob::headers_t& headers)
230{
231 d->requestHeaders = headers;
232}
233
234QUrlQuery BaseJob::query() const { return d->requestQuery; }
235
236void BaseJob::setRequestQuery(const QUrlQuery& query)
237{
238 d->requestQuery = query;
239}
240
241const RequestData& BaseJob::requestData() const { return d->requestData; }
242
243void BaseJob::setRequestData(RequestData&& data)
244{
245 std::swap(a&: d->requestData, b&: data);
246}
247
248const QByteArrayList& BaseJob::expectedContentTypes() const
249{
250 return d->expectedContentTypes;
251}
252
253void BaseJob::addExpectedContentType(const QByteArray& contentType)
254{
255 d->expectedContentTypes << contentType;
256}
257
258void BaseJob::setExpectedContentTypes(const QByteArrayList& contentTypes)
259{
260 d->expectedContentTypes = contentTypes;
261}
262
263QByteArrayList BaseJob::expectedKeys() const { return d->expectedKeys; }
264
265void BaseJob::addExpectedKey(const QByteArray& key) { d->expectedKeys << key; }
266
267void BaseJob::setExpectedKeys(const QByteArrayList& keys)
268{
269 d->expectedKeys = keys;
270}
271
272const QNetworkReply* BaseJob::reply() const { return d->reply.data(); }
273
274QNetworkReply* BaseJob::reply() { return d->reply.data(); }
275
276QUrl BaseJob::makeRequestUrl(QUrl baseUrl, const QByteArray& encodedPath,
277 const QUrlQuery& query)
278{
279 // Make sure the added path is relative even if it's not (the official
280 // API definitions have the leading slash though it's not really correct).
281 const auto pathUrl =
282 QUrl::fromEncoded(input: encodedPath.mid(index: encodedPath.startsWith(c: '/')),
283 mode: QUrl::StrictMode);
284 Q_ASSERT_X(pathUrl.isValid(), __FUNCTION__,
285 qPrintable(pathUrl.errorString()));
286 baseUrl = baseUrl.resolved(relative: pathUrl);
287 baseUrl.setQuery(query);
288 return baseUrl;
289}
290
291QNetworkRequest BaseJob::Private::prepareRequest() const
292{
293 QNetworkRequest req{ makeRequestUrl(baseUrl: connection->baseUrl(), encodedPath: apiEndpoint,
294 query: requestQuery) };
295 if (!requestHeaders.contains(key: "Content-Type"))
296 req.setHeader(header: QNetworkRequest::ContentTypeHeader, value: "application/json"_ls);
297 if (needsToken)
298 req.setRawHeader(headerName: "Authorization",
299 value: QByteArray("Bearer ") + connection->accessToken());
300 req.setAttribute(code: QNetworkRequest::BackgroundRequestAttribute, value: inBackground);
301 req.setAttribute(code: QNetworkRequest::RedirectPolicyAttribute,
302 value: QNetworkRequest::NoLessSafeRedirectPolicy);
303 req.setMaximumRedirectsAllowed(10);
304 req.setAttribute(code: QNetworkRequest::HttpPipeliningAllowedAttribute, value: true);
305 // Qt doesn't combine HTTP2 with SSL quite right, occasionally crashing at
306 // what seems like an attempt to write to a closed channel. If/when that
307 // changes, false should be turned to true below.
308 req.setAttribute(code: QNetworkRequest::Http2AllowedAttribute, value: false);
309 Q_ASSERT(req.url().isValid());
310 for (auto it = requestHeaders.cbegin(); it != requestHeaders.cend(); ++it)
311 req.setRawHeader(headerName: it.key(), value: it.value());
312 return req;
313}
314
315void BaseJob::Private::sendRequest(const QNetworkRequest& req)
316{
317 switch (verb) {
318 case HttpVerb::Get:
319 reply = connection->nam()->get(request: req);
320 break;
321 case HttpVerb::Post:
322 reply = connection->nam()->post(request: req, data: requestData.source());
323 break;
324 case HttpVerb::Put:
325 reply = connection->nam()->put(request: req, data: requestData.source());
326 break;
327 case HttpVerb::Delete:
328 reply = connection->nam()->sendCustomRequest(request: req, verb: "DELETE", data: requestData.source());
329 break;
330 }
331}
332
333void BaseJob::doPrepare() { }
334
335void BaseJob::onSentRequest(QNetworkReply*) { }
336
337void BaseJob::beforeAbandon() { }
338
339void BaseJob::initiate(ConnectionData* connData, bool inBackground)
340{
341 if (Q_LIKELY(connData && connData->baseUrl().isValid())) {
342 d->inBackground = inBackground;
343 d->connection = connData;
344 doPrepare();
345
346 if (d->needsToken && d->connection->accessToken().isEmpty())
347 setStatus(Unauthorised);
348 else if ((d->verb == HttpVerb::Post || d->verb == HttpVerb::Put)
349 && d->requestData.source()
350 && !d->requestData.source()->isReadable()) {
351 setStatus(code: FileError, message: "Request data not ready"_ls);
352 }
353 Q_ASSERT(status().code != Pending); // doPrepare() must NOT set this
354 if (Q_LIKELY(status().code == Unprepared)) {
355 d->connection->submit(job: this);
356 return;
357 }
358 qCWarning(d->logCat).noquote()
359 << "Request failed preparation and won't be sent:"
360 << d->dumpRequest();
361 } else {
362 qCCritical(d->logCat)
363 << "Developers, ensure the Connection is valid before using it";
364 setStatus(code: IncorrectRequest, message: tr(s: "Invalid server connection"));
365 }
366 // The status is no good, finalise
367 QTimer::singleShot(interval: 0, receiver: this, slot: &BaseJob::finishJob);
368}
369
370void BaseJob::sendRequest()
371{
372 if (status().code == Abandoned) {
373 // Normally sendRequest() shouldn't even be called on an abandoned job
374 qWarning(catFunc: d->logCat)
375 << "Won't proceed with the abandoned request:" << d->dumpRequest();
376 return;
377 }
378 Q_ASSERT(d->connection && status().code == Pending);
379 d->needsToken |= d->connection->needsToken(requestName: objectName());
380 auto req = d->prepareRequest();
381 emit aboutToSendRequest(req: &req);
382 d->sendRequest(req);
383 Q_ASSERT(d->reply);
384 connect(sender: reply(), signal: &QNetworkReply::finished, context: this, slot: [this] {
385 gotReply();
386 finishJob();
387 });
388 if (d->reply->isRunning()) {
389 connect(sender: reply(), signal: &QNetworkReply::metaDataChanged, context: this,
390 slot: [this] { checkReply(reply: reply()); });
391 connect(sender: reply(), signal: &QNetworkReply::uploadProgress, context: this,
392 slot: &BaseJob::uploadProgress);
393 connect(sender: reply(), signal: &QNetworkReply::downloadProgress, context: this,
394 slot: &BaseJob::downloadProgress);
395 d->timer.start(value: getCurrentTimeout());
396 qDebug(catFunc: d->logCat).noquote() << "Sent" << d->dumpRequest();
397 onSentRequest(reply());
398 emit sentRequest();
399 } else
400 qCritical(catFunc: d->logCat).noquote()
401 << "Request could not start:" << d->dumpRequest();
402}
403
404BaseJob::Status BaseJob::Private::parseJson()
405{
406 QJsonParseError error { .offset: 0, .error: QJsonParseError::MissingObject };
407 jsonResponse = QJsonDocument::fromJson(json: rawResponse, error: &error);
408 return { error.error == QJsonParseError::NoError ? NoError
409 : IncorrectResponse,
410 error.errorString() };
411}
412
413void BaseJob::gotReply()
414{
415 // Defer actually updating the status until it's finalised
416 auto statusSoFar = checkReply(reply: reply());
417 if (statusSoFar.good()
418 && d->expectedContentTypes == QByteArrayList { "application/json" }) //
419 {
420 d->rawResponse = reply()->readAll();
421 statusSoFar = d->parseJson();
422 if (statusSoFar.good() && !expectedKeys().empty()) {
423 const auto& responseObject = jsonData();
424 QByteArrayList missingKeys;
425 for (const auto& k: expectedKeys())
426 if (!responseObject.contains(key: QString::fromLatin1(ba: k)))
427 missingKeys.push_back(t: k);
428 if (!missingKeys.empty())
429 statusSoFar = { IncorrectResponse,
430 tr(s: "Required JSON keys missing: ")
431 + QString::fromLatin1(ba: missingKeys.join()) };
432 }
433 setStatus(statusSoFar);
434 if (!status().good()) // Bad JSON in a "good" reply: bail out
435 return;
436 }
437 // If the endpoint expects anything else than just (API-related) JSON
438 // reply()->readAll() is not performed and the whole reply processing
439 // is left to derived job classes: they may read it piecemeal or customise
440 // per content type in prepareResult(), or even have read it already
441 // (see, e.g., DownloadFileJob).
442 if (statusSoFar.good()) {
443 setStatus(prepareResult());
444 return;
445 }
446
447 d->rawResponse = reply()->readAll();
448 qCDebug(d->logCat).noquote()
449 << "Error body (truncated if long):" << rawDataSample(bytesAtMost: 500);
450 setStatus(prepareError(currentStatus: statusSoFar));
451}
452
453bool checkContentType(const QByteArray& type, const QByteArrayList& patterns)
454{
455 if (patterns.isEmpty())
456 return true;
457
458 // ignore possible appendixes of the content type
459 const auto ctype = type.split(sep: ';').front();
460
461 for (const auto& pattern: patterns) {
462 if (pattern.startsWith(c: '*') || ctype == pattern) // Fast lane
463 return true;
464
465 auto patternParts = pattern.split(sep: '/');
466 Q_ASSERT_X(patternParts.size() <= 2, __FUNCTION__,
467 qPrintable(
468 "BaseJob: Expected content type should have up to two /-separated parts; violating pattern: "_ls
469 + QString::fromLatin1(pattern)));
470
471 if (ctype.split(sep: '/').front() == patternParts.front()
472 && patternParts.back() == "*")
473 return true; // Exact match already went on fast lane
474 }
475
476 return false;
477}
478
479BaseJob::Status BaseJob::checkReply(const QNetworkReply* reply) const
480{
481 // QNetworkReply error codes are insufficient for our purposes (e.g. they
482 // don't allow to discern HTTP code 429) so check the original code instead
483 const auto httpCodeHeader =
484 reply->attribute(code: QNetworkRequest::HttpStatusCodeAttribute);
485 if (!httpCodeHeader.isValid()) {
486 qCWarning(d->logCat).noquote()
487 << "No valid HTTP headers from" << d->dumpRequest();
488 return { NetworkError, reply->errorString() };
489 }
490
491 const auto httpCode = httpCodeHeader.toInt();
492 if (httpCode / 100 == 2) // 2xx
493 {
494 if (reply->isFinished())
495 qDebug(catFunc: d->logCat).noquote() << httpCode << "<-" << d->dumpRequest();
496 if (!checkContentType(type: reply->rawHeader(headerName: "Content-Type"),
497 patterns: d->expectedContentTypes))
498 return { UnexpectedResponseTypeWarning,
499 "Unexpected content type of the response"_ls };
500 return NoError;
501 }
502 if (reply->isFinished())
503 qCWarning(d->logCat).noquote() << httpCode << "<-" << d->dumpRequest();
504
505 auto message = reply->errorString();
506 if (message.isEmpty())
507 message = reply->attribute(code: QNetworkRequest::HttpReasonPhraseAttribute)
508 .toString();
509
510 return Status::fromHttpCode(httpCode, msg: message);
511}
512
513BaseJob::Status BaseJob::prepareResult() { return Success; }
514
515BaseJob::Status BaseJob::prepareError(Status currentStatus)
516{
517 // Try to make sense of the error payload but be prepared for all kinds
518 // of unexpected stuff (raw HTML, plain text, foreign JSON among those)
519 if (!d->rawResponse.isEmpty()
520 && reply()->rawHeader(headerName: "Content-Type") == "application/json")
521 d->parseJson();
522
523 // By now, if d->parseJson() above succeeded then jsonData() will return
524 // a valid JSON object - or an empty object otherwise (in which case most
525 // of if's below will fall through retaining the current status)
526 const auto& errorJson = jsonData();
527 const auto errCode = errorJson.value(key: "errcode"_ls).toString();
528 if (error() == TooManyRequests || errCode == "M_LIMIT_EXCEEDED"_ls) {
529 QString msg = tr(s: "Too many requests");
530 int64_t retryAfterMs = errorJson.value(key: "retry_after_ms"_ls).toInt(defaultValue: -1);
531 if (retryAfterMs >= 0)
532 msg += tr(s: ", next retry advised after %1 ms").arg(a: retryAfterMs);
533 else // We still have to figure some reasonable interval
534 retryAfterMs = getNextRetryMs();
535
536 d->connection->limitRate(nextCallAfter: milliseconds(retryAfterMs));
537
538 return { TooManyRequests, msg };
539 }
540
541 if (errCode == "M_CONSENT_NOT_GIVEN"_ls) {
542 d->errorUrl = QUrl(errorJson.value(key: "consent_uri"_ls).toString());
543 return { UserConsentRequired };
544 }
545 if (errCode == "M_UNSUPPORTED_ROOM_VERSION"_ls
546 || errCode == "M_INCOMPATIBLE_ROOM_VERSION"_ls)
547 return { UnsupportedRoomVersion,
548 errorJson.contains(key: "room_version"_ls)
549 ? tr(s: "Requested room version: %1")
550 .arg(a: errorJson.value(key: "room_version"_ls).toString())
551 : errorJson.value(key: "error"_ls).toString() };
552 if (errCode == "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM"_ls)
553 return { CannotLeaveRoom,
554 tr(s: "It's not allowed to leave a server notices room") };
555 if (errCode == "M_USER_DEACTIVATED"_ls)
556 return { UserDeactivated };
557
558 // Not localisable on the client side
559 if (errorJson.contains(key: "error"_ls)) // Keep the code, update the message
560 return { currentStatus.code, errorJson.value(key: "error"_ls).toString() };
561
562 return currentStatus; // The error payload is not recognised
563}
564
565QJsonValue BaseJob::takeValueFromJson(const QString& key)
566{
567 if (!d->jsonResponse.isObject())
568 return QJsonValue::Undefined;
569 auto o = d->jsonResponse.object();
570 auto v = o.take(key);
571 d->jsonResponse.setObject(o);
572 return v;
573}
574
575void BaseJob::stop()
576{
577 // This method is (also) used to semi-finalise the job before retrying; so
578 // stop the timeout timer but keep the retry timer running.
579 d->timer.stop();
580 if (d->reply) {
581 d->reply->disconnect(receiver: this); // Ignore whatever comes from the reply
582 if (d->reply->isRunning()) {
583 qCWarning(d->logCat)
584 << this << "stopped without ready network reply";
585 d->reply->abort(); // Keep the reply object in case clients need it
586 }
587 } else
588 qCWarning(d->logCat) << this << "stopped with empty network reply";
589}
590
591void BaseJob::finishJob()
592{
593 stop();
594 switch(error()) {
595 case TooManyRequests:
596 emit rateLimited();
597 d->connection->submit(job: this);
598 return;
599 case Unauthorised:
600 if (!d->needsToken && !d->connection->accessToken().isEmpty()) {
601 // Rerun with access token (extension of the spec while
602 // https://github.com/matrix-org/matrix-doc/issues/701 is pending)
603 d->connection->setNeedsToken(objectName());
604 qCWarning(d->logCat) << this << "re-running with authentication";
605 emit retryScheduled(nextAttempt: d->retriesTaken, inMilliseconds: 0);
606 d->connection->submit(job: this);
607 return;
608 }
609 break;
610 case NetworkError:
611 case IncorrectResponse:
612 case Timeout:
613 if (d->retriesTaken < d->maxRetries) {
614 // TODO: The whole retrying thing should be put to
615 // Connection(Manager) otherwise independently retrying jobs make a
616 // bit of notification storm towards the UI.
617 const seconds retryIn = error() == Timeout ? 0s
618 : getNextRetryInterval();
619 ++d->retriesTaken;
620 qCWarning(d->logCat).nospace()
621 << this << ": retry #" << d->retriesTaken << " in "
622 << retryIn.count() << " s";
623 setStatus(code: Pending, message: "Pending retry"_ls);
624 d->retryTimer.start(value: retryIn);
625 emit retryScheduled(nextAttempt: d->retriesTaken, inMilliseconds: milliseconds(retryIn).count());
626 return;
627 }
628 [[fallthrough]];
629 default:;
630 }
631
632 Q_ASSERT(status().code != Pending);
633
634 // Notify those interested in any completion of the job including abandon()
635 emit finished(job: this);
636
637 emit result(job: this); // abandon() doesn't emit this
638 if (error())
639 emit failure(this);
640 else
641 emit success(this);
642
643 deleteLater();
644}
645
646seconds BaseJob::getCurrentTimeout() const
647{
648 return d->getCurrentTimeoutConfig().jobTimeout;
649}
650
651BaseJob::duration_ms_t BaseJob::getCurrentTimeoutMs() const
652{
653 return milliseconds(getCurrentTimeout()).count();
654}
655
656seconds BaseJob::getNextRetryInterval() const
657{
658 return d->getCurrentTimeoutConfig().nextRetryInterval;
659}
660
661BaseJob::duration_ms_t BaseJob::getNextRetryMs() const
662{
663 return milliseconds(getNextRetryInterval()).count();
664}
665
666milliseconds BaseJob::timeToRetry() const
667{
668 return d->retryTimer.isActive() ? d->retryTimer.remainingTimeAsDuration()
669 : 0s;
670}
671
672BaseJob::duration_ms_t BaseJob::millisToRetry() const
673{
674 return timeToRetry().count();
675}
676
677int BaseJob::maxRetries() const { return d->maxRetries; }
678
679void BaseJob::setMaxRetries(int newMaxRetries)
680{
681 d->maxRetries = newMaxRetries;
682}
683
684BaseJob::Status BaseJob::status() const { return d->status; }
685
686QByteArray BaseJob::rawData(int bytesAtMost) const
687{
688 return bytesAtMost > 0 && d->rawResponse.size() > bytesAtMost
689 ? d->rawResponse.left(n: bytesAtMost)
690 : d->rawResponse;
691}
692
693const QByteArray& BaseJob::rawData() const { return d->rawResponse; }
694
695QString BaseJob::rawDataSample(int bytesAtMost) const
696{
697 auto data = QString::fromUtf8(ba: rawData(bytesAtMost));
698 Q_ASSERT(data.size() <= d->rawResponse.size());
699 return data.size() == d->rawResponse.size()
700 ? data
701 : data + tr(s: "...(truncated, %Ln bytes in total)",
702 c: "Comes after trimmed raw network response",
703 n: static_cast<int>(d->rawResponse.size()));
704}
705
706QJsonObject BaseJob::jsonData() const
707{
708 return d->jsonResponse.object();
709}
710
711QJsonArray BaseJob::jsonItems() const
712{
713 return d->jsonResponse.array();
714}
715
716QString BaseJob::statusCaption() const
717{
718 switch (d->status.code) {
719 case Success:
720 return tr(s: "Success");
721 case Pending:
722 return tr(s: "Request still pending response");
723 case UnexpectedResponseTypeWarning:
724 return tr(s: "Warning: Unexpected response type");
725 case Abandoned:
726 return tr(s: "Request was abandoned");
727 case NetworkError:
728 return tr(s: "Network problems");
729 case Timeout:
730 return tr(s: "Request timed out");
731 case Unauthorised:
732 return tr(s: "Unauthorised request");
733 case ContentAccessError:
734 return tr(s: "Access error");
735 case NotFound:
736 return tr(s: "Not found");
737 case IncorrectRequest:
738 return tr(s: "Invalid request");
739 case IncorrectResponse:
740 return tr(s: "Response could not be parsed");
741 case TooManyRequests:
742 return tr(s: "Too many requests");
743 case RequestNotImplemented:
744 return tr(s: "Function not implemented by the server");
745 case NetworkAuthRequired:
746 return tr(s: "Network authentication required");
747 case UserConsentRequired:
748 return tr(s: "User consent required");
749 case UnsupportedRoomVersion:
750 return tr(s: "The server does not support the needed room version");
751 default:
752 return tr(s: "Request failed");
753 }
754}
755
756int BaseJob::error() const {
757 return d->status.code; }
758
759QString BaseJob::errorString() const {
760 return d->status.message; }
761
762QUrl BaseJob::errorUrl() const {
763 return d->errorUrl; }
764
765void BaseJob::setStatus(Status s)
766{
767 // The crash that led to this code has been reported in
768 // https://github.com/quotient-im/Quaternion/issues/566 - basically,
769 // when cleaning up children of a deleted Connection, there's a chance
770 // of pending jobs being abandoned, calling setStatus(Abandoned).
771 // There's nothing wrong with this; however, the safety check for
772 // cleartext access tokens below uses d->connection - which is a dangling
773 // pointer.
774 // To alleviate that, a stricter condition is applied, that for Abandoned
775 // and to-be-Abandoned jobs the status message will be disregarded entirely.
776 // We could rectify the situation by making d->connection a QPointer<>
777 // (and deriving ConnectionData from QObject, respectively) but it's
778 // a too edge case for the hassle.
779 if (d->status == s)
780 return;
781
782 if (d->status.code == Abandoned || s.code == Abandoned)
783 s.message.clear();
784
785 if (!s.message.isEmpty() && d->connection
786 && !d->connection->accessToken().isEmpty())
787 s.message.replace(before: QString::fromUtf8(ba: d->connection->accessToken()), after: "(REDACTED)"_ls);
788 if (!s.good())
789 qCWarning(d->logCat) << this << "status" << s;
790 d->status = std::move(s);
791 emit statusChanged(newStatus: d->status);
792}
793
794void BaseJob::setStatus(int code, QString message)
795{
796 setStatus({ code, std::move(message) });
797}
798
799void BaseJob::abandon()
800{
801 beforeAbandon();
802 d->timer.stop();
803 d->retryTimer.stop(); // In case abandon() was called between retries
804 setStatus(Abandoned);
805 if (d->reply)
806 d->reply->disconnect(receiver: this);
807 emit finished(job: this);
808
809 deleteLater();
810}
811
812void BaseJob::timeout()
813{
814 setStatus(code: Timeout, message: "The job has timed out"_ls);
815 finishJob();
816}
817
818void BaseJob::setLoggingCategory(QMessageLogger::CategoryFunction lcf)
819{
820 d->logCat = lcf;
821}
822