revert Merge pull request 'feat(widgets): add phase 4 validation and accessibility' (#6) from worktree/widgets-phase-4 into main Reviewed-on: #6
312 lines
12 KiB
PHP
312 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
if (!defined('ABSPATH')) {
|
||
define('ABSPATH', __DIR__ . DIRECTORY_SEPARATOR);
|
||
}
|
||
|
||
// Minimal WordPress-compatible stubs keep the schema audit runnable without a site checkout.
|
||
if (!function_exists('sanitize_text_field')) {
|
||
function sanitize_text_field($value): string {
|
||
return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? '');
|
||
}
|
||
}
|
||
if (!function_exists('__')) {
|
||
function __($text): string {
|
||
return (string) $text;
|
||
}
|
||
}
|
||
if (!function_exists('sanitize_textarea_field')) {
|
||
function sanitize_textarea_field($value): string {
|
||
return trim(strip_tags((string) $value));
|
||
}
|
||
}
|
||
if (!function_exists('wp_kses_post')) {
|
||
function wp_kses_post($value): string {
|
||
return strip_tags((string) $value, '<a><b><strong><em><br><p><span><ul><ol><li>');
|
||
}
|
||
}
|
||
if (!function_exists('esc_url_raw')) {
|
||
function esc_url_raw($value): string {
|
||
$value = trim((string) $value);
|
||
return filter_var($value, FILTER_VALIDATE_URL) ? $value : '';
|
||
}
|
||
}
|
||
if (!function_exists('absint')) {
|
||
function absint($value): int {
|
||
return abs((int) $value);
|
||
}
|
||
}
|
||
if (!function_exists('sanitize_hex_color')) {
|
||
function sanitize_hex_color($value): string {
|
||
$value = (string) $value;
|
||
return preg_match('/^#(?:[0-9a-fA-F]{3}){1,2}$/', $value) ? strtolower($value) : '';
|
||
}
|
||
}
|
||
|
||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||
|
||
use AKSRT\WidgetSystem\FieldSchema;
|
||
use AKSRT\WidgetSystem\PostQuery;
|
||
use AKSRT\WidgetSystem\RendererRegistry;
|
||
use AKSRT\WidgetSystem\WidgetAreas;
|
||
use AKSRT\WidgetSystem\WidgetRegistry;
|
||
use AKSRT\WidgetSystem\WidgetTransfer;
|
||
|
||
$errors = [];
|
||
$expected = [
|
||
'search', 'posts_toc', 'quote', 'icon_cards', 'video', 'embed', 'media_cards',
|
||
'icon_cover_cards', 'partners', 'combo_info', 'text_title', 'buttons', 'layout_gap',
|
||
'tag_cloud', 'recent_comments', 'links', 'notice', 'term_cards', 'term_aggregation',
|
||
'hot_posts', 'post_pay', 'posts', 'tab_posts', 'oneline_posts', 'pricing_plans',
|
||
'reviews', 'faq', 'stats', 'highlights', 'feature_intro', 'tilt_cases', 'slider',
|
||
'slider_media_combo', 'user_card', 'author_card', 'user_ranking', 'user_list',
|
||
];
|
||
|
||
$registry = WidgetRegistry::all();
|
||
if (count($registry) !== 37) {
|
||
$errors[] = 'Registry must contain exactly 37 widgets; got ' . count($registry) . '.';
|
||
}
|
||
if (array_keys($registry) !== $expected) {
|
||
$errors[] = 'Registry slugs or ordering differ from the frozen contract.';
|
||
}
|
||
|
||
$expected_areas = [
|
||
'all_top_fluid', 'all_bottom_fluid', 'all_sidebar_top', 'all_sidebar_bottom',
|
||
'home_top_fluid', 'home_top_content', 'home_bottom_content', 'home_bottom_fluid', 'home_sidebar',
|
||
'single_top_fluid', 'single_top_content', 'single_bottom_content', 'single_bottom_fluid', 'single_sidebar',
|
||
'cat_top_fluid', 'cat_top_content', 'cat_bottom_content', 'cat_bottom_fluid', 'cat_sidebar',
|
||
'tag_top_fluid', 'tag_top_content', 'tag_bottom_content', 'tag_bottom_fluid', 'tag_sidebar',
|
||
'search_top_fluid', 'search_top_content', 'search_bottom_content', 'search_bottom_fluid', 'search_sidebar',
|
||
'author_top_fluid', 'author_bottom_fluid', 'msg_top_fluid', 'msg_bottom_fluid',
|
||
'user_top_fluid', 'user_top_content', 'user_bottom_fluid',
|
||
'newposts_sidebar_top', 'newposts_sidebar_bottom', 'all_footer', 'mobile_nav_fluid',
|
||
];
|
||
if (WidgetAreas::static_ids() !== $expected_areas || count(WidgetAreas::static_ids()) !== 40) {
|
||
$errors[] = 'Widget area registry must contain the exact 40 static areas.';
|
||
}
|
||
|
||
$templates = WidgetTransfer::templates();
|
||
$expected_templates = ['article_home', 'article_list', 'links_navigation', 'article_sidebar', 'knowledge_landing'];
|
||
if (array_keys($templates) !== $expected_templates || count($templates) !== 5) {
|
||
$errors[] = 'Import/export must expose the exact five built-in templates.';
|
||
}
|
||
foreach ($templates as $template => $contract) {
|
||
foreach ((array) ($contract['sidebars'] ?? []) as $items) {
|
||
foreach ((array) $items as $item) {
|
||
if (!isset($registry[(string) ($item['widget'] ?? '')])) {
|
||
$errors[] = $template . ': template contains an unknown widget.';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$supported = FieldSchema::supported_types();
|
||
$walk = static function (array $fields, string $owner) use (&$walk, &$errors, $supported): void {
|
||
$ids = [];
|
||
foreach ($fields as $field) {
|
||
$id = (string) ($field['id'] ?? '');
|
||
$type = (string) ($field['type'] ?? 'text');
|
||
if (!in_array($type, $supported, true)) {
|
||
$errors[] = $owner . ': unsupported field type ' . $type . '.';
|
||
}
|
||
if ($id !== '') {
|
||
if (isset($ids[$id])) {
|
||
$errors[] = $owner . ': duplicate field id ' . $id . '.';
|
||
}
|
||
$ids[$id] = true;
|
||
}
|
||
if (!empty($field['fields']) && is_array($field['fields'])) {
|
||
$walk($field['fields'], $owner . '.' . ($id ?: $type));
|
||
}
|
||
}
|
||
};
|
||
|
||
foreach ($registry as $slug => $definition) {
|
||
if ($definition->id_base() !== 'aksrt_widget_' . $slug) {
|
||
$errors[] = $slug . ': invalid id_base.';
|
||
}
|
||
if (preg_match('/(?:bbs|forum|shop|cart|inventory|shipping)/i', $slug . ' ' . $definition->renderer())) {
|
||
$errors[] = $slug . ': forbidden forum/shop contract.';
|
||
}
|
||
$fields = $definition->fields();
|
||
$walk($fields, $slug);
|
||
$field_ids = array_column($fields, 'id');
|
||
foreach (['show_type', 'show_id_type', 'show_ids', 'animation_in', 'layout_pin_pc', 'layout_bg'] as $required) {
|
||
if (!in_array($required, $field_ids, true)) {
|
||
$errors[] = $slug . ': missing common field ' . $required . '.';
|
||
}
|
||
}
|
||
}
|
||
|
||
$renderers = RendererRegistry::all();
|
||
if (count($renderers) !== 37 || array_keys($renderers) !== $expected) {
|
||
$errors[] = 'Renderer registry must map the exact 37 frozen slugs.';
|
||
}
|
||
foreach ($renderers as $slug => $renderer) {
|
||
if (!is_callable([$renderer, 'render'])) {
|
||
$errors[] = $slug . ': renderer is not callable.';
|
||
}
|
||
}
|
||
|
||
$dynamic = ['posts', 'tab_posts', 'oneline_posts', 'hot_posts'];
|
||
foreach ($expected as $slug) {
|
||
if (RendererRegistry::supports_items($slug) !== in_array($slug, $dynamic, true)) {
|
||
$errors[] = $slug . ': invalid dynamic items capability.';
|
||
}
|
||
}
|
||
|
||
if (!defined('DAY_IN_SECONDS')) {
|
||
define('DAY_IN_SECONDS', 86400);
|
||
}
|
||
$query_args = PostQuery::arguments([
|
||
'count' => 7,
|
||
'category_ids' => '2,5',
|
||
'tag_ids' => '8,13',
|
||
'limit_day' => 30,
|
||
'orderby' => 'views',
|
||
'order' => 'ASC',
|
||
'pay_type' => 'video',
|
||
], 3);
|
||
if (($query_args['posts_per_page'] ?? 0) !== 7 || ($query_args['paged'] ?? 0) !== 3) {
|
||
$errors[] = 'Post query pagination contract failed.';
|
||
}
|
||
if (($query_args['category__in'] ?? []) !== [2, 5] || ($query_args['tag__in'] ?? []) !== [8, 13]) {
|
||
$errors[] = 'Post query taxonomy contract failed.';
|
||
}
|
||
if (($query_args['orderby'] ?? '') !== 'meta_value_num' || ($query_args['order'] ?? '') !== 'ASC') {
|
||
$errors[] = 'Post query ordering contract failed.';
|
||
}
|
||
$pay_query = json_encode($query_args['meta_query'] ?? []);
|
||
if (!is_string($pay_query) || !str_contains($pay_query, 'video')) {
|
||
$errors[] = 'Post query paid-content contract failed.';
|
||
}
|
||
|
||
$rest_source = (string) file_get_contents(dirname(__DIR__) . '/src/WidgetSystem/RestController.php');
|
||
foreach ([
|
||
"'permission_callback' => '__return_true'",
|
||
"'instance' => ['type' => 'string', 'required' => true",
|
||
"'page' => ['type' => 'integer'",
|
||
"'segment' => ['type' => 'string'",
|
||
'wp_get_sidebars_widgets()',
|
||
'$widget->get_settings()',
|
||
] as $contract) {
|
||
if (!str_contains($rest_source, $contract)) {
|
||
$errors[] = 'REST controller missing contract: ' . $contract;
|
||
}
|
||
}
|
||
if (preg_match('/\$_(?:GET|POST|REQUEST)/', $rest_source)) {
|
||
$errors[] = 'REST controller must use WP_REST_Request only.';
|
||
}
|
||
|
||
$frontend_files = [
|
||
dirname(__DIR__) . '/assets/widgets/frontend.css',
|
||
dirname(__DIR__) . '/assets/widgets/frontend.js',
|
||
];
|
||
foreach ($frontend_files as $frontend_file) {
|
||
if (!is_file($frontend_file) || filesize($frontend_file) === 0) {
|
||
$errors[] = 'Missing frontend widget asset: ' . basename($frontend_file);
|
||
}
|
||
}
|
||
|
||
$root = dirname(__DIR__);
|
||
$legacy_paths = [
|
||
'/src/Core/WidgetAjax.php',
|
||
'/src/Core/SetupPart1.php',
|
||
'/src/Widgets',
|
||
'/inc/modules/widgets-register.php',
|
||
'/inc/modules/widget-common.php',
|
||
'/inc/modules/widget-areas.php',
|
||
'/assets/css/frontend/widgets.css',
|
||
'/assets/css/frontend/widgets',
|
||
'/assets/css/frontend/widget-batch5.css',
|
||
'/assets/js/widget-set.js',
|
||
];
|
||
foreach ($legacy_paths as $legacy_path) {
|
||
if (file_exists($root . $legacy_path)) {
|
||
$errors[] = 'Legacy widget runtime remains: ' . $legacy_path;
|
||
}
|
||
}
|
||
|
||
$runtime_source = (string) file_get_contents($root . '/src/WidgetSystem/Runtime.php');
|
||
foreach (['WidgetAreas::class', 'WidgetRegistry::all()', 'register_widget(new SchemaWidget', 'unregister_core_widgets'] as $contract) {
|
||
if (!str_contains($runtime_source, $contract)) {
|
||
$errors[] = 'Widget runtime missing contract: ' . $contract;
|
||
}
|
||
}
|
||
$asset_source = (string) file_get_contents($root . '/src/WidgetSystem/AssetManager.php');
|
||
if (str_contains($asset_source, 'cdn.jsdelivr.net')) {
|
||
$errors[] = 'Widget assets must not use CDN dependencies.';
|
||
}
|
||
$admin_import_sources = (string) file_get_contents($root . '/src/Admin/ImportExportController.php')
|
||
. (string) file_get_contents($root . '/src/Admin/ImportExportControllerPart1.php');
|
||
foreach (['/widgets/export', '/widgets/import', '/widgets/templates', 'aksrt_widget_templates'] as $legacy_contract) {
|
||
if (str_contains($admin_import_sources, $legacy_contract)) {
|
||
$errors[] = 'Legacy widget import/export contract remains: ' . $legacy_contract;
|
||
}
|
||
}
|
||
|
||
$pinned_assets = [
|
||
'DPlayer.min.js' => '361689807fc1e856f868df91036e41195d2c255619bd1c5526849c5ad7491241',
|
||
'hls.min.js' => 'e876282a26116e323c8ed3e86bed3838a80dbbe3a2faa09f834bee86963ff760',
|
||
'flv.min.js' => '0668d36757cdc17804d5c0bd6fe8956d76370e73a733bf5bbc0eddbfbca2c6cc',
|
||
'dash.all.min.js' => '3e753969c16a2a78f420a25d24869f14721eb1d284418f9f22d35a31c3693cd9',
|
||
'vanilla-tilt.min.js' => 'de6fa8d3f40dbae2726a72f7d1ae46bb0588267af05355bef1d128f737f4a282',
|
||
];
|
||
foreach ($pinned_assets as $asset => $hash) {
|
||
$path = $root . '/assets/widgets/third-party/' . $asset;
|
||
if (!is_file($path)) {
|
||
$errors[] = 'Missing pinned widget dependency: ' . $asset;
|
||
} elseif (hash_file('sha256', $path) !== $hash) {
|
||
$errors[] = 'Pinned widget dependency changed: ' . $asset;
|
||
}
|
||
}
|
||
if (!is_file($root . '/assets/widgets/third-party/THIRD_PARTY.md')) {
|
||
$errors[] = 'Missing widget dependency license manifest.';
|
||
}
|
||
|
||
$posts = $registry['posts'];
|
||
$normalized = $posts->normalize([
|
||
'title' => '<script>alert(1)</script> 最新课程',
|
||
'show_type' => 'invalid',
|
||
'show_id_type' => 'show',
|
||
'show_ids' => '3, 3,9 bad -4',
|
||
'count' => 999,
|
||
'style' => 'broken',
|
||
'pay_type' => 'video',
|
||
]);
|
||
if ($normalized['count'] !== 50 || $normalized['style'] !== 'card' || $normalized['show_type'] !== 'all') {
|
||
$errors[] = 'Schema clamping or enum fallback failed.';
|
||
}
|
||
if ($normalized['show_ids'] !== '3,9,4') {
|
||
$errors[] = 'ID list normalization failed: ' . $normalized['show_ids'];
|
||
}
|
||
if (str_contains($normalized['title'], '<script>')) {
|
||
$errors[] = 'Text sanitization failed.';
|
||
}
|
||
|
||
$baseline_file = dirname(__DIR__) . '/.specs/baselines/widget-reference.json';
|
||
$baseline = json_decode((string) file_get_contents($baseline_file), true);
|
||
if (!is_array($baseline) || ($baseline['source_policy']['modern_widget_count'] ?? null) !== 37) {
|
||
$errors[] = 'Reference baseline is missing or invalid.';
|
||
} elseif (is_dir((string) ($baseline['source_root'] ?? ''))) {
|
||
foreach ((array) ($baseline['files'] ?? []) as $file) {
|
||
$path = rtrim((string) $baseline['source_root'], '\\/') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $file['path']);
|
||
if (!is_file($path)) {
|
||
$errors[] = 'Reference file missing: ' . $path;
|
||
continue;
|
||
}
|
||
if (hash_file('sha256', $path) !== ($file['sha256'] ?? '')) {
|
||
$errors[] = 'Reference file changed: ' . $file['path'];
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($errors) {
|
||
fwrite(STDERR, "Widget system audit failed:\n- " . implode("\n- ", $errors) . "\n");
|
||
exit(1);
|
||
}
|
||
|
||
echo "Widget system audit passed: 37 widgets, 40 areas, 5 templates, single runtime and pinned assets.\n";
|