OdrsReviewsBackend.cpp 14.8 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/***************************************************************************
 *   Copyright © 2013 Aleix Pol Gonzalez <aleixpol@blue-systems.com>       *
 *   Copyright © 2017 Jan Grulich <jgrulich@redhat.com>                    *
 *                                                                         *
 *   This program is free software; you can redistribute it and/or         *
 *   modify it under the terms of the GNU General Public License as        *
 *   published by the Free Software Foundation; either version 2 of        *
 *   the License or (at your option) version 3 or any later version        *
 *   accepted by the membership of KDE e.V. (or its successor approved     *
 *   by the membership of KDE e.V.), which shall act as a proxy            *
 *   defined in Section 14 of version 3 of the license.                    *
 *                                                                         *
 *   This program is distributed in the hope that it will be useful,       *
 *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
 *   GNU General Public License for more details.                          *
 *                                                                         *
 *   You should have received a copy of the GNU General Public License     *
 *   along with this program.  If not, see <http://www.gnu.org/licenses/>. *
 ***************************************************************************/

#include "OdrsReviewsBackend.h"
23
#include "AppStreamIntegration.h"
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
24
#include "CachedNetworkAccessManager.h"
25
26
27
28
29
30
31
32
33

#include <ReviewsBackend/Review.h>
#include <ReviewsBackend/Rating.h>

#include <resources/AbstractResource.h>
#include <resources/AbstractResourcesBackend.h>

#include <KIO/FileCopyJob>
#include <KUser>
34
#include <KLocalizedString>
35
36

#include <QCryptographicHash>
37
#include <QDir>
Laurent Montel's avatar
Laurent Montel committed
38
#include "libdiscover_debug.h"
39
40
41
42
43
44
45
46
47
48
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QFile>
#include <QFileInfo>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStandardPaths>

49
// #define APIURL "http://127.0.0.1:5000/1.0/reviews/api"
50
51
#define APIURL "https://odrs.gnome.org/1.0/reviews/api"

52
53
OdrsReviewsBackend::OdrsReviewsBackend()
    : AbstractReviewsBackend(nullptr)
54
55
56
    , m_isFetching(false)
{
    bool fetchRatings = false;
57
    const QUrl ratingsUrl(QStringLiteral(APIURL "/ratings"));
58
59
60
61
62
    const QUrl fileUrl = QUrl::fromLocalFile(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/ratings/ratings"));
    const QDir cacheDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation));

    // Create $HOME/.cache/discover/ratings folder
    cacheDir.mkdir(QStringLiteral("ratings"));
63
64
65

    if (QFileInfo::exists(fileUrl.toLocalFile())) {
        QFileInfo file(fileUrl.toLocalFile());
66
67
        // Refresh the cached ratings if they are older than one day
        if (file.lastModified().msecsTo(QDateTime::currentDateTime()) > 1000 * 60 * 60 * 24) {
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
            fetchRatings = true;
        }
    } else {
        fetchRatings = true;
    }

    if (fetchRatings) {
        m_isFetching = true;
        KIO::FileCopyJob *getJob = KIO::file_copy(ratingsUrl, fileUrl, -1, KIO::Overwrite | KIO::HideProgressInfo);
        connect(getJob, &KIO::FileCopyJob::result, this, &OdrsReviewsBackend::ratingsFetched);
    } else {
        parseRatings();
    }
}

void OdrsReviewsBackend::ratingsFetched(KJob *job)
{
    m_isFetching = false;
    if (job->error()) {
Laurent Montel's avatar
Laurent Montel committed
87
        qCWarning(LIBDISCOVER_LOG) << "Failed to fetch ratings " << job->errorString();
88
89
90
91
92
93
94
    } else {
        parseRatings();
    }
}

static QString osName()
{
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
95
    return AppStreamIntegration::global()->osRelease()->name();
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
}

static QString userHash()
{
    QString machineId;
    QFile file(QStringLiteral("/etc/machine-id"));
    if (file.open(QIODevice::ReadOnly)) {
        machineId = QString::fromUtf8(file.readAll());
        file.close();
    }

    if (machineId.isEmpty()) {
        return QString();
    }

Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
111
    QString salted = QStringLiteral("gnome-software[%1:%2]").arg(KUser().loginName(), machineId);
112
113
114
115
116
    return QString::fromUtf8(QCryptographicHash::hash(salted.toUtf8(), QCryptographicHash::Sha1).toHex());
}

