diff --git a/src/Admin/LogListTable.php b/src/Admin/LogListTable.php new file mode 100644 index 0000000..b4ce973 --- /dev/null +++ b/src/Admin/LogListTable.php @@ -0,0 +1,150 @@ + 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]); + } + + /** @return array */ + public function get_columns(): array + { + return [ + 'created_at' => __('Zeit (UTC)', 'wp-lit-mcp'), + 'user' => __('Benutzer', 'wp-lit-mcp'), + 'client' => __('Client', 'wp-lit-mcp'), + 'ability' => __('Tool', 'wp-lit-mcp'), + 'object' => __('Objekt', 'wp-lit-mcp'), + 'summary' => __('Zusammenfassung', 'wp-lit-mcp'), + 'revision' => __('Revision', 'wp-lit-mcp'), + 'result' => __('Ergebnis', 'wp-lit-mcp'), + ]; + } + + public function prepare_items(): void + { + $page = max(1, (int) $this->get_pagenum()); + $data = $this->log->query($this->filters, self::PER_PAGE, ($page - 1) * self::PER_PAGE); + $this->items = $data['rows']; + $this->_column_headers = [$this->get_columns(), [], []]; + $this->set_pagination_args(['total_items' => $data['total'], 'per_page' => self::PER_PAGE]); + } + + public function no_items(): void + { + esc_html_e('Keine Einträge.', 'wp-lit-mcp'); + } + + /** + * Highlights rows with result "partial" (a write that failed after an irreversible + * step, spec §2 principle 7/8.2) so admins notice them without reading every row. + * + * @param array $item + */ + public function single_row($item): void + { + $style = ($item['result'] ?? '') === 'partial' ? ' style="background:#fcf3e3;"' : ''; + printf('', $style); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string. + $this->single_row_columns($item); + echo ''; + } + + /** + * @param array $item + * @param string $column_name + */ + protected function column_default($item, $column_name): string + { + return match ($column_name) { + 'user' => $this->userName((int) ($item['user_id'] ?? 0)), + 'object' => $this->objectLink((string) ($item['object_type'] ?? ''), (string) ($item['object_id'] ?? '')), + 'revision' => $this->revisionLink((int) ($item['revision_id'] ?? 0)), + default => esc_html((string) ($item[$column_name] ?? '')), + }; + } + + private function userName(int $userId): string + { + $user = get_userdata($userId); + + return esc_html(is_object($user) ? (string) $user->display_name : '#' . $userId); + } + + /** + * Links by object_type (ruling C5/Q13): known post types (not attachment) via + * get_edit_post_link(); attachment via the media edit screen; nav_menu (a term id) + * via the menus screen; settings/menu_location/anything else has no admin screen + * to link to and is shown as plain text. + */ + private function objectLink(string $objectType, string $objectId): string + { + $label = trim($objectType . ' ' . $objectId); + $url = $objectId !== '' && ctype_digit($objectId) ? $this->urlFor($objectType, (int) $objectId) : ''; + + return $url !== '' ? sprintf('%s', esc_url($url), esc_html($label)) : esc_html($label); + } + + private function urlFor(string $objectType, int $id): string + { + if ($objectType === 'attachment') { + return get_post($id) instanceof WP_Post ? admin_url('upload.php?item=' . $id) : ''; + } + + if ($objectType === 'nav_menu') { + return get_term($id, 'nav_menu') instanceof WP_Term ? admin_url('nav-menus.php?menu=' . $id) : ''; + } + + if ($objectType !== '' && post_type_exists($objectType)) { + $link = get_edit_post_link($id, 'raw'); + + return is_string($link) ? $link : ''; + } + + return ''; + } + + /** + * Only shown when the user could actually act on it: revision_id set and the + * revision's parent post is one the current user may edit (ruling C5/Q13). + */ + private function revisionLink(int $revisionId): string + { + if ($revisionId <= 0) { + return '–'; + } + + $revision = get_post($revisionId); + $parentId = $revision instanceof WP_Post ? (int) $revision->post_parent : 0; + if ($parentId <= 0 || ! current_user_can('edit_post', $parentId)) { + return '–'; + } + + return sprintf( + '%s', + esc_url(admin_url('revision.php?revision=' . $revisionId)), + esc_html__('ansehen / wiederherstellen', 'wp-lit-mcp') + ); + } +} diff --git a/src/Admin/LogTab.php b/src/Admin/LogTab.php new file mode 100644 index 0000000..7aeddc1 --- /dev/null +++ b/src/Admin/LogTab.php @@ -0,0 +1,199 @@ +terminate = $terminate ?? static function (): void { + exit; + }; + } + + public function slug(): string + { + return 'protokoll'; + } + + public function label(): string + { + return __('Protokoll', 'wp-lit-mcp'); + } + + public function registerSettings(): void + { + } + + public function registerActions(): void + { + add_action('admin_post_' . self::CLEAR_ACTION, [$this, 'handleClear']); + } + + /** @return array{ability: string, user_id: int} */ + public function filters(): array + { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters. + $ability = isset($_GET['ability']) ? sanitize_text_field(wp_unslash((string) $_GET['ability'])) : ''; + $userId = isset($_GET['user_id']) ? absint($_GET['user_id']) : 0; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + return [ + 'ability' => preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $ability) === 1 ? $ability : '', + 'user_id' => $userId, + ]; + } + + /** + * Additional whitelisted filters (ruling C5/Q13: ability, result, user, date range). + * Kept separate from filters() so that method's return shape stays exactly what is + * unit-tested (and what earlier callers of this tab may already rely on). + * + * @return array{result: string, date_from: string, date_to: string} + */ + public function extraFilters(): array + { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters. + $result = isset($_GET['result']) ? sanitize_text_field(wp_unslash((string) $_GET['result'])) : ''; + $dateFrom = isset($_GET['date_from']) ? sanitize_text_field(wp_unslash((string) $_GET['date_from'])) : ''; + $dateTo = isset($_GET['date_to']) ? sanitize_text_field(wp_unslash((string) $_GET['date_to'])) : ''; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + return [ + 'result' => in_array($result, self::RESULTS, true) ? $result : '', + 'date_from' => self::isDate($dateFrom) ? $dateFrom : '', + 'date_to' => self::isDate($dateTo) ? $dateTo : '', + ]; + } + + private static function isDate(string $value): bool + { + return preg_match('#^\d{4}-\d{2}-\d{2}$#', $value) === 1; + } + + public function render(): void + { + if (! class_exists('WP_List_Table')) { + require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; + } + + $filters = $this->filters(); + $extra = $this->extraFilters(); + printf( + '

%s

', + esc_html(sprintf( + /* translators: %d: number of days */ + __('Einträge werden nach %d Tagen automatisch gelöscht (einstellbar im Tab „Limits“).', 'wp-lit-mcp'), + $this->limits->logRetentionDays() + )) + ); + printf('

%s

', esc_html__('Das Protokoll enthält nur Verweise, keine Inhalte.', 'wp-lit-mcp')); + + $this->renderFilterForm($filters, $extra); + + $table = new LogListTable($this->log, $filters + $extra); + $table->prepare_items(); + $table->display(); + + $this->renderClearForm(); + } + + /** + * @param array{ability: string, user_id: int} $filters + * @param array{result: string, date_from: string, date_to: string} $extra + */ + private function renderFilterForm(array $filters, array $extra): void + { + echo '
'; + printf('', esc_attr(SettingsPage::SLUG)); + printf('', esc_attr($this->slug())); + + echo ' '; + + wp_dropdown_users([ + 'name' => 'user_id', + 'show_option_all' => __('Alle Benutzer', 'wp-lit-mcp'), + 'selected' => $filters['user_id'], + ]); + echo ' '; + + echo ' '; + + printf( + ' ', + esc_html__('von', 'wp-lit-mcp'), + esc_attr($extra['date_from']) + ); + printf( + ' ', + esc_html__('bis', 'wp-lit-mcp'), + esc_attr($extra['date_to']) + ); + + submit_button(__('Filtern', 'wp-lit-mcp'), 'secondary', '', false); + echo '
'; + } + + private function renderClearForm(): void + { + printf('
', esc_url(admin_url('admin-post.php'))); + printf('', esc_attr(self::CLEAR_ACTION)); + wp_nonce_field(self::CLEAR_ACTION); + $confirmMessage = esc_js(__( + 'Das gesamte Protokoll unwiderruflich löschen?', + 'wp-lit-mcp' + )); + submit_button( + __('Protokoll leeren', 'wp-lit-mcp'), + 'delete', + 'submit', + false, + ['onclick' => "return window.confirm('" . $confirmMessage . "');"] + ); + echo '
'; + } + + public function handleClear(): void + { + if (! current_user_can('manage_options')) { + wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]); + } + + check_admin_referer(self::CLEAR_ACTION); + $this->log->clear(); + wp_safe_redirect(SettingsPage::url($this->slug(), ['lit_mcp_notice' => 'cleared'])); + ($this->terminate)(); + } +} diff --git a/src/ChangeLog/ChangeLog.php b/src/ChangeLog/ChangeLog.php index ef6160f..ae0e712 100644 --- a/src/ChangeLog/ChangeLog.php +++ b/src/ChangeLog/ChangeLog.php @@ -96,6 +96,80 @@ class ChangeLog return (int) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE created_at < %s", $cutoff)); } + /** + * Reads rows for the "Protokoll" admin tab (spec §8.3), newest first. All filters are + * optional and whitelisted by the caller (LogTab); values only ever reach the query + * through wpdb::prepare() placeholders. + * + * @param array{ + * ability?: string, user_id?: int, result?: string, date_from?: string, date_to?: string + * } $filters + * @return array{rows: list>, total: int} + */ + public function query(array $filters, int $limit, int $offset): array + { + $wpdb = $this->db(); + $table = $this->tableName(); + $where = ['1 = %d']; + $args = [1]; + if (($filters['ability'] ?? '') !== '') { + $where[] = 'ability = %s'; + $args[] = (string) $filters['ability']; + } + if ((int) ($filters['user_id'] ?? 0) > 0) { + $where[] = 'user_id = %d'; + $args[] = (int) $filters['user_id']; + } + if (($filters['result'] ?? '') !== '') { + $where[] = 'result = %s'; + $args[] = (string) $filters['result']; + } + if (($filters['date_from'] ?? '') !== '') { + $where[] = 'created_at >= %s'; + $args[] = $filters['date_from'] . ' 00:00:00'; + } + if (($filters['date_to'] ?? '') !== '') { + $where[] = 'created_at <= %s'; + $args[] = $filters['date_to'] . ' 23:59:59'; + } + $whereSql = implode(' AND ', $where); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $whereSql holds one %s/%d per filter actually present. + $total = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$whereSql}", ...$args)); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE {$whereSql} ORDER BY id DESC LIMIT %d OFFSET %d", + ...array_merge($args, [$limit, $offset]) + ), + 'ARRAY_A' + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + + return ['rows' => is_array($rows) ? array_values($rows) : [], 'total' => $total]; + } + + /** @return list */ + public function abilities(): array + { + $wpdb = $this->db(); + $table = $this->tableName(); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name. + $abilities = (array) $wpdb->get_col("SELECT DISTINCT ability FROM {$table} ORDER BY ability"); + + return array_values(array_map('strval', $abilities)); + } + + /** Deletes every row (button "Protokoll leeren", spec §8.3). Irreversible. */ + public function clear(): void + { + $wpdb = $this->db(); + $table = $this->tableName(); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name. + $wpdb->query("DELETE FROM {$table}"); + } + /** * RequestContext::clientName() is a client-chosen string (app-password name or * OAuth client_id) – treat it as untrusted input before it lands in the DB. diff --git a/src/Plugin.php b/src/Plugin.php index c6982c4..08e6482 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -12,6 +12,7 @@ use LIT\MCP\Admin\ConnectionRepository; use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\Admin\ConnectionTab; use LIT\MCP\Admin\LimitsTab; +use LIT\MCP\Admin\LogTab; use LIT\MCP\Admin\SettingsPage; use LIT\MCP\Admin\SiteHealth; use LIT\MCP\Admin\ToolsTab; @@ -85,6 +86,7 @@ final class Plugin new ConnectionTab($status), new AccessTab($access, new ConnectionRepository()), new ToolsTab($registry, $policy), + new LogTab($changeLog, $limits), new LimitsTab(), ]); if (is_admin()) { diff --git a/tests/Stubs/WpListTable.php b/tests/Stubs/WpListTable.php new file mode 100644 index 0000000..773aa13 --- /dev/null +++ b/tests/Stubs/WpListTable.php @@ -0,0 +1,90 @@ +()/column_default()) for LogListTable's own logic to be unit-tested + * without a full WP admin bootstrap. + */ + class WP_List_Table + { + /** @var list> */ + public $items = []; + + /** @var array{0: array, 1: list, 2: list}|null */ + public $_column_headers; + + /** @var array */ + protected $_args; + + /** @var array */ + protected $_pagination_args = []; + + /** @param array $args */ + public function __construct(array $args = []) + { + $this->_args = $args; + } + + public function get_pagenum(): int + { + return isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + + /** @param array $args */ + public function set_pagination_args(array $args): void + { + $this->_pagination_args = $args; + } + + /** @return array */ + public function pagination_args(): array + { + return $this->_pagination_args; + } + + /** @return array */ + public function get_columns(): array + { + return []; + } + + public function single_row($item): void + { + echo ''; + $this->single_row_columns($item); + echo ''; + } + + /** @param array $item */ + protected function single_row_columns($item): void + { + foreach (array_keys($this->get_columns()) as $column_name) { + $method = 'column_' . $column_name; + $value = method_exists($this, $method) ? $this->{$method}($item) : $this->column_default($item, $column_name); + printf('%s', $column_name, $value); + } + } + + /** @param array $item */ + protected function column_default($item, $column_name): string + { + return ''; + } + + public function no_items(): void + { + } + + public function prepare_items(): void + { + } + + public function display(): void + { + } + } +} diff --git a/tests/Unit/Admin/LogListTableTest.php b/tests/Unit/Admin/LogListTableTest.php new file mode 100644 index 0000000..9323c2d --- /dev/null +++ b/tests/Unit/Admin/LogListTableTest.php @@ -0,0 +1,203 @@ +justReturn(false); + Functions\when('post_type_exists')->justReturn(false); + Functions\when('get_post')->justReturn(null); + Functions\when('get_term')->justReturn(null); + Functions\when('current_user_can')->justReturn(false); + Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path); + } + + protected function tearDown(): void + { + unset($_GET['paged']); + parent::tearDown(); + } + + public function testPrepareItemsQueriesTheCurrentPageAndSetsPagination(): void + { + $_GET['paged'] = '3'; + $log = Mockery::mock(ChangeLog::class); + $log->expects('query') + ->with(['ability' => 'lit/content-update', 'user_id' => 0], 50, 100) + ->andReturn(['rows' => [['id' => '1']], 'total' => 120]); + + $table = new LogListTable($log, ['ability' => 'lit/content-update', 'user_id' => 0]); + $table->prepare_items(); + + self::assertSame([['id' => '1']], $table->items); + self::assertSame(['total_items' => 120, 'per_page' => 50], $table->pagination_args()); + } + + public function testColumnDefaultEscapesUntrustedClientAndSummaryText(): void + { + $table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]); + + $item = ['client' => 'Claude ', 'summary' => 'böse Zusammenfassung']; + + self::assertStringNotContainsString('