Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 209
0.00% covered (danger)
0.00%
0 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
ToolboxHelper
0.00% covered (danger)
0.00%
0 / 209
0.00% covered (danger)
0.00%
0 / 18
6642
0.00% covered (danger)
0.00%
0 / 1
 arrayMergeRecursiveDistinct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 validateRecaptcha
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
210
 stripCkEditorSchemesOnMediaInputs
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 assertNoInternalBoUrls
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 assertNoInternalUrls
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 mbReplace
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
90
 assertFieldIsUnique
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getOriginal
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 queryColumnWithLocales
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 dotStringToNestedArray
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 objectToArray
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 arrayToObject
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 validatePicture
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
182
 getUploadedFileFullPath
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 getUploadedFileMimeType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getClassesUsingNamespace
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getClassesUsingInterface
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getValidatedEnum
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace App\Lib\Helpers;
4
5use Carbon\Carbon;
6use Illuminate\Contracts\Database\Eloquent\Builder;
7use Illuminate\Database\Eloquent\Model;
8use Illuminate\Http\Request;
9use Illuminate\Support\Str;
10use Illuminate\Support\Arr;
11use Illuminate\Support\Facades\File;
12use Illuminate\Support\Facades\Http;
13use Illuminate\Support\Facades\Route;
14use Illuminate\Support\Facades\Storage;
15use Illuminate\Support\Facades\Validator;
16use Illuminate\Validation\Rule;
17use Illuminate\Validation\Rules\Enum;
18use Illuminate\Validation\ValidationException;
19use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
20use Symfony\Component\Finder\SplFileInfo;
21
22class ToolboxHelper
23{
24    /**
25     * @param array<int|string, mixed> $array1
26     * @param array<int|string, mixed> $array2
27     *
28     * @return array<int|string, mixed>
29     */
30    public static function arrayMergeRecursiveDistinct(array &$array1, array &$array2): array
31    {
32        $merged = $array1;
33        foreach ($array2 as $key => &$value) {
34            if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
35                $merged[$key] = self::arrayMergeRecursiveDistinct($merged[$key], $value);
36            } else {
37                $merged[$key] = $value;
38            }
39        }
40
41        return $merged;
42    }
43
44    /**
45     * Validate recaptcha
46     *
47     * @param \Illuminate\Http\Request $request
48     * @param boolean                  $isV3
49     * @return void
50     */
51    public static function validateRecaptcha(Request $request, bool $isV3 = true)
52    {
53        Validator::validate($request->all(), [
54            'g-recaptcha-response' => [
55                'required', 'string',
56                // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
57                function ($attribute, $value, $fail) use ($isV3) {
58                    try {
59                        $response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [
60                            'secret' => config('services.google.recaptcha.private'),
61                            'response' => \strval($value),
62                            'remoteip' => app('clientIp')
63                        ]);
64                        if ($e = $response->toException()) {
65                            throw $e;
66                        }
67                        if ($isV3) {
68                            if (
69                                !$response->json('success') or
70                                !$response->json('challenge_ts') or
71                                Carbon::parse($response->json('challenge_ts'))->lessThan(\now('utc')->subMinutes(5)) or
72                                $response->json('hostname') !== \parse_url(config('app.url'), \PHP_URL_HOST) or
73                                !$response->json('score')
74                            ) {
75                                throw new \RuntimeException();
76                            }
77                            if (\floatval($response->json('score')) < 0.8) {
78                                $fail("Vous n'avez pas obtenu un score assez élevé lors de la validation du recaptcha");
79                            }
80                            return;
81                        }
82                        if (
83                            !$response->json('success') or
84                            !$response->json('challenge_ts') or
85                            Carbon::parse($response->json('challenge_ts'))->lessThan(\now('utc')->subMinutes(5)) or
86                            $response->json('hostname') !== \parse_url(config('app.url'), \PHP_URL_HOST)
87                        ) {
88                            throw new \RuntimeException();
89                        }
90                    } catch (\Exception $e) {
91                        $fail('Erreur lors de la validation du formulaire veuillez réssayer');
92                    } //end try
93                }
94            ]
95        ], [], ['g-recaptcha-response' => 'reCaptcha']);
96    }
97
98    /**
99     * Strip scheme (https) on oembed urls
100     * Only embed allowed are for MP4 self hosted videos right now
101     *
102     * https is force by ckeditor MediaElements which natively supports
103     * only cloud hosted medias (youtube, spotify etc..)
104     *
105     * @param string $input
106     * @return string
107     */
108    public static function stripCkEditorSchemesOnMediaInputs(string $input): string
109    {
110        $input = \preg_replace('/(?<=<oembed url=")(https:\/\/)(?=.*?">)/m', '', $input);
111        return $input;
112    }
113
114    /**
115     * Assert That no Back Office url is present on txt
116     *
117     * @param string $field
118     * @param string $text
119     * @return string
120     * @throws \Illuminate\Validation\ValidationException If url is a bo url.
121     */
122    public static function assertNoInternalBoUrls(string $field, string $text): string
123    {
124        $routes = Route::getRoutes();
125        // * Url replace and email replace.
126        $text = preg_replace_callback(
127            // phpcs:ignore Generic.Files.LineLength.TooLong
128            '/(?P<url>(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/',
129            function ($matches) use ($routes, $field) {
130                $match = collect($matches)->first() ?? '';
131                $url   = $match;
132                try {
133                    $request = Request::create($url);
134                    // If a route is match then it do not throws exception.
135                    if (
136                        \parse_url(config('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST) &&
137                        trim($routes->match($request)->getPrefix() ?? '', '/') === 'bo'
138                    ) {
139                        // Removes the matched url.
140                        throw ValidationException::withMessages([
141                            $field => trans(
142                                'crud.libs.toolbox.assertNoInternalBoUrls',
143                                ['attribute' => $field]
144                            )
145                        ]);
146                    }
147                    return $match;
148                } catch (NotFoundHttpException $e) {
149                    return $match;
150                }
151            },
152            $text
153        );
154        return $text;
155    }
156
157    /**
158     * Assert That only external url is present on txt
159     *
160     * @param string $field
161     * @param string $text
162     * @return string
163     * @throws \Illuminate\Validation\ValidationException If url is a bo url.
164     */
165    public static function assertNoInternalUrls(string $field, string $text): string
166    {
167        self::assertNoInternalBoUrls($field, $text);
168        $routes = Route::getRoutes();
169        // * Url replace and email replace.
170        $text = preg_replace_callback(
171            // phpcs:ignore Generic.Files.LineLength.TooLong
172            '/(?P<url>(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/',
173            function ($matches) use ($routes, $field) {
174                $match = collect($matches)->first() ?? '';
175                $url   = $match;
176                try {
177                    $request = Request::create($url);
178                    // If a route is match then it do not throws exception.
179                    if (
180                        \parse_url(config('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST) &&
181                        $routes->match($request)
182                    ) {
183                        // Removes the matched url.
184                        throw ValidationException::withMessages([
185                            $field => trans(
186                                'crud.libs.toolbox.assertNoInternalUrls',
187                                ['attribute' => $field, 'appname' => config('app.name')]
188                            )
189                        ]);
190                    }
191                    return $match;
192                } catch (NotFoundHttpException $e) {
193                    return $match;
194                }
195            },
196            $text
197        );
198        return $text;
199    }
200
201    /**
202     * Mutltibyte string replace
203     *
204     * @param array|string $search
205     * @param array|string $replace
206     * @param array|string $subject
207     * @param integer      $count
208     * @return array|string
209     */
210    public static function mbReplace(
211        array|string $search,
212        array|string $replace,
213        array|string $subject,
214        int &$count = 0
215    ): array|string {
216        if (!is_array($search) && is_array($replace)) {
217            return false;
218        }
219        if (is_array($subject)) {
220            // Call mb_replace for each single string in $subject .
221            foreach ($subject as &$string) {
222                $string = &self::mbReplace($search, $replace, $string, $count);
223            }
224        } elseif (is_array($search)) {
225            if (!is_array($replace)) {
226                foreach ($search as &$string) {
227                    $subject = self::mbReplace($string, $replace, $subject, $count);
228                }
229            } else {
230                $n = max(count($search), count($replace));
231                while ($n--) {
232                    $subject = self::mbReplace(current($search), current($replace), $subject, $count);
233                    next($search);
234                    next($replace);
235                }
236            }
237        } else {
238            $parts   = mb_split(preg_quote($search), $subject);
239            $count   = count($parts) - 1;
240            $subject = implode($replace, $parts);
241        } //end if
242        return $subject;
243    }
244
245    /**
246     * Asserts that the field is unique
247     *
248     * @param string                                  $table
249     * @param string                                  $field
250     * @param string|integer|float|boolean|null|array $value
251     * @param integer|null                            $id
252     * @param callable|null                           $query
253     * @return void
254     * @throws \Illuminate\Validation\ValidationException If the field is not unique.
255     */
256    public static function assertFieldIsUnique(
257        string $table,
258        string $field,
259        string|int|float|bool|null|array $value,
260        ?int $id = null,
261        ?callable $query = null
262    ) {
263        if (!$query) {
264            Validator::make([$field => $value], [
265                self::mbReplace('.', '\.', $field) => Rule::unique($table, $field)->ignore($id),
266            ], [
267                self::mbReplace('.', '\.', "{$field}.unique")
268                => trans('crud.libs.toolbox.assertFieldIsUnique', ['attribute' => $field, 'value' => $value])
269            ])->validate();
270            return;
271        }
272        Validator::make([$field => $value], [
273            $field => Rule::unique($table)->where($query)->ignore($id),
274        ], [
275            $field . '.unique' => trans(
276                'crud.libs.toolbox.assertFieldIsUnique',
277                ['attribute' => $field, 'value' => $value]
278            )
279        ])->validate();
280    }
281
282    /**
283     * Get original polyfill with autotranslate behavior
284     *
285     * @param \Illuminate\Database\Eloquent\Model $model
286     * @param string                              $attribute
287     * @return mixed
288     */
289    public static function getOriginal(Model $model, string $attribute): mixed
290    {
291        if (\in_array('Spatie\Translatable\HasTranslations', \class_uses_recursive($model))) {
292            return $model->getTranslation(
293                $attribute,
294                $model->getLocale(),
295                $model->useFallbackLocale()
296            );
297        }
298        return $model->getOriginal($attribute);
299    }
300
301    /**
302     * Dynamic model query
303     *
304     * @param string                                          $defaultLocale
305     * @param \Illuminate\Contracts\Database\Eloquent\Builder $q
306     * @param string                                          $column
307     * @param string                                          $value
308     * @return \Illuminate\Contracts\Database\Eloquent\Builder
309     */
310    public static function queryColumnWithLocales(
311        string $defaultLocale,
312        Builder $q,
313        string $column,
314        string $value
315    ): Builder {
316        $currentLocale = app()->currentLocale();
317        $locales       = collect(config('app.locales'))
318            ->mapWithKeys(fn (string $locale) => [$locale => $locale]);
319        if (!$locales->has($defaultLocale)) {
320            // * Remove default locale.
321            $locales->pull($defaultLocale);
322        }
323        if (!$locales->has($currentLocale)) {
324            // * Remove current locale.
325            $locales->pull($currentLocale);
326        }
327
328        return $q->where(function (Builder $q) use ($defaultLocale, $currentLocale, $locales, $column, $value) {
329            // * Query default locale
330            $q->whereRaw("JSON_EXTRACT({$column}, '$.{$defaultLocale}') = '{$value}'");
331            if ($defaultLocale !== $currentLocale) {
332                // * Query current locale
333                $q->whereRaw("JSON_EXTRACT({$column}, '$.{$currentLocale}') = '{$value}'");
334            }
335            // * Query any other locale
336            $locales->each(fn ($locale) => $q->orWhereRaw(
337                "JSON_EXTRACT({$column}, '$.{$locale}') = '{$value}'"
338            ));
339        });
340    }
341
342    /**
343     * Generates an array from a dot separated string
344     *
345     * @param string $path
346     * @param mixed  $value
347     * @param string $separator
348     * @return array
349     */
350    public static function dotStringToNestedArray(string $path, mixed $value, string $separator = '.'): array
351    {
352        $arr  = $value;
353        $keys = array_reverse(explode($separator, $path));
354
355        foreach ($keys as $key) {
356            $arr = [$key => $arr];
357        }
358        return $arr;
359    }
360
361    /**
362     * Convert deeply an object to array
363     *
364     * @param array|object $input
365     * @return array
366     * @throws \RuntimeException If $input var is a scalar value.
367     */
368    public static function objectToArray($input): array
369    {
370        // Only process if it's an object or array being passed to the function.
371        if (is_object($input) or is_array($input)) {
372            $ret = (array)$input;
373            foreach ($ret as &$item) {
374                // Recursively process EACH element only if not scalar.
375                $item = (is_object($item) or is_array($item)) ?
376                    self::objectToArray($item) : $item;
377            }
378            return $ret;
379        } else {
380            throw new \RuntimeException('Cannot handle $input of type ' . \gettype($input));
381        }
382    }
383
384    /**
385     * Convert deeply an array to object
386     *
387     * @param array|object $input
388     * @return object
389     * @throws \RuntimeException If $input var is a scalar value.
390     */
391    public static function arrayToObject($input): object
392    {
393        // Only process if it's an object or array being passed to the function.
394        if (is_object($input) or is_array($input)) {
395            $ret = (object)$input;
396            foreach ($ret as &$item) {
397                // Recursively process EACH element only if not scalar.
398                $item = (is_object($item) or is_array($item)) ?
399                    self::arrayToObject($item) : $item;
400            }
401            return $ret;
402        } else {
403            throw new \RuntimeException('Cannot handle $input of type ' . \gettype($input));
404        }
405    }
406
407    /**
408     * Validate picture
409     *
410     * @param string      $attribute
411     * @param string|null $value
412     * @param callback    $fail
413     * @param object      $sizes
414     * @return void
415     */
416    public static function validatePicture(string $attribute, string|null $value, callable $fail, object $sizes)
417    {
418        if (!($useStorage = Storage::exists($value)) and !File::exists($value)) {
419            $fail(trans(':attribute n\'existe pas dans le système de fichier', [
420                'attribute' => $attribute
421            ]));
422            return;
423        }
424        /** @var string */
425        $fileFullPath = self::getUploadedFileFullPath($value, $useStorage);
426        if (
427            !\in_array(self::getUploadedFileMimeType($value, $useStorage), [
428                'image/jpeg', 'image/jpg', 'image/png', 'image/gif'
429            ])
430        ) {
431            $fail(trans(':attribute doit être une image de type \'image/jpeg,image/jpg,image/png,image/gif\'', [
432                'attribute' => $attribute
433            ]));
434        }
435        if (!($dimensions = \getimagesize($fileFullPath)) or !isset($dimensions[0]) or !isset($dimensions[1])) {
436            $fail(trans(':attribute impossible de valider les dimensions', [
437                'attribute' => $attribute
438            ]));
439        } else {
440            $width  = intval($dimensions[0]);
441            $height = intval($dimensions[1]);
442
443            $xMessage = trans(
444                ":attribute la largeur doit être comprise entre {$sizes->minWith}px et {$sizes->maxWith}px"
445            );
446            if ($sizes->minWith === $sizes->maxWith) {
447                $xMessage = trans(":attribute la largeur doit être de {$sizes->minWith}px");
448            }
449            $yMessage = trans(
450                ":attribute la hauteur doit être comprise entre {$sizes->minWith}px et {$sizes->maxWith}px"
451            );
452            if ($sizes->minHeight === $sizes->maxHeight) {
453                $yMessage = trans(":attribute la hauteur doit être de {$sizes->minWith}px");
454            }
455
456            if (($width < $sizes->minWith) or ($width > $sizes->maxWith)) {
457                $fail($xMessage);
458            }
459            if (($height < $sizes->minHeight) or ($height > $sizes->maxHeight)) {
460                $fail($yMessage);
461            }
462        } //end if
463    }
464
465    /**
466     * Get the file full path
467     *
468     * @param string  $value
469     * @param boolean $useStorage
470     * @return string
471     */
472    private static function getUploadedFileFullPath(string $value, bool $useStorage): string
473    {
474        /** @var \Illuminate\Filesystem\FilesystemAdapter */
475        $disk = Storage::disk();
476        return !$useStorage ? (realpath($value) ?: $value) : $disk->path($value);
477    }
478
479    /**
480     * Get the file mimetype
481     *
482     * @param string  $value
483     * @param boolean $useStorage
484     * @return string|false
485     */
486    private static function getUploadedFileMimeType(string $value, bool $useStorage)
487    {
488        return !$useStorage ? File::mimeType($value) : Storage::mimeType($value);
489    }
490
491    /**
492     * Fetch class list from a specific namespace
493     *
494     * @param string $namespace
495     * @return array<string> An array of classe namespaces
496     */
497    public static function getClassesUsingNamespace(string $namespace): array
498    {
499        return collect(File::files(\app_path(Str::of($namespace)->replaceFirst('\App', ''))))
500            ->filter(fn (SplFileInfo $splFileInfo) => $splFileInfo->getExtension() === 'php')
501            ->map(fn (SplFileInfo $splFileInfo) => Str::of($splFileInfo->getBasename())
502                ->replace('.' . $splFileInfo->getExtension(), '')->prepend("{$namespace}\\"))->all();
503    }
504
505    /**
506     * Get all classes from a specific namespace that implement a specific interface
507     *
508     * @param string $namespace
509     * @param string $interface
510     * @return array<string> An array of classe namespaces
511     */
512    public static function getClassesUsingInterface(string $namespace, string $interface): array
513    {
514        return Arr::where(
515            static::getClassesUsingNamespace($namespace),
516            fn (string $class_namespace) => \collect(class_implements($class_namespace, true))->filter()
517                ->contains(fn (string $class_interface) => $class_interface === $interface)
518        );
519    }
520
521    /**
522     * Get validated pagination from request.
523     *
524     * @param integer $default
525     * @param string  $field
526     * @param string  $enumPath
527     * @return integer
528     */
529    public static function getValidatedEnum(int $default, string $field, string $enumPath): int
530    {
531        try {
532            return \intval(Validator::make(
533                [$field => \request()->get($field)],
534                [$field => new Enum($enumPath)]
535            )->validated()[$field]);
536        } catch (ValidationException $e) {
537            return $default;
538        }
539    }
540}