Files
WP-LIT-MCP/src/Admin/LogListTable.php
T
janniclas.loosenandClaude Opus 5.5 b24eb6c3e9 fix(security): no direct HTTP access to plugin PHP files (audit L2, cheap part)
Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 19:32:12 +02:00

317 lines
11 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\Admin;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Post;
defined('ABSPATH') || exit;
// phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps -- method names are defined by WP_List_Table.
/**
* List of change log entries (spec §8.3, ruling C5/Q13), built like WordPress' own list
* screens: the change is the primary column with row actions, the other columns can be
* hidden and the page size set via "Ansicht anpassen". WP_List_Table must be loaded before
* this file (LogTab takes care of that).
*/
final class LogListTable extends \WP_List_Table
{
/** Longer client names are shortened in the "Wer" column (full name as tooltip). */
private const CLIENT_LENGTH = 22;
/** Entries younger than a day show a relative time. */
private const RELATIVE_SECONDS = 86400;
/** @var (callable(): void)|null */
private $renderFilters;
private LogObjects $objects;
private int $now;
/**
* @param array{
* ability: string, user_id: int, result?: string, date_from?: string, date_to?: string
* } $filters
* @param (callable(): void)|null $renderFilters prints the filter controls into the top table nav
* @param array<string, string> $toolLabels ability name => human label
* @param int|null $now current Unix time (tests), default time()
*/
public function __construct(
private ChangeLog $log,
private array $filters,
?callable $renderFilters = null,
private array $toolLabels = [],
?LogObjects $objects = null,
?int $now = null
) {
$this->renderFilters = $renderFilters;
$this->objects = $objects ?? new LogObjects();
$this->now = $now ?? time();
parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]);
}
/** @return array<string, string> */
public function get_columns(): array
{
return [
// Key "title" as on core's list screens: WordPress never offers it for hiding.
'title' => __('Änderung', 'wp-lit-mcp'),
'object' => __('Objekt', 'wp-lit-mcp'),
'user' => __('Wer', 'wp-lit-mcp'),
'created_at' => __('Zeit', 'wp-lit-mcp'),
];
}
/** @return string */
protected function get_default_primary_column_name()
{
return 'title';
}
/**
* Column headers and hidden columns come from WordPress (get_column_info()), so the
* "Ansicht anpassen" choices apply. Users and objects of the page are loaded in one go.
*/
public function prepare_items(): void
{
$perPage = $this->get_items_per_page(LogTab::PER_PAGE_OPTION, LogTab::PER_PAGE_DEFAULT);
$page = max(1, (int) $this->get_pagenum());
$data = $this->log->query($this->filters, $perPage, ($page - 1) * $perPage);
$this->items = $data['rows'];
$this->set_pagination_args(['total_items' => $data['total'], 'per_page' => $perPage]);
$userIds = array_values(array_unique(array_filter(array_map(
static fn (array $row): int => (int) ($row['user_id'] ?? 0),
$this->items
))));
if ($userIds !== []) {
cache_users($userIds);
}
$this->objects->prime($this->items);
}
public function no_items(): void
{
if (! LogTab::hasActiveFilter($this->filters)) {
esc_html_e('Noch keine Änderungen protokolliert.', 'wp-lit-mcp');
return;
}
printf(
'%s <a href="%s">%s</a>',
esc_html__('Keine Einträge für diese Filter.', 'wp-lit-mcp'),
esc_url(SettingsPage::url('protokoll')),
esc_html__('Filter zurücksetzen', 'wp-lit-mcp')
);
}
/**
* Filters left of the pagination in the top table nav, as on WordPress' own list screens.
*
* @param string $which
*/
protected function extra_tablenav($which): void
{
if ($which !== 'top' || $this->renderFilters === null) {
return;
}
echo '<div class="alignleft actions">';
($this->renderFilters)();
echo '</div>';
}
/**
* Marks rows whose write did not complete (result "partial": failed after an
* irreversible step, spec §2 principle 7/8.2) so admins notice them while scrolling.
*
* @param array<string, mixed> $item
*/
public function single_row($item): void
{
$status = self::status((string) ($item['result'] ?? ''));
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed class names.
echo $status === null ? '<tr>' : '<tr class="lit-mcp-row-' . $status[0] . '">';
$this->single_row_columns($item);
echo '</tr>';
}
/**
* Badge class and word for a result that is not "ok", null for "ok".
*
* @return array{0: string, 1: string}|null
*/
private static function status(string $result): ?array
{
return match ($result) {
'ok' => null,
'partial' => ['warning', __('teilweise', 'wp-lit-mcp')],
'failed', 'error' => ['error', __('fehlgeschlagen', 'wp-lit-mcp')],
default => ['error', __('unbekannt', 'wp-lit-mcp')],
};
}
/**
* Primary column: the summary, below it the tool's human label (technical name as tooltip).
*
* @param array<string, mixed> $item
*/
protected function column_title($item): string
{
$status = self::status((string) ($item['result'] ?? ''));
$badge = $status === null ? '' : sprintf(
'<span class="lit-mcp-badge lit-mcp-badge-%s">%s</span> ',
esc_attr($status[0]),
esc_html($status[1])
);
$ability = (string) ($item['ability'] ?? '');
$tool = isset($this->toolLabels[$ability])
? sprintf(
'<span class="lit-mcp-muted" title="%s">%s</span>',
esc_attr($ability),
esc_html($this->toolLabels[$ability])
)
: '<span class="lit-mcp-muted">' . esc_html($ability) . '</span>';
return sprintf(
'<div class="lit-mcp-summary">%s%s</div>%s',
$badge,
esc_html((string) ($item['summary'] ?? '')),
$tool
);
}
/**
* "Revision ansehen" and "Objekt öffnen" – each only where it leads somewhere the
* current user may go.
*
* @param array<string, mixed> $item
* @param string $column_name
* @param string $primary
*/
protected function handle_row_actions($item, $column_name, $primary): string
{
if ($column_name !== $primary) {
return '';
}
$actions = [];
$revision = $this->revisionUrl((int) ($item['revision_id'] ?? 0));
if ($revision !== '') {
$actions['revision'] = sprintf(
'<a href="%s">%s</a>',
esc_url($revision),
esc_html__('Revision ansehen', 'wp-lit-mcp')
);
}
$object = $this->object($item);
if ($object['url'] !== '') {
$actions['edit'] = sprintf(
'<a href="%s">%s</a>',
esc_url($object['url']),
esc_html__('Objekt öffnen', 'wp-lit-mcp')
);
}
return $actions === []
? (string) parent::handle_row_actions($item, $column_name, $primary)
: (string) $this->row_actions($actions);
}
/** @param array<string, mixed> $item */
protected function column_object($item): string
{
return $this->object($item)['html'];
}
/**
* @param array<string, mixed> $item
* @return array{html: string, url: string}
*/
private function object(array $item): array
{
return $this->objects->describe((string) ($item['object_type'] ?? ''), (string) ($item['object_id'] ?? ''));
}
/**
* The WordPress user; below it the connected client, shortened (full name as tooltip).
*
* @param array<string, mixed> $item
*/
protected function column_user($item): string
{
$userId = (int) ($item['user_id'] ?? 0);
$user = get_userdata($userId);
$html = esc_html(is_object($user) ? (string) $user->display_name : '#' . $userId);
$client = (string) ($item['client'] ?? '');
if ($client !== '') {
$html .= sprintf(
'<br><span class="lit-mcp-muted" title="%s">%s</span>',
esc_attr($client),
esc_html(mb_strimwidth($client, 0, self::CLIENT_LENGTH, '…'))
);
}
return $html;
}
/**
* Stored in UTC (MySQL format). Within the last 24 hours relative ("vor 5 Minuten"),
* otherwise date and time in the site's timezone and formats; full time plus UTC as
* tooltip, as on WordPress' comments screen.
*
* @param array<string, mixed> $item
*/
protected function column_created_at($item): string
{
$utc = (string) ($item['created_at'] ?? '');
$timestamp = preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/D', $utc) === 1
? strtotime($utc . ' UTC')
: false;
if ($timestamp === false) {
return '–';
}
$date = (string) wp_date((string) get_option('date_format'), $timestamp);
$time = (string) wp_date((string) get_option('time_format'), $timestamp);
$age = $this->now - $timestamp;
$text = $age >= 0 && $age < self::RELATIVE_SECONDS
/* translators: %s: time span, e.g. "5 Minuten" */
? sprintf(__('vor %s', 'wp-lit-mcp'), human_time_diff($timestamp, $this->now))
/* translators: 1: date, 2: time */
: sprintf(__('%1$s um %2$s', 'wp-lit-mcp'), $date, $time);
return sprintf(
'<time datetime="%s" title="%s">%s</time>',
esc_attr(gmdate('c', $timestamp)),
esc_attr(sprintf('%s %s (UTC: %s)', $date, $time, $utc)),
esc_html($text)
);
}
/**
* Only 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 revisionUrl(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 admin_url('revision.php?revision=' . $revisionId);
}
}