| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 15.08% covered (danger) | 15.08% | 19 / 126 | 0.00% covered (danger) | 0.00% | 0 / 7 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
StatisticsFlushEndpoint | 14.40% covered (danger) | 14.40% | 18 / 125 | 0.00% covered (danger) | 0.00% | 0 / 7 | 993.00 | 0.00% covered (danger) | 0.00% | 0 / 1
register | 0.00% covered (danger) | 0.00% | 0 / 4 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
handleFlush | 0.00% covered (danger) | 0.00% | 0 / 15 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
handleAck | 0.00% covered (danger) | 0.00% | 0 / 21 | 0.00% covered (danger) | 0.00% | 0 / 1 | 20 |
validateServerRequest | 0.00% covered (danger) | 0.00% | 0 / 18 | 0.00% covered (danger) | 0.00% | 0 / 1 | 132 |
handleEnqueue | 30.91% covered (danger) | 30.91% | 17 / 55 | 0.00% covered (danger) | 0.00% | 0 / 1 | 50.91 |
backlinkBelongsToThisSite | 0.00% covered (danger) | 0.00% | 0 / 10 | 0.00% covered (danger) | 0.00% | 0 / 1 | 56 |
maybeExit | 50.00% covered (danger) | 50.00% | 1 / 2 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2.50 |

