/*
Copyright (C) 2012 Sebastian Herbord. All rights reserved.
This file is part of Mod Organizer.
Mod Organizer 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 3 of the License, or
(at your option) any later version.
Mod Organizer 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 Mod Organizer. If not, see .
*/
#include "nxmaccessmanager.h"
#include "iplugingame.h"
#include "nexusinterface.h"
#include "nxmurl.h"
#include "report.h"
#include "utility.h"
#include "selfupdater.h"
#include "persistentcookiejar.h"
#include "settings.h"
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
using namespace MOBase;
using namespace std::chrono_literals;
const QString NexusBaseUrl("https://api.nexusmods.com/v1");
const QString NexusSSO("wss://sso.nexusmods.com");
const QString NexusSSOPage("https://www.nexusmods.com/sso?id=%1&application=modorganizer2");
ValidationProgressDialog::ValidationProgressDialog(Settings* s, NexusKeyValidator& v)
: m_settings(s), m_validator(v), m_updateTimer(nullptr), m_first(true)
{
ui.reset(new Ui::ValidationProgressDialog);
ui->setupUi(this);
connect(ui->hide, &QPushButton::clicked, [&]{ onHide(); });
connect(ui->cancel, &QPushButton::clicked, [&]{ onCancel(); });
}
void ValidationProgressDialog::setParentWidget(QWidget* w)
{
const auto wasVisible = isVisible();
hide();
setParent(w, windowFlags() | Qt::Dialog);
setModal(false);
if (w && wasVisible) {
setVisible(true);
raise();
}
}
void ValidationProgressDialog::start()
{
if (!m_updateTimer) {
m_updateTimer = new QTimer(this);
connect(m_updateTimer, &QTimer::timeout, [&]{ onTimer(); });
m_updateTimer->setInterval(100ms);
}
updateProgress();
m_updateTimer->start();
show();
}
void ValidationProgressDialog::stop()
{
if (m_updateTimer) {
m_updateTimer->stop();
}
hide();
}
void ValidationProgressDialog::showEvent(QShowEvent* e)
{
if (m_first) {
if (m_settings) {
m_settings->geometry().centerOnMainWindowMonitor(this);
}
m_first = false;
}
}
void ValidationProgressDialog::closeEvent(QCloseEvent* e)
{
hide();
e->ignore();
}
void ValidationProgressDialog::onHide()
{
hide();
}
void ValidationProgressDialog::onCancel()
{
m_validator.cancel();
}
void ValidationProgressDialog::onTimer()
{
updateProgress();
}
void ValidationProgressDialog::updateProgress()
{
const auto* current = m_validator.currentAttempt();
if (current) {
ui->progress->setRange(0, current->timeout().count());
ui->progress->setValue(current->elapsed().elapsed() / 1000);
} else {
// indeterminate
ui->progress->setRange(0, 0);
}
if (const auto* a=m_validator.lastAttempt()) {
ui->label->setText(a->message() + ". " + tr("Trying again..."));
} else if (current) {
ui->label->setText(tr("Connecting to Nexus..."));
} else {
ui->label->setText("?");
}
}
NexusSSOLogin::NexusSSOLogin()
: m_keyReceived(false), m_active(false)
{
m_timeout.setInterval(10s);
m_timeout.setSingleShot(true);
QObject::connect(
&m_socket, &QWebSocket::connected,
[&]{ onConnected(); });
QObject::connect(
&m_socket, qOverload(&QWebSocket::error),
[&](auto&& e){ onError(e); });
QObject::connect(
&m_socket, &QWebSocket::sslErrors,
[&](auto&& errors){ onSslErrors(errors); });
QObject::connect(
&m_socket, &QWebSocket::textMessageReceived,
[&](auto&& s){ onMessage(s); });
QObject::connect(
&m_socket, &QWebSocket::disconnected,
[&]{ onDisconnected(); });
QObject::connect(&m_timeout, &QTimer::timeout, [&]{ onTimeout(); });
}
QString NexusSSOLogin::stateToString(States s, const QString& e)
{
switch (s)
{
case ConnectingToSSO:
return QObject::tr("Connecting to Nexus...");
case WaitingForToken:
return QObject::tr("Waiting for Nexus...");
case WaitingForBrowser:
return
QObject::tr("Opened Nexus in browser.") + "\n" +
QObject::tr("Switch to your browser and accept the request.");
case Finished:
return QObject::tr("Finished.");
case Timeout:
return
QObject::tr("No answer from Nexus.") + "\n" +
QObject::tr("A firewall might be blocking Mod Organizer.");
case ClosedByRemote:
return
QObject::tr("Nexus closed the connection.") + "\n" +
QObject::tr("A firewall might be blocking Mod Organizer.");
case Cancelled:
return QObject::tr("Cancelled.");
case Error: // fall-through
default:
{
if (e.isEmpty()) {
return QString("%1").arg(s);
} else {
return e;
}
}
}
}
void NexusSSOLogin::start()
{
m_active = true;
setState(ConnectingToSSO);
m_timeout.start();
m_socket.open(NexusSSO);
}
void NexusSSOLogin::cancel()
{
if (m_active) {
abort();
setState(Cancelled);
}
}
void NexusSSOLogin::close()
{
if (m_active) {
m_active = false;
m_timeout.stop();
m_socket.close();
}
}
void NexusSSOLogin::abort()
{
m_active = false;
m_timeout.stop();
m_socket.abort();
}
bool NexusSSOLogin::isActive() const
{
return m_active;
}
void NexusSSOLogin::setState(States s, const QString& error)
{
if (stateChanged) {
stateChanged(s, error);
}
}
void NexusSSOLogin::onConnected()
{
setState(WaitingForToken);
m_keyReceived = false;
boost::uuids::random_generator generator;
boost::uuids::uuid sessionId = generator();
m_guid = boost::uuids::to_string(sessionId).c_str();
QJsonObject data;
data.insert(QString("id"), QJsonValue(m_guid));
data.insert(QString("protocol"), 2);
const QString message = QJsonDocument(data).toJson();
m_socket.sendTextMessage(message);
}
void NexusSSOLogin::onMessage(const QString& s)
{
const QJsonDocument doc = QJsonDocument::fromJson(s.toUtf8());
const QVariantMap root = doc.object().toVariantMap();
if (!root["success"].toBool()) {
close();
setState(Error, QString("There was a problem with SSO initialization: %1")
.arg(root["error"].toString()));
return;
}
const QVariantMap data = root["data"].toMap();
if (data.contains("connection_token")) {
// first answer
// open browser
const QUrl url = NexusSSOPage.arg(m_guid);
shell::Open(url);
m_timeout.stop();
setState(WaitingForBrowser);
} else {
// second answer
const auto key = data["api_key"].toString();
close();
if (keyChanged) {
keyChanged(key);
}
setState(Finished);
}
}
void NexusSSOLogin::onDisconnected()
{
if (m_active) {
if (!m_keyReceived) {
close();
setState(ClosedByRemote);
} else {
m_active = false;
}
}
}
void NexusSSOLogin::onError(QAbstractSocket::SocketError e)
{
if (m_active) {
close();
setState(Error, m_socket.errorString());
}
}
void NexusSSOLogin::onSslErrors(const QList& errors)
{
if (m_active) {
for (const auto& e : errors) {
setState(Error, e.errorString());
}
}
}
void NexusSSOLogin::onTimeout()
{
abort();
setState(Timeout);
}
ValidationAttempt::ValidationAttempt(std::chrono::seconds timeout)
: m_reply(nullptr), m_result(None)
{
m_timeout.setSingleShot(true);
m_timeout.setInterval(timeout);
QObject::connect(&m_timeout, &QTimer::timeout, [&]{ onTimeout(); });
}
void ValidationAttempt::start(NXMAccessManager& m, const QString& key)
{
if (!sendRequest(m, key)) {
return;
}
m_elapsed.start();
m_timeout.start();
log::debug(
"nexus: attempt started with timeout of {} seconds", timeout().count());
}
bool ValidationAttempt::sendRequest(
NXMAccessManager& m, const QString& key)
{
const QString requestUrl(NexusBaseUrl + "/users/validate");
QNetworkRequest request(requestUrl);
request.setRawHeader("APIKEY", key.toUtf8());
request.setHeader(QNetworkRequest::KnownHeaders::UserAgentHeader, m.userAgent().toUtf8());
request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json");
request.setRawHeader("Protocol-Version", "1.0.0");
request.setRawHeader("Application-Name", "MO2");
request.setRawHeader("Application-Version", m.MOVersion().toUtf8());
m_reply = m.get(request);
if (!m_reply) {
setFailure(SoftError, QObject::tr("Failed to request %1").arg(requestUrl));
return false;
}
QObject::connect(
m_reply, &QNetworkReply::finished,
[&]{ onFinished(); });
QObject::connect(
m_reply, &QNetworkReply::sslErrors,
[&](auto&& errors){ onSslErrors(errors); });
return true;
}
void ValidationAttempt::cancel()
{
if (!m_reply || m_result != None) {
// not running
return;
}
setFailure(Cancelled, QObject::tr("Cancelled"));
if (m_reply) {
m_reply->abort();
}
cleanup();
}
bool ValidationAttempt::done() const
{
return (m_result != None);
}
ValidationAttempt::Result ValidationAttempt::result() const
{
return m_result;
}
const QString& ValidationAttempt::message() const
{
return m_message;
}
std::chrono::seconds ValidationAttempt::timeout() const
{
return std::chrono::duration_cast(
m_timeout.intervalAsDuration());
}
QElapsedTimer ValidationAttempt::elapsed() const
{
return m_elapsed;
}
void ValidationAttempt::onFinished()
{
if (m_result == Cancelled) {
return;
}
log::debug("nexus: request has finished");
if (!m_reply) {
// shouldn't happen
log::error("nexus: reply is null");
setFailure(HardError, QObject::tr("Internal error"));
return;
}
const auto code = m_reply->attribute(
QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (code == 0) {
// request wasn't even sent
log::error("nexus: code is 0");
setFailure(SoftError, m_reply->errorString());
return;
}
const auto doc = QJsonDocument::fromJson(m_reply->readAll());
const auto headers = m_reply->rawHeaderPairs();
const auto httpError = m_reply->errorString();
const QJsonObject data = doc.object();
if (code != 200) {
// http request failed
QString s = m_reply->errorString();
const auto nexusMessage = data.value("message").toString();
if (!nexusMessage.isEmpty()) {
if (!s.isEmpty()) {
s += ", ";
}
s += nexusMessage;
}
if (s.isEmpty()) {
s = QObject::tr("HTTP code %1").arg(code);
} else {
s += QString(" (%1)").arg(code);
}
setFailure(SoftError, s);
return;
}
if (doc.isNull()) {
setFailure(HardError, QObject::tr("Invalid JSON"));
return;
}
if (!data.contains("user_id")) {
setFailure(HardError, QObject::tr("Bad response"));
return;
}
const int id = data.value("user_id").toInt();
const QString key = data.value("key").toString();
const QString name = data.value("name").toString();
const bool premium = data.value("is_premium").toBool();
if (key.isEmpty()) {
setFailure(HardError, QObject::tr("API key is empty"));
return;
}
const auto user = APIUserAccount()
.apiKey(key)
.id(QString("%1").arg(id))
.name(name)
.type(premium ? APIUserAccountTypes::Premium : APIUserAccountTypes::Regular)
.limits(NexusInterface::parseLimits(headers));
setSuccess(user);
}
void ValidationAttempt::onSslErrors(const QList& errors)
{
log::error("nexus: ssl errors");
for (auto& e : errors) {
log::error(" . {}", e.errorString());
}
setFailure(HardError, QObject::tr("SSL error"));
}
void ValidationAttempt::onTimeout()
{
setFailure(SoftError, QObject::tr("Timed out"));
}
void ValidationAttempt::setFailure(Result r, const QString& error)
{
if (r != Cancelled) {
// don't spam the log
log::error("nexus: {}", error);
}
cleanup();
m_result = r;
m_message = error;
if (failure) {
failure();
}
}
void ValidationAttempt::setSuccess(const APIUserAccount& user)
{
log::debug("nexus connection successful");
cleanup();
m_result = Success;
m_message = "";
if (success) {
success(user);
}
}
void ValidationAttempt::cleanup()
{
m_timeout.stop();
if (m_reply) {
m_reply->disconnect();
m_reply->deleteLater();
m_reply = nullptr;
}
}
NexusKeyValidator::NexusKeyValidator(Settings* s, NXMAccessManager& am)
: m_settings(s), m_manager(am)
{
}
NexusKeyValidator::~NexusKeyValidator()
{
cancel();
}
std::vector NexusKeyValidator::getTimeouts() const
{
if (m_settings) {
return m_settings->nexus().validationTimeouts();
} else {
return {10s, 15s, 20s};
}
}
void NexusKeyValidator::start(const QString& key, Behaviour b)
{
if (isActive()) {
log::debug("nexus: trying to start while ongoing; ignoring");
return;
}
m_key = key;
const auto timeouts = getTimeouts();
switch (b)
{
case OneShot:
{
createAttempts({timeouts[0]});
break;
}
case Retry:
{
createAttempts(timeouts);
break;
}
}
nextTry();
}
void NexusKeyValidator::createAttempts(
const std::vector& timeouts)
{
m_attempts.clear();
for (auto&& t : timeouts) {
m_attempts.push_back(std::make_unique(t));
}
}
void NexusKeyValidator::cancel()
{
log::debug("nexus: connection cancelled");
for (auto&& a : m_attempts) {
a->cancel();
}
}
bool NexusKeyValidator::isActive() const
{
for (auto&& a : m_attempts) {
if (!a->done()) {
return true;
}
}
return false;
}
const ValidationAttempt* NexusKeyValidator::lastAttempt() const
{
const ValidationAttempt* last = nullptr;
for (auto&& a : m_attempts) {
if (a->done()) {
last = a.get();
} else {
break;
}
}
return last;
}
const ValidationAttempt* NexusKeyValidator::currentAttempt() const
{
for (auto&& a : m_attempts) {
if (!a->done()) {
return a.get();
}
}
return nullptr;
}
bool NexusKeyValidator::nextTry()
{
for (auto&& a : m_attempts) {
if (!a->done()) {
a->success = [&](auto&& user){ onAttemptSuccess(*a, user); };
a->failure = [&]{ onAttemptFailure(*a); };
a->start(m_manager, m_key);
return true;
}
}
// no more
return false;
}
void NexusKeyValidator::onAttemptSuccess(
const ValidationAttempt& a, const APIUserAccount& u)
{
if (attemptFinished) {
attemptFinished(a);
}
setFinished(ValidationAttempt::Success, "", u);
}
void NexusKeyValidator::onAttemptFailure(const ValidationAttempt& a)
{
if (attemptFinished) {
attemptFinished(a);
}
switch (a.result())
{
case ValidationAttempt::SoftError:
{
if (!nextTry()) {
setFinished(a.result(), a.message(), {});
}
break;
}
case ValidationAttempt::HardError:
{
cancel();
setFinished(a.result(), a.message(), {});
break;
}
case ValidationAttempt::Cancelled:
{
setFinished(ValidationAttempt::Cancelled, QObject::tr("Cancelled"), {});
break;
}
}
}
void NexusKeyValidator::setFinished(
ValidationAttempt::Result r, const QString& message,
std::optional user)
{
if (finished) {
finished(r, message, user);
}
}
NXMAccessManager::NXMAccessManager(QObject *parent, Settings* s, const QString &moVersion)
: QNetworkAccessManager(parent)
, m_Settings(s)
, m_MOVersion(moVersion)
, m_validator(s, *this)
, m_validationState(NotChecked)
{
m_validator.finished = [&](auto&& r, auto&& m, auto&& u) {
onValidatorFinished(r, m, u);
};
m_validator.attemptFinished = [&](auto&& a) {
onValidatorAttemptFinished(a);
};
if (m_Settings) {
setCookieJar(new PersistentCookieJar(QDir::fromNativeSeparators(
m_Settings->paths().cache() + "/nexus_cookies.dat")));
}
}
void NXMAccessManager::setTopLevelWidget(QWidget* w)
{
if (w) {
if (m_ProgressDialog) {
m_ProgressDialog->setParentWidget(w);
}
} else {
m_ProgressDialog.reset();
m_validator.cancel();
}
}
QNetworkReply *NXMAccessManager::createRequest(
QNetworkAccessManager::Operation operation, const QNetworkRequest &request,
QIODevice *device)
{
if (request.url().scheme() != "nxm") {
return QNetworkAccessManager::createRequest(operation, request, device);
}
if (operation == GetOperation) {
emit requestNXMDownload(request.url().toString());
// eat the request, everything else will be done by the download manager
return QNetworkAccessManager::createRequest(QNetworkAccessManager::GetOperation,
QNetworkRequest(QUrl()));
} else if (operation == PostOperation) {
return QNetworkAccessManager::createRequest(operation, request, device);;
} else {
return QNetworkAccessManager::createRequest(operation, request, device);
}
}
void NXMAccessManager::showCookies() const
{
QUrl url(NexusBaseUrl + "/");
for (const QNetworkCookie &cookie : cookieJar()->cookiesForUrl(url)) {
log::debug("{} - {} (expires: {})",
cookie.name().constData(), cookie.value().constData(),
cookie.expirationDate().toString());
}
}
void NXMAccessManager::clearCookies()
{
PersistentCookieJar *jar = qobject_cast(cookieJar());
if (jar != nullptr) {
jar->clear();
} else {
log::warn("failed to clear cookies, invalid cookie jar");
}
}
void NXMAccessManager::startValidationCheck(const QString& key)
{
m_validationState = NotChecked;
m_validator.start(key, NexusKeyValidator::Retry);
if (m_ProgressDialog) {
// don't show the progress dialog on startup for the first attempt; the
// dialog will be shown in onValidatorAttemptFinished() if it failed
startProgress();
}
}
void NXMAccessManager::onValidatorFinished(
ValidationAttempt::Result r, const QString& message,
std::optional user)
{
stopProgress();
if (user) {
m_validationState = Valid;
emit credentialsReceived(*user);
emit validateSuccessful(true);
} else {
if (r == ValidationAttempt::Cancelled) {
m_validationState = NotChecked;
} else {
m_validationState = Invalid;
emit validateFailed(message);
}
}
}
void NXMAccessManager::onValidatorAttemptFinished(const ValidationAttempt& a)
{
if (!m_ProgressDialog) {
switch (a.result())
{
case ValidationAttempt::SoftError:
case ValidationAttempt::HardError:
{
startProgress();
break;
}
case ValidationAttempt::None:
case ValidationAttempt::Success:
case ValidationAttempt::Cancelled:
default:
{
// don't show the dialog
break;
}
}
}
}
bool NXMAccessManager::validated() const
{
if (m_validationState == Valid) {
return true;
}
if (m_validator.isActive()) {
const_cast(this)->startProgress();
}
return false;
}
void NXMAccessManager::refuseValidation()
{
m_validationState = Invalid;
}
bool NXMAccessManager::validateAttempted() const
{
return (m_validationState != NotChecked);
}
bool NXMAccessManager::validateWaiting() const
{
return m_validator.isActive();
}
void NXMAccessManager::apiCheck(const QString &apiKey, bool force)
{
if (m_validator.isActive()) {
return;
}
if (m_Settings && m_Settings->network().offlineMode()) {
m_validationState = NotChecked;
return;
}
if (force) {
m_validationState = NotChecked;
}
if (m_validationState == Valid) {
emit validateSuccessful(false);
return;
}
startValidationCheck(apiKey);
}
const QString& NXMAccessManager::MOVersion() const
{
return m_MOVersion;
}
QString NXMAccessManager::userAgent(const QString &subModule) const
{
QStringList comments;
QString os;
if (QSysInfo::productType() == "windows")
comments << ((QSysInfo::kernelType() == "winnt") ? "Windows_NT " : "Windows ") + QSysInfo::kernelVersion();
else
comments << QSysInfo::kernelType().left(1).toUpper() + QSysInfo::kernelType().mid(1)
<< QSysInfo::productType().left(1).toUpper() + QSysInfo::kernelType().mid(1) + " " + QSysInfo::productVersion();
if (!subModule.isEmpty()) {
comments << "module: " + subModule;
}
comments << ((QSysInfo::buildCpuArchitecture() == "x86_64") ? "x64" : "x86");
return QString("Mod Organizer/%1 (%2) Qt/%3").arg(m_MOVersion, comments.join("; "), qVersion());
}
void NXMAccessManager::clearApiKey()
{
m_validator.cancel();
emit credentialsReceived(APIUserAccount());
}
void NXMAccessManager::startProgress()
{
if (!m_ProgressDialog) {
m_ProgressDialog.reset(new ValidationProgressDialog(m_Settings, m_validator));
}
m_ProgressDialog->start();
}
void NXMAccessManager::stopProgress()
{
if (m_ProgressDialog) {
m_ProgressDialog->stop();
}
}