Cockatrice/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp
Bryce! 28597ef716
Fix Deckstats and TappedOut request lifetime and result handling (#7705)
* Fix deck analysis request lifetime, result URLs and form encoding [skip ci]

* Preserve unknown cards in deck analysis exports

* Share deck analysis URL validation and drop the unused request latch

Both analysis interfaces carried a copy of normalizeAnalysisUrl; move it to one
header next to them. They are constructed per analysis and analyzeDeck is called
once, so the analysisStarted latch was unreachable; remove it from both.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 06:22:29 +01:00

148 lines
6.1 KiB
C++

#include "tapped_out_interface.h"
#include "deck_analysis_url.h"
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/deck_list/tree/inner_deck_list_node.h"
#include <QByteArray>
#include <QDebug>
#include <QDesktopServices>
#include <QList>
#include <QMessageBox>
#include <QMessageLogger>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPointer>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <QRegularExpressionMatchIterator>
#include <QSharedPointer>
#include <QString>
#include <QStringList>
#include <QTextDocumentFragment>
#include <QUrl>
#include <QVariant>
#include <QWidget>
#include <functional>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/network/network_manager.h>
#include <version_string.h>
TappedOutInterface::TappedOutInterface(QObject *parent) : QObject(parent)
{
manager = NetworkManagerFactory::create(this);
connect(manager, &QNetworkAccessManager::finished, this, &TappedOutInterface::queryFinished);
}
void TappedOutInterface::showError(const QString &message)
{
// The notice belongs to the originating editor, not this one-shot request.
// Retire the request before opening UI: opening a window may reenter Qt.
QWidget *owner = qobject_cast<QWidget *>(parent());
auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner);
notice->setTextFormat(Qt::PlainText);
notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard);
notice->setAttribute(Qt::WA_DeleteOnClose);
deleteLater();
notice->open();
}
void TappedOutInterface::queryFinished(QNetworkReply *reply)
{
if (replyHandled) {
return;
}
replyHandled = true;
// All terminal paths release the reply before showing UI or invoking an
// external URL handler, either of which may destroy the originating editor.
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
showError(reply->errorString());
return;
}
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (httpStatus >= 200 && httpStatus < 400 && reply->hasRawHeader("Location")) {
const QByteArray location = reply->rawHeader("Location");
QUrl deckUrl = reply->url().resolved(QUrl::fromEncoded(location, QUrl::StrictMode));
if (location.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("tappedout.net"))) {
showError(tr("The reply from TappedOut contained an invalid deck address."));
return;
}
QPointer<TappedOutInterface> guard(this);
deleteLater();
if (!QDesktopServices::openUrl(deckUrl) && guard) {
showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1")
.arg(deckUrl.toDisplayString()));
}
return;
}
const QString data = QString::fromUtf8(reply->readAll());
QStringList errorMessageList = {tr("Unable to analyze the deck on TappedOut.")};
static const QRegularExpression errorsRx("<div class=\"alert alert-danger.*?<ul>(.*?)</ul>",
QRegularExpression::DotMatchesEverythingOption);
const auto match = errorsRx.match(data);
if (match.hasMatch()) {
static const QRegularExpression itemRx("<li>(.*?)</li>", QRegularExpression::DotMatchesEverythingOption);
auto items = itemRx.globalMatch(match.captured(1));
while (items.hasNext()) {
const QString error = QTextDocumentFragment::fromHtml(items.next().captured(1)).toPlainText().simplified();
if (!error.isEmpty()) {
errorMessageList.append(error);
}
}
}
qCWarning(TappedOutInterfaceLog) << "TappedOut analysis failed, HTTP status" << httpStatus;
showError(errorMessageList.join('\n'));
}
void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data)
{
DeckList mainboard, sideboard;
copyDeckSplitMainAndSide(deck, mainboard, sideboard);
data.append("name=" + QUrl::toPercentEncoding(deck.getName()));
data.append("&mainboard=" + QUrl::toPercentEncoding(mainboard.writeToString_Plain(false, true)));
data.append("&sideboard=" + QUrl::toPercentEncoding(sideboard.writeToString_Plain(false, true)));
}
void TappedOutInterface::analyzeDeck(const DeckList &deck)
{
QByteArray data;
getAnalyzeRequestData(deck, data);
QNetworkRequest request(QUrl("https://tappedout.net/mtg-decks/paste/"));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
// we interpret the redirect and open it in the browser instead, do not follow redirects
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy);
manager->post(request, data);
}
void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckList &mainboard, DeckList &sideboard)
{
auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) {
CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName());
// Exclude confirmed tokens, but preserve cards missing from the local
// database, including newly released cards in an imported deck.
if ((dbCard && dbCard->getIsToken()) || node->getName() == DECK_ZONE_MAYBEBOARD) {
return;
}
DecklistCardNode *addedCard;
if (node->getName() == DECK_ZONE_SIDE) {
addedCard = sideboard.addCard(card->getName(), node->getName(), -1);
} else {
addedCard = mainboard.addCard(card->getName(), node->getName(), -1);
}
addedCard->setNumber(card->getNumber());
};
source.forEachCard(copyMainOrSide);
}