Files
WP-LIT-MCP/src/Integrations/WordPress/Settings/SettingsWhitelist.php
T

230 lines
7.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Settings;
use WP_Post;
/**
* The WordPress options LIT MCP may read and write (spec §6.5), with validation.
*
* validate() arms are delegated to one-purpose helper methods rather than inline
* ternaries: PHPCompatibility's RemovedTernaryAssociativity sniff misreads a `match`
* whose arms are themselves ternaries as chained ternaries and rejects them, even
* though PHP itself parses them fine (controller ruling P3).
*/
final class SettingsWhitelist
{
/** key => [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<string, mixed> $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
{
// Clients say en_US for English; '' is WordPress' internal storage form for it
// (see normalize()) and is not accepted as input.
if ($value === 'en_US') {
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<string, mixed> $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<string, mixed> $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);
}
}