diff --git a/src/Integrations/WordPress/Settings/SettingsWhitelist.php b/src/Integrations/WordPress/Settings/SettingsWhitelist.php new file mode 100644 index 0000000..6639939 --- /dev/null +++ b/src/Integrations/WordPress/Settings/SettingsWhitelist.php @@ -0,0 +1,227 @@ + [group, type, description] */ + public const SETTINGS = [ + 'blogname' => ['general', 'string', 'Titel der Website'], + 'blogdescription' => ['general', 'string', 'Untertitel der Website'], + 'WPLANG' => ['general', 'string', 'Sprache (Locale, z. B. de_DE; en_US = Englisch); nur installierte Sprachen'], + 'timezone_string' => ['general', 'string', 'Zeitzone, z. B. Europe/Berlin'], + 'date_format' => ['general', 'string', 'Datumsformat (PHP), z. B. j. F Y'], + 'time_format' => ['general', 'string', 'Zeitformat (PHP), z. B. H:i'], + 'show_on_front' => ['reading', 'string', 'Startseite: posts (neueste Beiträge) oder page (statische Seite)'], + 'page_on_front' => ['reading', 'integer', 'ID der statischen Startseite (0 = keine)'], + 'page_for_posts' => ['reading', 'integer', 'ID der Beitragsseite (0 = keine)'], + 'posts_per_page' => ['reading', 'integer', 'Beiträge pro Seite (1–100)'], + 'default_comment_status' => ['discussion', 'string', 'Kommentare für neue Beiträge: open oder closed'], + 'default_ping_status' => ['discussion', 'string', 'Pingbacks für neue Beiträge: open oder closed'], + 'permalink_structure' => ['permalinks', 'string', 'Permalink-Struktur, z. B. /%postname%/ (leer = einfach)'], + 'site_icon' => ['site_icon', 'integer', 'Anhang-ID des Website-Icons (Bild, empfohlen 512×512; 0 = keins)'], + ]; + + public const BLOCKED = ['admin_email', 'users_can_register', 'default_role', 'siteurl', 'home']; + public const GROUPS = ['general', 'reading', 'discussion', 'permalinks', 'site_icon']; + + private const INTEGER_KEYS = ['page_on_front', 'page_for_posts', 'posts_per_page', 'site_icon']; + + public function current(string $key): mixed + { + $value = get_option($key); + if ($key === 'WPLANG') { + return $value === '' || $value === false ? 'en_US' : (string) $value; + } + + if (in_array($key, self::INTEGER_KEYS, true)) { + return (int) $value; + } + + return (string) $value; + } + + /** + * @param array $pending All values of the current call. + * @return string|null Error message, null when valid. + */ + public function validate(string $key, mixed $value, array $pending): ?string + { + if (in_array($key, self::BLOCKED, true)) { + return 'Gesperrt: aus Sicherheitsgründen nicht über LIT MCP änderbar.'; + } + + if (! isset(self::SETTINGS[$key])) { + return 'Nicht freigegeben; erlaubt: ' . implode(', ', array_keys(self::SETTINGS)) . '.'; + } + + return match ($key) { + 'blogname', 'blogdescription' => $this->checkText($value), + 'WPLANG' => $this->checkLanguage($value), + 'timezone_string' => $this->checkTimezone($value), + 'date_format', 'time_format' => $this->checkFormat($value), + 'show_on_front' => $this->checkShowOnFront($value, $pending), + 'page_on_front', 'page_for_posts' => $this->checkPage($key, $value, $pending), + 'posts_per_page' => $this->checkPostsPerPage($value), + 'default_comment_status', 'default_ping_status' => $this->checkOpenClosed($value), + 'permalink_structure' => $this->checkPermalink($value), + 'site_icon' => $this->checkSiteIcon($value), + }; + } + + public function normalize(string $key, mixed $value): mixed + { + if ($key === 'WPLANG' && $value === 'en_US') { + return ''; + } + + return $value; + } + + private function checkText(mixed $value): ?string + { + if (is_string($value) && mb_strlen($value) <= 200) { + return null; + } + + return 'Text bis 200 Zeichen erwartet.'; + } + + private function checkLanguage(mixed $value): ?string + { + if ($value === 'en_US' || $value === '') { + return null; + } + + $installed = get_available_languages(); + if (is_string($value) && in_array($value, $installed, true)) { + return null; + } + + return sprintf('Sprache nicht installiert; verfügbar: en_US, %s.', implode(', ', $installed)); + } + + private function checkTimezone(mixed $value): ?string + { + if (is_string($value) && in_array($value, timezone_identifiers_list(), true)) { + return null; + } + + return 'Zeitzonen-Kennung wie Europe/Berlin erwartet.'; + } + + private function checkFormat(mixed $value): ?string + { + if (is_string($value) && trim($value) !== '' && mb_strlen($value) <= 50) { + return null; + } + + return 'Nicht leeres Format bis 50 Zeichen erwartet.'; + } + + /** @param array $pending */ + private function checkShowOnFront(mixed $value, array $pending): ?string + { + if ($value === 'posts') { + return null; + } + + if ($value !== 'page') { + return 'posts oder page erwartet.'; + } + + $front = (int) ($pending['page_on_front'] ?? get_option('page_on_front')); + if ($front > 0) { + return null; + } + + return 'Für page zuerst page_on_front setzen (im selben Aufruf möglich).'; + } + + /** @param array $pending */ + private function checkPage(string $key, mixed $value, array $pending): ?string + { + if (! is_int($value) || $value < 0) { + return 'Seiten-ID (0 = keine) erwartet.'; + } + + if ($value === 0) { + return null; + } + + $page = get_post($value); + if (! $page instanceof WP_Post || $page->post_type !== 'page' || $page->post_status !== 'publish') { + return sprintf('%d ist keine veröffentlichte Seite.', $value); + } + + $other = $key === 'page_on_front' ? 'page_for_posts' : 'page_on_front'; + $otherValue = (int) ($pending[$other] ?? get_option($other)); + if ($otherValue === $value) { + return 'Startseite und Beitragsseite müssen verschiedene Seiten sein.'; + } + + return null; + } + + private function checkPostsPerPage(mixed $value): ?string + { + if (is_int($value) && $value >= 1 && $value <= 100) { + return null; + } + + return 'Ganzzahl von 1 bis 100 erwartet.'; + } + + private function checkOpenClosed(mixed $value): ?string + { + if (in_array($value, ['open', 'closed'], true)) { + return null; + } + + return 'open oder closed erwartet.'; + } + + private function checkPermalink(mixed $value): ?string + { + if ($value === '') { + return null; + } + + if ( + is_string($value) + && str_starts_with($value, '/') + && str_contains($value, '%') + && mb_strlen($value) <= 200 + && preg_match('/\s/', $value) !== 1 + ) { + return null; + } + + return 'Leer oder eine Struktur wie /%postname%/ erwartet.'; + } + + private function checkSiteIcon(mixed $value): ?string + { + if (! is_int($value) || $value < 0) { + return 'Anhang-ID (0 = keins) erwartet.'; + } + + if ($value === 0 || wp_attachment_is_image($value)) { + return null; + } + + return sprintf('%d ist kein Bild der Mediathek.', $value); + } +} diff --git a/src/Integrations/WordPress/Settings/WpSettingsGetAbility.php b/src/Integrations/WordPress/Settings/WpSettingsGetAbility.php new file mode 100644 index 0000000..71dd857 --- /dev/null +++ b/src/Integrations/WordPress/Settings/WpSettingsGetAbility.php @@ -0,0 +1,103 @@ + 'object', + 'properties' => ['group' => ['type' => 'string', 'enum' => SettingsWhitelist::GROUPS]], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'settings' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'key' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'type' => ['type' => 'string'], + 'value' => ['type' => ['string', 'integer']], + 'description' => ['type' => 'string'], + ], + 'required' => ['key', 'group', 'type', 'value', 'description'], + ], + ], + ], + 'required' => ['settings'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('manage_options') + ? true + : Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).'); + } + + public function execute(array $input): mixed + { + $group = isset($input['group']) ? (string) $input['group'] : ''; + $settings = []; + foreach (SettingsWhitelist::SETTINGS as $key => [$settingGroup, $type, $description]) { + if ($group !== '' && $settingGroup !== $group) { + continue; + } + + $settings[] = [ + 'key' => $key, + 'group' => $settingGroup, + 'type' => $type, + 'value' => $this->whitelist->current($key), + 'description' => $description, + ]; + } + + return ['settings' => $settings]; + } +} diff --git a/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php b/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php new file mode 100644 index 0000000..7ba3072 --- /dev/null +++ b/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php @@ -0,0 +1,227 @@ + 'object', + 'properties' => [ + 'settings' => [ + 'type' => 'object', + 'description' => 'Schlüssel → neuer Wert, mindestens einer, z. B. {"blogname": "Praxis Muster"}.', + ], + ], + 'required' => ['settings'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['previous' => ['type' => 'object']], ['previous']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('manage_options') + ? true + : Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).'); + } + + public function execute(array $input): mixed + { + $settings = is_array($input['settings'] ?? null) ? $input['settings'] : []; + if ($settings === []) { + return Errors::invalidInput('Keine Einstellungen angegeben.', ['settings' => 'mindestens ein Schlüssel']); + } + + $errors = $this->validateAll($settings); + if ($errors !== []) { + return Errors::invalidInput( + 'Einstellungen ungültig; nichts wurde gespeichert.', + $errors, + 'Werte gemäß fields korrigieren; aktuelle Werte über lit-wp-settings-get.' + ); + } + + return $this->write($settings); + } + + /** + * @param array $settings + * @return array + */ + private function validateAll(array $settings): array + { + $errors = []; + foreach ($settings as $key => $value) { + $error = $this->whitelist->validate((string) $key, $value, $settings); + if ($error !== null) { + $errors[(string) $key] = $error; + } + } + + return $errors; + } + + /** + * All values are already known-valid; writes only the ones that actually change. + * If a write fails partway through, the already-written keys are restored to their + * previous values so the call stays all-or-nothing. + * + * @param array $settings + */ + private function write(array $settings): array|WP_Error + { + $previous = []; + foreach ($settings as $key => $value) { + $previous[(string) $key] = $this->whitelist->current((string) $key); + } + + $changed = []; + try { + foreach ($settings as $key => $value) { + $key = (string) $key; + if ($previous[$key] === $value) { + continue; + } + + $this->writeOne($key, $value); + $changed[] = $key; + } + } catch (Throwable $e) { + return $this->fail($changed, $previous, $e); + } + + if ($changed === []) { + return ToolResult::ok('Keine Änderung – alle Werte waren bereits gesetzt.') + ->with('previous', JsonMap::of($previous)) + ->toArray(); + } + + $summary = 'Einstellungen geändert: ' . implode(', ', $changed); + $logId = $this->log->record($this->name(), 'option', implode(',', $changed), $summary); + + $result = ToolResult::ok($summary . '.'); + foreach ($changed as $key) { + $result->changed($key, 'option', 'updated', SettingsWhitelist::SETTINGS[$key][2]); + } + + return $result->logId($logId)->with('previous', JsonMap::of($previous))->toArray(); + } + + private function writeOne(string $key, mixed $value): void + { + if ($key === 'permalink_structure') { + $this->setPermalinkStructure((string) $value); + + return; + } + + if (update_option($key, $this->whitelist->normalize($key, $value)) === false) { + throw new RuntimeException(sprintf('update_option(%s) returned false', $key)); + } + } + + /** + * A write failed; restores the keys already written to their previous values and + * reports write_failed (fully restored) or partial_write (some keys could not be + * restored – the client is told which, so it can check and correct them). + * + * @param list $changed Keys written so far, in write order. + * @param array $previous Their values before this call. + */ + private function fail(array $changed, array $previous, Throwable $cause): WP_Error + { + $stuck = []; + foreach (array_reverse($changed) as $key) { + try { + $this->writeOne($key, $previous[$key]); + } catch (Throwable $e) { + $stuck[] = $key; + } + } + + error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + sprintf('[wp-lit-mcp] wp-settings-update failed: %s', $cause) + ); + + if ($stuck === []) { + return Errors::writeFailed('Eine Einstellung konnte nicht gespeichert werden.'); + } + + return Errors::partialWrite( + 'Eine Einstellung konnte nicht gespeichert werden.', + null, + null, + sprintf( + 'Möglicherweise auf dem neuen, nicht vollständigen Stand: %s.', + implode(', ', $stuck) + ), + 'Aktuelle Werte über lit-wp-settings-get prüfen und ggf. erneut auf die gewünschten Werte setzen.' + ); + } + + private function setPermalinkStructure(string $structure): void + { + global $wp_rewrite; + $wp_rewrite->set_permalink_structure($structure); + flush_rewrite_rules(); + } +} diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index a682a28..b08912d 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -25,6 +25,9 @@ use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\FieldValidator; use LIT\MCP\Integrations\WordPress\Schema\TermAssigner; +use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist; +use LIT\MCP\Integrations\WordPress\Settings\WpSettingsGetAbility; +use LIT\MCP\Integrations\WordPress\Settings\WpSettingsUpdateAbility; use LIT\MCP\Integrations\WordPress\Status\PostCloner; use LIT\MCP\Integrations\WordPress\Status\PostDuplicateAbility; use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility; @@ -58,6 +61,7 @@ final class WordPressIntegration implements Integration $presenter = new PostPresenter($this->schema); $writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner()); $cloner = new PostCloner(); + $whitelist = new SettingsWhitelist(); return [ new ContentTypesAbility($this->schema), @@ -78,6 +82,8 @@ final class WordPressIntegration implements Integration new MenuCreateAbility($this->log), new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $this->log), new MenuAssignLocationAbility($this->log), + new WpSettingsGetAbility($whitelist), + new WpSettingsUpdateAbility($whitelist, $this->log), ]; } } diff --git a/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php b/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php new file mode 100644 index 0000000..ea11f92 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php @@ -0,0 +1,82 @@ +validate('admin_email', 'x@y.de', [])); + self::assertStringStartsWith('Nicht freigegeben', (string) $whitelist->validate('template', 'twentyten', [])); + } + + public function testStaticFrontPageNeedsAPublishedDistinctPage(): void + { + Functions\when('get_option')->justReturn(0); + Functions\when('get_post')->alias(static fn (int $id) => match ($id) { + 5 => new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'publish']), + 6 => new WP_Post(['ID' => 6, 'post_type' => 'page', 'post_status' => 'draft']), + default => null, + }); + $whitelist = new SettingsWhitelist(); + + self::assertNotNull($whitelist->validate('show_on_front', 'page', [])); + self::assertNull($whitelist->validate('show_on_front', 'page', ['page_on_front' => 5])); + self::assertNull($whitelist->validate('page_on_front', 5, [])); + self::assertNotNull($whitelist->validate('page_on_front', 6, [])); + self::assertNotNull($whitelist->validate('page_on_front', 5, ['page_for_posts' => 5])); + self::assertNull($whitelist->validate('page_for_posts', 0, [])); + } + + public function testFormatsLanguagesAndRanges(): void + { + Functions\when('get_available_languages')->justReturn(['de_DE']); + Functions\when('wp_attachment_is_image')->alias(static fn (int $id): bool => $id === 33); + $whitelist = new SettingsWhitelist(); + + self::assertNull($whitelist->validate('WPLANG', 'de_DE', [])); + self::assertNull($whitelist->validate('WPLANG', 'en_US', [])); + self::assertStringContainsString('de_DE', (string) $whitelist->validate('WPLANG', 'fr_FR', [])); + self::assertNull($whitelist->validate('timezone_string', 'Europe/Berlin', [])); + self::assertNotNull($whitelist->validate('timezone_string', 'Mars/Base', [])); + self::assertNull($whitelist->validate('permalink_structure', '/%postname%/', [])); + self::assertNull($whitelist->validate('permalink_structure', '', [])); + self::assertNotNull($whitelist->validate('permalink_structure', 'postname', [])); + self::assertNotNull($whitelist->validate('posts_per_page', 0, [])); + self::assertNotNull($whitelist->validate('default_comment_status', 'maybe', [])); + self::assertNull($whitelist->validate('site_icon', 33, [])); + self::assertNotNull($whitelist->validate('site_icon', 34, [])); + self::assertSame('', $whitelist->normalize('WPLANG', 'en_US')); + } + + public function testCurrentNormalizesLanguageAndNumbers(): void + { + Functions\when('get_option')->alias(static fn (string $key) => ['WPLANG' => '', 'page_on_front' => '5'][$key]); + $whitelist = new SettingsWhitelist(); + + self::assertSame('en_US', $whitelist->current('WPLANG')); + self::assertSame(5, $whitelist->current('page_on_front')); + } +} diff --git a/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php b/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php new file mode 100644 index 0000000..2225125 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php @@ -0,0 +1,135 @@ +justReturn('Kunde'); + + $result = (new WpSettingsGetAbility(new SettingsWhitelist()))->execute(['group' => 'discussion']); + + self::assertSame(['default_comment_status', 'default_ping_status'], array_column($result['settings'], 'key')); + self::assertSame('discussion', $result['settings'][0]['group']); + } + + public function testUpdateIsAllOrNothing(): void + { + Functions\expect('update_option')->never(); + + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class))) + ->execute(['settings' => ['blogname' => 'Neu', 'admin_email' => 'x@y.de']]); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertArrayHasKey('admin_email', $result->get_error_data()['fields']); + } + + public function testUpdatesOnlyChangedValuesAndFlushesPermalinks(): void + { + Functions\when('get_option')->alias(static fn (string $key) => [ + 'blogname' => 'Alt', + 'blogdescription' => 'Gleich', + 'permalink_structure' => '', + ][$key]); + Functions\expect('update_option')->once()->with('blogname', 'Neu'); + $rewrite = Mockery::mock(); + $rewrite->expects('set_permalink_structure')->with('/%postname%/'); + $GLOBALS['wp_rewrite'] = $rewrite; + Functions\expect('flush_rewrite_rules')->once(); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/wp-settings-update', 'option', 'blogname,permalink_structure', Mockery::type('string'))->andReturn(30); + + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [ + 'blogname' => 'Neu', + 'blogdescription' => 'Gleich', + 'permalink_structure' => '/%postname%/', + ]]); + + self::assertSame(['blogname', 'permalink_structure'], array_column($result['changed'], 'id')); + self::assertSame(['blogname' => 'Alt', 'blogdescription' => 'Gleich', 'permalink_structure' => ''], $result['previous']); + self::assertSame(30, $result['log_id']); + } + + public function testRestoresPreviousValuesWhenAWriteFailsPartway(): void + { + Functions\when('get_option')->alias(static fn (string $key) => [ + 'blogname' => 'Alt', + 'blogdescription' => 'Vorher', + ][$key]); + Functions\expect('update_option')->once()->with('blogname', 'Neu')->andReturn(true); + Functions\expect('update_option')->once()->with('blogdescription', 'Auch neu')->andReturn(false); + Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(true); + $log = Mockery::mock(ChangeLog::class); + $log->shouldNotReceive('record'); + $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $file); + + try { + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [ + 'blogname' => 'Neu', + 'blogdescription' => 'Auch neu', + ]]); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + + self::assertSame('lit_write_failed', $result->get_error_code()); + } + + public function testReportsPartialWriteWhenRestoringAlsoFails(): void + { + Functions\when('get_option')->alias(static fn (string $key) => [ + 'blogname' => 'Alt', + 'blogdescription' => 'Vorher', + ][$key]); + Functions\expect('update_option')->once()->with('blogname', 'Neu')->andReturn(true); + Functions\expect('update_option')->once()->with('blogdescription', 'Auch neu')->andReturn(false); + Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(false); + $log = Mockery::mock(ChangeLog::class); + $log->shouldNotReceive('record'); + $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $file); + + try { + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [ + 'blogname' => 'Neu', + 'blogdescription' => 'Auch neu', + ]]); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + + self::assertSame('lit_partial_write', $result->get_error_code()); + self::assertStringContainsString('blogname', $result->get_error_message()); + } + + public function testSettingsToolsNeedManageOptions(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'manage_options'); + + self::assertSame('lit_forbidden', (new WpSettingsGetAbility(new SettingsWhitelist()))->permission([])->get_error_code()); + self::assertSame( + 'lit_forbidden', + (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class)))->permission([])->get_error_code() + ); + } +} diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php index 6e59c55..1c8fb28 100644 --- a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -33,6 +33,8 @@ final class WordPressIntegrationTest extends TestCase 'lit/menu-create', 'lit/menu-set-items', 'lit/menu-assign-location', + 'lit/wp-settings-get', + 'lit/wp-settings-update', ]; public function testProvidesTheWordPressAbilitiesInOrder(): void