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 | |
19 | using namespace Quotient; |
20 | using std::chrono::seconds, std::chrono::milliseconds; |
21 | using namespace std::chrono_literals; |
22 | |
23 | BaseJob::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 | |
53 | QDebug 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 | |
65 | class Q_DECL_HIDDEN BaseJob::Private { |
66 | public: |
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> ; |
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 | |
168 | inline bool isHex(QChar c) |
169 | { |
170 | return c.isDigit() || (c >= u'A' && c <= u'F') || (c >= u'a' && c <= u'f'); |
171 | } |
172 | |
173 | QByteArray 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 | |
188 | BaseJob::BaseJob(HttpVerb verb, const QString& name, QByteArray endpoint, |
189 | bool needsToken) |
190 | : BaseJob(verb, name, std::move(endpoint), QUrlQuery {}, RequestData {}, |
191 | needsToken) |
192 | {} |
193 | |
194 | BaseJob::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 | |
207 | BaseJob::~BaseJob() |
208 | { |
209 | stop(); |
210 | d->retryTimer.stop(); // See #398 |
211 | qCDebug(d->logCat) << this << "destroyed" ; |
212 | } |
213 | |
214 | QUrl BaseJob::requestUrl() const { return d->reply ? d->reply->url() : QUrl(); } |
215 | |
216 | bool BaseJob::isBackground() const { return d->inBackground; } |
217 | |
218 | const BaseJob::headers_t& BaseJob::() const |
219 | { |
220 | return d->requestHeaders; |
221 | } |
222 | |
223 | void BaseJob::(const headers_t::key_type& , |
224 | const headers_t::mapped_type& ) |
225 | { |
226 | d->requestHeaders[headerName] = headerValue; |
227 | } |
228 | |
229 | void BaseJob::(const BaseJob::headers_t& ) |
230 | { |
231 | d->requestHeaders = headers; |
232 | } |
233 | |
234 | QUrlQuery BaseJob::query() const { return d->requestQuery; } |
235 | |
236 | void BaseJob::setRequestQuery(const QUrlQuery& query) |
237 | { |
238 | d->requestQuery = query; |
239 | } |
240 | |
241 | const RequestData& BaseJob::requestData() const { return d->requestData; } |
242 | |
243 | void BaseJob::setRequestData(RequestData&& data) |
244 | { |
245 | std::swap(a&: d->requestData, b&: data); |
246 | } |
247 | |
248 | const QByteArrayList& BaseJob::expectedContentTypes() const |
249 | { |
250 | return d->expectedContentTypes; |
251 | } |
252 | |
253 | void BaseJob::addExpectedContentType(const QByteArray& contentType) |
254 | { |
255 | d->expectedContentTypes << contentType; |
256 | } |
257 | |
258 | void BaseJob::setExpectedContentTypes(const QByteArrayList& contentTypes) |
259 | { |
260 | d->expectedContentTypes = contentTypes; |
261 | } |
262 | |
263 | QByteArrayList BaseJob::expectedKeys() const { return d->expectedKeys; } |
264 | |
265 | void BaseJob::addExpectedKey(const QByteArray& key) { d->expectedKeys << key; } |
266 | |
267 | void BaseJob::setExpectedKeys(const QByteArrayList& keys) |
268 | { |
269 | d->expectedKeys = keys; |
270 | } |
271 | |
272 | const QNetworkReply* BaseJob::reply() const { return d->reply.data(); } |
273 | |
274 | QNetworkReply* BaseJob::reply() { return d->reply.data(); } |
275 | |
276 | QUrl 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 | |
291 | QNetworkRequest 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 | |
315 | void 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 | |
333 | void BaseJob::doPrepare() { } |
334 | |
335 | void BaseJob::onSentRequest(QNetworkReply*) { } |
336 | |
337 | void BaseJob::beforeAbandon() { } |
338 | |
339 | void 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 | |
370 | void 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 | |
404 | BaseJob::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 | |
413 | void 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 | |
453 | bool 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 | |
479 | BaseJob::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 = |
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 | |
513 | BaseJob::Status BaseJob::prepareResult() { return Success; } |
514 | |
515 | BaseJob::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 | |
565 | QJsonValue 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 | |
575 | void 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 | |
591 | void 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 | |
646 | seconds BaseJob::getCurrentTimeout() const |
647 | { |
648 | return d->getCurrentTimeoutConfig().jobTimeout; |
649 | } |
650 | |
651 | BaseJob::duration_ms_t BaseJob::getCurrentTimeoutMs() const |
652 | { |
653 | return milliseconds(getCurrentTimeout()).count(); |
654 | } |
655 | |
656 | seconds BaseJob::getNextRetryInterval() const |
657 | { |
658 | return d->getCurrentTimeoutConfig().nextRetryInterval; |
659 | } |
660 | |
661 | BaseJob::duration_ms_t BaseJob::getNextRetryMs() const |
662 | { |
663 | return milliseconds(getNextRetryInterval()).count(); |
664 | } |
665 | |
666 | milliseconds BaseJob::timeToRetry() const |
667 | { |
668 | return d->retryTimer.isActive() ? d->retryTimer.remainingTimeAsDuration() |
669 | : 0s; |
670 | } |
671 | |
672 | BaseJob::duration_ms_t BaseJob::millisToRetry() const |
673 | { |
674 | return timeToRetry().count(); |
675 | } |
676 | |
677 | int BaseJob::maxRetries() const { return d->maxRetries; } |
678 | |
679 | void BaseJob::setMaxRetries(int newMaxRetries) |
680 | { |
681 | d->maxRetries = newMaxRetries; |
682 | } |
683 | |
684 | BaseJob::Status BaseJob::status() const { return d->status; } |
685 | |
686 | QByteArray 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 | |
693 | const QByteArray& BaseJob::rawData() const { return d->rawResponse; } |
694 | |
695 | QString 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 | |
706 | QJsonObject BaseJob::jsonData() const |
707 | { |
708 | return d->jsonResponse.object(); |
709 | } |
710 | |
711 | QJsonArray BaseJob::jsonItems() const |
712 | { |
713 | return d->jsonResponse.array(); |
714 | } |
715 | |
716 | QString 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 | |
756 | int BaseJob::error() const { |
757 | return d->status.code; } |
758 | |
759 | QString BaseJob::errorString() const { |
760 | return d->status.message; } |
761 | |
762 | QUrl BaseJob::errorUrl() const { |
763 | return d->errorUrl; } |
764 | |
765 | void 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 | |
794 | void BaseJob::setStatus(int code, QString message) |
795 | { |
796 | setStatus({ code, std::move(message) }); |
797 | } |
798 | |
799 | void 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 | |
812 | void BaseJob::timeout() |
813 | { |
814 | setStatus(code: Timeout, message: "The job has timed out"_ls ); |
815 | finishJob(); |
816 | } |
817 | |
818 | void BaseJob::setLoggingCategory(QMessageLogger::CategoryFunction lcf) |
819 | { |
820 | d->logCat = lcf; |
821 | } |
822 | |