Skip to content
This repository was archived by the owner on Nov 20, 2025. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
332 changes: 332 additions & 0 deletions app/Http/Controllers/API/WpOrg/Core/VersionCheckController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
<?php

namespace App\Http\Controllers\API\WpOrg\Core;

use App\Http\Controllers\Controller;
use App\Models\WpOrg\Asset;
use Illuminate\Cache\CacheManager;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class VersionCheckController extends Controller
{
public const int CACHE_TTL = 60 * 60 * 6;

// TODO remove these, pass request around instead
private string $currentVersion;
private string $locale;

public function __construct(private readonly CacheManager $cache) {}

public function __invoke(Request $request): JsonResponse
{
// query args
// * version: string
// * php: string
// * locale: string
// * mysql: string
// * local_package: string
// * blogs: int
// * users: int
// * multisite_enabled: bool
// * initial_db_version: string
// * extensions: array (???)
// * platform_flags: {os: string, bits: 32|64}
// * image_support: {gd?: list<webp|avif|heic|jxl>, imagick?: list<webp|avif|heic|jxl>}
// * channel (optional): beta|rc|development|branch-development

// body args (all optional). Only translations is set from normal requests, others are sent during rollback.
// * translations: json of translations, format tbd
// * success: false (only ever set when false as result of an update failure)
// * error_code: string|int
// * error_data: mixed
// * rollback: bool
// * rollback_code: string|int
// * rollback_data: mixed

$this->currentVersion = $request->query('version') ?? (string)PHP_INT_MAX;
$this->locale = $request->query('locale') ?? '';

$response = new \stdClass();
$response->offers = $this->buildOffers();
$response->translations = $this->buildTranslations();

return response()->json($response);
}

/**
* @return array<int, \stdClass>
*/
private function buildOffers(): array
{
$offers = [];
$latestVersion = $this->getLatestCoreVersion();

if ($this->locale) {
$offers[] = $this->buildTranslatedUpgradeOffer($latestVersion);

// When a translated offer is needed, the API only adds a 'latest' en_US offer
// if the currently installed version is not the latest version.
if ($this->currentVersion !== $latestVersion) {
$offers[] = $this->buildUpgradeOffer($latestVersion);
}
} else {
$offers[] = $this->buildUpgradeOffer($latestVersion);
}

// Versions older than 5.1.19 get an extra upgrade offer.
if (version_compare($this->currentVersion, '5.1.19', '<')) {
$offers[] = $this->buildAutoupdateOffer('5.1.19');
}

$olderVersions = $this->getOlderVersions();
foreach ($olderVersions as $olderVersion) {
$offers[] = $this->buildAutoupdateOffer($olderVersion);
}

return $offers;
}

private function buildTranslatedUpgradeOffer(string $version): \stdClass
{
$offer = $this->buildUpgradeOffer($version);
$base = config('app.aspirecloud.download.base');
$offer->download = "{$base}release/$this->locale/wordpress-$version.zip";
$offer->locale = $this->locale;

$offer->packages->full = "{$base}release/$this->locale/wordpress-$version.zip";
$offer->packages->no_content = false;
$offer->packages->new_bundled = false;
$offer->packages->partial = false;
$offer->packages->rollback = false;

return $offer;
}

private function buildAutoupdateOffer(string $version): \stdClass
{
$offer = $this->buildUpgradeOffer($version);

$offer->response = 'autoupdate';
$offer->new_files = $this->getHasNewFiles($version);

return $offer;
}

private function buildUpgradeOffer(string $version): \stdClass
{
$offer = new \stdClass();

$offer->response = $this->currentVersion === $version ? 'latest' : 'upgrade';
$base = config('app.aspirecloud.download.base');
$offer->download = $base . 'release/wordpress-' . $version . '.zip';
$offer->locale = $offer->response === 'latest' && !$this->locale ? false : 'en_US';

$offer->packages = new \stdClass();
$offer->packages->full = "{$base}release/wordpress-$version.zip";
$offer->packages->no_content = "{$base}release/wordpress-$version-no-content.zip";
$offer->packages->new_bundled = "{$base}release/wordpress-$version-new-bundled.zip";

// Disabling these for now, pending more research from the team.
$offer->packages->partial = false;
$offer->packages->rollback = false;

$offer->current = $version;
$offer->version = $version;
$offer->php_version = $this->getPhpVersion($version);
$offer->mysql_version = $this->getMySqlVersion($version);
$offer->new_bundled = $this->getNewBundled();
$offer->partial_version = false;

return $offer;
}

/**
* @return array<int, \stdClass>
*/
private function buildTranslations(): array
{
if (!$this->locale) {
return [];
}

// There are other potential translations, possibly based on the slug.
// For now, this just generates a single translation package.
$base = config('app.aspirecloud.download.base');

$translation = new \stdClass();
$translation->type = 'core';
$translation->slug = 'default';
$translation->language = $this->locale;
$translation->version = $this->currentVersion;
$translation->updated = '2023-10-01T00:00:00Z'; // Store in DB from the translations file. Pull from DB for here.
Copy link
Copy Markdown
Collaborator Author

@costdev costdev Jun 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This data should be compared with the request body's subkey containing the currently installed translation's data including its updated time string. If the updated value is the same as the API's latest translation for that type/slug/version/locale, then no translation is needed. Otherwise, the API should provide the translation offer. If the value isn't set, or there's no body, or otherwise isn't available, the API should provide the translation offer.

$translation->package = "{$base}translation/core/$this->currentVersion/$this->locale.zip";
$translation->autoupdate = true;

return [$translation];
}

private function getLatestCoreVersion(): string
{
// technically semver comparison should be used, but core versions are always sortable lexicographically
$getLatest = fn()
=> Asset::query()
->where('type', 'core')
->orderBy('version', 'desc')
->first()
->version;

return $this->cache->remember('wporg.core.latest', self::CACHE_TTL, $getLatest);
}

private function getLatestCoreMajorVersion(): string
{
// Wordpress considers x.y to be a major version (it predates the semver standard)
[$x, $y] = explode('.', $this->getLatestCoreVersion());
return "$x.$y";
}

/**
* @return array<int, string>
*/
private function getOlderVersions(): array
{
// Probably pull from the database instead of hardcoding these.
// Should have the latest minor from every branch.
// If not going for completeness, can probably remove the versions
// below AspireUpdate's minimum WP version (currently 5.3).
$map = [
'6.8.1',
'6.8',
'6.7.2',
'6.6.2',
'6.6.5',
'6.4.5',
'6.3.5',
'6.2.6',
'6.1.7',
'6.0.9',
'5.9.10',
'5.8.10',
'5.7.12',
'5.6.14',
'5.5.15',
'5.4.16',
'5.3.18',
'5.2.21',
'5.1.19',
'5.0.22',
'4.9.26',
'4.8.25',
'4.7.29',
'4.6.29',
'4.5.32',
'4.4.33',
'4.3.34',
'4.2.38',
'4.1.41',
'4.0.38',
'3.9.40',
'3.8.41',
'3.7.41',
'3.6.1',
'3.5.2',
'3.4.2',
'3.3.3',
'3.2.1',
'3.1.4',
'3.0.6',
'2.9.2',
'2.8.6',
'2.7.1',
'2.6.5',
'2.5.1',
'2.3.3',
'2.2.3',
'2.1.3',
'2.0.11',
'1.5.2',
'1.2.2',
'1.0.2',
'0.72',
];

// If no version has been provided, all versions are considered older.
if ($this->currentVersion === (string)PHP_INT_MAX) {
return $map;
}

$versions = [];
foreach ($map as $version) {
if (version_compare($this->currentVersion, $version, '<')) {
$versions[] = $version;
}
}

return $versions;
}

private function getPhpVersion(string $version): string
{
// The WordPress versions that started a new minimum PHP version requirement.
$map = [
'6.6' => '7.2.24',
'6.3' => '7.0.0',
'5.2' => '5.6.20',
'4.1' => '5.2.4',
];

foreach ($map as $wpMajor => $php) {
if (version_compare($version, $wpMajor, '>=')) {
return $php;
}
}

return end($map);
}

private function getMySqlVersion(string $version): string
{
// The WordPress versions that started a new minimum MySQL version requirement.
$map = [
'6.5' => '5.5.5',
'4.1' => '5.0',
];

foreach ($map as $wpMajor => $mysql) {
if (version_compare($version, $wpMajor, '>=')) {
return $mysql;
}
}

return end($map);
}

private function getNewBundled(): string
{
// Appears to be the second latest major version.
$parts = explode('.', $this->getLatestCoreMajorVersion());
if ($parts[1] > 0) {
--$parts[1];
} else {
$parts[1] = 9;
--$parts[0];
}

return $parts[0] . '.' . $parts[1];
}

private function getHasNewFiles(string $version): bool
{
// WP Core relaxes filesystem checks when the API
// specifies that it's safe to do.
// The API specifies this by setting new_files to false.
// For now, return true so filesystem checks are not relaxed.
return true;

// Appears to depend on a comparison between
// the installed version and the offered version.
// $hasNewFiles = [];
// return array_key_exists($version, $hasNewFiles);
}
}
3 changes: 2 additions & 1 deletion routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Http\Controllers\API\WpOrg\Core\BrowseHappyController;
use App\Http\Controllers\API\WpOrg\Core\ImportersController;
use App\Http\Controllers\API\WpOrg\Core\ServeHappyController;
use App\Http\Controllers\API\WpOrg\Core\VersionCheckController;
use App\Http\Controllers\API\WpOrg\Plugins\PluginInformation_1_2_Controller;
use App\Http\Controllers\API\WpOrg\Plugins\PluginUpdateCheck_1_1_Controller;
use App\Http\Controllers\API\WpOrg\SecretKey\SecretKeyController;
Expand All @@ -29,6 +30,7 @@
$router->any('/core/browse-happy/{version}', BrowseHappyController::class)->where(['version' => '1.1']);
$router->any('/core/serve-happy/{version}', ServeHappyController::class)->where(['version' => '1.0']);
$router->get('/core/importers/{version}', ImportersController::class)->where(['version' => '1.[01]']);
$router->any('/core/version-check/{version}', VersionCheckController::class)->where(['version' => '1.[67]']);

$router->get('/plugins/info/1.2', PluginInformation_1_2_Controller::class);
$router->post('/plugins/update-check/1.1', PluginUpdateCheck_1_1_Controller::class);
Expand All @@ -45,7 +47,6 @@
$router->any('/core/credits/{version}', PassThroughController::class)->where(['version' => '1.[01]']);
$router->any('/core/handbook/{version}', PassThroughController::class)->where(['version' => '1.0']);
$router->any('/core/stable-check/{version}', PassThroughController::class)->where(['version' => '1.0']);
$router->any('/core/version-check/{version}', PassThroughController::class)->where(['version' => '1.[67]']);

$router->any('/events/{version}', PassThroughController::class)->where(['version' => '1.0']);

Expand Down