• 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

9.91
/app/Includes/AppHelper.php
1
<?php
2

3
namespace App\Includes;
4

5
use App\Models\Citizen;
6
use App\Models\Feed;
7
use App\Models\Profile;
8
use App\Models\Publication;
9
use Carbon\Carbon;
10
use DateTime;
11
use Illuminate\Http\UploadedFile;
12
use Illuminate\Support\Facades\Cache;
13
use Illuminate\Support\Facades\Http;
14
use Illuminate\Support\Facades\Log;
15
use TeamTeaTime\Forum\Models\Post;
16

17
class AppHelper
18
{
19
    /**
20
     * =========================================================================
21
     * FILE UPLOAD SECURITY HELPERS
22
     * =========================================================================
23
     * Added to prevent malicious file uploads (PHP shells, etc.).
24
     * Every upload endpoint MUST call one of these validation methods.
25
     */
26

27
    /**
28
     * Dangerous file extensions that must never be uploaded.
29
     * This blocklist prevents execution of server-side scripts.
30
     */
31
    private static $blockedExtensions = [
32
        'php', 'phtml', 'php5', 'php7', 'php8', 'phar', 'phps',
33
        'cgi', 'pl', 'py', 'sh', 'bash', 'exe', 'bat', 'cmd',
34
        'asp', 'aspx', 'jsp', 'shtml', 'htaccess', 'svg',
35
    ];
36

37
    /**
38
     * Allowed image extensions and their corresponding MIME types.
39
     */
40
    private static $allowedImageTypes = [
41
        'jpg' => ['image/jpeg'],
42
        'jpeg' => ['image/jpeg'],
43
        'png' => ['image/png'],
44
        'gif' => ['image/gif'],
45
        'webp' => ['image/webp'],
46
    ];
47

48
    /**
49
     * Allowed extensions for general file uploads (images + json + webm video).
50
     */
51
    private static $allowedUploadTypes = [
52
        'jpg' => ['image/jpeg'],
53
        'jpeg' => ['image/jpeg'],
54
        'png' => ['image/png'],
55
        'gif' => ['image/gif'],
56
        'webp' => ['image/webp'],
57
        'json' => ['application/json', 'text/plain'],
58
        'webm' => ['video/webm', 'audio/webm'],
59
        'markdown' => ['text/plain', 'text/markdown'],
60
    ];
61

62
    /**
63
     * Maximum upload file size in bytes (5 MB).
64
     */
65
    private static $maxFileSize = 5242880;
66

67
    /**
68
     * Check if a file extension is on the blocklist.
69
     *
70
     * @return bool True if the extension is blocked (dangerous).
71
     */
NEW
72
    public static function isExtensionBlocked(string $extension): bool
×
73
    {
NEW
74
        return in_array(strtolower(trim($extension)), self::$blockedExtensions, true);
×
75
    }
76

77
    /**
78
     * Validate an uploaded file (from $request->file()) for security.
79
     * Checks extension blocklist, allowed extensions, MIME type, and file size.
80
     *
81
     * @param  UploadedFile  $file
82
     * @param  array|null  $allowedTypes  Associative array of ext => [mimes]. Defaults to $allowedUploadTypes.
83
     * @return array ['valid' => bool, 'error' => string|null]
84
     */
NEW
85
    public static function validateUploadedFile($file, ?array $allowedTypes = null): array
×
86
    {
NEW
87
        if (! $file || ! $file->isValid()) {
×
NEW
88
            return ['valid' => false, 'error' => 'No valid file provided.'];
×
89
        }
90

NEW
91
        $allowedTypes = $allowedTypes ?? self::$allowedUploadTypes;
×
92

93
        // Check file size (max 5MB)
NEW
94
        if ($file->getSize() > self::$maxFileSize) {
×
NEW
95
            return ['valid' => false, 'error' => 'File exceeds maximum size of 5MB.'];
×
96
        }
97

98
        // Get and check extension against blocklist
NEW
99
        $extension = strtolower($file->getClientOriginalExtension());
×
NEW
100
        if (self::isExtensionBlocked($extension)) {
×
NEW
101
            Log::warning('Blocked file upload attempt with dangerous extension: '.$extension);
×
102

NEW
103
            return ['valid' => false, 'error' => 'File type is not allowed (blocked extension: '.$extension.').'];
×
104
        }
105

106
        // Check extension is in the allowed list
NEW
107
        if (! array_key_exists($extension, $allowedTypes)) {
×
NEW
108
            return ['valid' => false, 'error' => 'File extension .'.$extension.' is not permitted.'];
×
109
        }
110

111
        // Validate MIME type server-side using finfo (do NOT trust client-reported type)
NEW
112
        $finfo = new \finfo(FILEINFO_MIME_TYPE);
×
NEW
113
        $detectedMime = $finfo->file($file->getRealPath());
×
NEW
114
        $allowedMimes = $allowedTypes[$extension];
×
115

NEW
116
        if (! in_array($detectedMime, $allowedMimes, true)) {
×
NEW
117
            Log::warning('File MIME mismatch: extension='.$extension.', detected='.$detectedMime);
×
118

NEW
119
            return ['valid' => false, 'error' => 'File MIME type ('.$detectedMime.') does not match expected type for .'.$extension.'.'];
×
120
        }
121

122
        // Check for embedded PHP code in the file contents
NEW
123
        $contents = file_get_contents($file->getRealPath());
×
NEW
124
        if ($contents !== false && self::containsPhpCode($contents)) {
×
NEW
125
            Log::warning('Blocked file upload containing PHP code');
×
126

NEW
127
            return ['valid' => false, 'error' => 'File contains potentially dangerous content.'];
×
128
        }
129

NEW
130
        return ['valid' => true, 'error' => null];
×
131
    }
132

133
    /**
134
     * Validate a base64-decoded image for security.
135
     * Checks the extracted extension against blocklist and allowed list,
136
     * validates the decoded binary data MIME type via finfo, and checks size.
137
     *
138
     * @param  string  $dataUri  The full data URI (e.g. "data:image/png;base64,iVBOR...")
139
     * @return array ['valid' => bool, 'error' => string|null, 'extension' => string|null, 'data' => string|null]
140
     */
NEW
141
    public static function validateBase64Image(string $dataUri): array
×
142
    {
143
        // Parse the data URI -- expected format: "data:image/png;base64,XXXX"
NEW
144
        $parts = explode(';', $dataUri, 2);
×
NEW
145
        if (count($parts) < 2) {
×
NEW
146
            return ['valid' => false, 'error' => 'Invalid data URI format.', 'extension' => null, 'data' => null];
×
147
        }
148

149
        // Extract the MIME type from the data URI header (e.g. "data:image/png")
NEW
150
        $typePart = $parts[0];
×
NEW
151
        $mimeFromUri = '';
×
NEW
152
        if (strpos($typePart, '/') !== false) {
×
NEW
153
            $colonPos = strpos($typePart, ':');
×
NEW
154
            if ($colonPos !== false) {
×
NEW
155
                $mimeFromUri = substr($typePart, $colonPos + 1);
×
156
            }
157
        }
158

159
        // Extract the extension from the MIME type
NEW
160
        $extension = '';
×
NEW
161
        if (strpos($mimeFromUri, '/') !== false) {
×
NEW
162
            [, $extension] = explode('/', $mimeFromUri, 2);
×
163
        }
NEW
164
        $extension = strtolower(trim($extension));
×
165

166
        // Normalize jpeg -> jpg for consistency
NEW
167
        $lookupExt = ($extension === 'jpeg') ? 'jpg' : $extension;
×
168

169
        // Check blocked extensions
NEW
170
        if (self::isExtensionBlocked($extension) || self::isExtensionBlocked($lookupExt)) {
×
NEW
171
            Log::warning('Blocked base64 upload attempt with dangerous type: '.$extension);
×
172

NEW
173
            return ['valid' => false, 'error' => 'File type is not allowed (blocked type: '.$extension.').', 'extension' => null, 'data' => null];
×
174
        }
175

176
        // Check allowed image types
NEW
177
        if (! array_key_exists($lookupExt, self::$allowedImageTypes)) {
×
NEW
178
            return ['valid' => false, 'error' => 'Image type .'.$lookupExt.' is not permitted. Allowed: jpg, png, gif, webp.', 'extension' => null, 'data' => null];
×
179
        }
180

181
        // Decode the base64 data
NEW
182
        $dataPart = $parts[1];
×
NEW
183
        if (strpos($dataPart, ',') === false) {
×
NEW
184
            return ['valid' => false, 'error' => 'Invalid base64 data format.', 'extension' => null, 'data' => null];
×
185
        }
NEW
186
        [, $base64Data] = explode(',', $dataPart, 2);
×
NEW
187
        $decodedData = base64_decode($base64Data, true);
×
188

NEW
189
        if ($decodedData === false) {
×
NEW
190
            return ['valid' => false, 'error' => 'Failed to decode base64 data.', 'extension' => null, 'data' => null];
×
191
        }
192

193
        // Check file size (max 5MB)
NEW
194
        if (strlen($decodedData) > self::$maxFileSize) {
×
NEW
195
            return ['valid' => false, 'error' => 'Image exceeds maximum size of 5MB.', 'extension' => null, 'data' => null];
×
196
        }
197

198
        // Validate actual MIME type of the decoded binary data using finfo
NEW
199
        $finfo = new \finfo(FILEINFO_MIME_TYPE);
×
NEW
200
        $detectedMime = $finfo->buffer($decodedData);
×
201

NEW
202
        $acceptableMimes = self::$allowedImageTypes[$lookupExt] ?? [];
×
NEW
203
        if (! in_array($detectedMime, $acceptableMimes, true)) {
×
NEW
204
            Log::warning('Base64 image MIME mismatch: claimed='.$extension.', detected='.$detectedMime);
×
205

NEW
206
            return ['valid' => false, 'error' => 'Detected MIME type ('.$detectedMime.') does not match claimed image type ('.$lookupExt.').', 'extension' => null, 'data' => null];
×
207
        }
208

209
        // Check for embedded PHP code
NEW
210
        if (self::containsPhpCode($decodedData)) {
×
NEW
211
            Log::warning('Blocked base64 image upload containing PHP code');
×
212

NEW
213
            return ['valid' => false, 'error' => 'Image contains potentially dangerous content.', 'extension' => null, 'data' => null];
×
214
        }
215

NEW
216
        return ['valid' => true, 'error' => null, 'extension' => $lookupExt, 'data' => $decodedData];
×
217
    }
218

219
    /**
220
     * Sanitize a path segment (e.g. a public address) for safe use in file paths.
221
     * Prevents directory traversal attacks by stripping path separators and
222
     * enforcing an alphanumeric-only pattern.
223
     *
224
     * @return string|null Returns sanitized string or null if invalid.
225
     */
NEW
226
    public static function sanitizePathSegment(string $input): ?string
×
227
    {
228
        // Use basename to strip any directory components
NEW
229
        $sanitized = basename($input);
×
230
        // Only allow alphanumeric characters, underscores, and hyphens
NEW
231
        if (! preg_match('/^[a-zA-Z0-9_\-]+$/', $sanitized) || empty($sanitized)) {
×
NEW
232
            return null;
×
233
        }
234

NEW
235
        return $sanitized;
×
236
    }
237

238
    /**
239
     * Validate a Marscoin address format (Base58Check starting with 'M').
240
     * Valid Marscoin addresses are 25-35 characters, start with 'M',
241
     * and only use Base58 characters (no 0, O, I, l).
242
     */
243
    public static function isValidMarscoinAddress(string $address): bool
2✔
244
    {
245
        return (bool) preg_match('/^M[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{24,34}$/', $address);
2✔
246
    }
247

248
    /**
249
     * Validate an IPFS CID format (CIDv0 starting with 'Qm' or CIDv1 starting with 'b').
250
     */
NEW
251
    public static function isValidCID(string $cid): bool
×
252
    {
253
        // CIDv0: starts with Qm, 46 chars total, Base58
NEW
254
        $cidv0 = '/^Qm[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{44}$/';
×
255
        // CIDv1: starts with b, base32 encoded
NEW
256
        $cidv1 = '/^b[a-z2-7]{58,}$/';
×
257

NEW
258
        return (bool) (preg_match($cidv0, $cid) || preg_match($cidv1, $cid));
×
259
    }
260

261
    /**
262
     * Check if content contains PHP code markers that could be executed
263
     * if the file is somehow served through the web server.
264
     *
265
     * @return bool True if content contains PHP code indicators.
266
     */
NEW
267
    public static function containsPhpCode(string $content): bool
×
268
    {
269
        // Only check the first 256 bytes (header area) for PHP signatures
270
        // Binary image data often contains random byte sequences that match
271
        // PHP patterns, causing false positives on valid camera photos
NEW
272
        $header = substr($content, 0, 256);
×
NEW
273
        $patterns = ['<?php', '<?=', '<script language="php"', '<script language=\'php\''];
×
NEW
274
        foreach ($patterns as $pattern) {
×
NEW
275
            if (stripos($header, $pattern) !== false) {
×
NEW
276
                return true;
×
277
            }
278
        }
279

NEW
280
        return false;
×
281
    }
282

283
    /**
284
     * Write an .htaccess file in the upload directory to prevent PHP execution.
285
     * This is a defense-in-depth measure.
286
     */
NEW
287
    public static function writeUploadHtaccess(string $directory): void
×
288
    {
NEW
289
        $htaccessPath = rtrim($directory, '/').'/.htaccess';
×
NEW
290
        if (! file_exists($htaccessPath)) {
×
NEW
291
            $htaccessContent = "# Prevent PHP execution in upload directories\n";
×
NEW
292
            $htaccessContent .= "php_flag engine off\n";
×
NEW
293
            $htaccessContent .= "<FilesMatch \"\\.(php|phtml|php5|php7|php8|phar|phps|cgi|pl|py|sh|asp|aspx|jsp)$\">\n";
×
NEW
294
            $htaccessContent .= "    Require all denied\n";
×
NEW
295
            $htaccessContent .= "</FilesMatch>\n";
×
NEW
296
            $htaccessContent .= "AddHandler default-handler .php .phtml .php5 .phar\n";
×
NEW
297
            @file_put_contents($htaccessPath, $htaccessContent);
×
298
        }
299
    }
300

NEW
301
    public static function ago($time)
×
302
    {
NEW
303
        $periods = ['second', 'minute', 'hour', 'day', 'week', 'month', 'year', 'decade'];
×
NEW
304
        $lengths = ['60', '60', '24', '7', '4.35', '12', '10'];
×
305

NEW
306
        $now = time();
×
307

NEW
308
        $difference = $now - $time;
×
NEW
309
        $tense = 'ago';
×
310

NEW
311
        for ($j = 0; $difference >= $lengths[$j] && $j < count($lengths) - 1; $j++) {
×
NEW
312
            $difference /= $lengths[$j];
×
313
        }
314

NEW
315
        $difference = round($difference);
×
316

NEW
317
        if ($difference != 1) {
×
NEW
318
            $periods[$j] .= 's';
×
319
        }
320

NEW
321
        return "$difference $periods[$j] ago ";
×
322
    }
323

NEW
324
    public static function stats()
×
325
    {
NEW
326
        $array['coincount'] = 35000000;
×
NEW
327
        $json = AppHelper::file_get_contents_curl(config('blockchain.explorer.primary_url').'/api/status?q=getInfo');
×
NEW
328
        $array['network'] = json_decode($json, true);
×
NEW
329
        $json2 = AppHelper::file_get_contents_curl(config('blockchain.explorer.primary_url').'/api/status?q=getTxOutSetInfo');
×
NEW
330
        $total = json_decode($json2, true);
×
NEW
331
        if ($total && count($total) > 0) {
×
NEW
332
            $array['coincount'] = round($total['txoutsetinfo']['total_amount'], 2);
×
333
        }
334

NEW
335
        return $array;
×
336
    }
337

338
    public static function file_get_contents_curl($url, $timeout = 10)
1✔
339
    {
340
        $ch = curl_init();
1✔
341

342
        curl_setopt($ch, CURLOPT_AUTOREFERER, true);
1✔
343
        curl_setopt($ch, CURLOPT_HEADER, 0);
1✔
344
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-CMC_PRO_API_KEY: cf191ba7-4840-4a9a-bee4-617608afd8a4']);
1✔
345
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
1✔
346
        curl_setopt($ch, CURLOPT_URL, $url);
1✔
347
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
1✔
348
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
1✔
349
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
1✔
350

351
        $data = curl_exec($ch);
1✔
352
        curl_close($ch);
1✔
353

354
        return $data;
1✔
355
    }
356

NEW
357
    public static function uploadFile($name, $url)
×
358
    {
359

NEW
360
        $postField = [];
×
NEW
361
        $tmpfile = $_FILES[$name]['tmp_name'];
×
NEW
362
        $filename = basename($_FILES[$name]['name']);
×
NEW
363
        $postField['files'] = curl_file_create($tmpfile, $_FILES[$name]['type'], $filename);
×
NEW
364
        $headers = ['Content-Type' => 'multipart/form-data'];
×
NEW
365
        $curl_handle = curl_init();
×
NEW
366
        curl_setopt($curl_handle, CURLOPT_URL, $url);
×
367

NEW
368
        curl_setopt($curl_handle, CURLOPT_HTTPHEADER, $headers);
×
NEW
369
        curl_setopt($curl_handle, CURLOPT_POST, true);
×
NEW
370
        curl_setopt($curl_handle, CURLOPT_POSTFIELDS, $postField);
×
NEW
371
        curl_setopt($curl_handle, CURLOPT_RETURNTRANSFER, true);
×
NEW
372
        $returned_fileName = curl_exec($curl_handle);
×
NEW
373
        curl_close($curl_handle);
×
374

NEW
375
        return json_decode($returned_fileName);
×
376
    }
377

NEW
378
    public static function upload($filepath, $url)
×
379
    {
NEW
380
        $filename = realpath($filepath);
×
NEW
381
        if (! $filename || ! str_starts_with($filename, public_path()) && ! str_starts_with($filename, storage_path())) {
×
NEW
382
            throw new \RuntimeException('Upload path outside allowed directories');
×
383
        }
NEW
384
        $finfo = new \finfo(FILEINFO_MIME_TYPE);
×
NEW
385
        $mimetype = $finfo->file($filename);
×
NEW
386
        $ch = curl_init($url);
×
NEW
387
        $cfile = curl_file_create($filename, $mimetype, basename($filename));
×
NEW
388
        $data = ['file' => $cfile];
×
NEW
389
        $headers = ['Content-Type' => $mimetype];
×
NEW
390
        curl_setopt($ch, CURLOPT_URL, $url);
×
NEW
391
        curl_setopt($ch, CURLOPT_POST, 1);
×
NEW
392
        curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
×
NEW
393
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
×
NEW
394
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
×
NEW
395
        $result = curl_exec($ch);
×
NEW
396
        $r = curl_getinfo($ch);
×
NEW
397
        if ($r['http_code'] != 200) {
×
NEW
398
            $detais = json_decode($result, true);
×
NEW
399
            if (isset($detais['msg'])) {
×
NEW
400
                throw new \Exception($detais['msg'], 1);
×
401
            } else {
NEW
402
                return 'Error';
×
403
            }
404
        }
NEW
405
        $details = json_decode($result, true);
×
NEW
406
        $res = [];
×
407

NEW
408
        return $details['Hash'];
×
409
    }
410

NEW
411
    public static function uploadFolder($filepath, $url)
×
412
    {
NEW
413
        if (! is_dir($filepath) || ! is_readable($filepath)) {
×
NEW
414
            throw new \Exception('Directory is not accessible');
×
415
        }
416

NEW
417
        $files = scandir($filepath);
×
NEW
418
        $data = [];
×
NEW
419
        $headers = ['Content-Type: multipart/form-data'];
×
NEW
420
        $ch = curl_init($url);
×
421

NEW
422
        foreach ($files as $i => $filep) {
×
NEW
423
            $filename = realpath($filepath.'/'.$filep);
×
NEW
424
            if (! $filename || (! str_starts_with($filename, public_path()) && ! str_starts_with($filename, storage_path()))) {
×
NEW
425
                continue;
×
426
            }
NEW
427
            if (! is_file($filename)) {
×
NEW
428
                continue;
×
429
            }
430

NEW
431
            $finfo = new \finfo(FILEINFO_MIME_TYPE);
×
NEW
432
            $mimetype = $finfo->file($filename);
×
NEW
433
            $cfile = curl_file_create($filename, $mimetype, basename($filename));
×
NEW
434
            $data['file['.$i.']'] = $cfile;
×
435
        }
436

NEW
437
        curl_setopt_array($ch, [
×
NEW
438
            CURLOPT_URL => $url,
×
NEW
439
            CURLOPT_POST => true,
×
NEW
440
            CURLOPT_POSTFIELDS => $data,
×
NEW
441
            CURLOPT_HTTPHEADER => $headers,
×
NEW
442
            CURLOPT_RETURNTRANSFER => true,
×
NEW
443
        ]);
×
444

NEW
445
        $result = curl_exec($ch);
×
NEW
446
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
×
NEW
447
        curl_close($ch);
×
448

NEW
449
        if ($httpCode < 200 || $httpCode >= 300) {
×
NEW
450
            $details = json_decode($result, true);
×
NEW
451
            $errorMsg = $details['msg'] ?? 'Unknown error occurred';
×
NEW
452
            throw new \Exception($errorMsg);
×
453
        }
454

455
        // print_r($result);
456
        // die;
457
        // Prepare the result string by wrapping with brackets and replacing the '}{'
458
        // between JSON objects with '},{', which creates a JSON array
NEW
459
        $resultArrayString = '['.preg_replace('/}\s*{/', '},{', $result).']';
×
460

461
        // Decode the JSON array string
NEW
462
        $jsonResult = json_decode($resultArrayString, true);
×
463

NEW
464
        if (json_last_error() !== JSON_ERROR_NONE) {
×
NEW
465
            throw new \Exception('Invalid JSON returned from API');
×
466
        }
467

468
        // Now $jsonResult is a proper array
469
        // Search for the folder hash by finding the object with an empty "Name"
NEW
470
        $folderHash = '';
×
NEW
471
        foreach ($jsonResult as $item) {
×
NEW
472
            if ($item['Name'] === '') {
×
NEW
473
                $folderHash = $item['Hash'];
×
NEW
474
                break; // No need to continue if the folder hash is found
×
475
            }
476
        }
477

478
        // If the folder hash was found, return it as a JSON object
NEW
479
        if ($folderHash !== '') {
×
NEW
480
            return $folderHash;
×
481
        } else {
482
            // Handle the case where no folder hash was found
NEW
483
            throw new \Exception('Folder hash not found in API response');
×
484
        }
485

486
    }
487

NEW
488
    public static function file_post_content($url, $data)
×
489
    {
490

NEW
491
        $postdata = http_build_query($data);
×
492

NEW
493
        $opts = ['http' => [
×
NEW
494
            'method' => 'POST',
×
NEW
495
            'header' => 'Content-Type: application/x-www-form-urlencoded',
×
NEW
496
            'content' => $postdata,
×
NEW
497
        ],
×
NEW
498
        ];
×
499

NEW
500
        $context = stream_context_create($opts);
×
501

NEW
502
        $result = file_get_contents($url, false, $context);
×
503

NEW
504
        return $result;
×
505
    }
506

NEW
507
    public static function getUserFromCache($address)
×
508
    {
NEW
509
        $user = [];
×
NEW
510
        $file_path = './assets/citizen/'.$address.'/';
×
511

NEW
512
        if (! file_exists($file_path)) {
×
NEW
513
            mkdir($file_path);
×
NEW
514
            AppHelper::addUserToLocalCache($address);
×
515
        }
516

NEW
517
        $json_string = file_get_contents($file_path.'data.json');
×
NEW
518
        $user['data'] = json_decode($json_string);
×
NEW
519
        $user['pic'] = '/assets/citizen/'.$address.'/profile_pic.png';
×
NEW
520
        $user['vid'] = '/assets/citizen/'.$address.'/profile_video.webm';
×
521

NEW
522
        return $user;
×
523
    }
524

525
    /**
526
     * @todo Pulls in user data from IPFS links as per blockchain
527
     */
NEW
528
    public static function addUserToLocalCache($address)
×
529
    {
530
        // find user's GP transaction in cache
NEW
531
        $transaction_gp = Feed::where('address', '=', $address)->where('tag', '=', 'GP')->first();
×
532
        // pull up transaction using blockchain explorer
NEW
533
        $json = AppHelper::file_get_contents_curl(config('blockchain.explorer.fallback_url').'/api/tx/'.$transaction_gp['txid']);
×
NEW
534
        if ($json) {
×
NEW
535
            $tx = json_decode($json);
×
NEW
536
            $op_return = $tx->vout[0]->scriptPubKey->asm;
×
NEW
537
            $parts = explode(' ', $op_return);
×
NEW
538
            if (count($parts) > 0) {
×
NEW
539
                $ipfs_gp_hash = AppHelper::hex2str($parts[1]);
×
NEW
540
                $p = explode('_', $ipfs_gp_hash);
×
NEW
541
                if (count($p) > 0) {
×
NEW
542
                    $ipfs_hash = $p[1];
×
NEW
543
                    $data = AppHelper::file_get_contents_curl(config('blockchain.ipfs.gateway_url').$ipfs_hash);
×
NEW
544
                    $sanitizedAddr = preg_replace('/[^A-Za-z0-9]/', '', $address);
×
NEW
545
                    $file_path = public_path("assets/citizen/{$sanitizedAddr}/data.json");
×
NEW
546
                    if (! is_dir(dirname($file_path))) {
×
NEW
547
                        mkdir(dirname($file_path), 0755, true);
×
548
                    }
NEW
549
                    file_put_contents($file_path, $data);
×
NEW
550
                    $d = json_decode($data);
×
551

NEW
552
                    $img = AppHelper::file_get_contents_curl($d->data->picture);
×
NEW
553
                    $file_path = public_path("assets/citizen/{$sanitizedAddr}/profile_pic.png");
×
NEW
554
                    file_put_contents($file_path, $img);
×
555
                }
556
            }
557

558
        }
559

560
    }
561

562
    /**
563
     * Helper function keeping a local cache of the Marscoin blockchain embedded data feed
564
     * as it pertains to MartianRepublic protocol anchors.
565
     */
566
    public static function insertBlockchainCache($address, $uid, $action_tag, $message, $embedded_link, $txid)
2✔
567
    {
568
        // First, check if a duplicate entry exists
569
        $existingFeed = Feed::where('address', $address)
2✔
570
            ->where('tag', $action_tag)
2✔
571
            ->where('txid', $txid)
2✔
572
            ->first();
2✔
573

574
        if ($existingFeed) {
2✔
575
            // If a duplicate is found, return the txid
NEW
576
            return $existingFeed->txid;
×
577
        }
578

579
        // If no duplicate, proceed to create a new entry
580
        $feed = new Feed;
2✔
581
        if ($uid) {
2✔
582
            $feed->userid = $uid;
2✔
583
        }
584
        if (! $action_tag) {
2✔
NEW
585
            return false;
×
586
        }
587
        $feed->tag = $action_tag;
2✔
588
        $feed->address = $address;
2✔
589
        $feed->message = $message;
2✔
590
        $feed->embedded_link = $embedded_link;
2✔
591
        $feed->txid = $txid;
2✔
592
        $feed->save();
2✔
593

594
        return true;
2✔
595
    }
596

NEW
597
    public static function insertPublicationCache($uid, $local_path, $ipfs_hash, $title)
×
598
    {
NEW
599
        $pub = new Publication;
×
NEW
600
        if ($uid) {
×
NEW
601
            $pub->userid = $uid;
×
NEW
602
            $pub->ipfs_hash = $ipfs_hash;
×
NEW
603
            $pub->local_path = $local_path;
×
NEW
604
            $pub->title = $title;
×
NEW
605
            $pub->save();
×
606

NEW
607
            return true;
×
608
        }
609
    }
610

NEW
611
    public static function time_elapsed_string($datetime, $full = false)
×
612
    {
NEW
613
        date_default_timezone_set('America/New_York');
×
NEW
614
        $now = new DateTime;
×
NEW
615
        $ago = new DateTime($datetime);
×
NEW
616
        $diff = $now->diff($ago);
×
617

NEW
618
        $diff->w = floor($diff->d / 7);
×
NEW
619
        $diff->d -= $diff->w * 7;
×
620

NEW
621
        $string = [
×
NEW
622
            'y' => 'year',
×
NEW
623
            'm' => 'month',
×
NEW
624
            'w' => 'week',
×
NEW
625
            'd' => 'day',
×
NEW
626
            'h' => 'hour',
×
NEW
627
            'i' => 'minute',
×
NEW
628
            's' => 'second',
×
NEW
629
        ];
×
NEW
630
        foreach ($string as $k => &$v) {
×
NEW
631
            if ($diff->$k) {
×
NEW
632
                $v = $diff->$k.' '.$v.($diff->$k > 1 ? 's' : '');
×
633
            } else {
NEW
634
                unset($string[$k]);
×
635
            }
636
        }
637

NEW
638
        if (! $full) {
×
NEW
639
            $string = array_slice($string, 0, 1);
×
640
        }
641

NEW
642
        return $string ? implode(', ', $string).' ago' : 'just now';
×
643
    }
644

NEW
645
    public static function days_elapsed_string($datetime, $full = false)
×
646
    {
NEW
647
        date_default_timezone_set('America/New_York');
×
NEW
648
        $now = new DateTime;
×
NEW
649
        $ago = new DateTime($datetime);
×
NEW
650
        $diff = $now->diff($ago);
×
651

NEW
652
        return $diff->days;
×
653
    }
654

NEW
655
    public static function hex2str($hex)
×
656
    {
NEW
657
        $str = '';
×
NEW
658
        for ($i = 0; $i < strlen($hex); $i += 2) {
×
NEW
659
            $str .= chr(hexdec(substr($hex, $i, 2)));
×
660
        }
661

NEW
662
        return $str;
×
663
    }
664

665
    // Function to get the price from CoinGecko with caching
NEW
666
    public static function getMarscoinPrice()
×
667
    {
NEW
668
        $url = config('blockchain.price.coingecko_url');
×
669

670
        // Use the Cache facade with the remember method
NEW
671
        $marsPriceData = Cache::remember('marscoin_price', 5, function () use ($url) {
×
672
            // Inside the closure, fetch the data from CoinGecko
673
            try {
NEW
674
                $response = file_get_contents($url);
×
675

NEW
676
                return json_decode($response);
×
NEW
677
            } catch (\Exception $e) {
×
678
                // Handle the exception if the API call fails
NEW
679
                return null;
×
680
            }
NEW
681
        });
×
682

NEW
683
        if ($marsPriceData) {
×
NEW
684
            return $marsPriceData->marscoin->usd;
×
685
        }
686

687
        // Handle the case where the API call was not successful or caching failed
NEW
688
        return 0;
×
689
    }
690

691
    public static function getMarscoinBalance($publicAddr)
1✔
692
    {
693
        $cacheKey = 'marscoin_balance_'.$publicAddr;
1✔
694

695
        // Cache for 2 minutes to reduce API load while staying reasonably fresh
696
        return Cache::remember($cacheKey, 120, function () use ($publicAddr) {
1✔
697
            // Primary: use local pebas (Electrum-based, more reliable)
698
            try {
699
                $ctx = stream_context_create(['http' => ['timeout' => 5]]);
1✔
700
                $response = Http::timeout(5)->get(config('blockchain.pebas.url')."/api/mars/balance?address={$publicAddr}")->body();
1✔
NEW
701
                if ($response) {
×
NEW
702
                    $data = json_decode($response, true);
×
NEW
703
                    if (isset($data['balance'])) {
×
NEW
704
                        return (float) $data['balance'];
×
705
                    }
706
                }
707
            } catch (\Exception $e) {
1✔
708
                // Fall through to explorer
709
            }
710

711
            // Fallback: explorer API (returns balance in satoshis)
712
            try {
713
                $response = Http::timeout(5)->get(config('blockchain.explorer.primary_url')."/api/addr/{$publicAddr}/balance")->body();
1✔
714
                if ($response !== false && is_numeric($response)) {
1✔
715
                    return $response * 0.00000001;
1✔
716
                }
NEW
717
            } catch (\Exception $e) {
×
718
                // Both failed
719
            }
720

721
            return 0;
1✔
722
        });
1✔
723
    }
724

NEW
725
    public static function getMarscoinTotalReceived($publicAddr)
×
726
    {
NEW
727
        $url = config('blockchain.explorer.primary_url')."/api/addr/{$publicAddr}/totalReceived";
×
NEW
728
        $cacheKey = 'marscoin_total_received_'.$publicAddr;
×
729

NEW
730
        $totalReceived = Cache::remember($cacheKey, 300, function () use ($url) {
×
731
            try {
NEW
732
                $response = file_get_contents($url);
×
733

NEW
734
                return $response; // Assuming the response is the total amount received
×
NEW
735
            } catch (\Exception $e) {
×
NEW
736
                return null;
×
737
            }
NEW
738
        });
×
739

NEW
740
        if ($totalReceived !== null) {
×
NEW
741
            return $totalReceived * 0.00000001; // Convert from satoshis to Marscoin if necessary
×
742
        }
743

NEW
744
        return null;
×
745
    }
746

NEW
747
    public static function getMarscoinTotalSent($publicAddr)
×
748
    {
NEW
749
        $url = config('blockchain.explorer.primary_url')."/api/addr/{$publicAddr}/totalSent";
×
NEW
750
        $cacheKey = 'marscoin_total_sent_'.$publicAddr;
×
751

NEW
752
        $totalSent = Cache::remember($cacheKey, 300, function () use ($url) {
×
753
            try {
NEW
754
                $response = file_get_contents($url);
×
755

NEW
756
                return $response; // Assuming the response is the total amount sent
×
NEW
757
            } catch (\Exception $e) {
×
NEW
758
                return null;
×
759
            }
NEW
760
        });
×
761

NEW
762
        if ($totalSent !== null) {
×
NEW
763
            return $totalSent * 0.00000001; // Convert from satoshis to Marscoin if necessary
×
764
        }
765

NEW
766
        return null;
×
767
    }
768

NEW
769
    public static function getMarscoinTotalAmount()
×
770
    {
NEW
771
        $cacheKey = 'marscoin_total_amount';
×
772

NEW
773
        $totalAmount = Cache::remember($cacheKey, 180, function () {
×
774
            // Primary: marscoin-cli (fastest, most reliable)
775
            try {
NEW
776
                $output = shell_exec(config('blockchain.rpc.cli_path').' -datadir='.config('blockchain.rpc.data_dir').' gettxoutsetinfo 2>/dev/null');
×
NEW
777
                if ($output) {
×
NEW
778
                    $data = json_decode($output, true);
×
NEW
779
                    if ($data && isset($data['total_amount'])) {
×
NEW
780
                        return round($data['total_amount'], 2);
×
781
                    }
782
                }
NEW
783
            } catch (\Exception $e) {
×
784
                // Fall through to explorer
785
            }
786

787
            // Fallback: explorer API
788
            try {
NEW
789
                $url = config('blockchain.explorer.primary_url').'/api/status?q=getTxOutSetInfo';
×
NEW
790
                $response = file_get_contents($url);
×
NEW
791
                $data = json_decode($response, true);
×
NEW
792
                if ($data && isset($data['txoutsetinfo']['total_amount'])) {
×
NEW
793
                    return round($data['txoutsetinfo']['total_amount'], 2);
×
794
                }
NEW
795
            } catch (\Exception $e) {
×
NEW
796
                return 39000000;
×
797
            }
798

NEW
799
            return 39000000;
×
NEW
800
        });
×
801

NEW
802
        return $totalAmount;
×
803
    }
804

NEW
805
    public static function getMarscoinNetworkInfo()
×
806
    {
NEW
807
        $url = config('blockchain.explorer.secondary_url').'/api/status?q=getInfo';
×
NEW
808
        $cacheKey = 'marscoin_network_info';
×
809

NEW
810
        $networkInfo = Cache::remember($cacheKey, 60, function () use ($url) {
×
811
            try {
NEW
812
                $response = file_get_contents($url);
×
NEW
813
                $data = json_decode($response, true);
×
NEW
814
                if (is_array($data)) {
×
NEW
815
                    return $data; // Return the network info if the response is valid
×
816
                }
NEW
817
                Log::debug('Set Network status cache');
×
NEW
818
            } catch (\Exception $e) {
×
NEW
819
                return []; // Return an empty array in case of an error
×
820
            }
821

NEW
822
            return []; // Also return an empty array if the API does not return a valid response
×
NEW
823
        });
×
824

NEW
825
        return $networkInfo;
×
826
    }
827

828
    /**
829
     * Check for recent posts in the forum.
830
     *
831
     * @return int Number of recent posts.
832
     */
NEW
833
    public static function checkForRecentPosts()
×
834
    {
835
        // Define the time frame for "recent" posts. For example, within the last 24 hours.
NEW
836
        $recentThreshold = Carbon::now()->subDay();
×
837

838
        // Count the number of posts created after the recent threshold.
NEW
839
        $recentPostsCount = Post::where('created_at', '>', $recentThreshold)->count();
×
840

NEW
841
        return $recentPostsCount;
×
842
    }
843

844
    /**
845
     * Determines the Citizen Status of a user.
846
     *
847
     * @param  int  $userId  The ID of the user.
848
     * @return object An associative array containing the 'status' and 'type'.
849
     */
NEW
850
    public static function getCitizenStatus(int $userId)
×
851
    {
852
        // Default status for users without a profile entry.
NEW
853
        $statusDetails = [
×
NEW
854
            'status' => 'Newcomer',
×
NEW
855
            'type' => 'NC',
×
NEW
856
        ];
×
857

858
        // Attempt to retrieve the user's profile.
NEW
859
        $profile = Profile::where('userid', $userId)->first();
×
860

NEW
861
        if ($profile) {
×
NEW
862
            if ($profile->has_application > 0) {
×
NEW
863
                $statusDetails = [
×
NEW
864
                    'status' => 'Applicant',
×
NEW
865
                    'type' => 'AP',
×
NEW
866
                ];
×
NEW
867
            } elseif ($profile->general_public > 0) {
×
NEW
868
                $statusDetails = [
×
NEW
869
                    'status' => 'General Public',
×
NEW
870
                    'type' => 'GP',
×
NEW
871
                ];
×
872
            }
873

874
            // Check if the user is a citizen.
NEW
875
            $isCitizen = Citizen::where('userid', $userId)->exists();
×
NEW
876
            if ($isCitizen) {
×
NEW
877
                $statusDetails = [
×
NEW
878
                    'status' => 'Citizen',
×
NEW
879
                    'type' => 'CT',
×
NEW
880
                ];
×
881
            }
882
        }
883

NEW
884
        return (object) $statusDetails;
×
885
    }
886

NEW
887
    public static function createSlug($id, $title)
×
888
    {
889
        // Step 1: Concatenate
NEW
890
        $combined = $id.'-'.$title;
×
891

892
        // Step 2: Lowercase
NEW
893
        $combined = strtolower($combined);
×
894

895
        // Step 3: Remove special characters
NEW
896
        $combined = preg_replace('/[^a-z0-9\s-]/', '', $combined);
×
897

898
        // Step 4: Trim whitespace
NEW
899
        $combined = trim($combined);
×
900

901
        // Step 5: Replace spaces and underscores with hyphens
NEW
902
        $combined = preg_replace('/[\s_]+/', '-', $combined);
×
903

904
        // Step 6: Ensure uniqueness (not implemented here, depends on context)
905

NEW
906
        return $combined;
×
907
    }
908
}
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