Prepare auto updates

This commit is contained in:
Jan-Niclas Loosen
2026-11-04 13:51:01 +01:00
parent a066d47bfd
commit 3337a63c5d
15 changed files with 784 additions and 16 deletions
@@ -12,5 +12,5 @@ defined('ABSPATH') || exit;
*/
final class DiviReleases
{
public const TESTED = ['5.2.1', '5.14.0', '5.15.1'];
public const TESTED = ['5.2.1', '5.14.0', '5.15.1', '5.18.0', '5.18.2'];
}
@@ -54,5 +54,5 @@
"tb-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4",
"tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e",
"tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d",
"vb-sync-flags": "d13fea1922126b04edf7d60d6407aef66a37a57252581508fce9c43e1fdc45a0"
"vb-sync-flags": "eca9117be21e8af22f19b1767e6fd79e468d814fbb7eefa74de9ccb895855839"
}
+6 -1
View File
@@ -37,6 +37,8 @@ use LIT\MCP\Server\DiscoveryCanonical;
use LIT\MCP\Server\RequestContext;
use LIT\MCP\Server\ServerRegistrar;
use LIT\MCP\Server\VisibilityFilter;
use LIT\MCP\Updater\GiteaUpdater;
use LIT\MCP\Updater\PackageSignature;
defined('ABSPATH') || exit;
@@ -45,7 +47,7 @@ defined('ABSPATH') || exit;
*/
final class Plugin
{
public const VERSION = '0.7.0';
public const VERSION = '1.0.0';
private static string $pluginFile = '';
private static ?SettingsPage $settingsPage = null;
@@ -54,6 +56,9 @@ final class Plugin
{
self::$pluginFile = $pluginFile;
// Before the dependency check: a plugin that is inactive for a missing dependency must still be updatable.
(new GiteaUpdater(plugin_basename($pluginFile), dirname($pluginFile), new PackageSignature()))->register();
$missing = (new Dependencies())->missing();
if ($missing !== []) {
self::noticeMissing($missing);
+170
View File
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Updater;
use LIT\MCP\Log;
defined('ABSPATH') || exit;
/**
* Offers the newest Gitea release to WordPress' own update check. The plugin header's "Update URI" points at the
* Gitea host, so WordPress asks the filter update_plugins_<host> (about every 12 hours, which is also the only
* caching needed). The release carries the ZIP as asset wp-lit-mcp-<version>.zip.
*
* The ZIP is only installed with a valid signature (PackageSignature, asset <zip>.sig); a hijacked Gitea account
* alone cannot ship code.
*
* shortcut: public release assets only (WordPress downloads without credentials), a private repository would
* need a token in the downloads.
*/
final class GiteaUpdater
{
public const HOST = 'git.loosen-it.de';
public const REPOSITORY_URL = 'https://' . self::HOST . '/janniclas.loosen/WP-LIT-MCP';
private const API_URL = 'https://' . self::HOST . '/api/v1/repos/janniclas.loosen/WP-LIT-MCP/releases/latest';
private const RELEASE_DOWNLOADS = self::REPOSITORY_URL . '/releases/download/';
private const SLUG = 'wp-lit-mcp';
/**
* @param string $pluginBasename plugin_basename() of the main file, the only plugin answered for.
* @param string $pluginDir The plugin's folder; an update replaces it as a whole.
* @param PackageSignature $signature Checks the downloaded ZIP before WordPress unpacks it.
*/
public function __construct(
private string $pluginBasename,
private string $pluginDir,
private PackageSignature $signature
) {
}
public function register(): void
{
add_filter('update_plugins_' . self::HOST, [$this, 'offer'], 10, 3);
add_filter('upgrader_pre_download', [$this, 'verifyDownload'], 10, 2);
}
/**
* Downloads our package itself to check its signature before WordPress unpacks a byte. Fails closed: a missing
* or wrong signature file stops the update.
*
* @param mixed $reply What an earlier filter decided; false = WordPress downloads as usual.
* @return mixed The downloaded file's path, a WP_Error, or $reply for any package that is not ours.
*/
public function verifyDownload(mixed $reply, string $package): mixed
{
if ($reply !== false || ! str_starts_with($package, self::RELEASE_DOWNLOADS)) {
return $reply;
}
$file = download_url($package);
if (is_wp_error($file)) {
return $file;
}
// The signature is 89 bytes; never read more of an answer than that file can be.
$response = wp_safe_remote_get($package . '.sig', ['timeout' => 10, 'limit_response_size' => 1024]);
$signature = wp_remote_retrieve_body($response);
if (! $this->signature->verifies(basename($package), $file, $signature)) {
wp_delete_file($file);
Log::failure('update', 'signature of ' . basename($package) . ' is missing or does not verify');
return new \WP_Error(
'lit_mcp_bad_signature',
__('Die Signatur des Plugin-Pakets stimmt nicht. Das Update wurde nicht installiert.', 'wp-lit-mcp')
);
}
return $file;
}
/**
* Returns the latest release, newer or not: WordPress compares versions itself and lists the plugin under
* "update available" or "up to date" – only then the plugin list offers the auto-update switch.
*
* @param array<string, mixed>|false $update What WordPress knows so far (false = nothing).
* @param array<string, mixed> $pluginData Header data of the plugin asked about.
* @return array<string, mixed>|false
*/
public function offer(array|false $update, array $pluginData, string $pluginFile): array|false
{
// An update deletes the folder first: a git checkout would lose .git and uncommitted work.
if ($pluginFile !== $this->pluginBasename || is_dir($this->pluginDir . '/.git')) {
return $update;
}
$release = $this->fetchLatestRelease();
if ($release === null) {
return $update;
}
return [
'slug' => self::SLUG,
'version' => $release['version'],
'url' => self::REPOSITORY_URL,
'package' => $release['package'],
];
}
/** @return array{version: string, package: string}|null */
private function fetchLatestRelease(): ?array
{
$response = wp_remote_get(self::API_URL, ['timeout' => 5, 'headers' => ['Accept' => 'application/json']]);
if (is_wp_error($response)) {
return self::failed($response->get_error_message());
}
$code = wp_remote_retrieve_response_code($response);
if ($code !== 200) {
return self::failed('HTTP ' . $code);
}
$body = json_decode((string) wp_remote_retrieve_body($response), true);
$tag = is_array($body) ? (string) ($body['tag_name'] ?? '') : '';
if (preg_match('/^v?(\d+\.\d+\.\d+)$/D', $tag, $match) !== 1) {
return self::failed('release tag is no version x.y.z');
}
$version = $match[1];
$package = self::packageUrl((array) ($body['assets'] ?? []), $version);
if ($package === null) {
return self::failed(
sprintf('release %s has no asset %s-%s.zip in the repository', $tag, self::SLUG, $version)
);
}
return ['version' => $version, 'package' => $package];
}
/**
* The ZIP asset of this version, only from the repository's release downloads: the trust boundary of the update.
*
* @param array<array-key, mixed> $assets
*/
private static function packageUrl(array $assets, string $version): ?string
{
foreach ($assets as $asset) {
if (! is_array($asset)) {
continue;
}
$url = (string) ($asset['browser_download_url'] ?? '');
$isZip = ($asset['name'] ?? '') === self::SLUG . '-' . $version . '.zip';
if ($isZip && str_starts_with($url, self::RELEASE_DOWNLOADS)) {
return $url;
}
}
return null;
}
/** A failed check looks like "no update" on every site; the log is the only trace. */
private static function failed(string $reason): ?array
{
Log::failure('update check', $reason);
return null;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Updater;
defined('ABSPATH') || exit;
/**
* Checks the Ed25519 signature of a release ZIP. The private key never leaves the maintainer's machine, so a
* compromised Gitea account alone cannot ship code to the sites. bin/release-sign.php signs with the same message.
*/
final class PackageSignature
{
/** Base64 of the 32-byte public key; the matching private key signs the releases (bin/release-sign.php keygen). */
public const RELEASE_KEY = '8iPADRjYwxrnKK3bZ0nUnP2iXlIKpC1dJ0nZxCxOG2E=';
public function __construct(private string $publicKey = self::RELEASE_KEY)
{
}
/**
* The signed text: asset name and SHA-256 of the file. The name binds the signature to one version – a validly
* signed older ZIP does not pass as a newer release.
*/
public static function message(string $assetName, string $zipPath): string
{
return $assetName . "\n" . (string) hash_file('sha256', $zipPath);
}
public function verifies(string $assetName, string $zipPath, string $signatureBase64): bool
{
$signature = base64_decode(trim($signatureBase64), true);
$key = base64_decode($this->publicKey, true);
if (
$signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES
|| $key === false || strlen($key) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES
|| ! is_readable($zipPath)
) {
return false;
}
return sodium_crypto_sign_verify_detached($signature, self::message($assetName, $zipPath), $key);
}
}