diff --git a/.gitattributes b/.gitattributes index a55a796..5ad805a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ +/bin export-ignore /tests export-ignore /docs/superpowers export-ignore /.superpowers export-ignore diff --git a/.gitignore b/.gitignore index b3be7de..9cab10f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /vendor/ /vendor.lit-bak/ +/dist/ .phpunit.result.cache diff --git a/bin/release-sign.php b/bin/release-sign.php new file mode 100644 index 0000000..2fc9587 --- /dev/null +++ b/bin/release-sign.php @@ -0,0 +1,64 @@ + new key pair; prints the public key for PackageSignature + * php bin/release-sign.php sign prints the signature (the .sig asset) + * php bin/release-sign.php verify 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 (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 (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 '); + } + 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 | sign | verify '); +} diff --git a/bin/release.sh b/bin/release.sh new file mode 100755 index 0000000..49e9917 --- /dev/null +++ b/bin/release.sh @@ -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 )" +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" diff --git a/readme.txt b/readme.txt index f5e2760..25bbcd1 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: mcp, ai, divi, pods, oauth Requires at least: 6.9 Tested up to: 7.1 Requires PHP: 8.1 -Stable tag: 0.7.0 +Stable tag: 1.0.0 License: GPL-2.0-or-later 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 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 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 @@ -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 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 == 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= lit-mcp divi-check [--diff] [--update-reference]`. == 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 = * 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. diff --git a/src/Integrations/Divi/Release/DiviReleases.php b/src/Integrations/Divi/Release/DiviReleases.php index ef019eb..8654185 100644 --- a/src/Integrations/Divi/Release/DiviReleases.php +++ b/src/Integrations/Divi/Release/DiviReleases.php @@ -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']; } diff --git a/src/Integrations/Divi/Release/fingerprints.json b/src/Integrations/Divi/Release/fingerprints.json index 3b8c553..59908bc 100644 --- a/src/Integrations/Divi/Release/fingerprints.json +++ b/src/Integrations/Divi/Release/fingerprints.json @@ -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" } diff --git a/src/Plugin.php b/src/Plugin.php index f02e51a..4397a3a 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -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); diff --git a/src/Updater/GiteaUpdater.php b/src/Updater/GiteaUpdater.php new file mode 100644 index 0000000..b3e2c61 --- /dev/null +++ b/src/Updater/GiteaUpdater.php @@ -0,0 +1,170 @@ + (about every 12 hours, which is also the only + * caching needed). The release carries the ZIP as asset wp-lit-mcp-.zip. + * + * The ZIP is only installed with a valid signature (PackageSignature, asset .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|false $update What WordPress knows so far (false = nothing). + * @param array $pluginData Header data of the plugin asked about. + * @return array|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 $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; + } +} diff --git a/src/Updater/PackageSignature.php b/src/Updater/PackageSignature.php new file mode 100644 index 0000000..573e5f2 --- /dev/null +++ b/src/Updater/PackageSignature.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/tests/Fixtures/divi/reference/vb-sync-flags.txt b/tests/Fixtures/divi/reference/vb-sync-flags.txt index 2f424df..abcc7dd 100644 --- a/tests/Fixtures/divi/reference/vb-sync-flags.txt +++ b/tests/Fixtures/divi/reference/vb-sync-flags.txt @@ -115,13 +115,16 @@ function update( WP_REST_Request $request ) { // Create preview post link. // 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( - $post_id, - [ - 'preview_id' => $post_id, - 'preview_nonce' => wp_create_nonce( 'post_preview_' . $post_id ), - ] - ); + $preview_query_args = [ + '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( [ @@ -192,11 +195,13 @@ function update( WP_REST_Request $request ) { } } - // Persist main post content/meta only for singular VB saves. - // On non-singular pages (home/archive), request post_id may be the first - // main-query loop post and must not be converted to Divi (#49679). + // Persist main post content/meta only for singular VB saves, plus builder-enabled WooCommerce shop pages + // that hydrate post content while keeping `mainLoopType` as `post_type_archive` (#52168). + // 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 ); - $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 ( $should_persist_main_post diff --git a/tests/Unit/PluginHeaderTest.php b/tests/Unit/PluginHeaderTest.php index 2b18b04..99e40df 100644 --- a/tests/Unit/PluginHeaderTest.php +++ b/tests/Unit/PluginHeaderTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit; use LIT\MCP\Plugin; +use LIT\MCP\Updater\GiteaUpdater; /** The plugin header: version in step with Plugin::VERSION, neutral wording (no chatbot branding). */ final class PluginHeaderTest extends TestCase @@ -16,5 +17,6 @@ final class PluginHeaderTest extends TestCase self::assertMatchesRegularExpression('/^ \* Description: .*KI-Assistenten\.$/m', $header); self::assertStringNotContainsString('claude', strtolower($header)); self::assertStringContainsString(' * Version: ' . Plugin::VERSION . "\n", $header); + self::assertStringContainsString(' * Update URI: ' . GiteaUpdater::REPOSITORY_URL . "\n", $header); } } diff --git a/tests/Unit/Updater/GiteaUpdaterTest.php b/tests/Unit/Updater/GiteaUpdaterTest.php new file mode 100644 index 0000000..48069dc --- /dev/null +++ b/tests/Unit/Updater/GiteaUpdaterTest.php @@ -0,0 +1,251 @@ + */ + 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 */ + public static function unusableReleases(): array + { + $foreign = 'https://evil.example/releases/download/v1.0.0/' . self::ZIP_NAME; + + return [ + 'no JSON' => [''], + '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]], + ]); + } +} diff --git a/tests/Unit/Updater/PackageSignatureTest.php b/tests/Unit/Updater/PackageSignatureTest.php new file mode 100644 index 0000000..7acb41d --- /dev/null +++ b/tests/Unit/Updater/PackageSignatureTest.php @@ -0,0 +1,91 @@ +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 */ + 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())); + } +} diff --git a/wp-lit-mcp.php b/wp-lit-mcp.php index 7fdb0e4..01fde94 100644 --- a/wp-lit-mcp.php +++ b/wp-lit-mcp.php @@ -3,7 +3,8 @@ /** * Plugin Name: WP-LIT-MCP * 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 PHP: 8.1 * Tested up to: 7.1