Krita Source Code Documentation
Loading...
Searching...
No Matches
KisSupporterBundlesFetcher.cpp
Go to the documentation of this file.
1/*
2 * SPDX-License-Identifier: GPL-3.0-or-later
3 */
6#include <QCryptographicHash>
7#include <QDir>
8#include <QFile>
9#include <QHash>
10#include <QNetworkReply>
11#include <QRegularExpression>
12#include <QSaveFile>
13#include <QSqlDatabase>
14#include <QSqlError>
15#include <QSqlQuery>
16#include <QStandardPaths>
17#include <QtEndian>
18#include <kis_assert.h>
19#include <kis_debug.h>
20#include <klocalizedstring.h>
21
23 : QObject(parent)
24{
25 QDir cacheDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation));
26 m_indexDbPath = cacheDir.filePath(QStringLiteral("kissupporterbundlesindex.db"));
27 dbgResources << "Index db path:" << m_indexDbPath;
28}
29
31{
33 m_fetching = true;
34 QNetworkReply *reply = fetch(QStringLiteral("check0.txt"));
35 connect(reply, &QNetworkReply::finished, this, [=] {
37 reply->deleteLater();
38 });
39}
40
42{
43 return fetch(QStringLiteral("bundles/%1").arg(bundle.fileName));
44}
45
46bool KisSupporterBundlesFetcher::hasNetworkReplyError(QNetworkReply *reply, QString &outErrorMessage)
47{
48 if (reply->error() != QNetworkReply::NoError) {
49 outErrorMessage = reply->errorString();
50 return true;
51 }
52
53 int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
54 if (statusCode < 200 || statusCode > 300) {
55 outErrorMessage = i18n("Server responded with error %1.").arg(statusCode);
56 return true;
57 }
58
59 return false;
60}
61
63{
64 QString errorMessage;
65 if (hasNetworkReplyError(reply, errorMessage)) {
66 emitCheckTxtFetchFailed(errorMessage);
67 return;
68 }
69
70 QString content = QString::fromUtf8(reply->readAll());
71
72 // "Predefined failures" are for forward-compatibility for stuff like the
73 // format changing and old versions of Krita becoming unsupported or the
74 // service getting discontinued altogether.
76 return;
77 }
78
79 // The check0.txt is supposed to contain a version followed by an MD5 hash
80 // digest. For forward-compatibility, we'll ignore anything else afterwards.
81 QRegularExpression re(QStringLiteral("\\A\\s*([0-9]+)\\s+([0-9a-fA-F]+)\\b"));
82 QRegularExpressionMatch match = re.match(content);
83 if (!match.hasMatch()) {
84 emitCheckTxtFetchFailed(i18n("Invalid checksums file."));
85 return;
86 }
87
88 bool ok;
89 unsigned int expectedVersion = match.captured(1).toUInt(&ok);
90 dbgResources << "Expected version from fetched check0.txt:" << expectedVersion;
91 if (!ok) {
92 emitCheckTxtFetchFailed(i18n("Invalid checksums version."));
93 return;
94 }
95
96 QString expectedHash = match.captured(2);
97 dbgResources << "Expected hash from fetched check0.txt:" << expectedHash;
98
99 if (checkExistingIndexDb(expectedVersion, expectedHash)) {
101 } else {
102 fetchIndexDb(expectedVersion, expectedHash);
103 }
104}
105
107{
108 QRegularExpression re(QStringLiteral("\\A!failure=([a-zA-Z0-9]*)"), QRegularExpression::CaseInsensitiveOption);
109 QRegularExpressionMatch match = re.match(content);
110 if (!match.hasMatch()) {
111 return false;
112 }
113
114 QString failureType = match.captured(1);
115 QString errorMessage;
116 if (failureType.compare(QStringLiteral("outdated"), Qt::CaseInsensitive) == 0) {
117 errorMessage = i18n("You have to update Krita to use this service.");
118 } else if (failureType.compare(QStringLiteral("region"), Qt::CaseInsensitive) == 0) {
119 errorMessage = i18n("This service is not available in your region.");
120 } else if (failureType.compare(QStringLiteral("unavailable"), Qt::CaseInsensitive) == 0) {
121 errorMessage = i18n("This service is currently unavailable.");
122 } else if (failureType.compare(QStringLiteral("discontinued"), Qt::CaseInsensitive) == 0) {
123 errorMessage = i18n("This service has been discontinued.");
124 } else {
125 errorMessage = i18n("Unknown service error %1.").arg(failureType);
126 }
127
128 emitFailure(errorMessage);
129 return true;
130}
131
132bool KisSupporterBundlesFetcher::checkExistingIndexDb(unsigned int expectedVersion, const QString &expectedHash)
133{
134 QFile file(m_indexDbPath);
135 if (!file.open(QIODevice::ReadOnly)) {
136 dbgResources << "Could not open existing index db";
137 return false;
138 }
139
140 QByteArray bytes = file.readAll();
141 unsigned int version;
142 if (!readSqliteUserVersion(bytes, version)) {
143 dbgResources << "Could not read existing version";
144 return false;
145 }
146
147 if (version != expectedVersion) {
148 dbgResources << "Existing version" << version << "!=" << expectedVersion;
149 return false;
150 }
151
152 QString hash = QCryptographicHash::hash(bytes, QCryptographicHash::Md5).toHex();
153 if (hash != expectedHash) {
154 dbgResources << "Existing hash" << hash << "!=" << expectedHash;
155 return false;
156 }
157
158 dbgResources << "Cached index database is valid";
159 return true;
160}
161
162void KisSupporterBundlesFetcher::fetchIndexDb(unsigned int expectedVersion, const QString &expectedHash)
163{
164 QNetworkReply *reply = fetch(QStringLiteral("index0.db"));
165 connect(reply, &QNetworkReply::finished, this, [=] {
166 handleIndexDbFetched(reply, expectedVersion, expectedHash);
167 reply->deleteLater();
168 });
169}
170
172 unsigned int expectedVersion,
173 const QString &expectedHash)
174{
175 QString errorMessage;
176 if (hasNetworkReplyError(reply, errorMessage)) {
177 emitIndexDbFetchFailed(errorMessage);
178 return;
179 }
180
181 QByteArray bytes = reply->readAll();
182 unsigned int version;
183 if (!readSqliteUserVersion(bytes, version)) {
184 emitIndexDbFetchFailed(i18n("Not enough data."));
185 return;
186 }
187
188 dbgResources << "got version:" << version;
189 dbgResources << "expected:" << expectedVersion;
190 if (version != expectedVersion) {
191 emitIndexDbFetchFailed(i18n("Invalid version."));
192 return;
193 }
194
195 QString hash = QCryptographicHash::hash(bytes, QCryptographicHash::Md5).toHex();
196 dbgResources << "got hash:" << hash;
197 dbgResources << "expected:" << expectedHash;
198 if (hash != expectedHash) {
199 emitIndexDbFetchFailed(i18n("Invalid checksum."));
200 return;
201 }
202
203 QString dirName = QFileInfo(m_indexDbPath).path();
204 if (!QDir().mkpath(dirName)) {
205 emitIndexDbFetchFailed(i18n("Failed to create directory %1.").arg(dirName));
206 return;
207 }
208
209 QSaveFile saveFile(m_indexDbPath);
210 saveFile.setDirectWriteFallback(true);
211 if (!saveFile.open(QIODevice::WriteOnly)) {
212 emitIndexDbFetchFailed(i18n("Failed to open %1 for writing.").arg(m_indexDbPath));
213 return;
214 }
215
216 qint64 written = saveFile.write(bytes);
217 if (written != qint64(bytes.size()) || !saveFile.commit()) {
218 emitIndexDbFetchFailed(i18n("Failed to write %1.").arg(m_indexDbPath));
219 return;
220 }
221
223}
224
226{
227 QString errorMessage;
228 bool ok = doWithIndexDb(errorMessage, [this](QSqlDatabase &db, QString &outErrorMessage) {
229 QSqlQuery query(db);
230
231 QString selectBundlesSql = QStringLiteral(
232 "select bundle_id, file_name, size_in_bytes, source, title,\n"
233 " description, author, license, checksum, thumbnail\n"
234 "from bundle");
235 if (!query.exec(selectBundlesSql)) {
236 outErrorMessage = query.lastError().text();
237 return false;
238 }
239
240 QHash<long long, KisSupporterBundle> bundlesById;
241 while (query.next()) {
242 long long id = query.value(0).toLongLong();
243 KisSupporterBundle bundle = {
244 query.value(1).toString(),
245 query.value(2).toLongLong(),
246 query.value(3).toString(),
247 query.value(4).toString(),
248 query.value(5).toString(),
249 query.value(6).toString(),
250 query.value(7).toString(),
251 query.value(8).toString(),
252 QPixmap(),
253 QSet<QString>(),
254 QHash<QString, int>(),
255 };
256 bundle.thumbnail.loadFromData(query.value(9).toByteArray());
257 bundlesById.insert(id, bundle);
258 }
259
260 QString selectTagsSql = QStringLiteral("select bundle_id, tag from bundle_tag");
261 if (!query.exec(selectTagsSql)) {
262 outErrorMessage = query.lastError().text();
263 return false;
264 }
265
266 while (query.next()) {
267 long long id = query.value(0).toLongLong();
268 QHash<long long, KisSupporterBundle>::iterator it = bundlesById.find(id);
269 if (it != bundlesById.end()) {
270 it->tags.insert(query.value(1).toString());
271 }
272 }
273
274 QString selectResourceCountsSql = QStringLiteral(
275 "select bundle_id, media_type, amount\n"
276 "from bundle_resource_count");
277 if (query.exec(selectResourceCountsSql)) {
278 while (query.next()) {
279 long long id = query.value(0).toLongLong();
280 QHash<long long, KisSupporterBundle>::iterator it = bundlesById.find(id);
281 if (it != bundlesById.end()) {
282 it->resourceCountByMediaType.insert(query.value(1).toString(), query.value(2).toInt());
283 }
284 }
285 } else {
286 warnResources << "Error querying bundle resource counts:" << query.lastError().text();
287 // Not critical to have these counts, just keep going.
288 }
289
290 m_bundles = QVector<KisSupporterBundle>(bundlesById.begin(), bundlesById.end());
291 dbgResources << "Loaded" << m_bundles.size() << "supporter bundle(s)";
292 return true;
293 });
294
295 m_fetching = false;
296 emit fetchIndexDone(ok, errorMessage);
297}
298
299QNetworkReply *KisSupporterBundlesFetcher::fetch(const QString &path)
300{
301 QUrl url(QString::fromUtf8(BASE_URL));
302 url.setPath(url.path(QUrl::FullyDecoded) + path);
303
304 QNetworkRequest req;
305 req.setUrl(url);
306
307 if (!m_nam) {
308 m_nam = new KisNetworkAccessManager(this);
309 }
310
311 dbgResources << "Fetch" << url;
312 return m_nam->get(req);
313}
314
316{
317 emitFailure(i18n("Failed to retrieve bundle checksums: %1", errorMessage));
318}
319
321{
322 emitFailure(i18n("Failed to retrieve bundles: %1", errorMessage));
323}
324
325void KisSupporterBundlesFetcher::emitFailure(const QString &errorMessage)
326{
327 m_fetching = false;
328 Q_EMIT fetchIndexDone(false, errorMessage);
329}
330
331bool KisSupporterBundlesFetcher::doWithIndexDb(QString &outErrorMessage,
332 const std::function<bool(QSqlDatabase &, QString &)> &block)
333{
334 QString connectionName = QStringLiteral("KisSupporterBundlesFetcher");
335 bool ok;
336 {
337 QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connectionName);
338 db.setDatabaseName(m_indexDbPath);
339 if (db.open()) {
340 ok = block(db, outErrorMessage);
341 db.close();
342 } else {
343 ok = false;
344 outErrorMessage = db.lastError().text();
345 }
346 }
347 QSqlDatabase::removeDatabase(connectionName);
348 return ok;
349}
350
351bool KisSupporterBundlesFetcher::readSqliteUserVersion(const QByteArray &bytes, unsigned int &outVersion)
352{
353 // The SQLite format stores its "user version" value at index 60 as a 32 bit
354 // bigendian unsigned integer. See https://www.sqlite.org/fileformat.html
355 if (bytes.size() < 64) {
356 return false;
357 } else {
358 outVersion = qFromBigEndian<quint32>(bytes.constData() + 60);
359 return true;
360 }
361}
Network Access Manager for use with Krita.
void emitIndexDbFetchFailed(const QString &errorMessage)
void emitFailure(const QString &errorMessage)
QNetworkReply * fetch(const QString &path)
bool doWithIndexDb(QString &outErrorMessage, const std::function< bool(QSqlDatabase &, QString &)> &block)
bool checkExistingIndexDb(unsigned int expectedVersion, const QString &expectedHash)
QNetworkReply * fetchBundle(const KisSupporterBundle &bundle)
void emitCheckTxtFetchFailed(const QString &errorMessage)
void handleCheckTxtFetched(QNetworkReply *reply)
void handleIndexDbFetched(QNetworkReply *reply, unsigned int expectedVersion, const QString &expectedHash)
static bool hasNetworkReplyError(QNetworkReply *reply, QString &outErrorMessage)
QVector< KisSupporterBundle > m_bundles
void fetchIndexDone(bool success, const QString &errorMessage)
KisSupporterBundlesFetcher(QObject *parent=nullptr)
void fetchIndexDb(unsigned int expectedVersion, const QString &expectedHash)
static bool readSqliteUserVersion(const QByteArray &bytes, unsigned int &outVersion)
bool handleCheckTxtPredefinedFailure(const QString &content)
#define KIS_SAFE_ASSERT_RECOVER_RETURN(cond)
Definition kis_assert.h:128
#define warnResources
Definition kis_debug.h:85
#define dbgResources
Definition kis_debug.h:43