Prepare auto updates
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user