#include "seasonalbuildservice.h" #include "launcherpaths.h" #include "modloader.h" #include #include #include #include #include #include #include #include #include #include #include #include namespace { // Формат каталога, который понимает этот лаунчер. Сервер, отдающий версию // новее, обслуживает лаунчер новее — молча показывать половину полей нельзя. constexpr int kSupportedFormatVersion = 1; // Каталог весит килобайты, а новая ревизия сборки должна доезжать до игроков // быстро. Четверть часа — это и не поток запросов при каждом открытии окна, и // не полдня ожидания после публикации; кнопка «Обновить список» ходит с force. constexpr qint64 kCacheTtlSeconds = 15 * 60; constexpr int kTransferTimeoutMs = 20000; // id попадает в имена файлов и в сравнение с локальными записями, поэтому // набор символов узкий и проверяется до всего остального. bool isSafeCatalogId(const QString &id) { static const QRegularExpression re(QStringLiteral("^[a-z0-9][a-z0-9._-]{0,63}$")); return re.match(id).hasMatch(); } // Тот же запрет, что у идентификаторов версий в minecraftversion.cpp: строка // становится именем папки в versions. bool isSafeVersionId(const QString &id) { if (id.isEmpty() || id.size() > 128) return false; if (id.contains(u'/') || id.contains(u'\\') || id.contains(u':')) return false; return id != QLatin1String(".") && id != QLatin1String(".."); } bool isSha256Hex(const QString &value) { if (value.size() != 64) return false; for (const QChar c : value) { if (!((c >= u'0' && c <= u'9') || (c >= u'a' && c <= u'f') || (c >= u'A' && c <= u'F'))) return false; } return true; } // null или пустая строка — даты нет; в таблице на её месте прочерк. QDate readDate(const QJsonValue &value) { const QString text = value.toString(); return text.isEmpty() ? QDate() : QDate::fromString(text, Qt::ISODate); } QString dateToJson(const QDate &date) { return date.isValid() ? date.toString(Qt::ISODate) : QString(); } } // namespace SeasonalBuildService::SeasonalBuildService(QObject *parent) : QObject(parent) , m_network(new QNetworkAccessManager(this)) { } std::optional SeasonalBuildService::find(const QString &id) const { const auto it = m_indexById.constFind(id); if (it == m_indexById.constEnd()) return std::nullopt; return m_builds.at(it.value()); } void SeasonalBuildService::setBaseUrl(const QUrl &baseUrl) { if (m_baseUrl == baseUrl) return; m_baseUrl = baseUrl; // Ссылки на архивы привязаны к прежнему хосту, а проверка их происхождения // сверяется с базовым адресом — держать такие данные после смены сервера // нельзя. m_builds.clear(); m_indexById.clear(); m_fetchedAt = QDateTime(); m_cacheRead = false; m_lastError.clear(); emit buildsChanged(); } void SeasonalBuildService::applyBuilds(QList list) { m_builds = std::move(list); m_indexById.clear(); m_indexById.reserve(m_builds.size()); for (int i = 0; i < m_builds.size(); ++i) m_indexById.insert(m_builds.at(i).id, i); } QList SeasonalBuildService::parseCatalog(const QJsonObject &root, QString *error) const { const int format = root.value(QStringLiteral("formatVersion")).toInt(); if (format != kSupportedFormatVersion) { if (error) { *error = format > kSupportedFormatVersion ? tr("Сервер сборок отдаёт формат %1 — обновите лаунчер").arg(format) : tr("Неизвестный формат каталога сборок (%1)").arg(format); } return {}; } QList list; const QJsonArray builds = root.value(QStringLiteral("builds")).toArray(); list.reserve(builds.size()); for (const QJsonValue &value : builds) { const QJsonObject object = value.toObject(); SeasonalBuildEntry entry; entry.id = object.value(QStringLiteral("id")).toString(); entry.name = object.value(QStringLiteral("name")).toString().trimmed(); entry.revision = object.value(QStringLiteral("revision")).toInt(); entry.minecraftVersion = object.value(QStringLiteral("minecraftVersion")).toString(); entry.loader = object.value(QStringLiteral("loader")).toString(); entry.loaderVersion = object.value(QStringLiteral("loaderVersion")).toString().trimmed(); entry.modCount = object.value(QStringLiteral("modCount")).toInt(); entry.seasonStart = readDate(object.value(QStringLiteral("seasonStart"))); entry.seasonEnd = readDate(object.value(QStringLiteral("seasonEnd"))); entry.serverUrl = object.value(QStringLiteral("serverUrl")).toString().trimmed(); entry.javaMajor = object.value(QStringLiteral("javaMajor")).toInt(); entry.description = object.value(QStringLiteral("description")).toString().trimmed(); const QJsonObject archive = object.value(QStringLiteral("archive")).toObject(); entry.archiveUrl = QUrl(archive.value(QStringLiteral("url")).toString()); entry.archiveSize = qint64(archive.value(QStringLiteral("size")).toDouble()); entry.archiveSha256 = archive.value(QStringLiteral("sha256")).toString().toLower(); // Данные пришли из сети и превратятся в пути на диске и в запуск игры, // поэтому строка отбрасывается целиком при любой странности. if (!isSafeCatalogId(entry.id) || entry.name.isEmpty() || entry.revision <= 0) continue; if (!isSafeVersionId(entry.minecraftVersion)) continue; if (!entry.loader.isEmpty() && !loaderFromKey(entry.loader)) continue; // Загрузчик без версии установить нечем. if (!entry.loader.isEmpty() && entry.loaderVersion.isEmpty()) continue; if (!isSha256Hex(entry.archiveSha256) || entry.archiveSize < 0) continue; // Ссылка обязана вести на тот же сервер, с которого пришёл каталог: // подменённый index.json иначе увёл бы загрузку куда угодно. if (entry.archiveUrl.scheme() != m_baseUrl.scheme() || entry.archiveUrl.host() != m_baseUrl.host() || entry.archiveUrl.port(-1) != m_baseUrl.port(-1)) { continue; } // http допустим только если сам адрес сервера задан по http — так // работает отладка против локального python3 -m http.server. if (entry.archiveUrl.scheme() != QLatin1String("https") && entry.archiveUrl.scheme() != QLatin1String("http")) { continue; } list << entry; } // Свежие сезоны сверху: сначала те, что ещё идут, потом по дате старта. std::stable_sort(list.begin(), list.end(), [](const SeasonalBuildEntry &a, const SeasonalBuildEntry &b) { const bool aOpen = !a.seasonEnd.isValid(); const bool bOpen = !b.seasonEnd.isValid(); if (aOpen != bOpen) return aOpen; if (a.seasonStart != b.seasonStart) return a.seasonStart > b.seasonStart; return a.name.localeAwareCompare(b.name) < 0; }); return list; } void SeasonalBuildService::ensureLoaded(Callback callback, bool forceRefresh) { if (!m_baseUrl.isValid() || m_baseUrl.host().isEmpty()) { m_lastError = tr("Адрес сервера сборок не задан"); if (callback) { const QString error = m_lastError; QTimer::singleShot(0, this, [callback = std::move(callback), error] { callback(false, error); }); } return; } if (!m_cacheRead) { m_cacheRead = true; if (readCache()) emit buildsChanged(); } const bool fresh = hasData() && m_fetchedAt.isValid() && m_fetchedAt.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds; if (!forceRefresh && fresh) { // Колбэк, который то синхронный, то нет, ломает вызывающий код — // на попадании в кэш отвечаем через событийный цикл. if (callback) QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); }); return; } if (callback) m_pending << std::move(callback); if (m_refreshing) return; startRefresh(); } void SeasonalBuildService::startRefresh() { m_refreshing = true; emit refreshingChanged(); QUrl url = m_baseUrl; url.setPath(m_baseUrl.path() + QStringLiteral("/index.json")); QNetworkRequest request{url}; request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); request.setTransferTimeout(kTransferTimeoutMs); QNetworkReply *reply = m_network->get(request); connect(reply, &QNetworkReply::finished, this, [this, reply] { reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) { finishRefresh(false, tr("Не удалось получить список сборок: %1").arg(reply->errorString())); return; } QString parseError; QList parsed = parseCatalog(QJsonDocument::fromJson(reply->readAll()).object(), &parseError); // Пустой разбор — испорченный ответ; хороший кэш им не затираем. // Пустой каталог на сервере от испорченного не отличить, поэтому // «сборок нет» и «ответ битый» — одно и то же состояние: список // остаётся прежним, а причина уходит в lastError. if (parsed.isEmpty()) { finishRefresh(false, parseError.isEmpty() ? tr("Список сборок пуст или не разобран") : parseError); return; } applyBuilds(std::move(parsed)); m_fetchedAt = QDateTime::currentDateTimeUtc(); writeCache(); emit buildsChanged(); finishRefresh(true, {}); }); } void SeasonalBuildService::finishRefresh(bool ok, const QString &warning) { m_lastError = ok ? QString() : warning; m_refreshing = false; emit refreshingChanged(); // Строка ошибки — часть того, что показывает окно, поэтому список // перечитывается и на неудаче. emit buildsChanged(); // Колбэк может снова позвать ensureLoaded — список забираем себе заранее. const QList pending = std::exchange(m_pending, {}); for (const Callback &callback : pending) { // Сеть недоступна, но старые данные есть — работаем на них. if (!ok && hasData()) callback(true, warning); else callback(ok, warning); } } bool SeasonalBuildService::readCache() { QFile file(LauncherPaths::seasonalCatalogFile()); if (!file.open(QIODevice::ReadOnly)) return false; const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); // Кэш от другого сервера бесполезен: ссылки в нём ведут не туда. if (root.value(QStringLiteral("baseUrl")).toString() != m_baseUrl.toString()) return false; QList parsed = parseCatalog(root, nullptr); if (parsed.isEmpty()) return false; applyBuilds(std::move(parsed)); m_fetchedAt = QDateTime::fromString(root.value(QStringLiteral("fetchedAt")).toString(), Qt::ISODate); return true; } void SeasonalBuildService::writeCache() { // Папку кэша могли удалить уже после запуска лаунчера. if (!LauncherPaths::ensureRootExists()) return; QJsonArray builds; for (const SeasonalBuildEntry &entry : std::as_const(m_builds)) { builds << QJsonObject{ {QStringLiteral("id"), entry.id}, {QStringLiteral("name"), entry.name}, {QStringLiteral("revision"), entry.revision}, {QStringLiteral("minecraftVersion"), entry.minecraftVersion}, {QStringLiteral("loader"), entry.loader}, {QStringLiteral("loaderVersion"), entry.loaderVersion}, {QStringLiteral("modCount"), entry.modCount}, {QStringLiteral("seasonStart"), dateToJson(entry.seasonStart)}, {QStringLiteral("seasonEnd"), dateToJson(entry.seasonEnd)}, {QStringLiteral("serverUrl"), entry.serverUrl}, {QStringLiteral("javaMajor"), entry.javaMajor}, {QStringLiteral("description"), entry.description}, {QStringLiteral("archive"), QJsonObject{{QStringLiteral("url"), entry.archiveUrl.toString()}, {QStringLiteral("size"), double(entry.archiveSize)}, {QStringLiteral("sha256"), entry.archiveSha256}}}}; } // Своя обёртка: в каталоге нет ни отметки свежести кэша, ни адреса, с // которого он приехал, — а без них кэш нечем проверить. const QJsonObject root{{QStringLiteral("formatVersion"), kSupportedFormatVersion}, {QStringLiteral("fetchedAt"), m_fetchedAt.toString(Qt::ISODate)}, {QStringLiteral("baseUrl"), m_baseUrl.toString()}, {QStringLiteral("builds"), builds}}; QSaveFile file(LauncherPaths::seasonalCatalogFile()); if (!file.open(QIODevice::WriteOnly)) return; const QByteArray payload = QJsonDocument(root).toJson(QJsonDocument::Compact); if (file.write(payload) != payload.size()) { file.cancelWriting(); return; } file.commit(); }