| 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(ch: ':')); |
| 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(ch: 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 | |