File indexing completed on 2024-05-12 05:28:50
0001 /* 0002 * SPDX-FileCopyrightText: 2013 Aleix Pol Gonzalez <aleixpol@blue-systems.com> 0003 * SPDX-FileCopyrightText: 2017 Jan Grulich <jgrulich@redhat.com> 0004 * 0005 * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL 0006 */ 0007 0008 #include "OdrsReviewsBackend.h" 0009 #include "AppStreamIntegration.h" 0010 #include "CachedNetworkAccessManager.h" 0011 0012 #include <ReviewsBackend/Rating.h> 0013 #include <ReviewsBackend/Review.h> 0014 0015 #include <qnumeric.h> 0016 #include <resources/AbstractResource.h> 0017 #include <resources/AbstractResourcesBackend.h> 0018 0019 #include <KIO/FileCopyJob> 0020 #include <KLocalizedString> 0021 #include <KUser> 0022 0023 #include "libdiscover_debug.h" 0024 #include <QCryptographicHash> 0025 #include <QDir> 0026 #include <QFile> 0027 #include <QFileInfo> 0028 #include <QJsonArray> 0029 #include <QJsonObject> 0030 #include <QNetworkAccessManager> 0031 #include <QNetworkRequest> 0032 #include <QStandardPaths> 0033 0034 #include <QFutureWatcher> 0035 #include <QtConcurrentRun> 0036 0037 // #define APIURL "http://127.0.0.1:5000/1.0/reviews/api" 0038 #define APIURL "https://odrs.gnome.org/1.0/reviews/api" 0039 0040 QSharedPointer<OdrsReviewsBackend> OdrsReviewsBackend::global() 0041 { 0042 static QSharedPointer<OdrsReviewsBackend> var = nullptr; 0043 if (!var) { 0044 var = QSharedPointer<OdrsReviewsBackend>(new OdrsReviewsBackend()); 0045 } 0046 0047 return var; 0048 } 0049 0050 OdrsReviewsBackend::OdrsReviewsBackend() 0051 : AbstractReviewsBackend(nullptr) 0052 { 0053 fetchRatings(); 0054 } 0055 0056 OdrsReviewsBackend::~OdrsReviewsBackend() noexcept 0057 { 0058 qDeleteAll(m_ratings); 0059 } 0060 0061 void OdrsReviewsBackend::fetchRatings() 0062 { 0063 bool fetchRatings = false; 0064 const QUrl ratingsUrl(QStringLiteral(APIURL "/ratings")); 0065 const QUrl fileUrl = QUrl::fromLocalFile(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/ratings/ratings")); 0066 const QDir cacheDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)); 0067 0068 // Create $HOME/.cache/discover/ratings folder 0069 cacheDir.mkpath(QStringLiteral("ratings")); 0070 0071 if (QFileInfo::exists(fileUrl.toLocalFile())) { 0072 QFileInfo file(fileUrl.toLocalFile()); 0073 // Refresh the cached ratings if they are older than one day 0074 if (file.lastModified().msecsTo(QDateTime::currentDateTime()) > 1000 * 60 * 60 * 24) { 0075 fetchRatings = true; 0076 } 0077 } else { 0078 fetchRatings = true; 0079 } 0080 0081 qCWarning(LIBDISCOVER_LOG) << "OdrsReviewsBackend: Fetch ratings:" << fetchRatings; 0082 if (fetchRatings) { 0083 setFetching(true); 0084 KIO::FileCopyJob *getJob = KIO::file_copy(ratingsUrl, fileUrl, -1, KIO::Overwrite | KIO::HideProgressInfo); 0085 connect(getJob, &KIO::FileCopyJob::result, this, &OdrsReviewsBackend::ratingsFetched); 0086 } else { 0087 parseRatings(); 0088 } 0089 } 0090 0091 void OdrsReviewsBackend::setFetching(bool fetching) 0092 { 0093 if (fetching == m_isFetching) { 0094 return; 0095 } 0096 m_isFetching = fetching; 0097 Q_EMIT fetchingChanged(fetching); 0098 } 0099 0100 void OdrsReviewsBackend::ratingsFetched(KJob *job) 0101 { 0102 setFetching(false); 0103 if (job->error()) { 0104 qCWarning(LIBDISCOVER_LOG) << "OdrsReviewsBackend: Failed to fetch ratings:" << job->errorString(); 0105 } else { 0106 parseRatings(); 0107 } 0108 } 0109 0110 static QString osName() 0111 { 0112 return AppStreamIntegration::global()->osRelease()->name(); 0113 } 0114 0115 static QString userHash() 0116 { 0117 QString machineId; 0118 QFile file(QStringLiteral("/etc/machine-id")); 0119 if (file.open(QIODevice::ReadOnly)) { 0120 machineId = QString::fromUtf8(file.readAll()); 0121 file.close(); 0122 } 0123 0124 if (machineId.isEmpty()) { 0125 return QString(); 0126 } 0127 0128 QString salted = QStringLiteral("gnome-software[%1:%2]").arg(KUser().loginName(), machineId); 0129 return QString::fromUtf8(QCryptographicHash::hash(salted.toUtf8(), QCryptographicHash::Sha1).toHex()); 0130 } 0131 0132 void OdrsReviewsBackend::fetchReviews(AbstractResource *resource, int page) 0133 { 0134 if (resource->appstreamId().isEmpty()) { 0135 return; 0136 } 0137 Q_UNUSED(page) 0138 QString version = resource->isInstalled() ? resource->installedVersion() : resource->availableVersion(); 0139 if (version.isEmpty()) { 0140 version = QStringLiteral("unknown"); 0141 } 0142 setFetching(true); 0143 0144 const QJsonDocument document(QJsonObject{ 0145 {QStringLiteral("app_id"), resource->appstreamId()}, 0146 {QStringLiteral("distro"), osName()}, 0147 {QStringLiteral("user_hash"), userHash()}, 0148 {QStringLiteral("version"), version}, 0149 {QStringLiteral("locale"), QLocale::system().name()}, 0150 {QStringLiteral("limit"), -1}, 0151 }); 0152 0153 const auto json = document.toJson(QJsonDocument::Compact); 0154 QNetworkRequest request(QUrl(QStringLiteral(APIURL "/fetch"))); 0155 request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json; charset=utf-8")); 0156 request.setHeader(QNetworkRequest::ContentLengthHeader, json.size()); 0157 // Store reference to the resource for which we request reviews 0158 request.setOriginatingObject(resource); 0159 0160 auto reply = nam()->post(request, json); 0161 connect(reply, &QNetworkReply::finished, this, &OdrsReviewsBackend::reviewsFetched); 0162 } 0163 0164 void OdrsReviewsBackend::reviewsFetched() 0165 { 0166 const auto reply = qobject_cast<QNetworkReply *>(sender()); 0167 QScopedPointer<QNetworkReply, QScopedPointerDeleteLater> replyPtr(reply); 0168 const QByteArray data = reply->readAll(); 0169 const auto networkError = reply->error(); 0170 if (networkError != QNetworkReply::NoError) { 0171 qCWarning(LIBDISCOVER_LOG) << "OdrsReviewsBackend: Error fetching reviews:" << reply->errorString() << data; 0172 m_errorMessage = i18n("Technical error message: %1", reply->errorString()); 0173 Q_EMIT errorMessageChanged(); 0174 setFetching(false); 0175 return; 0176 } 0177 0178 QJsonParseError error; 0179 const QJsonDocument document = QJsonDocument::fromJson(data, &error); 0180 if (error.error) { 0181 qCWarning(LIBDISCOVER_LOG) << "OdrsReviewsBackend: Error parsing reviews:" << reply->url() << error.errorString(); 0182 } 0183 0184 const auto resource = qobject_cast<AbstractResource *>(reply->request().originatingObject()); 0185 Q_ASSERT(resource); 0186 parseReviews(document, resource); 0187 } 0188 0189 Rating *OdrsReviewsBackend::ratingForApplication(AbstractResource *resource) const 0190 { 0191 if (resource->appstreamId().isEmpty()) { 0192 return nullptr; 0193 } 0194 0195 return m_ratings[resource->appstreamId()]; 0196 } 0197 0198 void OdrsReviewsBackend::submitUsefulness(Review *review, bool useful) 0199 { 0200 const QJsonDocument document(QJsonObject{ 0201 {QStringLiteral("app_id"), review->applicationName()}, 0202 {QStringLiteral("user_skey"), review->getMetadata(QStringLiteral("ODRS::user_skey")).toString()}, 0203 {QStringLiteral("user_hash"), userHash()}, 0204 {QStringLiteral("distro"), osName()}, 0205 {QStringLiteral("review_id"), QJsonValue(double(review->id()))}, // if we really need uint64 we should get it in QJsonValue 0206 }); 0207 0208 QNetworkRequest request(QUrl(QStringLiteral(APIURL) + (useful ? QLatin1String("/upvote") : QLatin1String("/downvote")))); 0209 request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json; charset=utf-8")); 0210 request.setHeader(QNetworkRequest::ContentLengthHeader, document.toJson().size()); 0211 0212 auto reply = nam()->post(request, document.toJson()); 0213 connect(reply, &QNetworkReply::finished, this, &OdrsReviewsBackend::usefulnessSubmitted); 0214 } 0215 0216 void OdrsReviewsBackend::usefulnessSubmitted() 0217 { 0218 const auto reply = qobject_cast<QNetworkReply *>(sender()); 0219 const auto networkError = reply->error(); 0220 if (networkError == QNetworkReply::NoError) { 0221 qCWarning(LIBDISCOVER_LOG) << "OdrsReviewsBackend: Usefulness submitted"; 0222 } else { 0223 qCWarning(LIBDISCOVER_LOG).noquote() << "OdrsReviewsBackend: Failed to submit usefulness:" << reply->errorString(); 0224 Q_EMIT error(i18n("Error while submitting usefulness: %1", reply->errorString())); 0225 } 0226 reply->deleteLater(); 0227 } 0228 0229 QString OdrsReviewsBackend::userName() const 0230 { 0231 return KUser().property(KUser::FullName).toString(); 0232 } 0233 0234 void OdrsReviewsBackend::sendReview(AbstractResource *resource, 0235 const QString &summary, 0236 const QString &reviewText, 0237 const QString &rating, 0238 const QString &userName) 0239 { 0240 Q_ASSERT(resource); 0241 QJsonObject map = { 0242 {QStringLiteral("app_id"), resource->appstreamId()}, 0243 {QStringLiteral("user_skey"), resource->getMetadata(QStringLiteral("ODRS::user_skey")).toString()}, 0244 {QStringLiteral("user_hash"), userHash()}, 0245 {QStringLiteral("version"), resource->isInstalled() ? resource->installedVersion() : resource->availableVersion()}, 0246 {QStringLiteral("locale"), QLocale::system().name()}, 0247 {QStringLiteral("distro"), osName()}, 0248 {QStringLiteral("user_display"), QJsonValue::fromVariant(userName)}, 0249 {QStringLiteral("summary"), summary}, 0250 {QStringLiteral("description"), reviewText}, 0251 {QStringLiteral("rating"), rating.toInt() * 10}, 0252 }; 0253 0254 const QJsonDocument document(map); 0255 0256 const auto accessManager = nam(); 0257 QNetworkRequest request(QUrl(QStringLiteral(APIURL "/submit"))); 0258 request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json; charset=utf-8")); 0259 request.setHeader(QNetworkRequest::ContentLengthHeader, document.toJson().size()); 0260 0261 // Store what we need so we can immediately show our review once it is submitted 0262 // Use review_id 0 for now as odrs starts numbering from 1 and once reviews are re-downloaded we get correct id 0263 map.insert(QStringLiteral("review_id"), 0); 0264 resource->addMetadata(QStringLiteral("ODRS::review_map"), map); 0265 request.setOriginatingObject(resource); 0266 0267 accessManager->post(request, document.toJson()); 0268 connect(accessManager, &QNetworkAccessManager::finished, this, &OdrsReviewsBackend::reviewSubmitted); 0269 } 0270 0271 void OdrsReviewsBackend::reviewSubmitted(QNetworkReply *reply) 0272 { 0273 const auto networkError = reply->error(); 0274 if (networkError == QNetworkReply::NoError) { 0275 const auto resource = qobject_cast<AbstractResource *>(reply->request().originatingObject()); 0276 Q_ASSERT(resource); 0277 qCWarning(LIBDISCOVER_LOG) << "OdrsReviewsBackend: Review submitted for" << resource; 0278 if (resource) { 0279 const QJsonDocument document({resource->getMetadata(QStringLiteral("ODRS::review_map")).toObject()}); 0280 parseReviews(document, resource); 0281 } else { 0282 qCWarning(LIBDISCOVER_LOG) << "OdrsReviewsBackend: Failed to submit review: missing object"; 0283 } 0284 } else { 0285 qCWarning(LIBDISCOVER_LOG).noquote() << "OdrsReviewsBackend: Failed to submit review:" << reply->errorString(); 0286 Q_EMIT error(i18n("Error while submitting review: %1", reply->errorString())); 0287 } 0288 reply->deleteLater(); 0289 } 0290 0291 void OdrsReviewsBackend::parseRatings() 0292 { 0293 auto fw = new QFutureWatcher<QJsonDocument>(this); 0294 connect(fw, &QFutureWatcher<QJsonDocument>::finished, this, [this, fw] { 0295 const auto jsonDocument = fw->result(); 0296 fw->deleteLater(); 0297 const auto jsonObject = jsonDocument.object(); 0298 m_ratings.reserve(jsonObject.size()); 0299 for (auto it = jsonObject.begin(); it != jsonObject.end(); it++) { 0300 const auto appJsonObject = it.value().toObject(); 0301 0302 const int ratingCount = appJsonObject.value(QLatin1String("total")).toInt(); 0303 int ratingMap[] = { 0304 appJsonObject.value(QLatin1String("star0")).toInt(), 0305 appJsonObject.value(QLatin1String("star1")).toInt(), 0306 appJsonObject.value(QLatin1String("star2")).toInt(), 0307 appJsonObject.value(QLatin1String("star3")).toInt(), 0308 appJsonObject.value(QLatin1String("star4")).toInt(), 0309 appJsonObject.value(QLatin1String("star5")).toInt(), 0310 }; 0311 0312 const auto rating = new Rating(it.key(), ratingCount, ratingMap); 0313 m_ratings.insert(it.key(), rating); 0314 0315 const auto finder = [rating](Rating *review) { 0316 return review->ratingPoints() < rating->ratingPoints(); 0317 }; 0318 const auto topIt = std::find_if(m_top.begin(), m_top.end(), finder); 0319 if (topIt == m_top.end()) { 0320 if (m_top.size() < 25) { 0321 m_top.append(rating); 0322 } 0323 } else { 0324 m_top.insert(topIt, rating); 0325 } 0326 if (m_top.size() > 25) { 0327 m_top.resize(25); 0328 } 0329 } 0330 Q_EMIT ratingsReady(); 0331 }); 0332 fw->setFuture(QtConcurrent::run([] { 0333 QFile ratingsDocument(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/ratings/ratings")); 0334 if (!ratingsDocument.open(QIODevice::ReadOnly)) { 0335 qCWarning(LIBDISCOVER_LOG) << "OdrsReviewsBackend: Could not open file" << ratingsDocument.fileName(); 0336 return QJsonDocument::fromJson({}); 0337 } 0338 0339 QJsonParseError error; 0340 const auto ret = QJsonDocument::fromJson(ratingsDocument.readAll(), &error); 0341 if (error.error) { 0342 qCWarning(LIBDISCOVER_LOG) << "OdrsReviewsBackend: Error parsing ratings:" << ratingsDocument.errorString() << error.errorString(); 0343 } 0344 return ret; 0345 })); 0346 } 0347 0348 void OdrsReviewsBackend::parseReviews(const QJsonDocument &document, AbstractResource *resource) 0349 { 0350 setFetching(false); 0351 Q_ASSERT(resource); 0352 if (!resource) { 0353 return; 0354 } 0355 0356 const auto reviews = document.array(); 0357 if (!reviews.isEmpty()) { 0358 QList<ReviewPtr> reviewsList; 0359 for (auto it = reviews.begin(); it != reviews.end(); it++) { 0360 const QJsonObject review = it->toObject(); 0361 if (!review.isEmpty()) { 0362 // Same ranking algorythm Gnome Software uses 0363 const int usefulFavorable = review.value(QStringLiteral("karma_up")).toInt(); 0364 const int usefulNegative = review.value(QStringLiteral("karma_down")).toInt(); 0365 const int usefulTotal = usefulFavorable + usefulNegative; 0366 0367 qreal usefulWilson = 0.f; 0368 0369 /* from http://www.evanmiller.org/how-not-to-sort-by-average-rating.html */ 0370 if (usefulFavorable > 0 || usefulNegative > 0) { 0371 usefulWilson = ((usefulFavorable + 1.9208) / (usefulFavorable + usefulNegative) 0372 - 1.96 * sqrt((usefulFavorable * usefulNegative) / qreal(usefulFavorable + usefulNegative) + 0.9604) 0373 / (usefulFavorable + usefulNegative)) 0374 / (1 + 3.8416 / (usefulFavorable + usefulNegative)); 0375 usefulWilson *= 100.f; 0376 } 0377 0378 QDateTime dateTime; 0379 dateTime.setSecsSinceEpoch(review.value(QStringLiteral("date_created")).toInt()); 0380 0381 // If there is no score or the score is the same, base on the age 0382 const auto currentDateTime = QDateTime::currentDateTime(); 0383 const auto totalDays = static_cast<qreal>(dateTime.daysTo(currentDateTime)); 0384 0385 // use also the longest common subsequence of the version string to compute relevance 0386 const auto reviewVersion = review.value(QStringLiteral("version")).toString(); 0387 const auto availableVersion = resource->availableVersion(); 0388 qreal versionScore = 0; 0389 const int minLength = std::min(reviewVersion.length(), availableVersion.length()); 0390 if (minLength > 0) { 0391 for (int i = 0; i < minLength; ++i) { 0392 if (reviewVersion[i] != availableVersion[i] || i == minLength - 1) { 0393 versionScore = i; 0394 break; 0395 } 0396 } 0397 // Normalize 0398 versionScore = versionScore / qreal(std::max(reviewVersion.length(), availableVersion.length()) - 1); 0399 } 0400 0401 // Very random heuristic which weights usefulness with age and versioin similarity. Don't penalise usefulness more than 6 months 0402 usefulWilson = versionScore + 1.0 / std::max(1.0, totalDays) + usefulWilson / std::clamp(totalDays, 1.0, 93.0); 0403 0404 ReviewPtr r(new Review(review.value(QStringLiteral("app_id")).toString(), 0405 resource->packageName(), 0406 review.value(QStringLiteral("locale")).toString(), 0407 review.value(QStringLiteral("summary")).toString(), 0408 review.value(QStringLiteral("description")).toString(), 0409 review.value(QStringLiteral("user_display")).toString(), 0410 dateTime, 0411 usefulFavorable >= usefulNegative * 2 || review.value(QStringLiteral("reported")).toInt() > 4, 0412 review.value(QStringLiteral("review_id")).toInt(), 0413 review.value(QStringLiteral("rating")).toInt() / 10, 0414 usefulTotal, 0415 usefulFavorable, 0416 usefulWilson, 0417 reviewVersion)); 0418 // We can also receive just a json with app name and user info so filter these out as there is no review 0419 if (!r->summary().isEmpty() && !r->reviewText().isEmpty()) { 0420 reviewsList.append(r); 0421 // Needed for submitting usefulness 0422 r->addMetadata(QStringLiteral("ODRS::user_skey"), review.value(QStringLiteral("user_skey")).toString()); 0423 } 0424 0425 // We should get at least user_skey needed for posting reviews 0426 resource->addMetadata(QStringLiteral("ODRS::user_skey"), review.value(QStringLiteral("user_skey")).toString()); 0427 } 0428 } 0429 0430 Q_EMIT reviewsReady(resource, reviewsList, false); 0431 } 0432 } 0433 0434 bool OdrsReviewsBackend::isResourceSupported(AbstractResource *resource) const 0435 { 0436 return !resource->appstreamId().isEmpty(); 0437 } 0438 0439 void OdrsReviewsBackend::emitRatingFetched(AbstractResourcesBackend *backend, const QList<AbstractResource *> &resources) const 0440 { 0441 backend->emitRatingsReady(); 0442 for (const auto resource : resources) { 0443 if (m_ratings.contains(resource->appstreamId())) { 0444 Q_EMIT resource->ratingFetched(); 0445 } 0446 } 0447 } 0448 0449 QNetworkAccessManager *OdrsReviewsBackend::nam() 0450 { 0451 if (!m_delayedNam) { 0452 m_delayedNam = new CachedNetworkAccessManager(QStringLiteral("odrs"), this); 0453 } 0454 return m_delayedNam; 0455 }