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
33declare(strict_types=1);
34
35namespace SwapAds\Client\Statistics;
36
37use SwapAds\Client\License\LicenseManager;
38
39if (!defined('ABSPATH')) { /* test mode: skip WordPress bootstrap guard */ }
40
41final 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}