1 | <?php
2 | /**
3 | * SwapAds Client — StatisticsFlushEndpoint (F238).
4 | *
5 | * Per F238 design (locked 2026-07-31):
6 | * - Server's AdaptivePollCron polls this endpoint on adaptive schedule
7 | * - HMAC-signed request validates server identity (preventing random
8 | * 3rd parties from triggering drains)
9 | * - Returns the local pending_statistics queue
10 | * - Listens for ACK to clear events after server-side processing
11 | *
12 | * Auth (flush + ack):
13 | * - X-SwapAds-License-Id header (matches the operator's license)
14 | * - X-SwapAds-Timestamp header (must be within 5 min of now)
15 | * - X-SwapAds-Signature header (HMAC-SHA256 of "flush:<license_id>:<ts>"
16 | * using the license_key as the secret)
17 | *
18 | * TODO-CLIENT-API-002 + TODO-CLIENT-API-003 (2026-08-01):
19 | * - handleEnqueue() now:
20 | * * Origin allowlist: Origin/Referer must match home_url()
21 | * * Per-IP rate limit: 10 events/min via set_transient
22 | * * backlink_id must be in local offered_backlinks
23 | * * partner_id must match what operator declared
24 | * * visitor_ip + visitor_ua are HASHED, not stored raw
25 | * * Client-supplied visitor_ip/visitor_ua are stripped from body
26 | *
27 | * Naming-convention-locked (avoid tracker/ad/beacon trigger words).
28 | *
29 | * @since 1.5.3
30 | * @since 1.5.9 CRIT hardening (TODO-CLIENT-API-002/003)
31 | */
32 |
33 | declare(strict_types=1);
34 |
35 | namespace SwapAds\Client\Statistics;
36 |
37 | use SwapAds\Client\License\LicenseManager;
38 |
39 | if (!defined('ABSPATH')) { /* test mode: skip WordPress bootstrap guard */ }
40 |
41 | final class StatisticsFlushEndpoint
42 | {
43 | public const AJAX_ACTION_FLUSH = 'swapads_statistics_flush';
44 | public const AJAX_ACTION_ACK = 'swapads_statistics_ack';
45 | public const AJAX_ACTION_ENQUEUE = 'swapads_statistics_enqueue';
46 |
47 | public const HMAC_WINDOW_SECONDS = 300; // 5 minutes
48 |
49 | /**
50 | * Option key holding the operator's offered-backlinks list.
51 | * Keyed by backlink_id; each entry has id + partner_id.
52 | * Maintained by BacklinkCreatePage when an operator offers a backlink.
53 | */
54 | public const OPTION_OFFERED_BACKLINKS = 'swapads_client_offered_backlinks';
55 |
56 | /**
57 | * Per-IP rate limit (events per minute).
58 | */
59 | public const RATE_LIMIT_PER_MINUTE = 10;
60 |
61 | /**
62 | * Register admin-ajax handlers.
63 | */
64 | public static function register(): void
65 | {
66 | // Flush + ACK are server-initiated (no logged-in user needed; auth
67 | // via HMAC signature).
68 | add_action('wp_ajax_nopriv_' . self::AJAX_ACTION_FLUSH, [self::class, 'handleFlush']);
69 | add_action('wp_ajax_' . self::AJAX_ACTION_ACK, [self::class, 'handleAck']);
70 |
71 | // Enqueue is JS-initiated from public pages. Visitor does NOT need
72 | // to be logged in.
73 | // TODO-CLIENT-API-002 (2026-08-01): was 'wp_ajax_nopriv_' only.
74 | // We register BOTH so logged-in visitors also work; the origin /
75 | // rate-limit / backlink-ownership checks below prevent forgery.
76 | add_action('wp_ajax_nopriv_' . self::AJAX_ACTION_ENQUEUE, [self::class, 'handleEnqueue']);
77 | add_action('wp_ajax_' . self::AJAX_ACTION_ENQUEUE, [self::class, 'handleEnqueue']);
78 | }
79 |
80 | /**
81 | * Handle server's HMAC-signed flush request.
82 | * Returns the queued events + (optionally) bot-signature deltas.
83 | */
84 | public static function handleFlush(): void
85 | {
86 | $auth = self::validateServerRequest();
87 | if ($auth !== true) {
88 | status_header(401);
89 | wp_send_json(['error' => 'unauthorized', 'detail' => $auth], 401);
90 | self::maybeExit();
91 | return;
92 | }
93 |
94 | $events = StatisticsQueue::drain();
95 | // Re-enqueue: we don't drain on read; we wait for ACK. (Safer:
96 | // if server crashes between read + ACK, we don't lose events.)
97 | foreach ($events as $event) {
98 | StatisticsQueue::enqueue($event);
99 | }
100 |
101 | wp_send_json([
102 | 'events' => $events,
103 | 'count' => count($events),
104 | 'server_ts' => time(),
105 | ], 200);
106 | self::maybeExit();
107 | }
108 |
109 | /**
110 | * Handle server's ACK after it has stored the events.
111 | * Body: { license_id: int, last_event_ts: int, events_stored: int }
112 | * Removes events with enqueued_at <= last_event_ts.
113 | */
114 | public static function handleAck(): void
115 | {
116 | $auth = self::validateServerRequest();
117 | if ($auth !== true) {
118 | status_header(401);
119 | wp_send_json(['error' => 'unauthorized'], 401);
120 | self::maybeExit();
121 | return;
122 | }
123 | $raw = file_get_contents('php://input');
124 | $body = is_string($raw) ? (array) json_decode($raw, true) : [];
125 | $lastEventTs = (int) ($body['last_event_ts'] ?? 0);
126 | if ($lastEventTs <= 0) {
127 | status_header(400);
128 | wp_send_json(['error' => 'missing_last_event_ts'], 400);
129 | self::maybeExit();
130 | return;
131 | }
132 | $drained = StatisticsQueue::drainOlderThan($lastEventTs);
133 | wp_send_json([
134 | 'success' => true,
135 | 'drained_count' => count($drained),
136 | 'remaining_count' => StatisticsQueue::count(),
137 | ], 200);
138 | self::maybeExit();
139 | }
140 |
141 | /**
142 | * Validate the HMAC signature from the polling server.
143 | *
144 | * @return bool|string true on success, error string on failure.
145 | */
146 | private static function validateServerRequest()
147 | {
148 | $licenseId = isset($_SERVER['HTTP_X_SWAPADS_LICENSE_ID'])
149 | ? (int) $_SERVER['HTTP_X_SWAPADS_LICENSE_ID'] : 0;
150 | $timestamp = isset($_SERVER['HTTP_X_SWAPADS_TIMESTAMP'])
151 | ? (string) $_SERVER['HTTP_X_SWAPADS_TIMESTAMP'] : '';
152 | $signature = isset($_SERVER['HTTP_X_SWAPADS_SIGNATURE'])
153 | ? (string) $_SERVER['HTTP_X_SWAPADS_SIGNATURE'] : '';
154 | if ($licenseId <= 0 || $timestamp === '' || $signature === '') {
155 | return 'missing_headers';
156 | }
157 | // Window check.
158 | $ts = (int) $timestamp;
159 | if (abs(time() - $ts) > self::HMAC_WINDOW_SECONDS) {
160 | return 'timestamp_out_of_window';
161 | }
162 | // Get our license_key (HMAC secret).
163 | $licenseKey = LicenseManager::key();
164 | if (!is_string($licenseKey) || $licenseKey === '') {
165 | return 'license_not_activated';
166 | }
167 | // Verify signature.
168 | $expected = hash_hmac('sha256', "flush:{$licenseId}:{$timestamp}", $licenseKey);
169 | if (!hash_equals($expected, $signature)) {
170 | return 'bad_signature';
171 | }
172 | return true;
173 | }
174 |
175 | /**
176 | * Handle JS-initiated enqueue from the public-facing backlinks-
177 | * statistics.js bundle. Visitor click on .swapads-backlink anchor
178 | * fires this.
179 | *
180 | * TODO-CLIENT-API-002 + TODO-CLIENT-API-003 (2026-08-01):
181 | * Hardened against forgery + IP-based flooding + DoS amplification.
182 | *
183 | * Body: { backlink_id:int, partner_id:int, event_type:string,
184 | * ts_ms:int, page_url?:string, swapads_sid?:string }
185 | *
186 | * visitor_ip + visitor_ua are derived from the SERVER (PHP $_SERVER),
187 | * hashed with wp_salt('auth'), and stored as 32-char SHA-256 prefixes.
188 | * Client-supplied visitor_ip/visitor_ua in the body are STRIPPED.
189 | *
190 | * @since 1.5.3
191 | * @since 1.5.9 hardening
192 | */
193 | public static function handleEnqueue(): void
194 | {
195 | // 1. Origin allowlist: Origin/Referer must match this site.
196 | $origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? $_SERVER['HTTP_REFERER'] ?? '');
197 | $siteUrl = (string) home_url();
198 | if ($origin !== '' && $siteUrl !== '' && strpos($origin, $siteUrl) !== 0) {
199 | status_header(403);
200 | wp_send_json(['error' => 'forbidden_origin'], 403);
201 | self::maybeExit();
202 | return;
203 | }
204 |
205 | $raw = file_get_contents('php://input');
206 | $body = is_string($raw) ? (array) json_decode($raw, true) : [];
207 | $backlinkId = (int) ($body['backlink_id'] ?? 0);
208 | $partnerId = (int) ($body['partner_id'] ?? 0);
209 | $eventType = (string) ($body['event_type'] ?? '');
210 |
211 | // 2. Validate event_type BEFORE rate-limiting (cheap early reject).
212 | if (!in_array($eventType, ['view', 'click'], true)) {
213 | status_header(400);
214 | wp_send_json(['error' => 'invalid_event_type'], 400);
215 | self::maybeExit();
216 | return;
217 | }
218 | if ($backlinkId <= 0 || $partnerId <= 0) {
219 | status_header(400);
220 | wp_send_json(['error' => 'missing_ids'], 400);
221 | self::maybeExit();
222 | return;
223 | }
224 |
225 | // 3. Backlink ownership: backlink_id must be offered BY this site,
226 | // and partner_id must match what the operator declared.
227 | if (!self::backlinkBelongsToThisSite($backlinkId, $partnerId)) {
228 | status_header(403);
229 | wp_send_json(['error' => 'unknown_backlink'], 403);
230 | self::maybeExit();
231 | return;
232 | }
233 |
234 | // 4. Per-IP rate limit: RATE_LIMIT_PER_MINUTE events/minute.
235 | $ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
236 | $ipKey = 'swapads_stat_rl:' . md5($ip . '|' . gmdate('YmdHi'));
237 | $count = (int) get_transient($ipKey);
238 | if ($count >= self::RATE_LIMIT_PER_MINUTE) {
239 | status_header(429);
240 | wp_send_json(['error' => 'rate_limited'], 429);
241 | self::maybeExit();
242 | return;
243 | }
244 | set_transient($ipKey, $count + 1, 120);
245 |
246 | // 5. HASH the IP + UA (not raw). Strip any client-supplied
247 | // visitor_ip / visitor_ua to prevent spoofing.
248 | unset($body['visitor_ip'], $body['visitor_ua']);
249 | $body['visitor_ip_hash'] = substr(
250 | hash('sha256', $ip . wp_salt('auth')),
251 | 0,
252 | 32
253 | );
254 | $body['visitor_ua_hash'] = substr(
255 | hash('sha256', (string) ($_SERVER['HTTP_USER_AGENT'] ?? '') . wp_salt('auth')),
256 | 0,
257 | 32
258 | );
259 |
260 | $ok = StatisticsQueue::enqueue($body);
261 | if (!$ok) {
262 | status_header(400);
263 | wp_send_json(['error' => 'invalid_event'], 400);
264 | self::maybeExit();
265 | return;
266 | }
267 | wp_send_json(['success' => true, 'queue_size' => StatisticsQueue::count()], 200);
268 | self::maybeExit();
269 | }
270 |
271 | /**
272 | * Verify that the given backlink_id is offered by THIS site AND the
273 | * partner_id matches what the operator declared.
274 | *
275 | * Storage shape in wp_options('swapads_client_offered_backlinks'):
276 | * [{ id: int, partner_id: int, ... }, ...]
277 | *
278 | * @param int $backlinkId Backlink id from the request.
279 | * @param int $partnerId Partner id from the request.
280 | *
281 | * @return bool True if owned + partner matches.
282 | *
283 | * @since 1.5.9
284 | */
285 | private static function backlinkBelongsToThisSite(int $backlinkId, int $partnerId): bool
286 | {
287 | $offered = get_option(self::OPTION_OFFERED_BACKLINKS, []);
288 | if (!is_array($offered) || empty($offered)) {
289 | return false;
290 | }
291 | foreach ($offered as $row) {
292 | if (!is_array($row)) {
293 | continue;
294 | }
295 | if ((int) ($row['id'] ?? 0) === $backlinkId
296 | && (int) ($row['partner_id'] ?? 0) === $partnerId) {
297 | return true;
298 | }
299 | }
300 | return false;
301 | }
302 |
303 | /**
304 | * Conditionally call exit() — skipped in test mode so PHPUnit can
305 | * assert on the captured wp_send_json() payload.
306 | *
307 | * In production (real WP request), the constant SWAPADS_TESTING_EXIT_SKIP
308 | * is NOT defined and the process exits after the response is sent.
309 | *
310 | * @since 1.5.9
311 | */
312 | private static function maybeExit(): void
313 | {
314 | if (!defined('SWAPADS_TESTING_EXIT_SKIP')) {
315 | exit;
316 | }
317 | }
318 |
319 | }