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

marscoin / martianrepublic / 23804883405

31 Mar 2026 03:14PM UTC coverage: 11.195% (+1.8%) from 9.347%
23804883405

push

github

Martian Congress
refactor: Tier 1 — security fixes, route standardization, code style, docs

S9:  File upload path traversal — assert realpath() within allowed dirs,
     sanitize citizen address in file paths
S11: Citizen registry queries capped with LIMIT 100
S12: External HTTP calls — replaced file_get_contents with Http::timeout()
     in CongressController, Wallet/ApiController, AppHelper
A8:  All routes converted to Laravel 9+ array notation
     [Controller::class, 'method']
D2:  Created DEVELOPMENT.md — complete setup guide for contributors
D4:  Laravel Pint code style enforced across 211 files

Tests updated: public route assertions corrected after route syntax
standardization exposed that old string-notation routes never resolved
properly in tests.

127 tests, 251 assertions, 0 PHPStan errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

302 of 3262 new or added lines in 58 files covered. (9.26%)

139 existing lines in 24 files now uncovered.

620 of 5538 relevant lines covered (11.2%)

1.54 hits per line

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

4.87
/app/Http/Controllers/ApiController.php
1
<?php
2

3
namespace App\Http\Controllers;
4

5
use App\Includes\AppHelper;
6
use App\Includes\MarscoinECDSA;
7
use App\Models\Citizen;
8
use App\Models\CivicWallet;
9
use App\Models\Feed;
10
use App\Models\HDWallet;
11
use App\Models\MSession;
12
use App\Models\Posts;
13
use App\Models\Profile;
14
use App\Models\Threads;
15
use App\Models\User;
16
use BitcoinPHP\BitcoinECDSA\BitcoinECDSA;
17
use Carbon\Carbon;
18
use Illuminate\Database\Eloquent\ModelNotFoundException;
19
use Illuminate\Http\Request;
20
use Illuminate\Http\Response;
21
use Illuminate\Support\Facades\Auth;
22
use Illuminate\Support\Facades\Cache;
23
use Illuminate\Support\Facades\DB;
24
use Illuminate\Support\Facades\Hash;
25
use Illuminate\Support\Facades\Log;
26
use Illuminate\Support\Str;
27

28
class ApiController extends Controller
29
{
30
    public function allPublic()
1✔
31
    {
32
        $perPage = 25;
1✔
33
        $cacheKey = 'all_public_cache'; // Define a unique cache key for this query
1✔
34
        $excludedUserIds = [6462, 7601]; // ID of the user you want to exclude, for example, a test account
1✔
35

36
        // Attempt to get cached data. If not available, the closure will be run to fetch and cache the data.
37
        $feeds = Cache::remember($cacheKey, 60, function () use ($perPage, $excludedUserIds) {
1✔
38
            $feeds = Feed::with(['user' => function ($query) use ($excludedUserIds) {
1✔
39
                $query->select('id', 'fullname', 'created_at')
×
NEW
40
                    ->where('id', '!=', $excludedUserIds); // Exclude the specific user by ID
×
41
            }, 'user.profile' => function ($query) {
1✔
42
                $query->select('userid', 'general_public', 'endorse_cnt', 'citizen', 'has_application');
×
43
            }, 'user.citizen' => function ($query) {
1✔
44
                $query->select('userid', 'avatar_link', 'liveness_link')
×
NEW
45
                    ->whereNotNull('avatar_link');
×
46
            }])
1✔
47
                ->joinSub(
1✔
48
                    Feed::selectRaw('max(id) as latest_id, userid')
1✔
49
                        ->where('tag', 'GP') // Ensure the tag is 'GP'
1✔
50
                        ->groupBy('userid'),
1✔
51
                    'latest_feeds',
1✔
52
                    function ($join) {
1✔
53
                        $join->on('feed.id', '=', 'latest_feeds.latest_id');
1✔
54
                    }
1✔
55
                )
1✔
56
                ->whereHas('user.profile', function ($query) {
1✔
57
                    $query->where('tag', 'GP');
1✔
58
                })
1✔
59
                ->whereHas('user', function ($query) use ($excludedUserIds) {
1✔
60
                    $query->whereNotIn('id', $excludedUserIds);
1✔
61
                })
1✔
62
                ->orderByDesc('id')
1✔
63
                ->take($perPage)
1✔
64
                ->distinct()
1✔
65
                ->get();
1✔
66

67
            return $feeds; // Directly return the fetched feeds
1✔
68
        });
1✔
69

70
        return response()->json($feeds);
1✔
71
    }
72

UNCOV
73
    public function allCitizen()
×
74
    {
75
        $perPage = 25;
×
76
        $cacheKey = 'all_citizens_cache';
×
77
        $excludedUserId = '';
×
78

79
        $feeds = Cache::remember($cacheKey, 60, function () use ($perPage, $excludedUserId) {
×
80
            return Feed::with([
×
81
                'user' => function ($query) use ($excludedUserId) {
×
82
                    $query->where('id', '!=', $excludedUserId)
×
NEW
83
                        ->select('id', 'fullname', 'created_at');
×
84
                },
×
85
                'user.profile' => function ($query) {
×
86
                    $query->select('userid', 'general_public', 'endorse_cnt', 'citizen', 'has_application');
×
87
                },
×
88
                'user.citizen' => function ($query) {
×
89
                    $query->select('userid', 'avatar_link', 'liveness_link')
×
NEW
90
                        ->whereNotNull('avatar_link'); // Ensure that avatar_link is not NULL
×
NEW
91
                },
×
92
            ])
×
NEW
93
                ->whereHas('user', function ($query) use ($excludedUserId) {  // Ensure user exists and is not excluded
×
NEW
94
                    $query->where('id', '!=', $excludedUserId);
×
NEW
95
                })
×
NEW
96
                ->whereHas('user.citizen', function ($query) {  // Ensure user has valid citizen data
×
NEW
97
                    $query->whereNotNull('avatar_link');
×
NEW
98
                })
×
NEW
99
                ->whereHas('user.profile', function ($query) {  // Additional filters for the profile
×
NEW
100
                    $query->where('tag', 'CT');
×
NEW
101
                })
×
NEW
102
                ->select('id', 'address', 'userid', 'tag', 'message', 'embedded_link', 'txid', 'blockid', 'mined', 'updated_at', 'created_at')  // Only select columns from the feed table
×
NEW
103
                ->orderByDesc('id')
×
NEW
104
                ->take($perPage)
×
NEW
105
                ->get();
×
UNCOV
106
        });
×
107

108
        return response()->json($feeds);
×
109
    }
110

111
    // public function allApplicants()
112
    // {
113
    //     $perPage = 25;
114

115
    //     $applicants = User::whereHas('profile', function ($query) {
116
    //         $query->where('has_application', 1);
117
    //     })
118
    //     ->with(['profile', 'hdWallet' => function($query) {
119
    //         // If you only need the public_addr from HdWallet, select it specifically
120
    //         $query->select('user_id', 'public_addr');
121
    //     }])
122
    //     ->orderByDesc('id')
123
    //     ->paginate($perPage, ['id', 'fullname']);
124
    //     // Customize the result to match the raw query structure, if needed
125
    //     $customResult = $applicants->getCollection()->transform(function ($user) {
126
    //         return [
127
    //             'userid' => $user->id,
128
    //             'fullname' => $user->fullname,
129
    //             'address' => $user->hdWallet ? $user->hdWallet->public_addr : null,
130
    //         ];
131
    //     });
132

133
    //     return response()->json([
134
    //         'current_page' => $applicants->currentPage(),
135
    //         'data' => $customResult,
136
    //         'total' => $applicants->total(),
137
    //         'per_page' => $applicants->perPage(),
138
    //         'last_page' => $applicants->lastPage(),
139
    //     ]);
140
    // }
141

142
    public function allApplicants()
×
143
    {
144
        $perPage = 25;
×
145

146
        $applicants = User::whereHas('profile', function ($query) {
×
147
            $query->where('has_application', 1);
×
148
        })
×
NEW
149
            ->where('id', '!=', 6462) // Exclude user with id 6462
×
NEW
150
            ->with(['profile', 'hdWallet' => function ($query) {
×
NEW
151
                $query->select('user_id', 'public_addr');
×
NEW
152
            }, 'citizen']) // Add citizen relationship
×
NEW
153
            ->orderByDesc('id')
×
NEW
154
            ->paginate($perPage, ['id', 'fullname']);
×
155

156
        $customResult = $applicants->getCollection()->transform(function ($user) {
×
157
            return [
×
158
                'userid' => $user->id,
×
159
                'fullname' => $user->fullname,
×
160
                'address' => $user->hdWallet ? $user->hdWallet->public_addr : null,
×
161
                'citizen' => $user->citizen, // Include citizen data
×
162
            ];
×
163
        });
×
164

165
        return response()->json([
×
166
            'current_page' => $applicants->currentPage(),
×
167
            'data' => $customResult,
×
168
            'total' => $applicants->total(),
×
169
            'per_page' => $applicants->perPage(),
×
170
            'last_page' => $applicants->lastPage(),
×
171
        ]);
×
172
    }
173

UNCOV
174
    public function allFeed(Request $request)
×
175
    {
176
        $perPage = 25;
×
177
        $page = $request->input('page', 1);
×
178
        $excludedUserIds = [6462, 7601];
×
NEW
179
        $cacheKey = 'all_feed_cache_'.$page;
×
180

181
        $feeds = Cache::remember($cacheKey, 60, function () use ($perPage, $excludedUserIds) {
×
182
            return Feed::with(['user' => function ($query) use ($excludedUserIds) {
×
183
                $query->select('id', 'fullname', 'created_at')
×
184
                    ->whereNotIn('id', $excludedUserIds);
×
185
            }, 'user.profile' => function ($query) {
×
186
                $query->select('userid', 'general_public', 'endorse_cnt', 'citizen', 'has_application');
×
187
            }, 'user.citizen' => function ($query) {
×
188
                $query->select('userid', 'avatar_link', 'liveness_link')
×
189
                    ->whereNotNull('avatar_link');
×
190
            }])
×
NEW
191
                ->whereHas('user', function ($query) use ($excludedUserIds) {
×
NEW
192
                    $query->whereNotIn('id', $excludedUserIds);
×
NEW
193
                })
×
NEW
194
                ->whereNotNull('mined')
×
NEW
195
                ->select(
×
NEW
196
                    'id',
×
NEW
197
                    'address',
×
NEW
198
                    'userid',
×
NEW
199
                    'tag',
×
NEW
200
                    'message',
×
NEW
201
                    'embedded_link',
×
NEW
202
                    'txid',
×
NEW
203
                    'blockid',
×
NEW
204
                    'mined',
×
NEW
205
                    'created_at',
×
NEW
206
                    'updated_at'
×
NEW
207
                )
×
NEW
208
                ->orderByDesc('mined')
×
NEW
209
                ->orderByDesc('id')
×
NEW
210
                ->paginate($perPage);
×
UNCOV
211
        });
×
212

213
        return $this->paginatedResponse($feeds);
×
214
    }
215

216
    // Helper method to format paginated responses consistently
217
    private function paginatedResponse($feeds)
×
218
    {
219
        return response()->json([
×
220
            'current_page' => $feeds->currentPage(),
×
221
            'data' => $feeds->items(),
×
222
            'first_page_url' => $feeds->url(1),
×
223
            'from' => $feeds->firstItem(),
×
224
            'last_page' => $feeds->lastPage(),
×
225
            'last_page_url' => $feeds->url($feeds->lastPage()),
×
226
            'next_page_url' => $feeds->nextPageUrl(),
×
227
            'path' => $feeds->path(),
×
228
            'per_page' => $feeds->perPage(),
×
229
            'prev_page_url' => $feeds->previousPageUrl(),
×
230
            'to' => $feeds->lastItem(),
×
231
            'total' => $feeds->total(),
×
232
        ]);
×
233
    }
234

UNCOV
235
    public function showCitizen(Request $request, $address)
×
236
    {
237
        // Look up the Martian user by public address
238
        $martianWallet = CivicWallet::where('public_addr', $address)->first();
×
239

NEW
240
        if (! $martianWallet) {
×
241
            return response()->json(['message' => 'Martian not found.'], 404);
×
242
        }
243

244
        // Attempt to fetch the user's profile and additional data
245
        $citizen = Citizen::where('public_address', $address)->first();
×
246
        $profile = Profile::where('userid', $martianWallet->user_id)->first();
×
NEW
247
        $feedItems = Feed::where('userid', $martianWallet->user_id)->whereNotNull('mined')->whereNotIn('tag', ['GP', 'CT'])->orderBy('created_at', 'desc')->get();
×
248

249
        // Fetch the latest 3 activities
250
        $activity = DB::table('feed')
×
251
            ->join('users', 'feed.userid', '=', 'users.id')
×
252
            ->join('profile', 'feed.userid', '=', 'profile.userid')
×
253
            ->select('profile.userid', 'users.fullname', 'feed.tag', 'feed.mined')
×
254
            ->where('feed.userid', $martianWallet->user_id)
×
255
            ->orderBy('feed.id', 'desc')
×
256
            ->get();
×
257

258
        // Construct response
259
        $response = [
×
260
            'citizen' => $citizen,
×
261
            'profile' => [
×
262
                'general_public' => $profile ? $profile->general_public : null,
×
263
                'isCitizen' => $profile ? $profile->citizen : null,
×
264
            ],
×
265
            'feedItems' => $feedItems,
×
266
            'activity' => $activity,
×
267
        ];
×
268

269
        return response()->json($response);
×
270
    }
271

UNCOV
272
    public function token(Request $request)
×
273
    {
274
        $publicAddress = $request->input('a');
×
275
        $msg = $request->input('m');
×
276
        $sig = $request->input('s');
×
277
        $timestamp = $request->input('t');
×
278

NEW
279
        Log::debug('Address: '.$publicAddress);
×
NEW
280
        Log::debug('msg: '.$msg);
×
NEW
281
        Log::debug('sig: '.$sig);
×
NEW
282
        Log::debug('timestamp: '.$timestamp);
×
283

NEW
284
        if (empty($msg)) {
×
285
            $data = $request->json()->all();
×
286

287
            if (isset($data['data'])) {
×
288
                $msg = $data['data']['m'] ?? null;
×
289
                $sig = $data['data']['s'] ?? null;
×
290
                $publicAddress = $data['data']['a'] ?? null;
×
291
                $timestamp = $data['data']['t'] ?? '0';
×
292
            }
293
        }
294
        // Validate timestamp
295
        if (Carbon::now()->diffInSeconds(Carbon::createFromTimestamp($timestamp)) > 600) { // 5 minutes tolerance
×
296
            return response()->json(['error' => 'Request timestamp is too old.'], 401);
×
297
        }
298

NEW
299
        $bitcoinECDSA = new BitcoinECDSA;
×
NEW
300
        $marscoinECDSA = new MarscoinECDSA;
×
NEW
301
        if ($marscoinECDSA->checkSignatureForMessage($publicAddress, $sig, $msg)) {
×
UNCOV
302
            $wallet = CivicWallet::where('public_addr', $publicAddress)->first();
×
303

304
            if ($wallet) {
×
305
                // Wallet found, get the associated user
306
                $user = $wallet->user;
×
307
            } else {
308
                // Wallet not found, create a new user without a wallet
NEW
309
                $user = User::where('email', $publicAddress.'@martianrepublic.org')->first();
×
NEW
310
                if (! $user) {
×
311
                    $user = User::create([
×
312
                        'fullname' => 'UserWithoutWallet', // Or any other default or generated name
×
NEW
313
                        'email' => $publicAddress.'@martianrepublic.org',
×
314
                        'password' => Hash::make(Str::random(10)), // Random password
×
315
                    ]);
×
NEW
316
                    Log::debug('..created user');
×
317
                }
318
                $profile = Profile::where('userid', $user->id)->first();
×
NEW
319
                if (! $profile) {
×
320
                    $profile = Profile::create([
×
321
                        'userid' => $user->id,
×
322
                        'wallet_open' => 0,
×
323
                        'civic_wallet_open' => 0,
×
324
                        'has_application' => 0,
×
325
                    ]);
×
NEW
326
                    Log::debug('..created profile');
×
327
                }
328
                // create a citcache entry
329
                $citcache = Citizen::where('userid', $user->id)->first();
×
NEW
330
                if (! $citcache) {
×
331
                    $citizen = Citizen::create([
×
332
                        'userid' => $user->id,
×
333
                        'public_address' => $publicAddress,
×
334
                        'created_at' => now(),
×
335
                        'updated_at' => now(),
×
336
                    ]);
×
NEW
337
                    Log::debug('..created citizen cache');
×
338
                }
339
                // register civic wallet for the new user
340
                $wallet = CivicWallet::create([
×
341
                    'user_id' => $user->id,
×
342
                    'wallet_type' => 'MARS',
×
343
                    'backup' => 0,
×
344
                    'encrypted_seed' => '',
×
345
                    'public_addr' => $publicAddress,
×
346
                    'created_at' => now(),
×
NEW
347
                    'opened_at' => now(),
×
348
                ]);
×
NEW
349
                Log::debug('..created civic wallet');
×
350
            }
NEW
351
            if ($user->status == 'inactive') {
×
352
                return response()->json(['token' => 'inactive']);
×
353
            }
354

355
            $token = $user->createToken('authToken')->plainTextToken;
×
356

357
            return response()->json(['token' => $token]);
×
358
        } else {
UNCOV
359
            return response()->json(['message' => 'couldnt verify message'], 406);
×
360
        }
361
    }
362

363
    public function test()
×
364
    {
365

NEW
366
        $messageM = 'https://martianrepublic.org/api/token?a=MCHe2XTUEegyqsYc5ePe2dQiPtixLfhppR&t=1715354045';
×
NEW
367
        $addressM = 'MCHe2XTUEegyqsYc5ePe2dQiPtixLfhppR';
×
NEW
368
        $signatureM = 'H+Qrav6eWNrB0P5DiRaGRRR/RVtD8qd5dKlWA3FOfeiFc4h04769HtfMbsmxrrNPk0MeTJmwPCR9xg67f1NatOA=';
×
369

NEW
370
        $bitcoinECDSA = new BitcoinECDSA;
×
NEW
371
        $bitcoinECDSA->generateRandomPrivateKey(); // generate new random private key
×
372
        $address = $bitcoinECDSA->getAddress();
×
NEW
373
        $message = 'Test message';
×
374
        $signedMessage = $bitcoinECDSA->signMessage($message, true);
×
NEW
375
        echo 'message: '.PHP_EOL.'<br>';
×
NEW
376
        echo $message.PHP_EOL.'<br>';
×
NEW
377
        echo 'signed message: '.PHP_EOL.'<br>';
×
NEW
378
        echo $signedMessage.PHP_EOL.'<br>';
×
379
        // $signedMessage = "random";
380

381
        // loading Bitcoin crypto library
NEW
382
        if ($bitcoinECDSA->checkSignatureForMessage($address, $signedMessage, $message)) {       // verifying signature
×
NEW
383
            Log::debug('True');
×
NEW
384
            echo 'True<br>';
×
385
        } else {
NEW
386
            Log::debug('False');
×
NEW
387
            echo 'False<br>';
×
388
        }
389

NEW
390
        echo 'mars test';
×
NEW
391
        $marscoinECDSA = new MarscoinECDSA;
×
NEW
392
        echo 'Address; '.$addressM;
×
NEW
393
        echo 'Message:'.$messageM;
×
NEW
394
        echo 'sig:'.$signatureM;
×
395
        // loading Bitcoin crypto library
NEW
396
        if ($marscoinECDSA->checkSignatureForMessage($addressM, $signatureM, $messageM)) {       // verifying signature
×
NEW
397
            Log::debug('True');
×
NEW
398
            echo 'True<br>';
×
399
        } else {
NEW
400
            Log::debug('False');
×
NEW
401
            echo 'False<br>';
×
402
        }
403
    }
404

405
    // token access
UNCOV
406
    public function scitizen(Request $request)
×
407
    {
408

409
        $uid = Auth::user()->id;
×
410
        $citcache = Citizen::where('userid', '=', $uid)->first();
×
NEW
411
        if (is_null($citcache)) {
×
NEW
412
            $citcache = new Citizen;
×
413
        }
UNCOV
414
        $citcache->userid = $uid;
×
415

416
        $firstname = $request->input('firstname');
×
417
        $lastname = $request->input('lastname');
×
418
        $shortbio = $request->input('shortbio');
×
419
        $displayname = $request->input('displayname');
×
420

NEW
421
        if (isset($firstname) && isset($lastname)) {
×
NEW
422
            $fullname = $firstname.' '.$lastname;
×
423

424
            $citcache->firstname = $firstname;
×
425
            $citcache->lastname = $lastname;
×
426
            $user = User::where('id', '=', $uid)->first();
×
427
            $user->fullname = $fullname;
×
428
            $user->save();
×
429
            $profile = Profile::where('userid', '=', $uid)->first();
×
430
            $profile->has_application = 1;
×
431
            $profile->save();
×
432
        }
NEW
433
        if (isset($shortbio)) {
×
UNCOV
434
            $citcache->shortbio = $shortbio;
×
435
            $profile = Profile::where('userid', '=', $uid)->first();
×
436
            $profile->has_application = 1;
×
437
            $profile->save();
×
438
        }
NEW
439
        if (isset($displayname)) {
×
UNCOV
440
            $citcache->displayname = $displayname;
×
441
            $profile = Profile::where('userid', '=', $uid)->first();
×
442
            $profile->has_application = 1;
×
443
            $profile->save();
×
444
        }
445

446
        $citcache->save();
×
447
        // Fetch profile data
448
        $profile = Profile::where('userid', '=', $uid)->first();
×
449

450
        // Merge citizen and profile data
451
        $response = [
×
452
            'citizen' => $citcache,
×
453
            'profile' => [
×
454
                'citizen' => $profile->citizen ?? null,
×
455
                'endorse_cnt' => $profile->endorse_cnt ?? null,
×
456
                'general_public' => $profile->general_public ?? null,
×
457
                'has_application' => $profile->has_application ?? null,
×
NEW
458
            ],
×
459
        ];
×
460

461
        // Return the merged data as a JSON response
462
        return response()->json($response);
×
463
    }
464

465
    public function pinpic(Request $request)
×
466
    {
NEW
467
        $uid = Auth::user()->id;
×
NEW
468
        $hash = '';
×
469
        $dataPic = $request->input('picture');
×
470
        $type = $request->input('type');
×
471
        $public_address = $request->input('address');
×
472

473
        // --- SECURITY: Sanitize the public_address to prevent directory traversal ---
474
        $safeAddress = AppHelper::sanitizePathSegment($public_address);
×
475
        if ($safeAddress === null) {
×
476
            return response()->json(['error' => 'Invalid address format.'], 400);
×
477
        }
478

479
        // --- SECURITY: Validate the base64 image data (extension, MIME, size, PHP code) ---
480
        $validation = AppHelper::validateBase64Image($dataPic);
×
NEW
481
        if (! $validation['valid']) {
×
NEW
482
            Log::warning('pinpic upload rejected: '.$validation['error'].' (user: '.$uid.')');
×
483

UNCOV
484
            return response()->json(['error' => $validation['error']], 422);
×
485
        }
486

487
        $safeExtension = $validation['extension'];
×
488
        $decodedData = $validation['data'];
×
489

NEW
490
        $file_path = './assets/citizen/'.$safeAddress.'/';
×
NEW
491
        if (! file_exists($file_path)) {
×
UNCOV
492
            mkdir($file_path, 0755, true);
×
493
        }
494

495
        // --- SECURITY: Write .htaccess to prevent PHP execution in upload dir ---
496
        AppHelper::writeUploadHtaccess($file_path);
×
497

498
        // Use validated extension, not user-supplied one
NEW
499
        $file_path = './assets/citizen/'.$safeAddress.'/profile_pic.'.$safeExtension;
×
500

501
        file_put_contents($file_path, $decodedData);
×
NEW
502
        $hash = AppHelper::upload($file_path, config('blockchain.ipfs.api_url').'/api/v0/add?pin=true');
×
503

504
        $citcache = Citizen::where('userid', '=', $uid)->first();
×
NEW
505
        if (is_null($citcache)) {
×
NEW
506
            $citcache = new Citizen;
×
507
        }
508
        $citcache->userid = $uid;
×
509
        $citcache->avatar_link = config('blockchain.ipfs.gateway_url').$hash;
×
510
        $citcache->save();
×
511

NEW
512
        return (new Response(json_encode(['Hash' => $hash]), 200))
×
NEW
513
            ->header('Content-Type', 'application/json;');
×
514

515
    }
516

NEW
517
    public function pinvideo(Request $request)
×
518
    {
519

520
        Log::debug('pinvid');
×
521
        $uid = Auth::user()->id;
×
NEW
522
        $hash = '';
×
523
        $dataPic = $request->input('file');
×
524
        $type = $request->input('type');
×
525
        $public_address = $request->input('address');
×
526

527
        // --- SECURITY: Sanitize the public_address to prevent directory traversal ---
528
        $safeAddress = AppHelper::sanitizePathSegment($public_address);
×
529
        if ($safeAddress === null) {
×
530
            return response()->json(['error' => 'Invalid address format.'], 400);
×
531
        }
532

NEW
533
        Log::debug('public: '.$safeAddress);
×
NEW
534
        if ($request->hasFile('file')) {
×
535
            // --- SECURITY: Validate the uploaded file (extension, MIME, size, PHP code) ---
536
            $uploadedFile = $request->file('file');
×
537
            $validation = AppHelper::validateUploadedFile($uploadedFile, [
×
538
                'webm' => ['video/webm', 'audio/webm'],
×
539
            ]);
×
NEW
540
            if (! $validation['valid']) {
×
NEW
541
                Log::warning('pinvideo upload rejected: '.$validation['error'].' (user: '.$uid.')');
×
542

UNCOV
543
                return response()->json(['error' => $validation['error']], 422);
×
544
            }
545

546
            Log::debug('storing file');
×
NEW
547
            $file_path = './assets/citizen/'.$safeAddress.'/';
×
NEW
548
            if (! file_exists($file_path)) {
×
549
                mkdir($file_path, 0755, true);
×
550
                Log::debug('making dir');
×
551
            }
552

553
            // --- SECURITY: Write .htaccess to prevent PHP execution in upload dir ---
554
            AppHelper::writeUploadHtaccess($file_path);
×
555

NEW
556
            $file_path = './assets/citizen/'.$safeAddress.'/';
×
NEW
557
            $request->file('file')->move($file_path, 'profile_video.webm');
×
NEW
558
            $file_path = $file_path.'profile_video.webm';
×
NEW
559
            $hash = AppHelper::upload($file_path, config('blockchain.ipfs.api_url').'/api/v0/add?pin=true');
×
NEW
560
            Log::debug('upload complete: '.$hash);
×
561
            $citcache = Citizen::where('userid', '=', $uid)->first();
×
NEW
562
            if (is_null($citcache)) {
×
NEW
563
                $citcache = new Citizen;
×
564
            }
565
            $citcache->userid = $uid;
×
566
            $citcache->liveness_link = config('blockchain.ipfs.gateway_url').$hash;
×
567
            $citcache->save();
×
568
            Log::debug('saved cit data');
×
569

NEW
570
            return (new Response(json_encode(['Hash' => $hash]), 200))
×
NEW
571
                ->header('Content-Type', 'application/json;');
×
572
        } else {
UNCOV
573
            Log::debug('no file found!');
×
574
        }
575

576
    }
577

578
    public function marsAuth(Request $request)
×
579
    {
580
        $this->setCorsHeaders();
×
581

NEW
582
        if (! $request->isMethod('post')) {
×
NEW
583
            return $this->fastcode(400, 'bad request');
×
584
        }
585

586
        $challenge = $request->query('c');
×
587
        $session_string = $request->query('sid');
×
588
        $timestamp = hexdec($request->query('t'));
×
589
        $pubk = $request->input('pubk');
×
590
        $msg = $request->input('msg');
×
591
        $sig = $request->input('sig');
×
592
        $address = $request->input('addr');
×
593

594
        if ($timestamp < (time() - 600)) {
×
NEW
595
            return $this->fastcode(408, 'request timed out');
×
596
        }
597

598
        $session = MSession::where('sid', $session_string)->first();
×
599

NEW
600
        if ($session && in_array($challenge, explode(',', $session->v))) {
×
NEW
601
            $marscoinECDSA = new MarscoinECDSA;
×
602
            if ($marscoinECDSA->checkSignatureForMessage($address, $sig, $msg)) {
×
603
                $session->s = $address;
×
604
                $session->save();
×
605

NEW
606
                return $this->fastcode(200, 'successfully authenticated');
×
607
            } else {
608
                return $this->fastcode(406, "couldn't verify message");
×
609
            }
610
        } else {
NEW
611
            return $this->fastcode(404, 'no challenge found');
×
612
        }
613
    }
614

615
    public function checkAuth(Request $request)
×
616
    {
617
        $session_string = $request->query('sid');
×
618
        $session = MSession::where('sid', $session_string)->first();
×
619

NEW
620
        $authenticated = (! is_null($session) && ! empty($session->s)) ? true : false;
×
621

622
        return response()->json(['sid' => $session_string, 'authenticated' => $authenticated]);
×
623
    }
624

625
    private function fastcode($status, $message)
×
626
    {
NEW
627
        return response()->json(['status' => $status, 'message' => $message], $status);
×
628
    }
629

630
    private function setCorsHeaders()
×
631
    {
NEW
632
        header('Access-Control-Allow-Origin: *');
×
NEW
633
        header('Access-Control-Allow-Methods: GET, POST');
×
NEW
634
        header('Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Authorization');
×
635
    }
636

637
    public function wauth(Request $request)
×
638
    {
639
        Log::debug('MarsAuth');
×
640
        $sid = $request->query('sid');
×
641
        Log::debug('MarsAuth');
×
642

NEW
643
        if (! empty($sid)) {
×
644
            $mars_session = MSession::where('sid', $sid)->first(); // Retrieve the session using Eloquent
×
645

NEW
646
            if ($mars_session && ! is_null($mars_session->s) && ! empty($mars_session->s)) {
×
647
                // Attempt to retrieve a user via the public address
648
                $citizen = Citizen::where('public_address', $mars_session->s)->first();
×
649

650
                if ($citizen) {
×
651
                    // If a citizen record exists, retrieve the associated user
652
                    $user = User::find($citizen->userid);
×
653
                } else {
654
                    // Create a new user and citizen if no citizen record exists
655
                    $user = User::create([
×
NEW
656
                        'email' => $mars_session->s.'@martianrepublic.org',
×
657
                        'password' => Hash::make(Str::random(16)), // Generate a random password
×
658
                    ]);
×
659

660
                    $citizen = new Citizen([
×
661
                        'userid' => $user->id,
×
662
                        'firstname' => 'Unknown', // Set default or use input
×
663
                        'lastname' => 'Uknown',
×
664
                        'public_address' => $mars_session->s,
×
665
                    ]);
×
666

667
                    $citizen->save();
×
668
                }
669

670
                Auth::login($user); // Authenticate the user
×
671
                session()->save(); // Ensure the session is saved immediately
×
672

NEW
673
                Log::debug('User authenticated immediately after login: '.(Auth::check() ? 'true' : 'false'));
×
674

UNCOV
675
                return redirect('/wallet/dashboard');
×
676
            }
677

678
            return redirect('/login')->withErrors('Could not find User.');
×
679
        } else {
680
            // Handle invalid or expired SID
681
            return redirect('/login')->withErrors('Your session has expired or is invalid.');
×
682
        }
683
    }
684

685
    /**
686
     * Handles JSON storage and pinning to a distributed file system.
687
     *
688
     * @hideFromAPIDocumentation
689
     */
NEW
690
    public function pinjson(Request $request)
×
691
    {
NEW
692
        Log::info('in function');
×
NEW
693
        $public_address = $request->input('address');
×
NEW
694
        $type = $request->input('type');
×
NEW
695
        $json = $request->input('payload');
×
696

697
        // --- SECURITY: Sanitize the public_address to prevent directory traversal ---
NEW
698
        $safeAddress = AppHelper::sanitizePathSegment($public_address);
×
NEW
699
        if ($safeAddress === null) {
×
NEW
700
            return response()->json(['error' => 'Invalid address format.'], 400);
×
701
        }
702

703
        // --- SECURITY: Sanitize the type parameter to prevent path traversal ---
NEW
704
        $safeType = AppHelper::sanitizePathSegment($type);
×
NEW
705
        if ($safeType === null) {
×
NEW
706
            return response()->json(['error' => 'Invalid type format.'], 400);
×
707
        }
708

709
        // --- SECURITY: Check file size (max 5MB) ---
NEW
710
        if (strlen($json) > 5242880) {
×
NEW
711
            return response()->json(['error' => 'Payload exceeds maximum size of 5MB.'], 422);
×
712
        }
713

714
        // --- SECURITY: Reject payloads containing PHP code ---
NEW
715
        if (AppHelper::containsPhpCode($json)) {
×
NEW
716
            Log::warning('pinjson rejected: payload contains PHP code');
×
717

NEW
718
            return response()->json(['error' => 'Payload contains potentially dangerous content.'], 422);
×
719
        }
720

721
        // --- SECURITY: Validate that payload is valid JSON ---
NEW
722
        $decodedJson = json_decode($json);
×
NEW
723
        if ($json !== '' && json_last_error() !== JSON_ERROR_NONE) {
×
NEW
724
            return response()->json(['error' => 'Invalid JSON payload.'], 422);
×
725
        }
726

NEW
727
        $projectRoot = config('app.project_root', base_path());
×
NEW
728
        $base_path = $projectRoot.'/assets/citizen/'.$safeAddress;
×
729

730
        // Check and create the directory if it doesn't exist
NEW
731
        Log::info($base_path);
×
NEW
732
        clearstatcache();
×
NEW
733
        if (! is_dir($base_path)) {
×
NEW
734
            Log::info('Trying to create directory: '.$base_path);
×
NEW
735
            if (! mkdir($base_path, 0755, true)) {
×
NEW
736
                Log::error('Failed to create directory: '.$base_path);
×
737

NEW
738
                return response()->json(['error' => 'Failed to create directory. Check permissions.'], 500);
×
739
            }
NEW
740
            Log::info('Directory created: '.$base_path);
×
741
        }
742

743
        // Check if the directory is writable, regardless of whether it was just created or already existed
NEW
744
        if (! is_writable($base_path)) {
×
NEW
745
            Log::error('Directory not writable: '.$base_path);
×
746

NEW
747
            return response()->json(['error' => 'Directory is not writable. Check permissions.'], 500);
×
748
        }
749

750
        // --- SECURITY: Write .htaccess to prevent PHP execution in upload dir ---
NEW
751
        AppHelper::writeUploadHtaccess($base_path);
×
752

NEW
753
        $file_path = $base_path.'/'.$safeType.'.json';
×
754

755
        // Attempt to write the JSON data to the file
NEW
756
        if (file_put_contents($file_path, $json) === false) {
×
NEW
757
            return response()->json(['error' => 'Failed to write to file.'], 500);
×
758
        }
759

760
        try {
NEW
761
            Log::info('PermaJson: '.$file_path);
×
762

763
            // Check if the type contains the word 'log'
NEW
764
            if (strpos($safeType, 'log') !== false) {
×
765
                // The type contains 'log', use uploadFolder
NEW
766
                $apiResponse = AppHelper::uploadFolder($file_path, config('blockchain.ipfs.api_url').'/api/v0/add?pin=true&recursive=true&wrap-with-directory=true&quieter');
×
767
            } else {
768
                // The type does not contain 'log', use upload
NEW
769
                $apiResponse = AppHelper::upload($file_path, config('blockchain.ipfs.api_url').'/api/v0/add?pin=true');
×
770
            }
771

NEW
772
            if (is_string($apiResponse)) {
×
NEW
773
                $formattedResponse = ['Hash' => $apiResponse];
×
774
            } else {
NEW
775
                Log::error('Upload error: Formatting');
×
776

NEW
777
                return response()->json(['error' => 'formatting error'], 500);
×
778
            }
779

NEW
780
            return response()->json($formattedResponse, 200)->header('Content-Type', 'application/json;');
×
NEW
781
        } catch (\Exception $e) {
×
782
            // Handle exceptions during the upload and pinning process
NEW
783
            Log::error('Upload error: '.$e->getMessage());
×
784

NEW
785
            return response()->json(['error' => $e->getMessage()], 500);
×
786
        }
787
    }
788

NEW
789
    public function getThreadsByCategory($categoryId)
×
790
    {
791
        $threads = $this->fetchThreads($categoryId);
×
792

UNCOV
793
        return response()->json(['threads' => $threads]);
×
794
    }
795

796
    // private function fetchThreads($categoryId) {
797
    //     $threads = DB::table('forum_threads')
798
    //         ->where('forum_threads.category_id', $categoryId)
799
    //         ->leftJoin('users', 'forum_threads.author_id', '=', 'users.id')
800
    //         ->leftJoin('profile', 'users.id', '=', 'profile.userid')
801
    //         ->select(
802
    //             'forum_threads.id',
803
    //             'forum_threads.title',
804
    //             'forum_threads.created_at',
805
    //             'forum_threads.reply_count',
806
    //             'users.fullname as author_name'
807
    //         )
808
    //         ->orderBy('forum_threads.created_at', 'desc')
809
    //         ->get();
810
    //     return $threads;
811
    // }
812

NEW
813
    private function fetchThreads($categoryId)
×
814
    {
UNCOV
815
        $userId = Auth::id();
×
816

817
        $threads = DB::table('forum_threads')
×
818
            ->where('forum_threads.category_id', $categoryId)
×
819
            ->leftJoin('users', 'forum_threads.author_id', '=', 'users.id')
×
820
            ->leftJoin('profile', 'users.id', '=', 'profile.userid')
×
NEW
821
            ->leftJoin('user_blocks as ub', function ($join) use ($userId) {
×
822
                $join->on('forum_threads.author_id', '=', 'ub.blocked_user_id')
×
NEW
823
                    ->where('ub.user_id', '=', $userId);
×
824
            })
×
825
            ->select(
×
826
                'forum_threads.id',
×
827
                'forum_threads.title',
×
828
                'forum_threads.created_at',
×
829
                'forum_threads.reply_count',
×
830
                'users.fullname as author_name',
×
831
                DB::raw('IF(ub.blocked_user_id IS NOT NULL, true, false) as is_blocked')
×
832
            )
×
833
            ->orderBy('forum_threads.created_at', 'desc')
×
834
            ->get();
×
835

836
        return $threads;
×
837
    }
838

NEW
839
    public function getThreadComments($threadId)
×
840
    {
841
        // Assume $threadId is passed correctly to the function
842
        $comments = $this->fetchCommentsByThread($threadId);
×
843

UNCOV
844
        return response()->json(['comments' => $comments]);
×
845
    }
846

847
    // private function fetchCommentsByThread($threadId) {
848
    //     // The query as outlined above
849
    //     // Return the collection of comments
850
    //     $query = "
851
    //         WITH RECURSIVE CommentTree AS (
852
    //             SELECT
853
    //                 p.id,
854
    //                 p.thread_id,
855
    //                 p.author_id,
856
    //                 p.content,
857
    //                 p.post_id as pid,
858
    //                 p.created_at,
859
    //                 CHAR_LENGTH(p.content) as char_length_sum
860
    //             FROM
861
    //                 forum_posts p
862
    //             WHERE
863
    //                 p.thread_id = ? AND p.post_id IS NULL
864

865
    //             UNION ALL
866

867
    //             SELECT
868
    //                 p.id,
869
    //                 p.thread_id,
870
    //                 p.author_id,
871
    //                 p.content,
872
    //                 p.post_id,
873
    //                 p.created_at,
874
    //                 ct.char_length_sum + CHAR_LENGTH(p.content)
875
    //             FROM
876
    //                 forum_posts p
877
    //             INNER JOIN
878
    //                 CommentTree ct ON p.post_id = ct.id
879
    //         )
880
    //         SELECT
881
    //             ct.id,
882
    //             ct.thread_id,
883
    //             ct.author_id,
884
    //             u.fullname,
885
    //             ct.content,
886
    //             ct.created_at,
887
    //             ct.pid,
888
    //             CHAR_LENGTH(ct.content) as char_length_sum
889
    //         FROM
890
    //             CommentTree ct
891
    //         LEFT JOIN users u ON ct.author_id = u.id
892
    //         LEFT JOIN profile pr ON ct.author_id = pr.userid
893
    //         ORDER BY
894
    //             ct.pid ASC,
895
    //             ct.created_at ASC;
896
    //     ";
897

898
    //     $comments = DB::select($query, [$threadId]);
899
    //     $commentsCollection = collect($comments);
900

901
    //     return response()->json(['comments' => $commentsCollection]);
902
    // }
903

NEW
904
    private function fetchCommentsByThread($threadId)
×
905
    {
UNCOV
906
        $userId = Auth::id();
×
907

NEW
908
        $query = '
×
909
            WITH RECURSIVE CommentTree AS (
910
                SELECT
911
                    p.id,
912
                    p.thread_id,
913
                    p.author_id,
914
                    p.content,
915
                    p.post_id as pid,
916
                    p.created_at,
917
                    CHAR_LENGTH(p.content) as char_length_sum
918
                FROM
919
                    forum_posts p
920
                WHERE
921
                    p.thread_id = ? AND p.post_id IS NULL
922

923
                UNION ALL
924

925
                SELECT
926
                    p.id,
927
                    p.thread_id,
928
                    p.author_id,
929
                    p.content,
930
                    p.post_id,
931
                    p.created_at,
932
                    ct.char_length_sum + CHAR_LENGTH(p.content)
933
                FROM
934
                    forum_posts p
935
                INNER JOIN
936
                    CommentTree ct ON p.post_id = ct.id
937
            )
938
            SELECT
939
                ct.id,
940
                ct.thread_id,
941
                ct.author_id,
942
                u.fullname,
943
                ct.content,
944
                ct.created_at,
945
                ct.pid,
946
                CHAR_LENGTH(ct.content) as char_length_sum,
947
                IF(ub.blocked_user_id IS NOT NULL, true, false) as is_blocked
948
            FROM
949
                CommentTree ct
950
            LEFT JOIN users u ON ct.author_id = u.id
951
            LEFT JOIN profile pr ON ct.author_id = pr.userid
952
            LEFT JOIN user_blocks ub ON ub.blocked_user_id = ct.author_id AND ub.user_id = ?
953
            ORDER BY
954
                ct.pid ASC,
955
                ct.created_at ASC;
NEW
956
        ';
×
957

958
        $comments = DB::select($query, [$threadId, $userId]);
×
959
        $commentsCollection = collect($comments);
×
960

UNCOV
961
        return response()->json(['comments' => $commentsCollection]);
×
962
    }
963

NEW
964
    public function getAllCategoriesWithThreads()
×
965
    {
UNCOV
966
        $userId = Auth::id();
×
967

968
        $categories = DB::table('forum_categories')->get();
×
969

970
        // Fetch all threads in a single query instead of N+1
971
        $threads = DB::table('forum_threads')
×
972
            ->leftJoin('users', 'forum_threads.author_id', '=', 'users.id')
×
973
            ->leftJoin('profile', 'users.id', '=', 'profile.userid')
×
NEW
974
            ->leftJoin('user_blocks as ub', function ($join) use ($userId) {
×
975
                $join->on('forum_threads.author_id', '=', 'ub.blocked_user_id')
×
NEW
976
                    ->where('ub.user_id', '=', $userId);
×
977
            })
×
978
            ->select(
×
979
                'forum_threads.id',
×
980
                'forum_threads.category_id',
×
981
                'forum_threads.title',
×
982
                'forum_threads.created_at',
×
983
                'forum_threads.reply_count',
×
984
                'users.fullname as author_name',
×
985
                DB::raw('IF(ub.blocked_user_id IS NOT NULL, true, false) as is_blocked')
×
986
            )
×
987
            ->orderBy('forum_threads.created_at', 'desc')
×
988
            ->get()
×
989
            ->groupBy('category_id');
×
990

991
        foreach ($categories as $category) {
×
992
            $category->threads = $threads->get($category->id, collect());
×
993
        }
994

995
        return response()->json(['categories' => $categories]);
×
996
    }
997

UNCOV
998
    public function createThread(Request $request)
×
999
    {
1000
        $request->validate([
×
1001
            'category_id' => 'required|exists:forum_categories,id',
×
1002
            'title' => 'required|string|max:255',
×
1003
            'content' => 'required|string',
×
1004
        ]);
×
1005

NEW
1006
        $thread = new Threads;
×
1007
        $thread->category_id = $request->category_id;
×
1008
        $thread->author_id = Auth::id();
×
1009
        $thread->title = $request->title;
×
1010
        $thread->save();
×
1011

NEW
1012
        $post = new Posts;
×
1013
        $post->thread_id = $thread->id;
×
1014
        $post->author_id = Auth::id();
×
1015
        $post->content = $request->content;
×
1016
        $post->save();
×
1017

1018
        $thread->first_post_id = $post->id;
×
1019
        $thread->last_post_id = $post->id;
×
1020
        $thread->reply_count = 0;
×
1021
        $thread->save();
×
1022

1023
        return response()->json([
×
1024
            'message' => 'Thread created successfully',
×
1025
            'thread_id' => $thread->id,
×
1026
            'post_id' => $post->id,
×
1027
        ], 201);
×
1028
    }
1029

1030
    public function createComment(Request $request, $threadId)
×
1031
    {
1032
        $request->validate([
×
1033
            'content' => 'required|string',
×
1034
            'post_id' => 'nullable|exists:forum_posts,id',
×
1035
        ]);
×
1036

1037
        $thread = Threads::findOrFail($threadId);
×
1038

NEW
1039
        $post = new Posts;
×
1040
        $post->thread_id = $threadId;
×
1041
        $post->author_id = Auth::id();
×
1042
        $post->content = $request->content;
×
1043
        $post->post_id = $request->post_id; // This will be null for top-level comments
×
1044
        $post->save();
×
1045

1046
        $thread->last_post_id = $post->id;
×
1047
        $thread->reply_count += 1;
×
1048
        $thread->save();
×
1049

1050
        return response()->json([
×
1051
            'message' => 'Comment created successfully',
×
1052
            'post_id' => $post->id,
×
1053
        ], 201);
×
1054
    }
1055

UNCOV
1056
    public function blockUser(Request $request, $id)
×
1057
    {
NEW
1058
        Log::debug('in function');
×
NEW
1059
        $uid = Auth::user()->id;
×
NEW
1060
        Log::debug('auth happened');
×
UNCOV
1061
        $blockedUserId = $id;
×
1062

1063
        // Insert into `user_blocks` table if not already blocked
1064
        DB::table('user_blocks')->updateOrInsert(
×
1065
            ['user_id' => $uid, 'blocked_user_id' => $blockedUserId]
×
1066
        );
×
1067

1068
        return response()->json(['message' => 'User blocked successfully'], 201);
×
1069
    }
1070

1071
    public function handleEula(Request $request)
×
1072
    {
1073
        $uid = Auth::user()->id;
×
1074
        $profile = Profile::where('userid', $uid)->firstOrFail();
×
1075

1076
        return response()->json([
×
NEW
1077
            'is_signed' => (bool) $profile->signed_eula,
×
1078
        ]);
×
1079
    }
1080

1081
    public function setEula(Request $request)
×
1082
    {
1083
        $uid = Auth::user()->id;
×
1084
        $profile = Profile::where('userid', $uid)->firstOrFail();
×
NEW
1085
        if (! $profile->signed_eula) {
×
1086
            $profile->signed_eula = 1;
×
1087
            $profile->save();
×
1088
        }
1089

1090
        return response()->json([
×
1091
            'message' => 'EULA signed successfully',
×
NEW
1092
            'is_signed' => true,
×
1093
        ]);
×
1094
    }
1095

UNCOV
1096
    public function deleteUser(Request $request, $id)
×
1097
    {
1098
        try {
1099
            // Users can only deactivate their own account
1100
            $authUser = Auth::user();
×
1101
            if ((int) $authUser->id !== (int) $id) {
×
1102
                return response()->json(['message' => 'Unauthorized: you can only delete your own account.'], 403);
×
1103
            }
1104

1105
            $user = User::findOrFail($id);
×
1106

NEW
1107
            Log::debug('Deleting... '.$id);
×
1108

1109
            $user->update([
×
NEW
1110
                'status' => 'inactive',
×
1111
            ]);
×
NEW
1112
            Log::debug('Status updated');
×
1113

1114
            // Revoke all tokens
1115
            if (method_exists($user, 'tokens')) {
×
1116
                $user->tokens()->delete();
×
1117
            }
NEW
1118
            Log::debug('Token wiped...');
×
1119

1120
            return response()->json([
×
NEW
1121
                'message' => 'User deleted successfully',
×
1122
            ], 200);
×
1123

UNCOV
1124
        } catch (ModelNotFoundException $e) {
×
1125
            return response()->json([
×
NEW
1126
                'message' => 'User not found',
×
1127
            ], 404);
×
1128
        } catch (\Exception $e) {
×
NEW
1129
            \Log::error('Error deleting user: '.$e->getMessage());
×
1130

1131
            return response()->json([
×
NEW
1132
                'message' => 'An error occurred while deleting the user',
×
1133
            ], 500);
×
1134
        }
1135
    }
1136
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc