• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-server / #2551

19 Dec 2025 10:01AM UTC coverage: 89.472% (-0.06%) from 89.531%
#2551

push

php-coveralls

edwh
Simplify AI summary prompt text

Remove comparison to raw git commits in the AI-generated summary header.

26592 of 29721 relevant lines covered (89.47%)

31.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.66
/include/API.php
1
<?php
2

3
namespace Freegle\Iznik;
4

5

6
require_once(IZNIK_BASE . '/include/db.php');
1✔
7
require_once(IZNIK_BASE . '/include/misc/Loki.php');
1✔
8
global $dbhr, $dbhm;
1✔
9

10
use GeoIp2\Database\Reader;
11

12
class API
13
{
14
    public static function headers() {
15
        $call = array_key_exists('call', $_REQUEST) ? $_REQUEST['call'] : NULL;
329✔
16
        $type = array_key_exists('type', $_REQUEST) ? $_REQUEST['type'] : 'GET';
329✔
17

18
        // We allow anyone to use our API.
19
        //
20
        // Suppress errors on the header command for UT
21
        if (!(($call == 'image' || $call == 'profile') && $type == 'GET')) {
329✔
22
            # For images we'll set the content type later.
23
            @header('Content-type: application/json');
328✔
24
        }
25

26
        // Access-Control-Allow-Origin not now added by nginx.
27
        @header('Access-Control-Allow-Origin: *');
329✔
28
        @header('Access-Control-Allow-Headers: ' . (array_key_exists('HTTP_ACCESS_CONTROL_REQUEST_HEADERS', $_SERVER) ? $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] : "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Iznik-PHP-Session")); // X-HTTP-Method-Override not needed
329✔
29
        @header('Access-Control-Allow-Credentials: TRUE');
329✔
30
        @header('P3P:CP="IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT"');
329✔
31
    }
32

33
    public static function call()