void OdrsReviewsBackend::fetchReviews(AbstractResource *app, int page)
{
Jan Grulich's avatar
Jan Grulich committed
117
    Q_UNUSED(page)
118
119
    m_isFetching = true;

120
121
122
123
124
125
    const QJsonDocument document(QJsonObject{
            {QStringLiteral("app_id"), app->appstreamId()},
            {QStringLiteral("distro"), osName()},
            {QStringLiteral("user_hash"), userHash()},
            {QStringLiteral("version"), app->isInstalled() ? app->installedVersion() : app->availableVersion()},
            {QStringLiteral("locale"), QLocale::system().name()},
126
            {QStringLiteral("limit"), -1}
127
    });
128

Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
129
    const auto json = document.toJson(QJsonDocument::Compact);
130
    QNetworkRequest request(QUrl(QStringLiteral(APIURL "/fetch")));
131
    request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json; charset=utf-8"));
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
132
    request.setHeader(QNetworkRequest::ContentLengthHeader, json.size());
133
134
135
    // Store reference to the app for which we request reviews
    request.setOriginatingObject(app);

Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
136
    auto reply = nam()->post(request, json);
137
    connect(reply, &QNetworkReply::finished, this, &OdrsReviewsBackend::reviewsFetched);
138
139
}

140
void OdrsReviewsBackend::reviewsFetched()
141
{
142
    QNetworkReply* reply = qobject_cast<QNetworkReply*>(sender());
Laurent Montel's avatar
Laurent Montel committed
143
    QScopedPointer<QNetworkReply, QScopedPointerDeleteLater> replyPtr(reply);
144
    const QByteArray data = reply->readAll();
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
145
    if (reply->error() != QNetworkReply::NoError) {
146
        qCWarning(LIBDISCOVER_LOG) << "error fetching reviews:" << reply->errorString() << data;
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
147
148
149
        m_isFetching = false;
        return;
    }
150

Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
151
152
153
154
    const QJsonDocument document = QJsonDocument::fromJson(data);
    AbstractResource *resource = qobject_cast<AbstractResource*>(reply->request().originatingObject());
    Q_ASSERT(resource);
    parseReviews(document, resource);
155
156
157
158
}

Rating * OdrsReviewsBackend::ratingForApplication(AbstractResource *app) const
{
159
    if (app->appstreamId().isEmpty()) {
160
161
162
163
164
165
        return nullptr;
    }

    return m_ratings[app->appstreamId()];
}

Jan Grulich's avatar
Jan Grulich committed
166
void OdrsReviewsBackend::submitUsefulness(Review *review, bool useful)
167
{
168
169
    const QJsonDocument document(QJsonObject{
                     {QStringLiteral("app_id"), review->applicationName()},
Jan Grulich's avatar
Jan Grulich committed
170
171
172
                     {QStringLiteral("user_skey"), review->getMetadata(QStringLiteral("ODRS::user_skey")).toString()},
                     {QStringLiteral("user_hash"), userHash()},
                     {QStringLiteral("distro"), osName()},
173
174
                     {QStringLiteral("review_id"), QJsonValue(double(review->id()))} //if we really need uint64 we should get it in QJsonValue
    });
Jan Grulich's avatar
Jan Grulich committed
175

Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
176
    QNetworkRequest request(QUrl(QStringLiteral(APIURL) + (useful ? QLatin1String("/upvote") : QLatin1String("/downvote"))));
Jan Grulich's avatar
Jan Grulich committed
177
178
179
    request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json; charset=utf-8"));
    request.setHeader(QNetworkRequest::ContentLengthHeader, document.toJson().size());

180
    auto reply = nam()->post(request, document.toJson());
181
    connect(reply, &QNetworkReply::finished, this, &OdrsReviewsBackend::usefulnessSubmitted);
Jan Grulich's avatar
Jan Grulich committed
182
183
}

184
void OdrsReviewsBackend::usefulnessSubmitted()
Jan Grulich's avatar
Jan Grulich committed
185
{
186
187
    QNetworkReply* reply = qobject_cast<QNetworkReply*>(sender());

Jan Grulich's avatar
Jan Grulich committed
188
    if (reply->error() == QNetworkReply::NoError) {
Laurent Montel's avatar
Laurent Montel committed
189
        qCWarning(LIBDISCOVER_LOG) << "Usefullness submitted";
Jan Grulich's avatar
Jan Grulich committed
190
    } else {
Laurent Montel's avatar
Laurent Montel committed
191
        qCWarning(LIBDISCOVER_LOG) << "Failed to submit usefulness: " << reply->errorString();
Jan Grulich's avatar
Jan Grulich committed
192
    }
Laurent Montel's avatar
Laurent Montel committed
193
    reply->deleteLater();
194
195
}

196
197
198
199
200
QString OdrsReviewsBackend::userName() const
{
    return i18n("%1 (%2)", KUser().property(KUser::FullName).toString(), KUser().loginName());
}

Jan Grulich's avatar
Jan Grulich committed
201
void OdrsReviewsBackend::submitReview(AbstractResource *res, const QString &summary, const QString &description, const QString &rating)
202
{
203
    QJsonObject map = {{QStringLiteral("app_id"), res->appstreamId()},
Jan Grulich's avatar
Jan Grulich committed
204
205
206
207
208
                     {QStringLiteral("user_skey"), res->getMetadata(QStringLiteral("ODRS::user_skey")).toString()},
                     {QStringLiteral("user_hash"), userHash()},
                     {QStringLiteral("version"), res->isInstalled() ? res->installedVersion() : res->availableVersion()},
                     {QStringLiteral("locale"), QLocale::system().name()},
                     {QStringLiteral("distro"), osName()},
209
                     {QStringLiteral("user_display"), QJsonValue::fromVariant(KUser().property(KUser::FullName))},
Jan Grulich's avatar
Jan Grulich committed
210
211
212
213
                     {QStringLiteral("summary"), summary},
                     {QStringLiteral("description"), description},
                     {QStringLiteral("rating"), rating.toInt() * 10}};

214
    const QJsonDocument document(map);
Jan Grulich's avatar
Jan Grulich committed
215

216
    QNetworkAccessManager *accessManager = nam();
217
    QNetworkRequest request(QUrl(QStringLiteral(APIURL "/submit")));
Jan Grulich's avatar
Jan Grulich committed
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
    request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json; charset=utf-8"));
    request.setHeader(QNetworkRequest::ContentLengthHeader, document.toJson().size());

    // Store what we need so we can immediately show our review once it is submitted
    // Use review_id 0 for now as odrs starts numbering from 1 and once reviews are re-downloaded we get correct id
    map.insert(QStringLiteral("review_id"), 0);
    res->addMetadata(QStringLiteral("ODRS::review_map"), map);
    request.setOriginatingObject(res);

    accessManager->post(request, document.toJson());
    connect(accessManager, &QNetworkAccessManager::finished, this, &OdrsReviewsBackend::reviewSubmitted);
}

