Merge pull request #13 from unmojang/evan-goode/curseforge

Fetch CurseForge API key from official files
This commit is contained in:
Evan Goode 2024-06-23 22:58:01 -04:00 committed by GitHub
commit 5e9b3c2baa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 77 additions and 37 deletions

View File

@ -246,7 +246,6 @@ set(Launcher_MSA_CLIENT_ID "" CACHE STRING "Client ID you can get from Microsoft
# https://support.curseforge.com/en/support/solutions/articles/9000207405-curse-forge-3rd-party-api-terms-and-conditions # https://support.curseforge.com/en/support/solutions/articles/9000207405-curse-forge-3rd-party-api-terms-and-conditions
# NOTE: CurseForge requires you to change this if you make any kind of derivative work. # NOTE: CurseForge requires you to change this if you make any kind of derivative work.
set(Launcher_CURSEFORGE_API_KEY "" CACHE STRING "API key for the CurseForge platform") set(Launcher_CURSEFORGE_API_KEY "" CACHE STRING "API key for the CurseForge platform")
set(Launcher_CURSEFORGE_API_KEY_API_URL "https://cf.polymc.org/api" CACHE STRING "URL to fetch the Curseforge API key from.")
set(Launcher_COMPILER_NAME ${CMAKE_CXX_COMPILER_ID}) set(Launcher_COMPILER_NAME ${CMAKE_CXX_COMPILER_ID})
set(Launcher_COMPILER_VERSION ${CMAKE_CXX_COMPILER_VERSION}) set(Launcher_COMPILER_VERSION ${CMAKE_CXX_COMPILER_VERSION})

View File

@ -117,7 +117,6 @@ Config::Config()
IMGUR_CLIENT_ID = "@Launcher_IMGUR_CLIENT_ID@"; IMGUR_CLIENT_ID = "@Launcher_IMGUR_CLIENT_ID@";
MSA_CLIENT_ID = "@Launcher_MSA_CLIENT_ID@"; MSA_CLIENT_ID = "@Launcher_MSA_CLIENT_ID@";
FLAME_API_KEY = "@Launcher_CURSEFORGE_API_KEY@"; FLAME_API_KEY = "@Launcher_CURSEFORGE_API_KEY@";
FLAME_API_KEY_API_URL = "@Launcher_CURSEFORGE_API_KEY_API_URL@";
META_URL = "@Launcher_META_URL@"; META_URL = "@Launcher_META_URL@";
GLFW_LIBRARY_NAME = "@Launcher_GLFW_LIBRARY_NAME@"; GLFW_LIBRARY_NAME = "@Launcher_GLFW_LIBRARY_NAME@";

View File

@ -148,11 +148,6 @@ class Config {
*/ */
QString FLAME_API_KEY; QString FLAME_API_KEY;
/**
* URL to fetch the Client API key for CurseForge from
*/
QString FLAME_API_KEY_API_URL;
/** /**
* Metadata repository URL prefix * Metadata repository URL prefix
*/ */

View File

@ -84,6 +84,7 @@
#include <mutex> #include <mutex>
#include <QAccessible> #include <QAccessible>
#include <QCheckBox>
#include <QCommandLineParser> #include <QCommandLineParser>
#include <QDebug> #include <QDebug>
#include <QDir> #include <QDir>
@ -1204,12 +1205,25 @@ void Application::performMainStartupAction()
} }
{ {
bool shouldFetch = m_settings->get("FlameKeyShouldBeFetchedOnStartup").toBool(); bool shouldFetch = m_settings->get("FlameKeyShouldBeFetchedOnStartup").toBool();
if (!BuildConfig.FLAME_API_KEY_API_URL.isEmpty() && shouldFetch && !(capabilities() & Capability::SupportsFlame)) { if (shouldFetch && !(capabilities() & Capability::SupportsFlame)) {
// don't ask, just fetch QMessageBox msgBox{ m_mainWindow };
QString apiKey = GuiUtil::fetchFlameKey(); msgBox.setWindowTitle(tr("Fetch CurseForge Core API key?"));
if (!apiKey.isEmpty()) { msgBox.setText(tr("Would you like to fetch the official CurseForge app's API key now?"));
m_settings->set("FlameKeyOverride", apiKey); msgBox.setInformativeText(
updateCapabilities(); tr("Using the official CurseForge app's API key may break CurseForge's terms of service but should allow Fjord Launcher "
"to download all mods in a modpack without you needing to download any of them manually."));
msgBox.setStandardButtons(QMessageBox::No | QMessageBox::Yes);
msgBox.setDefaultButton(QMessageBox::Yes);
msgBox.setModal(true);
const auto& result = msgBox.exec();
if (result == QMessageBox::Yes) {
const auto& apiKey = GuiUtil::fetchFlameKey();
if (!apiKey.isEmpty()) {
m_settings->set("FlameKeyOverride", apiKey);
updateCapabilities();
}
} }
m_settings->set("FlameKeyShouldBeFetchedOnStartup", false); m_settings->set("FlameKeyShouldBeFetchedOnStartup", false);
} }

View File

@ -26,9 +26,36 @@
FetchFlameAPIKey::FetchFlameAPIKey(QObject* parent) : Task{ parent } {} FetchFlameAPIKey::FetchFlameAPIKey(QObject* parent) : Task{ parent } {}
// Here, we fetch the API key from the files of the official CurseForge app. We
// use the macOS disk image (as opposed to the zipped Linux AppImage) since
// there is only one layer of DEFLATE decompression to get through. We
// range-request the specific ~500KiB zlib block from the archive.org mirror
// that contains the API key.
// See also https://git.sakamoto.pl/domi/curseme/src/commit/388ac991eb57dedd5d1aca45f418deb221d757d1/getToken.sh
const QUrl CURSEFORGE_APP_URL{
"https://web.archive.org/web/20240520233008if_/https://curseforge.overwolf.com/downloads/curseforge-latest.dmg"
};
// To find these offsets:
// 1. Download the disk image from CURSEFORGE_APP_URL and run
// dmg2img -V ./curseforge-latest.dmg
// 2. Use a hex editor to find the address of the string "cfCoreApiKey" inside
// curseforge-latest.img.
// 3. In the output of `dmg2img -V`, find the `in_addr`, the `in_size`, and the
// ` out_size` of the block that contains this address.
const uint32_t IN_ADDR{ 4640617 };
const uint32_t IN_SIZE{ 511977 };
const uint32_t OUT_SIZE{ 1048576 };
void FetchFlameAPIKey::executeTask() void FetchFlameAPIKey::executeTask()
{ {
QNetworkRequest req(BuildConfig.FLAME_API_KEY_API_URL); QNetworkRequest req{ CURSEFORGE_APP_URL };
// Request only a single block of the disk image file
const auto& rangeHeader = QString("bytes=%1-%2").arg(IN_ADDR).arg(IN_ADDR + IN_SIZE);
req.setRawHeader("Range", rangeHeader.toUtf8());
m_reply.reset(APPLICATION->network()->get(req)); m_reply.reset(APPLICATION->network()->get(req));
connect(m_reply.get(), &QNetworkReply::downloadProgress, this, &Task::setProgress); connect(m_reply.get(), &QNetworkReply::downloadProgress, this, &Task::setProgress);
connect(m_reply.get(), &QNetworkReply::finished, this, &FetchFlameAPIKey::downloadFinished); connect(m_reply.get(), &QNetworkReply::finished, this, &FetchFlameAPIKey::downloadFinished);
@ -43,29 +70,41 @@ void FetchFlameAPIKey::executeTask()
emitFailed(m_reply->errorString()); emitFailed(m_reply->errorString());
}); });
setStatus(tr("Fetching Curseforge core API key")); setStatus(tr("Fetching Curseforge core API key (may take a few seconds)..."));
} }
void FetchFlameAPIKey::downloadFinished() void FetchFlameAPIKey::downloadFinished()
{ {
auto res = m_reply->readAll(); auto res = m_reply->readAll();
auto doc = QJsonDocument::fromJson(res);
qDebug() << doc; // Prepend expected size header. See https://doc.qt.io/qt-6/qbytearray.html#qUncompress-1
QByteArray expectedSizeHeader;
QDataStream expectedSizeHeaderStream{ &expectedSizeHeader, QIODevice::WriteOnly };
expectedSizeHeaderStream.setByteOrder(QDataStream::BigEndian);
expectedSizeHeaderStream << OUT_SIZE;
try { res.prepend(expectedSizeHeader);
auto obj = Json::requireObject(doc);
auto success = Json::requireBoolean(obj, "ok"); const auto& block = qUncompress(res);
if (block.isEmpty()) {
if (success) { emitFailed("Couldn't decompress Curseforge app data.");
m_result = Json::requireString(obj, "token");
emitSucceeded();
} else {
emitFailed("The API returned an output indicating failure.");
}
} catch (Json::JsonException&) {
qCritical() << "Output: " << res;
emitFailed("The API returned an unexpected JSON output.");
} }
const char* precedingString = "\"cfCoreApiKey\":\"";
const QByteArray preceding{ precedingString };
const auto& precedingIndex = block.indexOf(preceding);
if (precedingIndex == -1) {
emitFailed(QString("Couldn't find string '%1'.").arg(precedingString));
}
const auto& startIndex = precedingIndex + preceding.size();
const auto& finalIndex = block.indexOf(QByteArray{ "\"" }, startIndex);
if (finalIndex == -1) {
emitFailed("Couldn't find closing \" for cfCoreApiKey value.");
}
const auto& keyByteArray = block.mid(startIndex, finalIndex - startIndex);
m_result = QString{ keyByteArray };
qDebug() << "Fetched Flame API key: " << m_result;
emitSucceeded();
} }