34
    {
35
        global $dbhr, $dbhm;
329✔
36

37
        $ip = Utils::presdef('REMOTE_ADDR', $_SERVER, '');
329✔
38

39
        if (file_exists("/tmp/noheaders-$ip")) {
329✔
40
            echo json_encode(array('ret' => 1, 'status' => 'Not  logged in'));
×
41
            exit(0);
×
42
        }
43

44
        $scriptstart = microtime(TRUE);
329✔
45

46
        $entityBody = file_get_contents('php://input');
329✔
47

48
        if ($entityBody) {
329✔
49
            $parms = json_decode($entityBody, TRUE);
×
50
            if (json_last_error() == JSON_ERROR_NONE) {
×
51
                # We have been passed parameters in JSON.
52
                foreach ($parms as $parm => $val) {
×
53
                    $_REQUEST[$parm] = $val;
×
54
                }
55
            } else {
56
                # In some environments (e.g. PHP-FPM 7.2) parameters passed as an encoded form for PUT aren't parsed correctly,
57
                # probably because we have a rewrite that adds a parameter to the URL, and PHP-FPM doesn't want to have both
58
                # URL and form parameters.
59
                #
60
                # This may be a bug or a feature, but it messes us up.  So decode anything we can find that has not already
61
                # been decoded by our interpreter (if it did it, it's likely to be better).
62
                #
63
                # We needed this code when the app didn't contain the use of HTTP_X_HTTP_METHOD_OVERRIDE, and it's useful
64
                # anyway in case the client forgets.
65
                parse_str($entityBody, $params);
×
66
                foreach ($params as $key => $val) {
×
67
                    if (!array_key_exists($key, $_REQUEST)) {
×
68
                        $_REQUEST[$key] = $val;
×
69
                    }
70
                }
71
            }
72
        }
73

74
        if (array_key_exists('REQUEST_METHOD', $_SERVER)) {
329✔
75
            $_SERVER['REQUEST_METHOD'] = strtoupper($_SERVER['REQUEST_METHOD']);
329✔
76
            $_REQUEST['type'] = $_SERVER['REQUEST_METHOD'];
329✔
77
        }
78

79
        if (array_key_exists('HTTP_X_HTTP_METHOD_OVERRIDE', $_SERVER)) {
329✔
80
            # Used by Backbone's emulateHTTP to work around servers which don't handle verbs like PATCH very well.
81
            #
82
            # We use this because when we issue a PATCH we don't seem to be able to get the body parameters.
83
            $_REQUEST['type'] = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'];
1✔
84
            #error_log("Request method override to {$_REQUEST['type']}");
85
        }
86

87
        // Record timing.
88
        list ($tusage, $rusage) = API::requestStart();
329✔
89

90
        API::headers();
329✔
91

92
        // @codeCoverageIgnoreStart
93
        if (file_exists(IZNIK_BASE . '/http/maintenance_on.html')) {
94
            echo json_encode(array('ret' => 111, 'status' => 'Down for maintenance'));
95
            exit(0);
96
        }
97
        // @codeCoverageIgnoreEnd
98

99
        # Check if IP is blocked
100
        $ip = Utils::presdef('REMOTE_ADDR', $_SERVER, '');
329✔
101
        $ipBlocker = new IPBlocker($dbhr, $dbhm);
329✔
102

103
        if ($ipBlocker->isBlocked($ip)) {
329✔
104
            echo json_encode(array(
×
105
                'ret' => 403,
×
106
                'status' => 'Access denied'
×
107
            ));
×
108
            exit(0);
×
109
        }
110

111
        $includetime = microtime(TRUE) - $scriptstart;
329✔
112

113
        # All API calls come through here.
114
        #error_log("Request " . var_export($_REQUEST, TRUE));
115
        #error_log("Server " . var_export($_SERVER, TRUE));
116

117
        if (Utils::pres('HTTP_X_REAL_IP', $_SERVER)) {
329✔
118
            # We jump through hoops to get the real IP address. This is one of them.
119
            $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP'];
×
120
        }
121

122
        if (array_key_exists('model', $_REQUEST)) {
329✔
123
            # Used by Backbone's emulateJSON to work around servers which don't handle requests encoded as
124
            # application/json.
125
            $_REQUEST = array_merge($_REQUEST, json_decode($_REQUEST['model'], TRUE));
1✔
126
            unset($_REQUEST['model']);
1✔
127
        }
128

129
        # Include the API call
130
        $call = Utils::pres('call', $_REQUEST);
329✔
131

132
        if ($call) {
329✔
133
            $fn = IZNIK_BASE . '/http/api/' . $call . '.php';
329✔
134
            if (file_exists($fn)) {
329✔
135
                require_once($fn);
322✔
136
            }
137
        }
138

139
        if (Utils::presdef('type', $_REQUEST, null) == 'OPTIONS') {
329✔
140
            # We don't bother returning different values for different calls.
141
            http_response_code(204);
1✔
142
            @header('Allow: POST, GET, DELETE, PUT');
1✔
143
            @header('Access-Control-Allow-Methods:  POST, GET, DELETE, PUT');
1✔
144
        } else {
145
            # Actual API calls
146
            $ret = array('ret' => 1000, 'status' => 'Invalid API call');
328✔
147
            $t = microtime(TRUE);
328✔
148

149
            # We wrap the whole request in a retry handler.  This is so that we can deal with errors caused by
150
            # conflicts within the Percona cluster.
151
            $apicallretries = 0;
328✔
152

153
            # This is an optimisation for User.php.
154
            if (session_status() !== PHP_SESSION_NONE) {
328✔
155
                $_SESSION['modorowner'] = Utils::presdef('modorowner', $_SESSION, []);
328✔
156

157
                # Add X-User-Id header for HAProxy per-user rate limiting.
158
                $userId = Utils::presdef('id', $_SESSION, NULL);
328✔
159
                if ($userId) {
328✔
160
                    @header('X-User-Id: ' . $userId);
244✔
161
                }
162
            }
163

164
            $encoded_ret = NULL;
328✔
165

166
            do {
167
                if (Utils::presdef('type', $_REQUEST, null) != 'GET') {
328✔
168
                    # Check that we're not posting from a blocked country.
169
                    try {
170
                        $reader = new Reader(MMDB);
249✔
171
                        $ip = Utils::presdef('REMOTE_ADDR', $_SERVER, null);
249✔
172

173
                        if ($ip) {
249✔
174
                            $record = $reader->country($ip);
166✔
175
                            $country = $record->country->name;
×
176
                            # Failed to look it up.
177
                            $countries = $dbhr->preQuery("SELECT * FROM spam_countries WHERE country = ?;", [$country]);
×
178
                            foreach ($countries as $country) {
83✔
179
                                error_log("Block post from {$country['country']} " . var_export($_REQUEST, TRUE));
×
180
                                $encoded_ret = json_encode(array('ret' => 0, 'status' => 'Success'));
×
181
                                echo $encoded_ret;
×
182
                                break 2;
×
183
                            }
184
                        }
185
                    } catch (\Exception $e) {
166✔
186
                        // Don't log to Sentry - this can happen validly for some IPs (e.g. on CircleCI).
187
                    }
188
                }
189

190
                # Duplicate protection.  We upload multiple images so don't protect against those.
191
                if ((DUPLICATE_POST_PROTECTION > 0) &&
328✔
192
                    array_key_exists('REQUEST_METHOD', $_SERVER) &&
328✔
193
                    ((Utils::presdef('type', $_REQUEST, null) == 'POST' || Utils::presdef('type', $_REQUEST, null) == 'PUT')) &&
328✔
194
                    $call != 'image') {
328✔
195
                    # We want to make sure that we don't get duplicate POST requests within the same session.  We can't do this
196
                    # using information stored in the session because when Redis is used as the session handler, there is
197
                    # no session locking, and therefore two requests in quick succession could be allowed.  So instead
198
                    # we use Redis directly with a roll-your-own mutex.  We use the IP address as a key - if there are
199
                    # multiple sessions from the same IP then we might get FALSE positives/negatives.
200
                    $reqData = $_REQUEST;
204✔
201
                    unset($reqData['requestid']);
204✔
202
                    $reqData['call'] = preg_replace('/(\?|&)requestid=[0-9]+?/', '', $reqData['call']);
204✔
203
                    $url = preg_replace('/(\?|&)requestid=[0-9]+?/', '', $_SERVER['REQUEST_URI']);
204✔
204
                    $req = $url . serialize($reqData);
204✔
205
                    $ip = Utils::presdef('REMOTE_ADDR', $_SERVER, NULL);
204✔
206
                    $lockkey = "POST_LOCK_$ip";
204✔
207
                    $datakey = "POST_DATA_$ip";
204✔
208
                    $uid = uniqid('', TRUE);
204✔
209
                    $predis = new \Redis();
204✔
210
                    $predis->pconnect(REDIS_CONNECT);
204✔
211

212
                    # Get a lock.
213
                    $start = time();
204✔
214
                    $count = 0;
204✔
215

216
                    do {
217
                        $rc = $predis->setNx($lockkey, $uid);
204✔
218

219
                        if ($rc) {
204✔
220
                            # We managed to set it.  Ideally we would set an expiry time to make sure that if we got
221
                            # killed right now, this session wouldn't hang.  But that's an extra round trip on each
222
                            # API call, and the worst case would be a single session hanging, which we can live with.
223

224
                            # Sound out the last POST.
225
                            $last = $predis->get($datakey);
204✔
226

227
                            # Some actions are ok, so we exclude those.
228
                            if (!in_array($call, ['session', 'correlate', 'chatrooms', 'upload']) &&
204✔
229
                                $last ==  $req) {
204✔
230
                                # The last POST request was the same.  So this is a duplicate.
231
                                $predis->del($lockkey);
1✔
232
                                $ret = array(
1✔
233
                                    'ret' => 999,
1✔
234
                                    'text' => 'Duplicate request - rejected.',
1✔
235
                                    'data' => $_REQUEST
1✔
236
                                );
1✔
237
                                $encoded_ret = json_encode($ret);
1✔
238
                                echo $encoded_ret;
1✔
239
                                break 2;
1✔
240
                            }
241

242
                            # The last request wasn't the same.  Save this one.
243
                            $predis->set($datakey, $req);
204✔
244
                            $predis->expire($datakey, DUPLICATE_POST_PROTECTION);
204✔
245

246
                            # We're good to go - release the lock.
247
                            $predis->del($lockkey);
204✔
248
                            break;
204✔
249
                            // @codeCoverageIgnoreStart
250
                        } else {
251
                            # We didn't get the lock - another request for this session must have it.
252
                            $count++;
253
                            #error_log("Sleep for duplicate POST lock key $lockkey attempt $count, waited " . (time() - $start));
254
                            usleep(100000);
255
                        }
256
                    } while (time() < $start + 45);
257
                    // @codeCoverageIgnoreEnd
258
                }
259

260
                try {
261
                    # Each call is inside a file with a suitable name.
262
                    #
263
                    # call_user_func doesn't scale well on multicores with some versions of PHP, so we need can't figure out the function from
264
                    # the call name - use a switch instead.
265
                    switch ($call) {
266
                        case 'abtest':
328✔
267
                            $ret = abtest();
1✔
268
                            break;
1✔
269
                        case 'activity':
327✔
270
                            $ret = activity();
1✔
271
                            break;
1✔
272
                        case 'authority':
326✔
273
                            $ret = authority();
1✔
274
                            break;
1✔
275
                        case 'address':
325✔
276
                            $ret = address();
3✔
277
                            break;
3✔
278
                        case 'alert':
323✔
279
                            $ret = alert();
1✔
280
                            break;
1✔
281
                        case 'adview':
322✔
282
                            # Remove after 2021-05-05.
283
                            $ret = [
×
284
                                'ret' => 0,
×
285
                                'status' => 'Success',
×
286
                                'data' => []
×
287
                            ];
×
288
                            break;
×
289
                        case 'admin':
322✔
290
                            $ret = admin();
4✔
291
                            break;
4✔
292
                        case 'config':
319✔
293
                            $ret = config();
1✔
294
                            break;
1✔
295
                        case 'changes':
318✔
296
                            $ret = changes();
1✔
297
                            break;
1✔
298
                        case 'dashboard':
318✔
299
                            $ret = dashboard();
5✔
300
                            break;
5✔
301
                        case 'error':
315✔
302
                            $ret = error();
1✔
303
                            break;
1✔
304
                        case 'export':
314✔
305
                            $ret = export();
1✔
306
                            break;
1✔
307
                        case 'exception':
313✔
308
                            # For UT
309
                            throw new \Exception();
1✔
310
                        case 'image':
313✔
311
                            $ret = image();
26✔
312
                            break;
26✔
313
                        case 'isochrone':
293✔
314
                            $ret = isochrone();
2✔
315
                            break;
2✔
316
                        case 'jobs':
293✔
317
                            $ret = jobs();
1✔
318
                            break;
1✔
319
                        case 'profile':
292✔
320
                            $ret = profile();
1✔
321
                            break;
1✔
322
                        case 'socialactions':
291✔
323
                            $ret = socialactions();
×
324
                            break;
×
325
                        case 'messages':
291✔
326
                            $ret = messages();
25✔
327
                            break;
25✔
328
                        case 'message':
277✔
329
                            $ret = message();
75✔
330
                            break;
75✔
331
                        case 'invitation':
215✔
332
                            $ret = invitation();
1✔
333
                            break;
1✔
334
                        case 'item':
214✔
335
                            $ret = item();
4✔
336
                            break;
4✔
337
                        case 'usersearch':
210✔
338
                            $ret = usersearch();
1✔
339
                            break;
1✔
340
                        case 'memberships':
209✔
341
                            $ret = memberships();
30✔
342
                            break;
30✔
343
                        case 'merge':
184✔
344
                            $ret = merge();
2✔
345
                            break;
2✔
346
                        case 'spammers':
182✔
347
                            $ret = spammers();
4✔
348
                            break;
4✔
349
                        case 'session':
179✔
350
                            $ret = session();
50✔
351
                            break;
50✔
352
                        case 'simulation':
144✔
353
                            $ret = simulation();
9✔
354
                            break;
9✔
355
                        case 'group':
135✔
356
                            $ret = group();
9✔
357
                            break;
9✔
358
                        case 'groups':
126✔
359
                            $ret = groups();
1✔
360
                            break;
1✔
361
                        case 'communityevent':
125✔
362
                            $ret = communityevent();
2✔
363
                            break;
2✔
364
                        case 'domains':
123✔
365
                            $ret = domains();
1✔
366
                            break;
1✔
367
                        case 'locations':
122✔
368
                            $ret = locations();
5✔
369
                            break;
5✔
370
                        case 'logo':
117✔
371
                            $ret = logo();
1✔
372
                            break;
1✔
373
                        case 'modconfig':
116✔
374
                            $ret = modconfig();
10✔
375
                            break;
10✔
376
                        case 'stdmsg':
113✔
377
                            $ret = stdmsg();
3✔
378
                            break;
3✔
379
                        case 'bulkop':
110✔
380
                            $ret = bulkop();
4✔
381
                            break;
4✔
382
                        case 'comment':
106✔
383
                            $ret = comment();
4✔
384
                            break;
4✔
385
                        case 'user':
102✔
386
                            $ret = user();
29✔
387
                            break;
29✔
388
                        case 'chatrooms':
77✔
389
                            $ret = chatrooms();
19✔
390
                            break;
19✔
391
                        case 'chatmessages':
71✔
392
                            $ret = chatmessages();
16✔
393
                            break;
16✔
394
                        case 'poll':
55✔
395
                            $ret = poll();
2✔
396
                            break;
2✔
397
                        case 'request':
53✔
398
                            $ret = request();
1✔
399
                            break;
1✔
400
                        case 'shortlink':
52✔
401
                            $ret = shortlink();
2✔
402
                            break;
2✔
403
                        case 'stories':
50✔
404
                            $ret = stories();
3✔
405
                            break;
3✔
406
                        case 'donations':
48✔
407
                            $ret = donations();
2✔
408
                            break;
2✔
409
                        case 'giftaid':
46✔
410
                            $ret = giftaid();
4✔
411
                            break;
4✔
412
                        case 'status':
42✔
413
                            $ret = status();
1✔
414
                            break;
1✔
415
                        case 'volunteering':
41✔
416
                            $ret = volunteering();
4✔
417
                            break;
4✔
418
                        case 'logs':
37✔
419
                            $ret = logs();
1✔
420
                            break;
1✔
421
                        case 'newsfeed':
36✔
422
                            $ret = newsfeed();
14✔
423
                            break;
14✔
424
                        case 'noticeboard':
24✔
425
                            $ret = noticeboard();
3✔
426
                            break;
3✔
427
                        case 'notification':
22✔
428
                            $ret = notification();
3✔
429
                            break;
3✔
430
                        case 'mentions':
19✔
431
                            $ret = mentions();
1✔
432
                            break;
1✔
433
                        case 'microvolunteering':
18✔
434
                            $ret = microvolunteering();
5✔
435
                            break;
5✔
436
                        case 'team':
13✔
437
                            $ret = team();
1✔
438
                            break;
1✔
439
                        case 'tryst':
12✔
440
                            $ret = tryst();
4✔
441
                            break;
4✔
442
                        case 'src':
8✔
443
                            $ret = src();
1✔
444
                            break;
1✔
445
                        case 'visualise':
7✔
446
                            $ret = visualise();
1✔
447
                            break;
1✔
448
                        case 'stripecreateintent':
6✔
449
                            $ret = stripecreateintent();
×
450
                            break;
×
451
                        case 'stripecreatesubscription':
6✔
452
                            $ret = stripecreatesubscription();
×
453
                            break;
×
454
                        case 'echo':
6✔
455
                            $ret = array_merge($_REQUEST, $_SERVER);
2✔
456
                            break;
2✔
457
                        case 'DBexceptionWork':
4✔
458
                            # For UT
459
                            if ($apicallretries < 2) {
1✔
460
                                error_log("Fail DBException $apicallretries");
1✔
461
                                throw new DBException();
1✔
462
                            }
463

464
                            break;
1✔
465
                        case 'DBexceptionFail':
4✔
466
                            # For UT
467
                            throw new DBException();
1✔
468
                        case 'DBleaveTrans':
3✔
469
                            # For UT
470
                            $dbhm->beginTransaction();
1✔
471

472
                            break;
1✔
473
                    }
474

475
                    # If we get here, everything worked.
476
                    if ($call == 'upload') {
328✔
477
                        # Output is handled within the lib.
478
                    } else {
479
                        if (Utils::pres('redirectto', $ret)) {
328✔
480
                            header("Location: {$ret['redirectto']}", TRUE, 302);
1✔
481
                        } else if (Utils::pres('img', $ret)) {
328✔
482
                            # This is an image we want to output.  Can cache forever - if an image changes it would get a new id
483
                            @header('Content-Type: image/jpeg');
×
484
                            @header('Content-Length: ' . strlen($ret['img']));
×
485
                            @header('Cache-Control: max-age=5360000');
×
486
                            print $ret['img'];
×
487
                        } else {
488
                            # This is a normal API call.  Add profiling info.
489
                            $duration = (microtime(TRUE) - $scriptstart);
328✔
490
                            $ret['call'] = $call;
328✔
491
                            $ret['type'] = Utils::presdef('type', $_REQUEST, null);
328✔
492
                            $ret['session'] = session_id();
328✔
493
                            $ret['duration'] = $duration;
328✔
494
                            $ret['cpucost'] = API::getCpuUsage($tusage, $rusage);
328✔
495
                            $ret['dbwaittime'] = $dbhr->getWaitTime() + $dbhm->getWaitTime();
328✔
496
                            $ret['includetime'] = $includetime;
328✔
497
                            //                $ret['remoteaddr'] = Utils::presdef('REMOTE_ADDR', $_SERVER, '-');
498
                            //                $ret['_server'] = $_SERVER;
499

500
                            Utils::filterResult($ret);
328✔
501

502
                            # Set X-User-Role header for HAProxy to exempt mods/support/admin from rate limiting.
503
                            # Use whoAmI which caches the user object.
504
                            $me = Session::whoAmI($dbhr, $dbhm);
328✔
505
                            if ($me) {
328✔
506
                                $systemrole = $me->getPrivate('systemrole');
250✔
507
                                if ($systemrole && $systemrole != User::SYSTEMROLE_USER) {
250✔
508
                                    @header('X-User-Role: ' . $systemrole);
138✔
509
                                }
510
                            }
511

512
                            $encoded_ret = json_encode($ret, JSON_PARTIAL_OUTPUT_ON_ERROR);
328✔
513
                            echo $encoded_ret;
328✔
514

515
                            if ($duration > 5000) {
328✔
516
                                # Slow call.
517
                                $stamp = microtime(TRUE);
×
518
                                error_log("Slow API call for user " . Utils::presdef('id', $_SESSION, NULL) . " $call stamp $stamp");
×
519
                                file_put_contents("/tmp/iznik.slowapi.$stamp", "User # " . Utils::presdef('id', $_SESSION, NULL) . " request " . var_export($_REQUEST, TRUE) . " response " . var_export($ret, TRUE));
×
520
                            }
521
                        }
522
                    }
523

524
                    if ($apicallretries > 0) {
328✔
525
                        error_log("API call $call worked after $apicallretries");
1✔
526
                    }
527

528
                    break;
328✔
529
                } catch (\Exception $e) {
1✔
530
                    # This is our retry handler.
531
                    if ($e instanceof DBException) {
1✔
532
                        # This is a DBException.  We want to retry, which means we just go round the loop
533
                        # again.
534
                        error_log(
1✔
535
                            "DB Exception try $apicallretries," . $e->getMessage() . ", " . $e->getTraceAsString()
1✔
536
                        );
1✔
537
                        $apicallretries++;
1✔
538

539
                        if ($apicallretries >= API_RETRIES) {
1✔
540
                            if ($call != 'DBexceptionFail') {
1✔
541
                                # Don't log deliberate exceptions in UT.
542
                                \Sentry\captureException($e);
×
543
                            }
544

545
                            $ret = [
1✔
546
                                'ret' => 997,
1✔
547
                                'status' => 'DB operation failed after retry',
1✔
548
                                'exception' => $e->getMessage()
1✔
549
                            ];
1✔
550
                            $encoded_ret = json_encode($ret);
1✔
551
                            echo $encoded_ret;
1✔
552
                        }
553
                    } else {
554
                        # Something else.
555
                        if ($call != 'exception') {
1✔
556
                            # Don't log deliberate exceptions in UT.
557
                            \Sentry\captureException($e);
×
558
                        }
559

560
                        error_log(
1✔
561
                            "Uncaught exception at " . $e->getFile() . " line " . $e->getLine() . " " . $e->getMessage()
1✔
562
                        );
1✔
563
                        $ret = ['ret' => 998, 'status' => 'Unexpected error', 'exception' => $e->getMessage()];
1✔
564
                        $encoded_ret = json_encode($ret);
1✔
565
                        echo $encoded_ret;
1✔
566
                        break;
1✔
567
                    }
568

569
                    # Make sure the duplicate POST detection doesn't throw us.
570
                    $_REQUEST['retry'] = uniqid('', TRUE);
1✔
571
                }
572
            } while ($apicallretries < API_RETRIES);
1✔
573

574
            if (BROWSERTRACKING && (Utils::presdef('type', $_REQUEST, null) != 'GET') &&
328✔
575
                (gettype($ret) == 'array' && !array_key_exists('nolog', $ret)) &&
328✔
576
                (Utils::presdef('action', $_REQUEST, null) != 'MarkSeen')) {
328✔
577
                # Save off the API call and result, except for the (very frequent) event tracking calls.  Don't
578
                # save GET calls as they don't change the DB and there are a lot of them.  Don't save MarkSeen
579
                # calls as they are very frequent and not useful for debugging.
580
                #
581
                # Beanstalk has a limit on the size of job that it accepts; no point trying to log absurdly large
582
                # API requests.
583
                $r = $_REQUEST;
246✔
584
                $headers = Session::getallheaders();
246✔
585
                $r['headers'] = $headers;
246✔
586
                $req = json_encode($r);
246✔
587
                $rsp = $encoded_ret;
246✔
588

589
                if (strlen($req) + strlen($rsp) > 180000) {
246✔
590
                    $req = substr($req, 0, 1000);
1✔
591
                    $rsp = substr($rsp, 0, 1000);
1✔
592
                }
593

594
                $sql = "INSERT INTO logs_api (`userid`, `ip`, `session`, `request`, `response`) VALUES (" .
246✔
595
                    (session_status() !== PHP_SESSION_NONE ? Utils::presdef('id', $_SESSION,'NULL') : 'NULL') .
246✔
596
                    ", '" . Utils::presdef('REMOTE_ADDR', $_SERVER, '') . "', " . $dbhr->quote(session_id()) .
246✔
597
                    ", " . $dbhr->quote($req) . ", " . $dbhr->quote($rsp) . ");";
246✔
598
                $dbhm->background($sql);
246✔
599
            }
600

601
            # Log API request to Loki (fire-and-forget, doesn't block response).
602
            # This is done via shutdown function to ensure response is sent first.
603
            # Generate a unique request_id to correlate API logs with their headers.
604
            # Format: timestamp_ms (hex) + random bytes for uniqueness within same ms.
605
            $requestId = dechex((int)(microtime(TRUE) * 1000)) . bin2hex(random_bytes(4));
328✔
606

607
            $lokiLogData = [
328✔
608
                'call' => $call,
328✔
609
                'method' => Utils::presdef('type', $_REQUEST, 'GET'),
328✔
610
                'statusCode' => Utils::presdef('ret', $ret, 0),
328✔
611
                'duration' => (microtime(TRUE) - $scriptstart) * 1000, // ms
328✔
612
                'userId' => session_status() !== PHP_SESSION_NONE ? Utils::presdef('id', $_SESSION, NULL) : NULL,
328✔
613
                'ip' => Utils::presdef('REMOTE_ADDR', $_SERVER, ''),
328✔
614
                'queryParams' => $_GET,
328✔
615
                'requestBody' => Utils::presdef('type', $_REQUEST, 'GET') === 'POST' ? $_POST : [],
328✔
616
                'responseBody' => is_array($ret) ? $ret : [],
328✔
617
                'requestId' => $requestId,
328✔
618
            ];
328✔
619

620
            # Capture request headers from $_SERVER (HTTP_* format).
621
            $requestHeaders = [];
328✔
622
            foreach ($_SERVER as $key => $value) {
328✔
623
                if (strpos($key, 'HTTP_') === 0) {
328✔
624
                    # Convert HTTP_USER_AGENT to User-Agent format.
625
                    $headerName = str_replace('_', '-', substr($key, 5));
328✔
626
                    $headerName = ucwords(strtolower($headerName), '-');
328✔
627
                    $requestHeaders[$headerName] = $value;
328✔
628
                } elseif (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH'])) {
328✔
629
                    $headerName = str_replace('_', '-', $key);
×
630
                    $headerName = ucwords(strtolower($headerName), '-');
×
631
                    $requestHeaders[$headerName] = $value;
×
632
                }
633
            }
634

635
            # Capture response headers (must be done before output).
636
            $responseHeaders = [];
328✔
637
            foreach (headers_list() as $header) {
328✔
638
                $parts = explode(':', $header, 2);
×
639
                if (count($parts) === 2) {
×
640
                    $responseHeaders[trim($parts[0])] = trim($parts[1]);
×
641
                }
642
            }
643

644
            $lokiHeaderData = [
328✔
645
                'requestHeaders' => $requestHeaders,
328✔
646
                'responseHeaders' => $responseHeaders,
328✔
647
            ];
328✔
648

649
            register_shutdown_function(function() use ($lokiLogData, $lokiHeaderData) {
328✔
650
                $loki = Loki::getInstance();
×
651
                if ($loki->isEnabled()) {
×
652
                    $loki->logApiRequestFull(
×
653
                        'v1',
×
654
                        $lokiLogData['method'],
×
655
                        $lokiLogData['call'],
×
656
                        $lokiLogData['statusCode'],
×
657
                        $lokiLogData['duration'],
×
658
                        $lokiLogData['userId'],
×
659
                        [
×
660
                            'ip' => $lokiLogData['ip'],
×
661
                            'request_id' => $lokiLogData['requestId'],
×
662
                        ],
×
663
                        $lokiLogData['queryParams'],
×
664
                        $lokiLogData['requestBody'],
×
665
                        $lokiLogData['responseBody']
×
666
                    );
×
667

668
                    # Log headers separately (7-day retention for debugging).
669
                    # Include request_id for correlation with main API log.
670
                    $loki->logApiHeaders(
×
671
                        'v1',
×
672
                        $lokiLogData['method'],
×
673
                        $lokiLogData['call'],
×
674
                        $lokiHeaderData['requestHeaders'],
×
675
                        $lokiHeaderData['responseHeaders'],
×
676
                        $lokiLogData['userId'],
×
677
                        $lokiLogData['requestId']
×
678
                    );
×
679

680
                    $loki->flush();
×
681
                }
682
            });
328✔
683

684
            # Any outstanding transaction is a bug; force a rollback to avoid locks lasting beyond this call.
685
            if ($dbhm->inTransaction()) {
328✔
686
                $dbhm->rollBack();
1✔
687
            }
688

689
            if (session_status() !== PHP_SESSION_NONE) {
328✔
690
                if (Utils::presdef('type', $_REQUEST, null) != 'GET') {
328✔
691
                    # This might have changed things.
692
                    $_SESSION['modorowner'] = [];
249✔
693
                }
694

695
                # Update our last access time for this user.  his is used to return our
696
                # roster status in ChatRoom.php, and also for spotting idle members.
697
                #
698
                # Do this here, as we might not be logged in at the start if we had a persistent token but no PHP session.
699
                $id = Utils::pres('id', $_SESSION);
328✔
700
                $last = intval(Utils::presdef('lastaccessupdate', $_SESSION, 0));
328✔
701
                if ($id && (abs(time() - $last) > 600)) {
328✔
702
                    $dbhm->background("UPDATE users SET lastaccess = NOW() WHERE id = $id;");
250✔
703
                    $_SESSION['lastaccessupdate'] = time();
250✔
704
                }
705
            }
706
        }
707
    }
708

709
    public static function requestStart() {
710
        $dat = getrusage();
329✔
711
        return ([ microtime(TRUE), $dat["ru_utime.tv_sec"]*1e6+$dat["ru_utime.tv_usec"] ]);
329✔
712
    }
713

714
    public static function getCpuUsage($tusage, $rusage) {
715
        $dat = getrusage();
328✔
716
        $dat["ru_utime.tv_usec"] = ($dat["ru_utime.tv_sec"]*1e6 + $dat["ru_utime.tv_usec"]) - $rusage;
328✔
717
        $time = (microtime(TRUE) - $tusage) * 1000000;
328✔
718

719
        // cpu per request
720
        $cpu = $time > 0 ? $dat["ru_utime.tv_usec"] / $time / 1000 : 0;
328✔
721

722
        return $cpu;
328✔
723
    }
724
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc