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
+1
View File
@@ -1,3 +1,4 @@
/bin export-ignore
/tests export-ignore /tests export-ignore
/docs/superpowers export-ignore /docs/superpowers export-ignore
/.superpowers export-ignore /.superpowers export-ignore
+1
View File
@@ -1,3 +1,4 @@
/vendor/ /vendor/
/vendor.lit-bak/ /vendor.lit-bak/
/dist/
.phpunit.result.cache .phpunit.result.cache
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* Release signing for WP-LIT-MCP (called by bin/release.sh, usable by hand).
*
* php bin/release-sign.php keygen <key-file> new key pair; prints the public key for PackageSignature
* php bin/release-sign.php sign <zip> <key-file> prints the signature (the .sig asset)
* php bin/release-sign.php verify <zip> <sig-file> checks against the key built into the plugin
*/
declare(strict_types=1);
// The class guards against direct web access; this is the command line.
define('ABSPATH', __DIR__ . '/');
require_once __DIR__ . '/../src/Updater/PackageSignature.php';
use LIT\MCP\Updater\PackageSignature;
function fail(string $message): never
{
fwrite(STDERR, $message . "\n");
exit(1);
}
[, $command, $first, $second] = $argv + [null, '', '', ''];
switch ($command) {
case 'keygen':
if ($first === '' || file_exists($first)) {
fail('usage: keygen <key-file> (an existing file is never overwritten)');
}
$pair = sodium_crypto_sign_keypair();
if (! is_dir(dirname($first)) && ! mkdir(dirname($first), 0700, true)) {
fail('cannot create ' . dirname($first));
}
// 0600 before the secret is written, so it is never readable by others.
touch($first);
chmod($first, 0600);
file_put_contents($first, base64_encode(sodium_crypto_sign_secretkey($pair)));
echo base64_encode(sodium_crypto_sign_publickey($pair)), "\n";
break;
case 'sign':
$secret = is_readable($second) ? base64_decode(trim((string) file_get_contents($second)), true) : false;
if (! is_readable($first) || $secret === false || strlen($secret) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
fail('usage: sign <zip> <key-file> (readable ZIP, key file from keygen)');
}
$message = PackageSignature::message(basename($first), $first);
echo base64_encode(sodium_crypto_sign_detached($message, $secret)), "\n";
break;
case 'verify':
if (! is_readable($first) || ! is_readable($second)) {
fail('usage: verify <zip> <sig-file>');
}
if (! (new PackageSignature())->verifies(basename($first), $first, (string) file_get_contents($second))) {
fail('signature does not verify against the key built into the plugin');
}
echo "ok\n";
break;
default:
fail('usage: keygen <key-file> | sign <zip> <key-file> | verify <zip> <sig-file>');
}
Executable
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Builds and signs a WP-LIT-MCP release from the committed state; with --publish it also tags and publishes it.
#
# bin/release.sh dry run: checks, tests, build, signature, ZIP checks – publishes nothing
# bin/release.sh --publish additionally: git tag + push, Gitea release with ZIP and .sig, check of the result
#
# Needs: bsdtar, composer, curl, jq, php (sodium). For --publish a Gitea token with write access to the repository in
# $GITEA_TOKEN. The signing key (php bin/release-sign.php keygen) lives outside the repository.
#
# Environment: LIT_MCP_RELEASE_KEY (default ~/.config/wp-lit-mcp/release.key), LIT_MCP_DIVI_CHECK (command that must
# succeed before a release, default: ddev wp --user=janniclas lit-mcp divi-check).
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
repo_url="https://git.loosen-it.de/janniclas.loosen/WP-LIT-MCP"
api="https://git.loosen-it.de/api/v1/repos/janniclas.loosen/WP-LIT-MCP"
key="${LIT_MCP_RELEASE_KEY:-$HOME/.config/wp-lit-mcp/release.key}"
divi_check="${LIT_MCP_DIVI_CHECK:-ddev wp --user=janniclas lit-mcp divi-check}"
publish=0
[[ "${1:-}" == "--publish" ]] && publish=1
fail() { echo "ABBRUCH: $*" >&2; exit 1; }
step() { echo "==> $*"; }
# --- 1. The three version places agree, the tree is committed, nothing is released twice ----------------------------
step "Version und Zustand prüfen"
version="$(sed -n "s/.*const VERSION = '\(.*\)';/\1/p" src/Plugin.php)"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "Plugin::VERSION ist keine Version x.y.z: '$version'"
[[ "$(sed -n 's/^ \* Version: //p' wp-lit-mcp.php)" == "$version" ]] || fail "Header-Version in wp-lit-mcp.php weicht ab"
[[ "$(sed -n 's/^Stable tag: //p' readme.txt)" == "$version" ]] || fail "Stable tag in readme.txt weicht ab"
grep -q "^= $version =\$" readme.txt || fail "readme.txt hat keinen Changelog-Eintrag für $version"
[[ -z "$(git status --porcelain)" ]] || fail "Arbeitsbaum nicht sauber: erst committen, das ZIP entsteht aus HEAD"
! git rev-parse -q --verify "refs/tags/v$version" >/dev/null || fail "Tag v$version existiert schon"
[[ -r "$key" ]] || fail "Signierschlüssel fehlt: $key (php bin/release-sign.php keygen <Datei>)"
if [[ $publish -eq 1 ]]; then
[[ -n "${GITEA_TOKEN:-}" ]] || fail "GITEA_TOKEN ist nicht gesetzt"
fi
# --- 2. Tests and the Divi check must be green ------------------------------------------------------------------------
step "Tests"
composer test
step "Divi-Prüfung ($divi_check)"
eval "$divi_check" || fail "Divi-Prüfung nicht grün: kein Release"
# --- 3. Build from the commit: git archive honours export-ignore, then production dependencies ----------------------
step "ZIP bauen"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
mkdir "$work/wp-lit-mcp"
git archive HEAD | tar -x -C "$work/wp-lit-mcp"
(cd "$work/wp-lit-mcp" && composer install --no-dev --optimize-autoloader --no-interaction --quiet)
name="wp-lit-mcp-$version.zip"
mkdir -p dist
zip="$root/dist/$name"
rm -f "$zip" "$zip.sig"
(cd "$work" && bsdtar --format zip -cf "$zip" wp-lit-mcp)
# --- 4. Look into the ZIP like WordPress will: one folder, the plugin, its autoloader, the right version --------------
step "ZIP prüfen"
entries="$(bsdtar -tf "$zip")"
if grep -qv '^wp-lit-mcp/' <<<"$entries"; then fail "ZIP enthält Einträge außerhalb von wp-lit-mcp/"; fi
for need in wp-lit-mcp/wp-lit-mcp.php wp-lit-mcp/vendor/autoload_packages.php; do
grep -qx "$need" <<<"$entries" || fail "Im ZIP fehlt $need"
done
if grep -qE '^wp-lit-mcp/(tests|bin|docs/superpowers|\.git)(/|$)' <<<"$entries"; then
fail "Entwicklungsdateien im ZIP"
fi
[[ "$(bsdtar -xOf "$zip" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \
|| fail "Version im ZIP stimmt nicht mit $version überein"
# --- 5. Sign, and prove the signature against the key built into the plugin -------------------------------------------
step "Signieren"
php bin/release-sign.php sign "$zip" "$key" > "$zip.sig"
php bin/release-sign.php verify "$zip" "$zip.sig" >/dev/null || fail "Signatur passt nicht zum Schlüssel im Plugin (PackageSignature::RELEASE_KEY)"
if [[ $publish -eq 0 ]]; then
echo "Probelauf in Ordnung: dist/$name und dist/$name.sig"
echo "Veröffentlichen: GITEA_TOKEN=… bin/release.sh --publish"
exit 0
fi
# --- 6. Publish ---------------------------------------------------------------------------------------------------------
step "Tag v$version setzen und pushen"
git tag -a "v$version" -m "WP-LIT-MCP $version"
git push origin "v$version"
step "Release auf Gitea anlegen"
auth=(-H "Authorization: token $GITEA_TOKEN")
notes="$(awk -v header="= $version =" '$0 == header { on = 1; next } /^= / { on = 0 } on' readme.txt)"
release_id="$(curl -fsS "${auth[@]}" -H 'Content-Type: application/json' \
-d "$(jq -n --arg tag "v$version" --arg body "$notes" '{tag_name: $tag, name: $tag, body: $body}')" \
"$api/releases" | jq -r .id)"
for file in "$zip" "$zip.sig"; do
curl -fsS "${auth[@]}" -F "attachment=@$file" "$api/releases/$release_id/assets?name=$(basename "$file")" >/dev/null
done
# --- 7. Check what a customer site will see: latest release, public asset URLs, a signature that verifies ------------
step "Ergebnis prüfen (ohne Token, wie eine Kundenseite)"
latest="$(curl -fsS "$api/releases/latest")"
[[ "$(jq -r .tag_name <<<"$latest")" == "v$version" ]] || fail "releases/latest liefert nicht v$version"
asset_url() { jq -r --arg n "$1" '.assets[] | select(.name == $n) | .browser_download_url' <<<"$latest"; }
zip_url="$(asset_url "$name")"
sig_url="$(asset_url "$name.sig")"
for url in "$zip_url" "$sig_url"; do
[[ "$url" == "$repo_url/releases/download/"* ]] || fail "Asset-URL nicht wie erwartet: '$url'"
done
mkdir "$work/check"
curl -fsSL "$zip_url" -o "$work/check/$name"
curl -fsSL "$sig_url" -o "$work/check/$name.sig"
php bin/release-sign.php verify "$work/check/$name" "$work/check/$name.sig" >/dev/null \
|| fail "Heruntergeladenes Release besteht die Signaturprüfung nicht"
echo "Release v$version veröffentlicht: $repo_url/releases/tag/v$version"
+18 -1
View File
@@ -4,7 +4,7 @@ Tags: mcp, ai, divi, pods, oauth
Requires at least: 6.9 Requires at least: 6.9
Tested up to: 7.1 Tested up to: 7.1
Requires PHP: 8.1 Requires PHP: 8.1
Stable tag: 0.7.0 Stable tag: 1.0.0
License: GPL-2.0-or-later License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -103,6 +103,11 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll,
(Standard 5 MB, höchstens 32 MB; Base64-Upload und Download höchstens 10 MB); Import per URL nur von öffentlich erreichbaren Adressen (interne/lokale Adressen werden (Standard 5 MB, höchstens 32 MB; Base64-Upload und Download höchstens 10 MB); Import per URL nur von öffentlich erreichbaren Adressen (interne/lokale Adressen werden
abgelehnt). abgelehnt).
* Updates: Etwa alle 12 Stunden fragt WordPress im Rahmen der Update-Prüfung bei git.loosen-it.de nach dem
neuesten Release dieses Plugins (öffentliche Gitea-Schnittstelle, ohne Zugangsdaten). Dabei geht wie bei jeder
WordPress-Anfrage die Website-Adresse im User-Agent mit; weitere Daten der Website werden nicht übertragen.
Die Anfrage kommt von LIT MCP selbst, nicht von wordpress.org.
* Debug-Log: Sind WP_DEBUG und WP_DEBUG_LOG gleichzeitig an, schreibt die mitgelieferte Bibliothek * Debug-Log: Sind WP_DEBUG und WP_DEBUG_LOG gleichzeitig an, schreibt die mitgelieferte Bibliothek
mcp-oauth jede MCP-Anfrage (bis 8 KB, Tokens und Passwörter geschwärzt) samt Tool-Argumenten – also mcp-oauth jede MCP-Anfrage (bis 8 KB, Tokens und Passwörter geschwärzt) samt Tool-Argumenten – also
z. B. Seiteninhalte, Entwürfe und Base64-Uploads – nach wp-content/debug.log. Auf Live- und z. B. Seiteninhalte, Entwürfe und Base64-Uploads – nach wp-content/debug.log. Auf Live- und
@@ -190,12 +195,24 @@ Die Änderung wirkt auf allen Seiten mit diesem Modul. LIT MCP speichert erst na
betroffenen Seiten nennt, und einer ausdrücklichen Bestätigung (confirm); vorher entsteht eine Revision des betroffenen Seiten nennt, und einer ausdrücklichen Bestätigung (confirm); vorher entsteht eine Revision des
Bibliothekseintrags. Bibliothekseintrags.
= Wie bekomme ich Updates? =
Neue Versionen erscheinen als Release auf git.loosen-it.de; WordPress zeigt sie unter Plugins und Dashboard →
Aktualisierungen an wie jedes andere Plugin. Automatisch einspielen lässt sich ein Update mit dem Schalter
„Automatische Updates aktivieren“ in der Plugin-Liste. Ein Update ersetzt den Plugin-Ordner; ein Entwicklungs-
Checkout mit .git-Ordner bekommt deshalb kein Update angeboten. Jedes Paket ist signiert (Ed25519); WordPress
installiert nur, was zur Signatur im Plugin passt. Versionen vor 1.0.0 kennen diesen
Weg nicht: der Wechsel auf 1.0.0 ist einmalig von Hand (ZIP hochladen), danach läuft es über WordPress.
== Divi-Freigabe == == Divi-Freigabe ==
LIT MCP schreibt nur mit Divi-Versionen, die für diese Plugin-Version geprüft sind. Nach einem Divi-Update lesen die Divi-Werkzeuge weiter; schreibende Divi-Werkzeuge antworten mit „divi_untested“, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung startet und die Version freigibt. Die Prüfung legt eine Entwurfsseite an, schreibt und rendert sie und löscht sie danach endgültig. Hat Divi Stellen geändert, die LIT MCP nachbildet, ist die Version „inkompatibel“ (divi_incompatible) – dann hilft nur ein Update von LIT MCP. Für Entwickler: `wp --user=<admin> lit-mcp divi-check [--diff] [--update-reference]`. LIT MCP schreibt nur mit Divi-Versionen, die für diese Plugin-Version geprüft sind. Nach einem Divi-Update lesen die Divi-Werkzeuge weiter; schreibende Divi-Werkzeuge antworten mit „divi_untested“, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung startet und die Version freigibt. Die Prüfung legt eine Entwurfsseite an, schreibt und rendert sie und löscht sie danach endgültig. Hat Divi Stellen geändert, die LIT MCP nachbildet, ist die Version „inkompatibel“ (divi_incompatible) – dann hilft nur ein Update von LIT MCP. Für Entwickler: `wp --user=<admin> lit-mcp divi-check [--diff] [--update-reference]`.
== Changelog == == Changelog ==
= 1.0.0 =
* Updates über WordPress: Das neueste Release von git.loosen-it.de erscheint als Plugin-Update (Update URI); automatisches Einspielen über den Schalter in der Plugin-Liste. Der Wechsel auf 1.0.0 ist einmalig von Hand.
= 0.7.0 = = 0.7.0 =
* Designsystem schreiben (Etappe 4): Presets lesen, anlegen, ändern, löschen, einzelnen Modulen und ganzen Seiten zuweisen und Verweise überall entfernen (Probelauf, Bestätigung, Snapshot, Wiederherstellen). * Designsystem schreiben (Etappe 4): Presets lesen, anlegen, ändern, löschen, einzelnen Modulen und ganzen Seiten zuweisen und Verweise überall entfernen (Probelauf, Bestätigung, Snapshot, Wiederherstellen).
* Globale Farben und Variablen anlegen und ändern; Variablen werden archiviert, nicht gelöscht; Verweise auf Variablen durch ihren Wert ersetzen. * Globale Farben und Variablen anlegen und ändern; Variablen werden archiviert, nicht gelöscht; Verweise auf Variablen durch ihren Wert ersetzen.
@@ -12,5 +12,5 @@ defined('ABSPATH') || exit;
*/ */
final class DiviReleases 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-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4",
"tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e", "tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e",
"tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d", "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\RequestContext;
use LIT\MCP\Server\ServerRegistrar; use LIT\MCP\Server\ServerRegistrar;
use LIT\MCP\Server\VisibilityFilter; use LIT\MCP\Server\VisibilityFilter;
use LIT\MCP\Updater\GiteaUpdater;
use LIT\MCP\Updater\PackageSignature;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -45,7 +47,7 @@ defined('ABSPATH') || exit;
*/ */
final class Plugin final class Plugin
{ {
public const VERSION = '0.7.0'; public const VERSION = '1.0.0';
private static string $pluginFile = ''; private static string $pluginFile = '';
private static ?SettingsPage $settingsPage = null; private static ?SettingsPage $settingsPage = null;
@@ -54,6 +56,9 @@ final class Plugin
{ {
self::$pluginFile = $pluginFile; 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(); $missing = (new Dependencies())->missing();
if ($missing !== []) { if ($missing !== []) {
self::noticeMissing($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);
}
}
+16 -11
View File
@@ -115,13 +115,16 @@ function update( WP_REST_Request $request ) {
// Create preview post link. // Create preview post link.
// Equivalent of this on WordPress block editor can be seen at WP_REST_Autosaves_Controller->prepare_item_for_response(). // Equivalent of this on WordPress block editor can be seen at WP_REST_Autosaves_Controller->prepare_item_for_response().
$preview_url = get_preview_post_link( $preview_query_args = [
$post_id, 'preview_id' => $post_id,
[ 'preview_nonce' => wp_create_nonce( 'post_preview_' . $post_id ),
'preview_id' => $post_id, ];
'preview_nonce' => wp_create_nonce( 'post_preview_' . $post_id ),
] if ( ET_BUILDER_LAYOUT_POST_TYPE === get_post_type( $post_id ) ) {
); $preview_query_args['p'] = $post_id;
}
$preview_url = get_preview_post_link( $post_id, $preview_query_args );
return self::response_success( return self::response_success(
[ [
@@ -192,11 +195,13 @@ function update( WP_REST_Request $request ) {
} }
} }
// Persist main post content/meta only for singular VB saves. // Persist main post content/meta only for singular VB saves, plus builder-enabled WooCommerce shop pages
// On non-singular pages (home/archive), request post_id may be the first // that hydrate post content while keeping `mainLoopType` as `post_type_archive` (#52168).
// main-query loop post and must not be converted to Divi (#49679). // On other non-singular pages (home/archive), request post_id may be the first main-query loop post
// and must not be converted to Divi (#49679).
$main_post = get_post( $post_id ); $main_post = get_post( $post_id );
$should_persist_main_post = $main_post instanceof \WP_Post && 'singular' === $main_loop_type; $should_persist_main_post = $main_post instanceof \WP_Post
&& self::_should_persist_main_post_content( $post_id, $main_loop_type );
if ( if (
$should_persist_main_post $should_persist_main_post
+2
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit; namespace LIT\MCP\Tests\Unit;
use LIT\MCP\Plugin; use LIT\MCP\Plugin;
use LIT\MCP\Updater\GiteaUpdater;
/** The plugin header: version in step with Plugin::VERSION, neutral wording (no chatbot branding). */ /** The plugin header: version in step with Plugin::VERSION, neutral wording (no chatbot branding). */
final class PluginHeaderTest extends TestCase final class PluginHeaderTest extends TestCase
@@ -16,5 +17,6 @@ final class PluginHeaderTest extends TestCase
self::assertMatchesRegularExpression('/^ \* Description: .*KI-Assistenten\.$/m', $header); self::assertMatchesRegularExpression('/^ \* Description: .*KI-Assistenten\.$/m', $header);
self::assertStringNotContainsString('claude', strtolower($header)); self::assertStringNotContainsString('claude', strtolower($header));
self::assertStringContainsString(' * Version: ' . Plugin::VERSION . "\n", $header); self::assertStringContainsString(' * Version: ' . Plugin::VERSION . "\n", $header);
self::assertStringContainsString(' * Update URI: ' . GiteaUpdater::REPOSITORY_URL . "\n", $header);
} }
} }
+251
View File
@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Updater;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Tests\Unit\TestCase;
use LIT\MCP\Updater\GiteaUpdater;
use LIT\MCP\Updater\PackageSignature;
use Mockery;
use WP_Error;
final class GiteaUpdaterTest extends TestCase
{
private const BASENAME = 'wp-lit-mcp/wp-lit-mcp.php';
private const ZIP_NAME = 'wp-lit-mcp-1.0.0.zip';
private const ZIP = GiteaUpdater::REPOSITORY_URL . '/releases/download/v1.0.0/' . self::ZIP_NAME;
private string $logFile = '';
private string|false $previousLog = '';
private string $secretKey = '';
private string $publicKey = '';
/** @var list<string> */
private array $tempFiles = [];
protected function setUp(): void
{
parent::setUp();
Functions\stubTranslationFunctions();
// error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead.
$this->logFile = $this->tempFile();
$this->previousLog = ini_set('error_log', $this->logFile);
$pair = sodium_crypto_sign_keypair();
$this->secretKey = sodium_crypto_sign_secretkey($pair);
$this->publicKey = base64_encode(sodium_crypto_sign_publickey($pair));
}
protected function tearDown(): void
{
ini_set('error_log', (string) $this->previousLog);
foreach ($this->tempFiles as $file) {
if (file_exists($file)) {
unlink($file);
}
}
parent::tearDown();
}
public function testRegistersItsFilters(): void
{
$updater = $this->updater();
Filters\expectAdded('update_plugins_' . GiteaUpdater::HOST)->once()->with([$updater, 'offer'], 10, 3);
Filters\expectAdded('upgrader_pre_download')->once()->with([$updater, 'verifyDownload'], 10, 2);
$updater->register();
}
public function testLatestReleaseIsOfferedWithItsPackage(): void
{
$this->givenRelease(200, self::releaseBody('v1.0.0', self::ZIP_NAME, self::ZIP));
$update = $this->updater()->offer(false, [], self::BASENAME);
self::assertSame('1.0.0', $update['version']);
self::assertSame(self::ZIP, $update['package']);
self::assertSame('wp-lit-mcp', $update['slug']);
self::assertSame('', $this->log());
}
public function testOtherPluginsAreLeftAlone(): void
{
Functions\expect('wp_remote_get')->never();
self::assertFalse($this->updater()->offer(false, [], 'anderes/anderes.php'));
}
public function testGitCheckoutIsNeverOffered(): void
{
$dir = sys_get_temp_dir() . '/lit-mcp-checkout-' . uniqid();
mkdir($dir . '/.git', 0777, true);
Functions\expect('wp_remote_get')->never();
try {
$updater = new GiteaUpdater(self::BASENAME, $dir, new PackageSignature($this->publicKey));
self::assertFalse($updater->offer(false, [], self::BASENAME));
} finally {
rmdir($dir . '/.git');
rmdir($dir);
}
}
public function testUnreachableGiteaOffersNothingAndLogsWhy(): void
{
Functions\when('wp_remote_get')->justReturn(new WP_Error('http_request_failed', 'cURL error 28'));
self::assertFalse($this->updater()->offer(false, [], self::BASENAME));
self::assertStringContainsString('update check failed: cURL error 28', $this->log());
}
public function testHttpErrorOffersNothingAndLogsTheStatus(): void
{
$this->givenRelease(404, '{"message":"not found"}');
self::assertFalse($this->updater()->offer(false, [], self::BASENAME));
self::assertStringContainsString('update check failed: HTTP 404', $this->log());
}
/** @dataProvider unusableReleases */
public function testUnusableReleaseOffersNothingAndLogsIt(string $body): void
{
$this->givenRelease(200, $body);
self::assertFalse($this->updater()->offer(false, [], self::BASENAME));
self::assertStringContainsString('update check failed', $this->log());
}
/** @return array<string, array{string}> */
public static function unusableReleases(): array
{
$foreign = 'https://evil.example/releases/download/v1.0.0/' . self::ZIP_NAME;
return [
'no JSON' => ['<html>'],
'tag is no version' => [self::releaseBody('latest', self::ZIP_NAME, self::ZIP)],
'pre-release tag' => [self::releaseBody('v1.0.0-rc1', self::ZIP_NAME, self::ZIP)],
'tag with trailing newline' => [self::releaseBody("v1.0.0\n", self::ZIP_NAME, self::ZIP)],
'asset missing' => [self::releaseBody('v1.0.0', 'other.zip', self::ZIP)],
'release without assets' => ['{"tag_name":"v1.0.0"}'],
'package from a foreign host' => [self::releaseBody('v1.0.0', self::ZIP_NAME, $foreign)],
'package outside the release downloads' => [
self::releaseBody('v1.0.0', self::ZIP_NAME, 'https://' . GiteaUpdater::HOST . '/other/repo/x.zip'),
],
];
}
public function testDownloadWithAValidSignatureIsHandedToWordPress(): void
{
$zip = $this->tempFile('release bytes');
$this->givenDownload($zip, $this->sign(self::ZIP_NAME, $zip));
self::assertSame($zip, $this->updater()->verifyDownload(false, self::ZIP));
self::assertFileExists($zip);
}
public function testDownloadWithAForeignSignatureIsDeletedAndRefused(): void
{
$zip = $this->tempFile('release bytes');
$other = $this->tempFile('other bytes');
$this->givenDownload($zip, $this->sign(self::ZIP_NAME, $other));
$result = $this->updater()->verifyDownload(false, self::ZIP);
self::assertInstanceOf(WP_Error::class, $result);
self::assertFileDoesNotExist($zip);
self::assertStringContainsString('update failed: signature of ' . self::ZIP_NAME, $this->log());
}
public function testSignatureOfAnotherVersionDoesNotPassAsThisOne(): void
{
$zip = $this->tempFile('old release bytes');
$this->givenDownload($zip, $this->sign('wp-lit-mcp-0.9.0.zip', $zip));
self::assertInstanceOf(WP_Error::class, $this->updater()->verifyDownload(false, self::ZIP));
}
public function testDownloadWithoutSignatureFileIsRefused(): void
{
$zip = $this->tempFile('release bytes');
$this->givenDownload($zip, '');
self::assertInstanceOf(WP_Error::class, $this->updater()->verifyDownload(false, self::ZIP));
self::assertFileDoesNotExist($zip);
}
public function testFailedDownloadIsReturnedAsIs(): void
{
$error = new WP_Error('download_failed', 'cURL error 28');
Functions\expect('download_url')->once()->with(self::ZIP)->andReturn($error);
self::assertSame($error, $this->updater()->verifyDownload(false, self::ZIP));
}
public function testOtherPackagesAndEarlierDecisionsAreNotTouched(): void
{
Functions\expect('download_url')->never();
self::assertFalse($this->updater()->verifyDownload(false, 'https://downloads.wordpress.org/plugin/x.zip'));
self::assertSame('/tmp/earlier.zip', $this->updater()->verifyDownload('/tmp/earlier.zip', self::ZIP));
}
private function updater(): GiteaUpdater
{
// __DIR__ has no .git folder.
return new GiteaUpdater(self::BASENAME, __DIR__, new PackageSignature($this->publicKey));
}
private function tempFile(string $contents = ''): string
{
$file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-test');
file_put_contents($file, $contents);
$this->tempFiles[] = $file;
return $file;
}
private function sign(string $assetName, string $zipPath): string
{
$message = PackageSignature::message($assetName, $zipPath);
return base64_encode(sodium_crypto_sign_detached($message, $this->secretKey));
}
private function log(): string
{
return (string) file_get_contents($this->logFile);
}
private function givenDownload(string $zip, string $signatureBody): void
{
Functions\expect('download_url')->once()->with(self::ZIP)->andReturn($zip);
Functions\expect('wp_safe_remote_get')->once()
->with(self::ZIP . '.sig', Mockery::type('array'))
->andReturn(['body' => $signatureBody]);
Functions\when('wp_remote_retrieve_body')->justReturn($signatureBody);
Functions\when('wp_delete_file')->alias(static function (string $file): void {
unlink($file);
});
}
private function givenRelease(int $status, string $body): void
{
$latestRelease = Mockery::pattern('#^https://git\.loosen-it\.de/api/v1/repos/.+/releases/latest$#');
Functions\expect('wp_remote_get')->once()
->with($latestRelease, Mockery::type('array'))
->andReturn(['body' => $body]);
Functions\when('wp_remote_retrieve_response_code')->justReturn($status);
Functions\when('wp_remote_retrieve_body')->justReturn($body);
}
private static function releaseBody(string $tag, string $assetName, string $url): string
{
// Data providers run before setUp(), so the wp_json_encode() stub does not exist yet.
return (string) json_encode([ // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
'tag_name' => $tag,
'assets' => [['name' => $assetName, 'browser_download_url' => $url]],
]);
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Updater;
use LIT\MCP\Updater\PackageSignature;
use PHPUnit\Framework\TestCase;
final class PackageSignatureTest extends TestCase
{
private string $zip = '';
private string $secretKey = '';
private PackageSignature $signature;
protected function setUp(): void
{
$pair = sodium_crypto_sign_keypair();
$this->secretKey = sodium_crypto_sign_secretkey($pair);
$this->signature = new PackageSignature(base64_encode(sodium_crypto_sign_publickey($pair)));
$this->zip = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-sig');
file_put_contents($this->zip, 'zip bytes');
}
protected function tearDown(): void
{
unlink($this->zip);
}
public function testASignatureOverNameAndContentVerifies(): void
{
$signature = $this->sign('wp-lit-mcp-1.0.0.zip');
self::assertTrue($this->signature->verifies('wp-lit-mcp-1.0.0.zip', $this->zip, $signature));
}
public function testAnotherAssetNameDoesNotVerify(): void
{
$signature = $this->sign('wp-lit-mcp-1.0.0.zip');
self::assertFalse($this->signature->verifies('wp-lit-mcp-1.0.1.zip', $this->zip, $signature));
}
public function testChangedContentDoesNotVerify(): void
{
$signature = $this->sign('x.zip');
file_put_contents($this->zip, 'changed bytes');
self::assertFalse($this->signature->verifies('x.zip', $this->zip, $signature));
}
public function testAnotherKeyDoesNotVerify(): void
{
$foreign = new PackageSignature(base64_encode(sodium_crypto_sign_publickey(sodium_crypto_sign_keypair())));
self::assertFalse($foreign->verifies('x.zip', $this->zip, $this->sign('x.zip')));
}
/** @dataProvider malformedInput */
public function testMalformedInputFailsClosed(string $signature, string $publicKey, string $zipPath): void
{
$subject = new PackageSignature($publicKey === 'real' ? $this->realKey() : $publicKey);
self::assertFalse($subject->verifies('x.zip', $zipPath === 'zip' ? $this->zip : $zipPath, $signature));
}
/** @return array<string, array{string, string, string}> */
public static function malformedInput(): array
{
return [
'signature is no base64' => ['not base64 !!', 'real', 'zip'],
'signature is empty' => ['', 'real', 'zip'],
'signature has the wrong length' => [base64_encode('short'), 'real', 'zip'],
'no key configured' => ['AAAA', '', 'zip'],
'key has the wrong length' => ['AAAA', base64_encode('short'), 'zip'],
'file is missing' => ['AAAA', 'real', '/nonexistent/x.zip'],
];
}
private function sign(string $assetName): string
{
$message = PackageSignature::message($assetName, $this->zip);
return base64_encode(sodium_crypto_sign_detached($message, $this->secretKey));
}
private function realKey(): string
{
return base64_encode(sodium_crypto_sign_publickey(sodium_crypto_sign_keypair()));
}
}
+2 -1
View File
@@ -3,7 +3,8 @@
/** /**
* Plugin Name: WP-LIT-MCP * Plugin Name: WP-LIT-MCP
* Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für KI-Assistenten. * Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für KI-Assistenten.
* Version: 0.7.0 * Version: 1.0.0
* Update URI: https://git.loosen-it.de/janniclas.loosen/WP-LIT-MCP
* Requires at least: 6.9 * Requires at least: 6.9
* Requires PHP: 8.1 * Requires PHP: 8.1
* Tested up to: 7.1 * Tested up to: 7.1