void OdrsReviewsBackend::reviewSubmitted(QNetworkReply *reply)
{
    if (reply->error() == QNetworkReply::NoError) {
Laurent Montel's avatar
Laurent Montel committed
234
        qCWarning(LIBDISCOVER_LOG) << "Review submitted";
Jan Grulich's avatar
Jan Grulich committed
235
        AbstractResource *resource = qobject_cast<AbstractResource*>(reply->request().originatingObject());
236
        const QJsonArray array = {resource->getMetadata(QStringLiteral("ODRS::review_map")).toObject()};
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
237
        const QJsonDocument document(array);
Jan Grulich's avatar
Jan Grulich committed
238
239
240
241
242
        // Remove local file with reviews so we can re-download it next time to get our review
        QFile file(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/reviews/%1.json").arg(array.first().toObject().value(QStringLiteral("app_id")).toString()));
        file.remove();
        parseReviews(document, resource);
    } else {
Laurent Montel's avatar
Laurent Montel committed
243
        qCWarning(LIBDISCOVER_LOG) << "Failed to submit review: " << reply->errorString();
Jan Grulich's avatar
Jan Grulich committed
244
    }
Laurent Montel's avatar
Laurent Montel committed
245
    reply->deleteLater();
246
247
248
249
}

void OdrsReviewsBackend::parseRatings()
{
250
    QFile ratingsDocument(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/ratings/ratings"));
251
252
    if (ratingsDocument.open(QIODevice::ReadOnly)) {
        QJsonDocument jsonDocument = QJsonDocument::fromJson(ratingsDocument.readAll());
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
253
        const QJsonObject jsonObject = jsonDocument.object();
254
        m_ratings.reserve(jsonObject.size());
255
256
257
258
259
260
261
262
263
264
265
266
        for (auto it = jsonObject.begin(); it != jsonObject.end(); it++) {
            QJsonObject appJsonObject = it.value().toObject();

            const int ratingCount =  appJsonObject.value(QLatin1String("total")).toInt();
            QVariantMap ratingMap = { { QStringLiteral("star0"), appJsonObject.value(QLatin1String("star0")).toInt() },
                                      { QStringLiteral("star1"), appJsonObject.value(QLatin1String("star1")).toInt() },
                                      { QStringLiteral("star2"), appJsonObject.value(QLatin1String("star2")).toInt() },
                                      { QStringLiteral("star3"), appJsonObject.value(QLatin1String("star3")).toInt() },
                                      { QStringLiteral("star4"), appJsonObject.value(QLatin1String("star4")).toInt() },
                                      { QStringLiteral("star5"), appJsonObject.value(QLatin1String("star5")).toInt() } };

            Rating *rating = new Rating(it.key(), ratingCount, ratingMap);
267
            rating->setParent(this);
268
269
270
271
272
273
274
275
276
277
278
            m_ratings.insert(it.key(), rating);
        }
        ratingsDocument.close();

        Q_EMIT ratingsReady();
    }
}

void OdrsReviewsBackend::parseReviews(const QJsonDocument &document, AbstractResource *resource)
{
    m_isFetching = false;
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
279
280
281
282
    Q_ASSERT(resource);
    if (!resource) {
        return;
    }
283
284

    QJsonArray reviews = document.array();
285
    if (!reviews.isEmpty()) {
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
286
        QVector<ReviewPtr> reviewList;
287
        for (auto it = reviews.begin(); it != reviews.end(); it++) {
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
288
            const QJsonObject review = it->toObject();
289
290
291
292
            if (!review.isEmpty()) {
                const int usefulFavorable = review.value(QStringLiteral("karma_up")).toInt();
                const int usefulTotal = review.value(QStringLiteral("karma_down")).toInt() + usefulFavorable;
                QDateTime dateTime;
Laurent Montel's avatar
Laurent Montel committed
293
                dateTime.setSecsSinceEpoch(review.value(QStringLiteral("date_created")).toInt());
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
294
                ReviewPtr r(new Review(review.value(QStringLiteral("app_id")).toString(), resource->packageName(),
295
296
297
298
                                       review.value(QStringLiteral("locale")).toString(), review.value(QStringLiteral("summary")).toString(),
                                       review.value(QStringLiteral("description")).toString(), review.value(QStringLiteral("user_display")).toString(),
                                       dateTime, true, review.value(QStringLiteral("review_id")).toInt(),
                                       review.value(QStringLiteral("rating")).toInt() / 10, usefulTotal, usefulFavorable,
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
299
                                       review.value(QStringLiteral("version")).toString()));
300
                // We can also receive just a json with app name and user info so filter these out as there is no review
Jan Grulich's avatar
Jan Grulich committed
301
                if (!r->summary().isEmpty() && !r->reviewText().isEmpty()) {
302
                    reviewList << r;
Jan Grulich's avatar
Jan Grulich committed
303
304
                    // Needed for submitting usefulness
                    r->addMetadata(QStringLiteral("ODRS::user_skey"), review.value(QStringLiteral("user_skey")).toString());
305
                }
Jan Grulich's avatar
Jan Grulich committed
306
307
308

                // We should get at least user_skey needed for posting reviews
                resource->addMetadata(QStringLiteral("ODRS::user_skey"), review.value(QStringLiteral("user_skey")).toString());
309
310
311
312
313
314
            }
        }

        Q_EMIT reviewsReady(resource, reviewList, false);
    }
}
315
316
317
318
319

bool OdrsReviewsBackend::isResourceSupported(AbstractResource* res) const
{
    return !res->appstreamId().isEmpty();
}
320
321
322
323
324
325
326
327
328
329

void OdrsReviewsBackend::emitRatingFetched(AbstractResourcesBackend* b, const QList<AbstractResource *>& resources) const
{
    b->emitRatingsReady();
    foreach(AbstractResource* res, resources) {
        if (m_ratings.contains(res->appstreamId())) {
            Q_EMIT res->ratingFetched();
        }
    }
}
330
331
332
333

QNetworkAccessManager * OdrsReviewsBackend::nam()
{
    if (!m_delayedNam) {
Aleix Pol Gonzalez's avatar
Aleix Pol Gonzalez committed
334
        m_delayedNam = new CachedNetworkAccessManager(QStringLiteral("odrs"), this);
335
336
337
    }
    return m_delayedNam;
}