Line data Source code
1 : /*
2 : * SPDX-License-Identifier: GPL-3.0-only
3 : * MuseScore-CLA-applies
4 : *
5 : * MuseScore Studio
6 : * Music Composition & Notation
7 : *
8 : * Copyright (C) 2025 MuseScore Limited and others
9 : *
10 : * This program is free software: you can redistribute it and/or modify
11 : * it under the terms of the GNU General Public License version 3 as
12 : * published by the Free Software Foundation.
13 : *
14 : * This program is distributed in the hope that it will be useful,
15 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 : * GNU General Public License for more details.
18 : *
19 : * You should have received a copy of the GNU General Public License
20 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
21 : */
22 :
23 : #include "abstractcloudservice.h"
24 :
25 : #include <QBuffer>
26 : #include <QOAuth2AuthorizationCodeFlow>
27 : #include <QJsonDocument>
28 : #include <QJsonObject>
29 : #include <QUrlQuery>
30 : #include <QHttpMultiPart>
31 : #include <QRandomGenerator>
32 : #include <QEventLoop>
33 : #include <QTimer>
34 :
35 : #include "clouderrors.h"
36 : #include "multiwindows/resourcelockguard.h"
37 : #include "network/networkerrors.h"
38 : #include "global/iapplication.h"
39 : #include "global/io/ioretcodes.h"
40 : #include "draw/types/color.h"
41 :
42 : #include "oauthhttpserverreplyhandler.h"
43 :
44 : #include "log.h"
45 :
46 : using namespace muse;
47 : using namespace muse::cloud;
48 : using namespace muse::network;
49 : using namespace muse::async;
50 :
51 : const QString muse::cloud::ACCESS_TOKEN_KEY("access_token");
52 : const QString muse::cloud::REFRESH_TOKEN_KEY("refresh_token");
53 :
54 : static const std::string CLOUD_ACCESS_TOKEN_RESOURCE_NAME("CLOUD_ACCESS_TOKEN");
55 : static const std::string STATUS_KEY("status");
56 :
57 16 : static Ret retFromStatus(int status, bool isAlreadyUploaded)
58 : {
59 16 : Ret ret;
60 :
61 16 : switch (status) {
62 2 : case 400:
63 2 : ret = make_ret(cloud::Err::Status400_InvalidRequest);
64 2 : break;
65 2 : case 401:
66 2 : ret = make_ret(cloud::Err::Status401_AuthorizationRequired);
67 2 : break;
68 3 : case 403:
69 3 : ret = make_ret(isAlreadyUploaded ? cloud::Err::Status403_AccountNotActivated : cloud::Err::Status403_NotOwner);
70 3 : break;
71 3 : case 404:
72 3 : ret = make_ret(cloud::Err::Status404_NotFound);
73 3 : break;
74 0 : case 409:
75 0 : ret = make_ret(cloud::Err::Status409_Conflict);
76 0 : break;
77 2 : case 422:
78 2 : ret = make_ret(cloud::Err::Status422_ValidationFailed);
79 2 : break;
80 1 : case 429:
81 1 : ret = make_ret(cloud::Err::Status429_RateLimitExceeded);
82 1 : break;
83 2 : case 500:
84 2 : ret = make_ret(cloud::Err::Status500_InternalServerError);
85 2 : break;
86 1 : default:
87 1 : ret = make_ret(cloud::Err::UnknownStatusCode);
88 1 : ret.setText("Unknown status code: " + std::to_string(status));
89 1 : break;
90 : }
91 :
92 16 : ret.setData(STATUS_KEY, status);
93 :
94 16 : return ret;
95 0 : }
96 :
97 57 : AbstractCloudService::AbstractCloudService(const modularity::ContextPtr& iocCtx, QObject* parent)
98 57 : : QObject(parent), Contextable(iocCtx)
99 : {
100 57 : m_userAuthorized.val = false;
101 57 : }
102 :
103 57 : void AbstractCloudService::init()
104 : {
105 58 : TRACEFUNC;
106 :
107 57 : m_serverConfig = serverConfig();
108 57 : m_networkManager = networkManagerCreator()->makeNetworkManager();
109 :
110 57 : multiwindowsProvider()->resourceChanged().onReceive(this, [this](const std::string& resourceName) {
111 0 : if (resourceName == CLOUD_ACCESS_TOKEN_RESOURCE_NAME) {
112 0 : readTokens();
113 : }
114 0 : });
115 :
116 57 : if (readTokens()) {
117 151 : executeAsyncRequest([this]() { return downloadAccountInfo(); })
118 50 : .onResolve(this, [](const Ret& ret) {
119 50 : if (!ret) {
120 0 : LOGE() << ret.toString();
121 : }
122 50 : });
123 : }
124 57 : }
125 :
126 0 : void AbstractCloudService::initOAuthIfNecessary()
127 : {
128 0 : if (m_oauth2) {
129 0 : return;
130 : }
131 :
132 : // We initialize the OAuth etc lazily, to save resources, but also so that firewall warnings
133 : // will only appear at the moment that the user tries to log in, not on every launch.
134 :
135 0 : m_oauth2 = new QOAuth2AuthorizationCodeFlow(this);
136 :
137 0 : m_replyHandler = new OAuthHttpServerReplyHandler(iocContext(), this);
138 0 : m_replyHandler->setRedirectUrl(m_serverConfig.signInSuccessUrl);
139 :
140 0 : m_oauth2->setAuthorizationUrl(m_serverConfig.authorizationUrl);
141 0 : m_oauth2->setTokenUrl(m_serverConfig.accessTokenUrl);
142 0 : m_oauth2->setPkceMethod(QOAuth2AuthorizationCodeFlow::PkceMethod::None);
143 0 : m_oauth2->setModifyParametersFunction([this](QAbstractOAuth::Stage, QMultiMap<QString, QVariant>* parameters) {
144 0 : for (const QString& key : m_serverConfig.authorizationParameters.keys()) {
145 0 : parameters->replace(key, m_serverConfig.authorizationParameters.value(key));
146 0 : }
147 0 : });
148 :
149 0 : m_oauth2->setReplyHandler(m_replyHandler);
150 :
151 0 : connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, this, &AbstractCloudService::openUrl);
152 0 : connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::granted, this, &AbstractCloudService::onUserAuthorized);
153 :
154 0 : connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::serverReportedErrorOccurred,
155 0 : [](const QString& error, const QString& errorDescription, const QUrl& uri) {
156 0 : LOGE() << "Error during authorization: " << error << "\n Description: " << errorDescription << "\n URI: " << uri.toString();
157 0 : });
158 : }
159 :
160 57 : bool AbstractCloudService::readTokens()
161 : {
162 58 : TRACEFUNC;
163 :
164 57 : io::path_t tokensPath = tokensFilePath();
165 57 : RetVal<ByteArray> tokensData;
166 : {
167 57 : mi::ReadResourceLockGuard resource_guard(multiwindowsProvider(), CLOUD_ACCESS_TOKEN_RESOURCE_NAME);
168 57 : tokensData = fileSystem()->readFile(tokensPath);
169 57 : }
170 :
171 57 : if (!tokensData.ret) {
172 7 : if (tokensData.ret.code() == (int)io::Err::FSNotExist) {
173 7 : LOGI() << "Could not find the tokens file: " << tokensPath;
174 : } else {
175 0 : LOGE() << tokensData.ret.toString();
176 : }
177 :
178 7 : return false;
179 : }
180 :
181 50 : QJsonParseError err;
182 50 : QJsonDocument tokensDoc = QJsonDocument::fromJson(tokensData.val.toQByteArrayNoCopy(), &err);
183 50 : if (err.error != QJsonParseError::NoError || !tokensDoc.isObject()) {
184 0 : LOGE() << "Error on parse tokens file: " << err.errorString();
185 0 : return false;
186 : }
187 :
188 50 : QJsonObject saveObject = tokensDoc.object();
189 :
190 50 : m_accessToken = saveObject[ACCESS_TOKEN_KEY].toString();
191 50 : m_refreshToken = saveObject[REFRESH_TOKEN_KEY].toString();
192 :
193 50 : return true;
194 57 : }
195 :
196 5 : Ret AbstractCloudService::saveTokens()
197 : {
198 6 : TRACEFUNC;
199 :
200 5 : QJsonObject tokensObject;
201 5 : tokensObject[ACCESS_TOKEN_KEY] = m_accessToken;
202 5 : tokensObject[REFRESH_TOKEN_KEY] = m_refreshToken;
203 5 : QJsonDocument tokensDoc(tokensObject);
204 5 : QByteArray json = tokensDoc.toJson();
205 :
206 5 : Ret ret;
207 : {
208 5 : mi::WriteResourceLockGuard resource_guard(multiwindowsProvider(), CLOUD_ACCESS_TOKEN_RESOURCE_NAME);
209 5 : ret = fileSystem()->writeFile(tokensFilePath(), ByteArray::fromQByteArrayNoCopy(json));
210 5 : }
211 :
212 5 : return ret;
213 5 : }
214 :
215 0 : void AbstractCloudService::removeTokens()
216 : {
217 : {
218 0 : mi::WriteResourceLockGuard resource_guard(multiwindowsProvider(), CLOUD_ACCESS_TOKEN_RESOURCE_NAME);
219 0 : Ret ret = fileSystem()->remove(tokensFilePath());
220 0 : if (!ret) {
221 0 : LOGE() << ret.toString();
222 : }
223 0 : }
224 :
225 0 : clearTokens();
226 0 : }
227 :
228 0 : void AbstractCloudService::clearTokens()
229 : {
230 0 : m_accessToken.clear();
231 0 : m_refreshToken.clear();
232 0 : setAccountInfo(AccountInfo());
233 0 : }
234 :
235 62 : io::path_t AbstractCloudService::tokensFilePath() const
236 : {
237 62 : return configuration()->tokensFilePath(m_serverConfig.serverCode.toStdString());
238 : }
239 :
240 0 : void AbstractCloudService::onUserAuthorized()
241 : {
242 0 : TRACEFUNC;
243 :
244 0 : initOAuthIfNecessary();
245 :
246 0 : m_accessToken = m_oauth2->token();
247 0 : m_refreshToken = m_oauth2->refreshToken();
248 :
249 0 : LOGD() << "========== access " << m_accessToken << " ========= refresh " << m_refreshToken;
250 :
251 0 : saveTokens();
252 :
253 0 : downloadAccountInfo().onResolve(this, [](const Ret& ret) {
254 0 : if (!ret) {
255 0 : LOGE() << ret.toString();
256 : }
257 0 : });
258 0 : }
259 :
260 176 : RequestHeaders AbstractCloudService::defaultHeaders() const
261 : {
262 176 : return configuration()->headers();
263 : }
264 :
265 106 : RetVal<QUrl> AbstractCloudService::prepareUrlForRequest(QUrl apiUrl, const QVariantMap& params) const
266 : {
267 106 : if (m_accessToken.isEmpty()) {
268 5 : return make_ret(cloud::Err::AccessTokenIsEmpty);
269 : }
270 :
271 101 : QUrlQuery query;
272 101 : query.addQueryItem(ACCESS_TOKEN_KEY, m_accessToken);
273 :
274 134 : for (auto it = params.cbegin(); it != params.cend(); ++it) {
275 33 : query.addQueryItem(it.key(), it.value().toString());
276 : }
277 :
278 101 : apiUrl.setQuery(query);
279 :
280 101 : return RetVal<QUrl>::make_ok(apiUrl);
281 101 : }
282 :
283 0 : void AbstractCloudService::signIn()
284 : {
285 0 : if (m_userAuthorized.val) {
286 0 : return;
287 : }
288 :
289 0 : initOAuthIfNecessary();
290 :
291 0 : m_oauth2->setAuthorizationUrl(m_serverConfig.authorizationUrl);
292 0 : m_oauth2->grant();
293 : }
294 :
295 0 : void AbstractCloudService::signUp()
296 : {
297 0 : if (m_userAuthorized.val) {
298 0 : return;
299 : }
300 :
301 0 : initOAuthIfNecessary();
302 :
303 0 : m_oauth2->setAuthorizationUrl(m_serverConfig.signUpUrl);
304 0 : m_oauth2->grant();
305 : }
306 :
307 0 : void AbstractCloudService::signOut()
308 : {
309 0 : if (!m_userAuthorized.val) {
310 0 : return;
311 : }
312 :
313 0 : TRACEFUNC;
314 :
315 0 : QVariantMap params;
316 0 : params[REFRESH_TOKEN_KEY] = m_refreshToken;
317 :
318 0 : RetVal<QUrl> signOutUrl = prepareUrlForRequest(m_serverConfig.logoutApiUrl, params);
319 0 : if (!signOutUrl.ret) {
320 0 : LOGE() << signOutUrl.ret.toString();
321 0 : return;
322 : }
323 :
324 0 : if (signOutUrl.val.isEmpty()) {
325 0 : removeTokens();
326 0 : return;
327 : }
328 :
329 0 : RetVal<Progress> progress = m_networkManager->get(signOutUrl.val, nullptr, m_serverConfig.headers);
330 0 : if (!progress.ret) {
331 0 : LOGE() << progress.ret.toString();
332 0 : removeTokens();
333 0 : return;
334 : }
335 :
336 0 : progress.val.finished().onReceive(this, [this](const ProgressResult& res) {
337 0 : if (!res.ret) {
338 0 : LOGE() << res.ret;
339 : }
340 :
341 0 : removeTokens();
342 0 : });
343 0 : }
344 :
345 4 : ValCh<bool> AbstractCloudService::userAuthorized() const
346 : {
347 4 : return m_userAuthorized;
348 : }
349 :
350 4 : const AccountInfo& AbstractCloudService::accountInfo() const
351 : {
352 4 : return m_accountInfo;
353 : }
354 :
355 0 : Promise<Ret> AbstractCloudService::checkCloudIsAvailable() const
356 : {
357 : static constexpr int ANSWER_TIMEOUT_MS = 5000;
358 :
359 0 : return async::make_promise<Ret>([this](auto resolve) {
360 0 : RetVal<Progress> progress = m_networkManager->get(m_serverConfig.serverAvailabilityUrl, nullptr, m_serverConfig.headers);
361 0 : if (!progress.ret) {
362 0 : return resolve(progress.ret);
363 : }
364 :
365 0 : auto answered = std::make_shared<bool>(false);
366 :
367 0 : progress.val.finished().onReceive(this, [answered, resolve](const ProgressResult& res) {
368 0 : *answered = true;
369 0 : (void)resolve(res.ret);
370 : });
371 :
372 0 : QTimer::singleShot(ANSWER_TIMEOUT_MS, [answered, progress]() mutable {
373 0 : if (!*answered) {
374 0 : progress.val.cancel();
375 : }
376 : });
377 :
378 0 : return Promise<Ret>::dummy_result();
379 0 : });
380 : }
381 :
382 50 : void AbstractCloudService::setAccountInfo(const AccountInfo& info)
383 : {
384 50 : if (m_accountInfo == info) {
385 1 : return;
386 : }
387 :
388 49 : m_accountInfo = info;
389 49 : m_userAuthorized.set(info.isValid());
390 : }
391 :
392 90 : Promise<Ret> AbstractCloudService::executeAsyncRequest(const AsyncRequestCallback& requestCallback)
393 : {
394 : //! NOTE: helps to avoid memory leak due to self-capture
395 90 : auto callback = new AsyncRequestCallback(requestCallback);
396 :
397 180 : return requestCallback().then<Ret>(this, [this, callback](const Ret& ret, auto resolve) {
398 86 : if (ret) {
399 60 : delete callback;
400 60 : return resolve(ret);
401 : }
402 :
403 : // Check whether tokens have expired...
404 26 : if (statusCode(ret) != USER_UNAUTHORIZED_STATUS_CODE) {
405 19 : delete callback;
406 19 : return resolve(ret);
407 : }
408 :
409 : // Update tokens and retry request
410 7 : updateTokens().onResolve(this, [this, callback, resolve](const Ret& ret) {
411 7 : if (!ret) {
412 2 : delete callback;
413 2 : (void)resolve(ret);
414 3 : return;
415 : }
416 :
417 5 : Ret saveTokensRet = saveTokens();
418 5 : if (!saveTokensRet) {
419 1 : delete callback;
420 1 : (void)resolve(saveTokensRet);
421 1 : return;
422 : }
423 :
424 4 : (*callback)().onResolve(this, [resolve, callback](const Ret& ret) {
425 4 : delete callback;
426 4 : (void)resolve(ret);
427 : });
428 5 : });
429 :
430 7 : return Promise<Ret>::dummy_result();
431 180 : });
432 : }
433 :
434 19 : Ret AbstractCloudService::uploadingDownloadingRetFromRawRet(const Ret& rawRet, bool isAlreadyUploaded) const
435 : {
436 19 : if (rawRet) {
437 1 : return rawRet; // OK
438 : }
439 :
440 18 : int status = statusCode(rawRet);
441 18 : if (status) {
442 16 : return retFromStatus(status, isAlreadyUploaded);
443 : }
444 :
445 2 : switch (rawRet.code()) {
446 1 : case int(network::Err::NetworkError):
447 : case int(network::Err::Timeout):
448 : case int(network::Err::Abort):
449 1 : return make_ret(cloud::Err::NetworkError);
450 : }
451 :
452 1 : return rawRet;
453 : }
454 :
455 46 : int AbstractCloudService::statusCode(const Ret& ret) const
456 : {
457 46 : return ret.data<int>(STATUS_KEY, 0);
458 : }
459 :
460 14 : void AbstractCloudService::printServerReply(const QBuffer& reply) const
461 : {
462 14 : const QByteArray& data = reply.data();
463 :
464 14 : if (!data.isEmpty()) {
465 6 : LOGD() << "Server reply: " << data;
466 : }
467 14 : }
468 :
469 0 : const QString& AbstractCloudService::accessToken() const
470 : {
471 0 : return m_accessToken;
472 : }
473 :
474 5 : void AbstractCloudService::setAccessToken(const QString& token)
475 : {
476 5 : m_accessToken = token;
477 5 : }
478 :
479 7 : const QString& AbstractCloudService::refreshToken() const
480 : {
481 7 : return m_refreshToken;
482 : }
483 :
484 5 : void AbstractCloudService::setRefreshToken(const QString& token)
485 : {
486 5 : m_refreshToken = token;
487 5 : }
488 :
489 0 : void AbstractCloudService::openUrl(const QUrl& url)
490 : {
491 0 : Ret ret = platformInteractive()->openUrl(url);
492 0 : if (!ret) {
493 0 : LOGE() << ret.toString();
494 : }
495 0 : }
496 :
497 0 : QString AbstractCloudService::logoColor() const
498 : {
499 0 : const ui::ThemeList& themens = uiConfig()->themes();
500 0 : bool isDarkMode = uiConfig()->isDarkMode();
501 :
502 0 : for (const ui::ThemeInfo& theme : themens) {
503 0 : if ((isDarkMode && theme.codeKey == ui::DARK_THEME_CODE)
504 0 : || (!isDarkMode && theme.codeKey == ui::LIGHT_THEME_CODE)) {
505 0 : return theme.values[ui::FONT_PRIMARY_COLOR].toString();
506 : }
507 : }
508 :
509 0 : return QString::fromStdString(draw::Color::BLACK.toString());
510 0 : }
|