View File

@ -54,9 +54,6 @@
QString GuiUtil::fetchFlameKey(QWidget* parentWidget) QString GuiUtil::fetchFlameKey(QWidget* parentWidget)
{ {
if (BuildConfig.FLAME_API_KEY_API_URL.isEmpty())
return "";
ProgressDialog prog(parentWidget); ProgressDialog prog(parentWidget);
auto flameKeyTask = std::make_unique<FetchFlameAPIKey>(); auto flameKeyTask = std::make_unique<FetchFlameAPIKey>();
prog.execWithTask(flameKeyTask.get()); prog.execWithTask(flameKeyTask.get());

View File

@ -84,9 +84,6 @@ APIPage::APIPage(QWidget* parent) : QWidget(parent), ui(new Ui::APIPage)
ui->metaURL->setPlaceholderText(BuildConfig.META_URL); ui->metaURL->setPlaceholderText(BuildConfig.META_URL);
ui->userAgentLineEdit->setPlaceholderText(BuildConfig.USER_AGENT); ui->userAgentLineEdit->setPlaceholderText(BuildConfig.USER_AGENT);
if (BuildConfig.FLAME_API_KEY_API_URL.isEmpty())
ui->fetchKeyButton->hide();
loadSettings(); loadSettings();
resetBaseURLNote(); resetBaseURLNote();

View File

@ -294,7 +294,7 @@
</sizepolicy> </sizepolicy>
</property> </property>
<property name="text"> <property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Note: you probably don't need to set this if CurseForge already works.&lt;/p&gt;&lt;p&gt;&lt;span style=&quot; font-weight:700;&quot;&gt;Using the Official Curseforge Launcher's key may break Curseforge's Terms of service, but should allow Fjord Launcher to download all mods in a modpack without you needing to download any of them manually.&lt;/span&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string> <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Note: you probably don't need to set this if CurseForge already works.&lt;/p&gt;&lt;p&gt;&lt;span style=&quot; font-weight:700;&quot;&gt;Using the official CurseForge app's API key may break CurseForge's terms of service but should allow Fjord Launcher to download all mods in a modpack without you needing to download any of them manually.&lt;/span&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property> </property>
<property name="wordWrap"> <property name="wordWrap">
<bool>true</bool> <bool>true</bool>