PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / feedback / FeedbackPayloadSchemaGuard.php

FeedbackPayloadSchemaGuard.php in 404 Solution trunk, at includes/feedback/FeedbackPayloadSchemaGuard.php

154 lines 5.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 require_once __DIR__ . '/PayloadSchema.php';
8 require_once __DIR__ . '/ReportPayloadJsonSchemaValidator.php';
9 require_once __DIR__ . '/FeedbackTransportLog.php';
10
11 /**
12 * Schema gate for feedback report payloads. Owns every check that gives
13 * the transport layer confidence the payload will not be rejected at the
14 * developer endpoint, plus the PII redaction sweep that runs immediately
15 * before any network/email send.
16 *
17 * normalize() - coerce schema-edge values (empty contact_email -> null)
18 * validate() - shared report.schema.json check (logs on failure)
19 * assert() - throws when buildPayload() output would 400 the server
20 * redact() - run PII redaction over string payload values
21 * logContractWarnings() - log per-type contract warnings from the local schema
22 */
23 class ABJ_404_Solution_FeedbackPayloadSchemaGuard {
24
25 /**
26 * Coerce edge cases that the report schema rejects but callers commonly
27 * supply (e.g. empty-string contact_email vs null).
28 *
29 * @param array<string, mixed> $payload
30 * @return array<string, mixed>
31 */
32 public static function normalize(array $payload): array {
33 if (array_key_exists('contact_email', $payload) && is_string($payload['contact_email']) &&
34 trim($payload['contact_email']) === '') {
35 $payload['contact_email'] = null;
36 }
37 return $payload;
38 }
39
40 /**
41 * Validate a payload against the shared report.schema.json. Logs a warn
42 * on failure with the detail string so admins can correlate logs.
43 *
44 * @param array<string, mixed> $payload
45 * @param string $type One of: error, heartbeat, uninstall, support_request
46 * @return array{valid: bool, reason: string, detail: string}
47 */
48 public static function validate(array $payload, string $type): array {
49 $validation = ABJ_404_Solution_ReportPayloadJsonSchemaValidator::validate($payload);
50 if (!empty($validation['valid'])) {
51 return $validation;
52 }
53
54 $detail = isset($validation['detail']) && is_scalar($validation['detail'])
55 ? (string)$validation['detail']
56 : '';
57 ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf(
58 'abj404_transport: type=%s contract_validation_failed detail=%s',
59 $type,
60 $detail
61 ));
62 return $validation;
63 }
64
65 /**
66 * Throw if the payload violates the shared schema. Used by the builder
67 * to fail-fast on producer drift before a malformed payload reaches
68 * the transport layer.
69 *
70 * @param array<string, mixed> $payload
71 * @param string $type One of: error, heartbeat, uninstall, support_request
72 * @param string $context Caller name (e.g. 'buildPayload') for the
73 * exception message.
74 * @return void
75 */
76 public static function assert(array $payload, string $type, string $context): void {
77 $validation = self::validate($payload, $type);
78 if (!empty($validation['valid'])) {
79 return;
80 }
81
82 $detail = isset($validation['detail']) && is_scalar($validation['detail'])
83 ? (string)$validation['detail']
84 : 'unknown validation failure';
85 throw new \UnexpectedValueException(sprintf(
86 'FeedbackTransport::%s produced payload that violates %s for type=%s: %s',
87 $context,
88 ABJ_404_Solution_ReportPayloadJsonSchemaValidator::SCHEMA_RELATIVE_PATH,
89 $type,
90 $detail
91 ));
92 }
93
94 /**
95 * Run PII redaction over every string value in a payload before it
96 * leaves the plugin (defense-in-depth for the transport layer).
97 *
98 * @param array<string, mixed> $payload
99 * @return array<string, mixed>
100 */
101 public static function redact(array $payload): array {
102 if (!function_exists('abj_service_optional')) {
103 return $payload;
104 }
105 /** @var ABJ_404_Solution_PiiRedactor|null $redactor */
106 $redactor = abj_service_optional('pii_redactor');
107 if (!$redactor instanceof ABJ_404_Solution_PiiRedactor) {
108 return $payload;
109 }
110 foreach ($payload as $key => $value) {
111 if (is_string($value)) {
112 if (in_array($key, array('user_message', 'reply_email', 'contact_email', 'debug_log_excerpt'), true)) {
113 continue;
114 }
115 $payload[$key] = $redactor->redact($value);
116 }
117 }
118 return $payload;
119 }
120
121 /**
122 * Log warn-level contract warnings for any per-type local schema
123 * violations. The local schema is a tighter subset of the shared
124 * report schema that catches producer drift the shared schema would
125 * accept but the server treats as malformed.
126 *
127 * @param array<string, mixed> $payload
128 * @param string $type One of: error, heartbeat, uninstall, support_request
129 * @return void
130 */
131 public static function logContractWarnings(array $payload, string $type): void {
132 static $schemas = null;
133 if ($schemas === null) {
134 $schemaFile = __DIR__ . '/../schema/feedback_payload_schema.php';
135 if (!file_exists($schemaFile)) {
136 return;
137 }
138 $schemas = require $schemaFile;
139 }
140 if (!isset($schemas[$type])) {
141 return;
142 }
143 $violations = ABJ_404_Solution_PayloadSchema::validate($schemas[$type], $payload);
144 if (empty($violations)) {
145 return;
146 }
147 ABJ_404_Solution_FeedbackTransportLog::log('warn', sprintf(
148 'abj404_transport: internal payload contract warning for type=%s: %s',
149 $type,
150 implode('; ', $violations)
151 ));
152 }
153 }
154