From 656120f042517162dde073787648fc3c4f1d944f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Wed, 6 Apr 2022 10:36:10 +0200 Subject: [PATCH] TMP - Arcadia Xml Jobs WIP - Api WIP --- .env.example | 4 +- .env.testing.example | 47 + .gitignore | 3 + app/Console/Commands/LdcWebStatusChecker.php | 78 ++ app/Console/Commands/UserCreate.php | 49 + app/Console/Commands/XmlBrandReader.php | 37 + app/Console/Commands/XmlIncoReader.php | 4 +- app/Console/Kernel.php | 9 +- app/Enums/ApiError.php | 84 ++ app/Enums/ApiStatus.php | 16 + app/Exceptions/AbortXmlImportException.php | 4 +- app/Exceptions/Api/ApiErrorException.php | 101 ++ app/Exceptions/Api/ApplicationException.php | 25 + app/Exceptions/Handler.php | 143 ++- app/Exceptions/InvalidXmlException.php | 4 +- app/Http/Controllers/Api/V1/ApiController.php | 19 + .../Controllers/Api/V1/XmlIncoController.php | 58 ++ app/Http/Kernel.php | 10 +- app/Http/Middleware/ApiCheckTimeStamps.php | 38 + app/Http/Middleware/ApiResponse.php | 57 ++ app/Http/Middleware/JsonOnly.php | 24 + app/Http/Middleware/XssSanitizer.php | 25 + app/Http/Requests/Api/V1/PaginatorRequest.php | 31 + .../Requests/Api/V1/XmlIncosIndexRequest.php | 36 + .../Resources/Api/V1/ApiResponseResource.php | 42 + app/Http/Resources/Api/V1/XmlIncoResource.php | 66 ++ app/Jobs/{ => Arcadia}/XArcadiaFlux.php | 155 ++- app/Jobs/Arcadia/XmlBrandReaderJob.php | 55 + .../XmlIncoReaderJob.php} | 250 +++-- app/Jobs/{ => Arcadia}/XmlJobs.php | 294 +++--- app/Jobs/SetServiceStats.php | 81 ++ app/Lib/Helper.php | 180 ++++ app/Lib/Utils.php | 209 ---- app/Models/ArcadiaBrand.php | 90 ++ app/Models/Service.php | 224 ++++ app/Models/ServiceStat.php | 39 + app/Models/User.php | 18 + app/Models/XmlInco.php | 93 +- app/Policies/ArcadiaBrandPolicy.php | 52 + app/Policies/Rules/UserStaticRules.php | 32 + app/Providers/AppServiceProvider.php | 57 ++ app/Providers/AuthServiceProvider.php | 55 +- app/Providers/RouteServiceProvider.php | 19 +- composer.json | 33 +- composer.lock | 960 +++++++++++++++--- config/api.php | 30 + config/app.php | 10 +- config/auth.php | 10 +- config/exception-handler.php | 4 +- config/filesystems.php | 4 + config/ldcweb.php | 5 + config/mail.php | 5 +- config/trustedproxy.php | 58 ++ database/factories/ArcadiaBrandFactory.php | 21 + database/factories/ServiceFactory.php | 20 + database/factories/UserFactory.php | 4 +- database/factories/XmlIncoFactory.php | 105 ++ ..._05_144756_create_arcadia_brands_table.php | 35 + ...22_04_05_145011_create_services_table.php} | 15 +- ...055_create_arcadia_brand_service_table.php | 31 + ...2_04_19_102629_create_xml_incos_table.php} | 6 +- ...2022_04_19_145010_create_service_stats.php | 37 + database/seeders/DatabaseSeeder.php | 37 +- phpunit.xml | 48 +- .../documents/arcadia/marque.xsd | 0 resources/documents/inco/incollector.xsd | 270 +++++ resources/seeders/xml_inco/fakeimage1.png | Bin 0 -> 3118277 bytes resources/seeders/xml_inco/fakeimage2.png | Bin 0 -> 3257179 bytes .../{emails => mails}/invalidxml.blade.php | 6 +- routes/api.php | 26 +- routes/channels.php | 18 - routes/console.php | 19 - storage/.gitignore | 1 - storage/inco/xsd/incollector.xsd | 273 ----- .../{inco/xsd => marque/archives}/.gitkeep | 0 storage/marque/invalid/.gitkeep | 0 test.sh | 13 + tests/Api/V1/ApiBase.php | 77 ++ tests/Api/V1/ApiTest.php | 186 ++++ tests/Api/V1/XmlIncoTest.php | 161 +++ tests/Feature/ExampleTest.php | 20 - tests/Unit/ExampleTest.php | 18 - 82 files changed, 4411 insertions(+), 1072 deletions(-) create mode 100644 .env.testing.example create mode 100644 app/Console/Commands/LdcWebStatusChecker.php create mode 100644 app/Console/Commands/UserCreate.php create mode 100644 app/Console/Commands/XmlBrandReader.php create mode 100644 app/Enums/ApiError.php create mode 100644 app/Enums/ApiStatus.php create mode 100644 app/Exceptions/Api/ApiErrorException.php create mode 100644 app/Exceptions/Api/ApplicationException.php create mode 100644 app/Http/Controllers/Api/V1/ApiController.php create mode 100644 app/Http/Controllers/Api/V1/XmlIncoController.php create mode 100644 app/Http/Middleware/ApiCheckTimeStamps.php create mode 100644 app/Http/Middleware/ApiResponse.php create mode 100644 app/Http/Middleware/JsonOnly.php create mode 100644 app/Http/Middleware/XssSanitizer.php create mode 100644 app/Http/Requests/Api/V1/PaginatorRequest.php create mode 100644 app/Http/Requests/Api/V1/XmlIncosIndexRequest.php create mode 100644 app/Http/Resources/Api/V1/ApiResponseResource.php create mode 100644 app/Http/Resources/Api/V1/XmlIncoResource.php rename app/Jobs/{ => Arcadia}/XArcadiaFlux.php (58%) create mode 100644 app/Jobs/Arcadia/XmlBrandReaderJob.php rename app/Jobs/{XmlIncoReader.php => Arcadia/XmlIncoReaderJob.php} (72%) rename app/Jobs/{ => Arcadia}/XmlJobs.php (60%) create mode 100644 app/Jobs/SetServiceStats.php create mode 100644 app/Lib/Helper.php delete mode 100644 app/Lib/Utils.php create mode 100644 app/Models/ArcadiaBrand.php create mode 100644 app/Models/Service.php create mode 100644 app/Models/ServiceStat.php create mode 100644 app/Policies/ArcadiaBrandPolicy.php create mode 100644 app/Policies/Rules/UserStaticRules.php create mode 100644 config/api.php create mode 100644 config/ldcweb.php create mode 100644 config/trustedproxy.php create mode 100644 database/factories/ArcadiaBrandFactory.php create mode 100644 database/factories/ServiceFactory.php create mode 100644 database/factories/XmlIncoFactory.php create mode 100644 database/migrations/2022_04_05_144756_create_arcadia_brands_table.php rename database/migrations/{2019_12_14_000001_create_personal_access_tokens_table.php => 2022_04_05_145011_create_services_table.php} (54%) create mode 100644 database/migrations/2022_04_08_112055_create_arcadia_brand_service_table.php rename database/migrations/{2021_11_19_102629_create_xml_incos_table.php => 2022_04_19_102629_create_xml_incos_table.php} (88%) create mode 100644 database/migrations/2022_04_19_145010_create_service_stats.php rename storage/inco/.gitkeep => resources/documents/arcadia/marque.xsd (100%) create mode 100644 resources/documents/inco/incollector.xsd create mode 100644 resources/seeders/xml_inco/fakeimage1.png create mode 100644 resources/seeders/xml_inco/fakeimage2.png rename resources/views/{emails => mails}/invalidxml.blade.php (84%) delete mode 100644 routes/channels.php delete mode 100644 routes/console.php delete mode 100644 storage/.gitignore delete mode 100644 storage/inco/xsd/incollector.xsd rename storage/{inco/xsd => marque/archives}/.gitkeep (100%) create mode 100644 storage/marque/invalid/.gitkeep create mode 100755 test.sh create mode 100644 tests/Api/V1/ApiBase.php create mode 100644 tests/Api/V1/ApiTest.php create mode 100644 tests/Api/V1/XmlIncoTest.php delete mode 100644 tests/Feature/ExampleTest.php delete mode 100644 tests/Unit/ExampleTest.php diff --git a/.env.example b/.env.example index 11f5848..ee6c860 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ -APP_NAME=Laravel +APP_NAME=Incollector APP_ENV=local APP_KEY= APP_DEBUG=true -APP_URL=http://localhost +APP_URL=https://incollector.s-web.test LOG_CHANNEL=stack LOG_DEPRECATIONS_CHANNEL=null diff --git a/.env.testing.example b/.env.testing.example new file mode 100644 index 0000000..b4c3ae7 --- /dev/null +++ b/.env.testing.example @@ -0,0 +1,47 @@ +APP_NAME=Incollector +APP_ENV=testing +APP_KEY=base64:oRXNjgvWgIcboJN2O0NgaBt3kMUUfgyQ1Wv0S9/7sAA= +APP_DEBUG=true +APP_URL="http://127.0.0.1:8888" + +LOG_CHANNEL=stack +LOG_LEVEL=debug + +DB_CONNECTION=mysql +DB_HOST=dev-mariadb +DB_PORT=3306 +DB_DATABASE=testing-incollector +DB_USERNAME= +DB_PASSWORD= + +BROADCAST_DRIVER=log +CACHE_DRIVER=file +QUEUE_CONNECTION=database +SESSION_DRIVER=file +SESSION_LIFETIME=120 + +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=smtp +MAIL_HOST=mailhog +MAIL_PORT=1025 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS=incollector@ldc.fr +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= + +PUSHER_APP_ID= +PUSHER_APP_KEY= +PUSHER_APP_SECRET= +PUSHER_APP_CLUSTER=mt1 + +MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" +MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" diff --git a/.gitignore b/.gitignore index d30a3b9..356de13 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,11 @@ .vscode /storage/*.key +/storage/inco/* +/storage/marque/* /vendor .env +.env.testing .env.backup .phpunit.result.cache docker-compose.override.yml diff --git a/app/Console/Commands/LdcWebStatusChecker.php b/app/Console/Commands/LdcWebStatusChecker.php new file mode 100644 index 0000000..1ffec37 --- /dev/null +++ b/app/Console/Commands/LdcWebStatusChecker.php @@ -0,0 +1,78 @@ + 0) { + foreach ($files as $file) { + $data['logs'] = $data['logs'] + filesize($file); + } + } + + $data['code']['version'] = app()->version(); + + // Send by cURL. + $url = 'https://ldc-web.ldc.fr/api/status-checker'; + $ch = curl_init($url); + $payload = ['token' => 'CE8S52Qq7stf5k8frF4T', 'datas' => json_encode($data)]; + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_exec($ch); + curl_close($ch); + return 0; + } +} diff --git a/app/Console/Commands/UserCreate.php b/app/Console/Commands/UserCreate.php new file mode 100644 index 0000000..8de4f9f --- /dev/null +++ b/app/Console/Commands/UserCreate.php @@ -0,0 +1,49 @@ +ask('What is the firstname?'); + $lastname = $this->ask('What is the lastname?'); + $email = $this->ask('What is the email?'); + $pwd = $this->secret('What is the password?'); + + $user = new User(); + $user->firstname = $firstname; + $user->lastname = $lastname; + $user->email = $email; + $user->password = $pwd; + $user->saveOrFail(); + + $this->info('User created.'); + return 0; + } +} diff --git a/app/Console/Commands/XmlBrandReader.php b/app/Console/Commands/XmlBrandReader.php new file mode 100644 index 0000000..5ceae78 --- /dev/null +++ b/app/Console/Commands/XmlBrandReader.php @@ -0,0 +1,37 @@ +info('Running the Job, this could take some time.'); + $job = new XmlBrandReaderJob(); + $job->console = $this; + $job->handle(); + $this->info('Job completed'); + } +} diff --git a/app/Console/Commands/XmlIncoReader.php b/app/Console/Commands/XmlIncoReader.php index aee0898..b9b4620 100644 --- a/app/Console/Commands/XmlIncoReader.php +++ b/app/Console/Commands/XmlIncoReader.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use App\Jobs\XmlIncoReader as JobsXmlIncoReader; +use App\Jobs\Arcadia\XmlIncoReaderJob; use Illuminate\Console\Command; class XmlIncoReader extends Command @@ -29,7 +29,7 @@ class XmlIncoReader extends Command public function handle() { $this->info('Running the Job, this could take some time.'); - $job = new JobsXmlIncoReader(); + $job = new XmlIncoReaderJob(); $job->console = $this; $job->handle(); $this->info('Job completed'); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 940765a..fab7506 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,6 +2,7 @@ namespace App\Console; +use App\Console\Commands\LdcWebStatusChecker; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -12,8 +13,7 @@ class Kernel extends ConsoleKernel * * @var array */ - protected $commands = [ - ]; + protected $commands = []; /** * Define the application's command schedule. @@ -23,8 +23,7 @@ class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule) { - // $schedule->command('inspire')->hourly(); - // $schedule->job(new XmlIncoReader)->weeklyOn(1, '8:00'); + $schedule->command(LdcWebStatusChecker::class)->everyFifteenMinutes(); } /** @@ -35,7 +34,5 @@ class Kernel extends ConsoleKernel protected function commands() { $this->load(__DIR__ . '/Commands'); - - require base_path('routes/console.php'); } } diff --git a/app/Enums/ApiError.php b/app/Enums/ApiError.php new file mode 100644 index 0000000..b5c58e4 --- /dev/null +++ b/app/Enums/ApiError.php @@ -0,0 +1,84 @@ + 101, + 'validationFailed' => 102, + 'apiAuthFailed' => 119, + 'methodNotAllowed' => 150, + 'wrongTimeStamp' => 151, + 'missingParameter' => 400, + 'authorizationException' => 403, + 'throttleRequestsException' => 429 + ]; + } + + /** + * Libellés des possibilités de cet énumération (label) + * + * @return array + */ + public static function labels(): array + { + return [ + 'modelNotFound' => '%s introuvable.', + 'validationFailed' => 'Impossible de valider les données', + 'apiAuthFailed' => 'Echec de l\'authentification client à l\'Api LDC-CRM pour ce service', + 'methodNotAllowed' => 'This method is not allowed for this request', + 'wrongTimeStamp' => 'Your request is malformed, please check timestamp header and parameter', + 'missingParameter' => 'The request is missing some parameters', + 'authorizationException' => 'You are not autorized to access this', + 'throttleRequestsException' => 'Throttle: too many request have been attempted, please retry later' + ]; + } +} diff --git a/app/Enums/ApiStatus.php b/app/Enums/ApiStatus.php new file mode 100644 index 0000000..c6a87a9 --- /dev/null +++ b/app/Enums/ApiStatus.php @@ -0,0 +1,16 @@ +error = $error; + $this->message = $message; + $this->httpCode = $httpCode; + parent::__construct( + strlen($this->message) ? $this->message : $error->label, + $error->value, + $previous + ); + } + + /** + * Get the Api Error + * + * @return ApiError + */ + public function getApiError(): ApiError + { + return $this->error; + } + + /** + * Get the http code + * + * @return integer + */ + public function getStatusCode(): int + { + return $this->httpCode; + } + + /** + * This is used by laravel only. + * It uses Log class to print the error. + * + * @return void + */ + public function report() + { + // @codeCoverageIgnoreStart + Log::debug(sprintf('CRM API: %s', $this->getMessage())); + // @codeCoverageIgnoreEnd + } +} diff --git a/app/Exceptions/Api/ApplicationException.php b/app/Exceptions/Api/ApplicationException.php new file mode 100644 index 0000000..06355f4 --- /dev/null +++ b/app/Exceptions/Api/ApplicationException.php @@ -0,0 +1,25 @@ +getMessage())); + } +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index a8f2183..44f2465 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -2,7 +2,20 @@ namespace App\Exceptions; +use App\Enums\ApiError; +use App\Enums\ApiStatus; +use App\Exceptions\Api\ApiErrorException; +use App\Http\Resources\Api\V1\ApiResponseResource; +use App\Lib\Helper; +use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Auth\AuthenticationException; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Http\Exceptions\ThrottleRequestsException; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; use Kwaadpepper\ExceptionHandler\Exceptions\ExceptionHandler; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Throwable; class Handler extends ExceptionHandler @@ -10,18 +23,18 @@ class Handler extends ExceptionHandler /** * A list of the exception types that are not reported. * - * @var string[] + * @var array */ protected $dontReport = [ - ]; + ApiErrorException::class + ]; /** * A list of the inputs that are never flashed for validation exceptions. * - * @var string[] + * @var array */ protected $dontFlash = [ - 'current_password', 'password', 'password_confirmation', ]; @@ -33,7 +46,125 @@ class Handler extends ExceptionHandler */ public function register() { - $this->reportable(function (Throwable $e) { - }); + } + + /** + * Render an exception into an HTTP response. + * + * @param mixed $request + * @param \Throwable $e + * @return \Symfony\Component\HttpFoundation\Response + */ + public function render($request, Throwable $e) + { + if (Helper::isRequestForApiV1($request) and $request->isJson()) { + return $this->handleApiV1Exception($request, $e); + } //end if + // @codeCoverageIgnoreStart + return parent::render($request, $e); + // @codeCoverageIgnoreEnd + } + + /** + * Generate a json response for any apiV2 error case + * + * @param Request $request + * @param \Throwable $e + * @return JsonResponse + */ + private function handleApiV1Exception(Request $request, \Throwable $e): JsonResponse + { + switch (get_class($e)) { + case AuthenticationException::class: + /** @var AuthenticationException $e */ + return $this->getReponse( + $request, + 401, + ApiError::apiAuthFailed()->value, + sprintf('%s, %s', ApiError::apiAuthFailed()->label, $e->getMessage()) + ); + case MethodNotAllowedHttpException::class: + /** @var MethodNotAllowedHttpException $e */ + return $this->getReponse( + $request, + $e->getStatusCode(), + ApiError::methodNotAllowed()->value, + sprintf('%s, %s', ApiError::methodNotAllowed()->label, $e->getMessage()) + ); + case ValidationException::class: + /** @var ValidationException $e */ + return $this->getReponse( + $request, + $e->status, + ApiError::validationFailed()->value, + sprintf(ApiError::validationFailed()->label, $e->getMessage()), + ['errors' => $e->errors()] + ); + case ModelNotFoundException::class: + /** @var ModelNotFoundException $e */ + return $this->getReponse( + $request, + 404, + ApiError::modelNotFound()->value, + sprintf( + ApiError::modelNotFound()->label, + \array_slice(explode('\\', $e->getModel()), -1)[0] + ) + ); + case ApiErrorException::class: + /** @var ApiErrorException $e */ + return $this->getReponse( + $request, + $e->getStatusCode(), + $e->getCode(), + $e->getMessage() + ); + case ThrottleRequestsException::class: + /** @var ThrottleRequestsException $e */ + return $this->getReponse( + $request, + 429, + ApiError::throttleRequestsException()->value, + ApiError::throttleRequestsException()->label + ); + case AuthorizationException::class: + /** @var AuthorizationException $e */ + return $this->getReponse( + $request, + 403, + ApiError::authorizationException()->value, + ApiError::authorizationException()->label + ); + } //end switch + // @codeCoverageIgnoreStart + if (config('app.env') === 'testing') { + // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Found + print_r((string)$e); + } + return $this->getReponse($request, 500, $e->getCode(), $e->getMessage()); + // @codeCoverageIgnoreEnd + } + + /** + * Generate an ApiV2 json response + * + * @param Request $request + * @param integer $httpCode + * @param integer $apiCode + * @param string $message + * @param array $data + * @return JsonResponse + */ + private static function getReponse( + Request $request, + int $httpCode, + int $apiCode, + string $message, + array $data = [] + ): JsonResponse { + return (new ApiResponseResource( + (object)array_merge($data, ['message' => $message, 'code' => $apiCode]), + ApiStatus::KO() + ))->toResponse($request)->setStatusCode($httpCode); } } diff --git a/app/Exceptions/InvalidXmlException.php b/app/Exceptions/InvalidXmlException.php index 4db050d..a24f1f0 100644 --- a/app/Exceptions/InvalidXmlException.php +++ b/app/Exceptions/InvalidXmlException.php @@ -2,8 +2,8 @@ namespace App\Exceptions; -use Exception; +use ErrorException; -class InvalidXmlException extends Exception +class InvalidXmlException extends ErrorException { } diff --git a/app/Http/Controllers/Api/V1/ApiController.php b/app/Http/Controllers/Api/V1/ApiController.php new file mode 100644 index 0000000..7edee2f --- /dev/null +++ b/app/Http/Controllers/Api/V1/ApiController.php @@ -0,0 +1,19 @@ +json(); + } +} diff --git a/app/Http/Controllers/Api/V1/XmlIncoController.php b/app/Http/Controllers/Api/V1/XmlIncoController.php new file mode 100644 index 0000000..b60b9b9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/XmlIncoController.php @@ -0,0 +1,58 @@ +arcadiaBrand; + + $xmlIncos = XmlInco::query(); + + if ($arcadiaBrand) { + // * Make sure we are accessing xmlIncos that belongs to our service. + $this->authorize('arcadiaBrandBelongsToService', $arcadiaBrand); + + $xmlIncos->whereHas('arcadiaBrands', function ($q) use ($arcadiaBrand) { + $q->whereId($arcadiaBrand->id); + }); + } + + return XmlIncoResource::collection($xmlIncos->simplePaginate( + $request->per_page, + ['*'], + 'page', + $request->page + )->items()); + } + + /** + * Return a specific xmlInco. + * + * @param XmlInco $xmlInco + * @return JsonResource + */ + public function show(XmlInco $xmlInco): JsonResource + { + // * Make sure we are accessing a xmlInco that belongs to our service. + $this->authorize('arcadiaBrandBelongsToService', $xmlInco->arcadiaBrand); + + return new XmlIncoResource($xmlInco); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 39910d7..7e65da8 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -14,12 +14,12 @@ class Kernel extends HttpKernel * @var array */ protected $middleware = [ - // \App\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustProxies::class, \Fruitcake\Cors\HandleCors::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \App\Http\Middleware\TrimStrings::class, + \App\Http\Middleware\XssSanitizer::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, ]; @@ -33,16 +33,18 @@ class Kernel extends HttpKernel \App\Http\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, - // \Illuminate\Session\Middleware\AuthenticateSession::class, + \Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, ], 'api' => [ - // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, 'throttle:api', - \Illuminate\Routing\Middleware\SubstituteBindings::class, + \App\Http\Middleware\JsonOnly::class, + \App\Http\Middleware\ApiResponse::class, + \App\Http\Middleware\ApiCheckTimeStamps::class, + \Illuminate\Routing\Middleware\SubstituteBindings::class ], ]; diff --git a/app/Http/Middleware/ApiCheckTimeStamps.php b/app/Http/Middleware/ApiCheckTimeStamps.php new file mode 100644 index 0000000..be7f528 --- /dev/null +++ b/app/Http/Middleware/ApiCheckTimeStamps.php @@ -0,0 +1,38 @@ +timestamp) or !Carbon::hasFormat($request->timestamp, 'Y-m-d H:i:s')) { + throw new ApiErrorException(ApiError::wrongTimeStamp()); + } + if ( + Carbon::parse($request->timestamp, 'utc') + ->isBefore(Carbon::now('UTC')->subSeconds(config('api.timestampTimeout', 10))) + ) { + throw new ApiErrorException(ApiError::wrongTimeStamp()); + } + return $next($request); + } +} diff --git a/app/Http/Middleware/ApiResponse.php b/app/Http/Middleware/ApiResponse.php new file mode 100644 index 0000000..f9dffc5 --- /dev/null +++ b/app/Http/Middleware/ApiResponse.php @@ -0,0 +1,57 @@ +header('Cache-Control', 'No-Store'); + + /** @var \App\Models\Service|null */ + if (\optional($service = app(Service::class))->id) { + $service->incrementStat(); + } + + /** @var object $data */ + $data = $response->getData(); + + // If reponse is already a V2ApiResponse then just return it. This happens if error handler comes to action. + if ( + isset($data->status) and + isset($data->data) and + isset($data->version) and + in_array($data->status, [ApiStatus::OK()->value, ApiStatus::KO()->value]) + ) { + return $response; + } + + /** @var \App\Enums\ApiStatus $status */ + $status = intval($response->getStatusCode() / 100) == 2 ? ApiStatus::OK() : ApiStatus::KO(); + return $response->setData(new ApiResponseResource((object)$response->getData(), $status)); + } +} diff --git a/app/Http/Middleware/JsonOnly.php b/app/Http/Middleware/JsonOnly.php new file mode 100644 index 0000000..8faf331 --- /dev/null +++ b/app/Http/Middleware/JsonOnly.php @@ -0,0 +1,24 @@ +isJson() or !$request->expectsJson() or is_null(json_decode($request->getContent()))) { + abort(400, 'Invalid JSON'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/XssSanitizer.php b/app/Http/Middleware/XssSanitizer.php new file mode 100644 index 0000000..6192424 --- /dev/null +++ b/app/Http/Middleware/XssSanitizer.php @@ -0,0 +1,25 @@ +all(); + array_walk_recursive($input, function (&$input) { + $input = strip_tags($input); + }); + $request->merge($input); + return $next($request); + } +} diff --git a/app/Http/Requests/Api/V1/PaginatorRequest.php b/app/Http/Requests/Api/V1/PaginatorRequest.php new file mode 100644 index 0000000..113b0bd --- /dev/null +++ b/app/Http/Requests/Api/V1/PaginatorRequest.php @@ -0,0 +1,31 @@ +check(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'page' => 'sometimes|numeric|min:1', + 'per_page' => 'sometimes|numeric|min:1|max:100', + ]; + } +} diff --git a/app/Http/Requests/Api/V1/XmlIncosIndexRequest.php b/app/Http/Requests/Api/V1/XmlIncosIndexRequest.php new file mode 100644 index 0000000..f4f93fc --- /dev/null +++ b/app/Http/Requests/Api/V1/XmlIncosIndexRequest.php @@ -0,0 +1,36 @@ +merge([ + 'brand' => $this->brand ? ArcadiaBrand::whereId($this->brand)->firstOrFail() : null + ]); + return auth()->check(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return \array_merge( + parent::rules(), + [ + 'brand' => 'nullable' + ] + ); + } +} diff --git a/app/Http/Resources/Api/V1/ApiResponseResource.php b/app/Http/Resources/Api/V1/ApiResponseResource.php new file mode 100644 index 0000000..277a992 --- /dev/null +++ b/app/Http/Resources/Api/V1/ApiResponseResource.php @@ -0,0 +1,42 @@ +status = $status; + $this->data = $data; + } + + /** + * Transform the resource into an array. + * + * @param mixed $request + * @return array + */ + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.Missing + public function toArray($request): array + { + return [ + 'status' => $this->status->value, + 'data' => (array)$this->data, + 'version' => config('api.version') + ]; + } +} diff --git a/app/Http/Resources/Api/V1/XmlIncoResource.php b/app/Http/Resources/Api/V1/XmlIncoResource.php new file mode 100644 index 0000000..7757d09 --- /dev/null +++ b/app/Http/Resources/Api/V1/XmlIncoResource.php @@ -0,0 +1,66 @@ + '', + 'no_art' => '', + 'prod_saison' => '', + 'allegations' => '', + 'conseil_prepa' => '', + 'astuces' => '', + 'descriptif' => '', + 'arg_vente' => '', + 'nb_portion' => '', + 'typ_portion' => '', + 'pds_moy_theo' => '', + 'lib_conso' => '', + 'lst_ing' => '', + 'lib_allergene' => '', + 'nutritional_values' => '', + 'pictures' => '', + ]; + + /** + * XmlIncoResource + * + * @param XmlInco $xmlInco + */ + public function __construct(XmlInco $xmlInco) + { + $this->xmlInco = $xmlInco; + } + + /** + * Transform the resource into an array. + * + * @param \Illuminate\Http\Request|null $request + * @return array + * @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass + */ + public function toArray($request = null) + { + $data = array_intersect_key( + $this->xmlInco->toArray(), + $this->getSkeleton() + ); + + return $data; + } + + /** + * Returns the resource skeletton + * + * @return array + */ + public static function getSkeleton(): array + { + return self::$skeleton; + } +} diff --git a/app/Jobs/XArcadiaFlux.php b/app/Jobs/Arcadia/XArcadiaFlux.php similarity index 58% rename from app/Jobs/XArcadiaFlux.php rename to app/Jobs/Arcadia/XArcadiaFlux.php index 56818a0..cf73d14 100644 --- a/app/Jobs/XArcadiaFlux.php +++ b/app/Jobs/Arcadia/XArcadiaFlux.php @@ -1,9 +1,8 @@ getDisk()->exists($filename)) { + $this->log( + sprintf( + 'File %s could not be moved to invalid (not found)', + $filename + ), + LogType::warning() + ); + return; + } + if ($this->getDisk()->exists(sprintf('%s/%s', $this->invalidFolderName, $filename))) { + $ext = \pathinfo($filename, \PATHINFO_EXTENSION); + $newFileName = \sprintf( + '%s_%d.%s', + \substr($filename, 0, 0 - (strlen($ext) + 1)), + \time(), + $ext + ); + } + try { + if (!$this->getDisk()->move($filename, sprintf('%s/%s', $this->invalidFolderName, $newFileName))) { + $this->log( + sprintf( + 'File %s could not be moved to invalid', + $filename + ), + LogType::warning() + ); + } + } catch (\Exception $e) { + $this->log( + sprintf( + 'File %s could not be moved to invalid', + $filename + ), + LogType::warning() + ); + } + } + + /** + * Suppression des fichiers dans + * invalid qui ont plus de 6 mois + * + * @return void + */ + protected function clearOldInvalids(): void + { + $disk = $this->getDisk(); + $invalids = $disk->files($this->invalidFolderName); + + // /** @var File */ + foreach ($invalids as $invalid) { + $pi = \pathinfo($invalid, \PATHINFO_BASENAME); + if (in_array($pi, $this->ignoreFiles)) { + continue; + } + + if ($disk->lastModified($invalid) < strtotime(Carbon::now()->subMonth(6))) { + if (!$disk->delete($invalid)) { + Log::critical(sprintf('%s : Could not delete file %s', get_class($this), $invalid)); + }; + } + } + } + /** * Move the file to archives * @@ -107,7 +196,7 @@ abstract class XArcadiaFlux implements ShouldQueue LogType::warning() ); } - } catch (Exception $e) { + } catch (\Exception $e) { $this->log( sprintf( 'File %s could not be moved to archives', @@ -160,6 +249,7 @@ abstract class XArcadiaFlux implements ShouldQueue * @param string $message * @param LogType $logtype * @return void + * @throws \ErrorException If logtype is not handled. */ protected function log(string $message, LogType $logtype = null): void { @@ -187,8 +277,8 @@ abstract class XArcadiaFlux implements ShouldQueue Log::critical($message); break; default: - throw new Exception(sprintf('%s : unhandled log type %s', get_class($this), $logtype->label)); - }//end switch + throw new \ErrorException(sprintf('%s : unhandled log type %s', get_class($this), $logtype->label)); + } //end switch $this->consolePrint($message, $logtype); } @@ -198,9 +288,11 @@ abstract class XArcadiaFlux implements ShouldQueue * @param string $message * @param LogType $logtype * @return void + * @throws \ErrorException If logtype is not handled. */ protected function consolePrint(string $message, LogType $logtype): void { + $this->clearProgressBarFromLine(); if ($this->console) { switch ($logtype->value) { case LogType::debug()->value: @@ -216,8 +308,59 @@ abstract class XArcadiaFlux implements ShouldQueue $this->console->error($message); break; default: - throw new Exception(sprintf('%s : unhandled log type %s', get_class($this), $logtype->label)); + throw new \ErrorException(sprintf('%s : unhandled log type %s', get_class($this), $logtype->label)); } } } + + /** + * Start ProgressBar + * + * @param integer $total + * @return void + */ + protected function startProgressBar(int $total) + { + if ($this->console) { + $this->progressBar = $this->console->getOutput() + ->createProgressBar($total - 1); + $this->progressBar->start(); + } + } + + /** + * Clear the line to write someting + * + * @return void + */ + protected function clearProgressBarFromLine() + { + if ($this->progressBar) { + $this->progressBar->clear(); + } + } + + /** + * Increment 1 on progress bar + * + * @return void + */ + protected function incrementProgressBar() + { + if ($this->progressBar) { + $this->progressBar->advance(); + } + } + + /** + * End the progress bar + * + * @return void + */ + protected function endProgressBar() + { + if ($this->progressBar) { + $this->progressBar->finish(); + } + } } diff --git a/app/Jobs/Arcadia/XmlBrandReaderJob.php b/app/Jobs/Arcadia/XmlBrandReaderJob.php new file mode 100644 index 0000000..76070b2 --- /dev/null +++ b/app/Jobs/Arcadia/XmlBrandReaderJob.php @@ -0,0 +1,55 @@ + 'arcadia_id', + 'libMarq' => 'libelle', + ]; + + /** + * Fields that are allowed to be null + * + * @var array + */ + protected $nullableFields = []; + + /** + * Unique keys for updating rows + * + * @var array + */ + protected $uniqueKeyField = ['arcadia_id']; + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + + $this->parseData('dam', ArcadiaBrand::class); + } +} diff --git a/app/Jobs/XmlIncoReader.php b/app/Jobs/Arcadia/XmlIncoReaderJob.php similarity index 72% rename from app/Jobs/XmlIncoReader.php rename to app/Jobs/Arcadia/XmlIncoReaderJob.php index c41d374..64f65d0 100644 --- a/app/Jobs/XmlIncoReader.php +++ b/app/Jobs/Arcadia/XmlIncoReaderJob.php @@ -1,19 +1,36 @@ 'exploitant', // Consider this sub array * 'EXPLOITANT.ID_SOCIETE' => 'id_societe', * 'EXPLOITANT.NOM_SOCIETE' => 'nom_societe', + * @var array Columns to take from the xml. */ protected $takeColumns = [ 'NO_ART' => 'id', -// primary key on model + // primary key on model // this wil not return anything cf doc above 'ID_SOCIETE' => 'id_societe', 'NOM_SOCIETE' => 'nom_societe', @@ -40,22 +58,22 @@ class XmlIncoReader extends XmlJobs 'NOM' => 'nom', 'NUMERO' => 'numero', 'TAILLE_REF' => 'taille_ref', -// $poidsRef censé être dans INCO_COMPO (et généralement 100g) + // $poidsRef censé être dans INCO_COMPO (et généralement 100g) 'ALLEGATIONS' => 'allegations', 'CONSEIL_PREPA' => 'conseil_prepa', 'ASTUCES' => 'astuces', 'DESCRIPTIF' => 'descriptif', -// $descriptif + // $descriptif 'ARG_VENTE' => 'arg_vente', 'NB_PORTION' => 'nb_portion', 'TYP_PORTION' => 'typ_portion', 'PDS_MOY_THEO' => 'pds_moy_theo', 'LIB_CONSO' => 'lib_conso', 'LST_ING' => 'lst_ing', -// $ingredients + // $ingredients // list alergene 'LST_ALLERGENE' => 'lst_allergene', -// Consider this sub array + // Consider this sub array 'LST_ALLERGENE.LIB_ALLERGENE' => 'lib_allergene', // Allegations @@ -65,12 +83,12 @@ class XmlIncoReader extends XmlJobs 'INCO_COMPO' => 'inco_compo', -// Consider this sub array + // Consider this sub array 'INCO_COMPO.NOM' => 'nom', 'INCO_COMPO.TAILLE_REF' => 'taille_ref', 'INCO_COMPO.VAL_ENERG_KJ_NUT' => 'val_energ_kj_nut', 'INCO_COMPO.VAL_ENERG_KJ_R' => 'val_energ_kj_r', -// _R apports de la taille_ref + // _R apports de la taille_ref 'INCO_COMPO.VAL_ENERG_KCAL_R' => 'val_energ_kcal_r', 'INCO_COMPO.VAL_ENERG_KCAL_NUT' => 'val_energ_kcal_nut', 'INCO_COMPO.MAT_GRASS_R' => 'mat_grass_r', @@ -92,7 +110,7 @@ class XmlIncoReader extends XmlJobs 'INCO_COMPO.FRUITS_R' => 'fruits_r', 'INCO_COMPO.FRUITS_COQUE_SECH_R' => 'fruits_coque_sech_r', 'INCO_COMPO.POURCENT_APPORT_REF_E14_R' => 'pourcent_apport_ref_e14_r', -// POURCENT_*_P apports journaliers par portion + // POURCENT_*_P apports journaliers par portion 'INCO_COMPO.ENER_KJO_R' => 'ener_kjo_r', 'INCO_COMPO.POURCENT_APPORT_REF_KJO_R' => 'pourcent_apport_ref_kjo_r', 'INCO_COMPO.POURCENT_APPORT_REF_FAT_R' => 'pourcent_apport_ref_fat_r', @@ -110,9 +128,9 @@ class XmlIncoReader extends XmlJobs 'INCO_COMPO.POURCENT_APPORT_REF_SALTEQ_R' => 'pourcent_apport_ref_salteq_r', 'INCO_COMPO.APPORT_REF' => 'apport_ref', 'INCO_COMPO.TAILLE_PORT' => 'taille_port', -// $poidsPortion + // $poidsPortion 'INCO_COMPO.VAL_ENERG_KJ_P' => 'val_energ_kj_p', -// _P apports par portion + // _P apports par portion 'INCO_COMPO.VAL_ENERG_KCAL_P' => 'val_energ_kcal_p', 'INCO_COMPO.MAT_GRASS_P' => 'mat_grass_p', 'INCO_COMPO.AGS_P' => 'ags_p', @@ -155,9 +173,9 @@ class XmlIncoReader extends XmlJobs 'INCO_COMPO.COMPOSANTE_P' => 'composante_p', 'INCO_COMPO.POURCENT_FRUITS' => 'pourcent_fruits', 'INCO_COMPO.SCORE_NUTRI' => 'score_nutri', -// du coup nutriscore ? + // du coup nutriscore ? 'INCO_COMPO.CLASSEMENT' => 'classement', -// $nutriscore mais pas censé être dans INCO_COMPO + // $nutriscore mais pas censé être dans INCO_COMPO 'INCO_COMPO.MAT_GRASS_R_PREC' => 'mat_grass_r_prec', 'INCO_COMPO.AGS_R_PREC' => 'ags_r_prec', 'INCO_COMPO.GLUCIDES_R_PREC' => 'glucides_r_prec', @@ -170,6 +188,11 @@ class XmlIncoReader extends XmlJobs 'INCO_COMPO.FRUITS_COQUE_SECH_R_PREC' => 'fruits_coque_sech_r_prec', ]; + /** + * Fields that are allowed to be null + * + * @var array + */ protected $nullableFields = [ 'taille_ref', 'conseil_prepa', @@ -283,8 +306,18 @@ class XmlIncoReader extends XmlJobs 'inco_compo.fruits_coque_sech_r_prec' ]; + /** + * Unique keys for updating rows + * + * @var array + */ protected $uniqueKeyField = ['id']; + /** + * Columns to merge in + * + * @var array + */ protected $mergeColumns = [ 'allegations' => [ 'allegat_sante', @@ -390,15 +423,20 @@ class XmlIncoReader extends XmlJobs ] ]; + /** + * Execute the job. + * + * @return void + */ public function handle() { $this->parseData('INCO', XmlInco::class, function (SimpleXMLElement $xmlData, array &$line) { - // merge columns + // * merge columns foreach ($this->mergeColumns as $key => $values) { $line[$key] = []; - // merge columns and remove merged columns from line + // * merge columns and remove merged columns from line array_map(function ($kValue) use (&$line, $key) { if (array_key_exists($kValue, $line)) { $line[$key][$kValue] = $line[$kValue]; @@ -415,16 +453,27 @@ class XmlIncoReader extends XmlJobs // } } - // Boolean cast prod_saison + // * Boolean cast prod_saison $line['prod_saison'] = isset($line['prod_saison']) ? - (strtolower($line['prod_saison']) == 'non' ? false : true) : false; + (strtolower($line['prod_saison']) == 'non' ? false : true) : false; - $arImgPaths = $this->parseImage($xmlData); - $line['picture'] = $arImgPaths; + /** @var \Illuminate\Database\Eloquent\Model|null $model */ + $model = XmlInco::where($this->getUniqueValues($line))->first(); + + $line['pictures'] = $this->updateOrCreateImagesFiles( + $this->parseImages($xmlData), + $model + ); }); } - private function parseImage(SimpleXMLElement $xmlData) + /** + * Parse images + * + * @param SimpleXMLElement $xmlData + * @return array List of arrays [imgNamePath, imageData] + */ + private function parseImages(SimpleXMLElement $xmlData): array { $images = $xmlData->INCO->MEDIA_VISUEL_EAI->MEDIA_VISUEL_EAI; $arImgPaths = array(); @@ -432,51 +481,138 @@ class XmlIncoReader extends XmlJobs foreach ($images as $image) { $imageData = trim((string)$image); - if (strlen($imageData)) { - $imageData = base64_decode($imageData); - $imgName = strval($xmlData->INCO->INCO_COMPO->TBL_INCO_COMPO->ID_INCO); + if (strlen($imageData) and $imageData = base64_decode($imageData)) { + $imgName = strval($xmlData->INCO->INCO_COMPO->TBL_INCO_COMPO->ID_INCO); if (empty($imgName)) { $imgName = strval($xmlData->INCO->NO_ART); } $imgName = $imgName . "_" . $index; $imgNamePath = sprintf('%s/%s', rtrim($this->imagesPath, '/'), $imgName); - $disk = $this->getDisk(); - $info = getimagesizefromstring($imageData); - if ($info !== false) { - $mime = $info['mime']; - switch ($mime) { - case 'image/jpeg': - $imgExtension = 'jpg'; - break; - case 'image/png': - $imgExtension = 'png'; - break; - case 'image/gif': - $imgExtension = 'gif'; - break; - default: - throw new AbortXmlImportException(sprintf('Unknown image type %s', $imgName)); - } + if ($info === false) { + Log::debug("Could not get file info from $imgName"); + continue; + } + switch ($info['mime'] ?? '') { + case 'image/jpeg': + $imgExtension = 'jpg'; + break; + case 'image/png': + $imgExtension = 'png'; + break; + case 'image/gif': + $imgExtension = 'gif'; + break; + default: + Log::debug("Unknown image type from $imgName"); + } - $imgNamePath = sprintf('%s.%s', $imgNamePath, $imgExtension); + $imgNamePath = sprintf('%s.%s', $imgNamePath, $imgExtension); - if (!$disk->put($imgNamePath, $imageData)) { - throw new AbortXmlImportException(sprintf('The image %s could not be saved', $imgNamePath)); - } - - $arImgPaths[$index] = sprintf('%s/%s', $this->disk, $imgNamePath); - $index++; - } else { - // probably not an image could be a PDF, PPT, AI anything is possible - }//end if - }//end if - }//end foreach + $arImgPaths[$index] = compact('imgNamePath', 'imageData'); + $index++; + } //end if + } //end foreach return $arImgPaths; } + /** + * Update or create images + * + * @param array $images + * @param \Illuminate\Database\Eloquent\Model|null $model + * @return array Pictures pathes with checksums as keys that will be save into model. + */ + private function updateOrCreateImagesFiles(array $images, Model $model = null): array + { + $disk = $this->getDisk(); + + $oldChecksums = []; + $oldPicturesPaths = []; + + if ($model) { + /** @var array $oldChecksums */ + $oldChecksums = \array_keys($model->pictures ?? []); + /** @var array $oldPicturesPaths */ + $oldPicturesPaths = $model->pictures ?? []; + } + + /** @var array $oldPictures old pictures pathes with checksums as keys */ + $oldPictures = \array_combine($oldChecksums, $oldPicturesPaths); + + /** @var array $newPictures new pictures pathes with checksums as keys */ + $newPictures = collect($images)->mapWithKeys(function (array $imageProps) { + /** @var string $imgNamePath */ + /** @var string $imageData */ + extract($imageProps); + + $imageFinalPath = sprintf('%s/%s', $this->disk, $imgNamePath); + + // ? Sucessfully calculate checksum ? + if (!($newFileChk = hash('crc32b', $imageFinalPath . $imageData))) { + throw new \ErrorException('crc32b doesnt seems to be supported.'); + } + return [$newFileChk => $imageFinalPath]; + }); + + /** @var array $modelPictures pictures that wont change */ + $modelPictures = \array_intersect_key($oldPictures, $newPictures); + + /** @var \Illuminate\Support\Collection $removePictures pictures that we will remove (delete from disk) */ + $removePictures = collect(\array_diff_key($oldPictures, $newPictures)); + + /** @var \Illuminate\Support\Collection $addPictures pictures that we will create (save on disk) */ + $addPictures = collect(\array_diff_key($newPictures, $oldPictures)); + + // * Save or remove pictures from storage as needed + collect($images)->mapWithKeys(function ( + array $imageProps, + string $checksum + ) use ( + $disk, + $removePictures, + $addPictures + ) { + /** @var string $imgNamePath */ + /** @var string $imageData */ + extract($imageProps); + + if ($addPictures->has($checksum)) { + // * Save new picture + if (!$disk->put($imgNamePath, $imageData)) { + throw new AbortXmlImportException(sprintf('The image %s could not be saved', $imgNamePath)); + } + } + + if ($removePictures->has($checksum)) { + // * Remove old picture (if exists) + if ($disk->exists($imgNamePath) and !$disk->delete($imgNamePath)) { + throw new AbortXmlImportException(sprintf('The image %s could not be delete', $imgNamePath)); + } + } + }); + + return $newPictures; + } + + /** + * Get Unique Values for a specific file + * + * @param array $line + * @return array + */ + private function getUniqueValues(array $line): array + { + /** @var array Unique Key columns */ + $uniqueColumnsKey = []; + foreach ($this->uniqueKeyField as $uField) { + $uniqueColumnsKey[$uField] = $line[$uField]; + } + return $uniqueColumnsKey; + } + /** * Recursive extract to array * introduce sort of Hook to transform @@ -520,7 +656,7 @@ class XmlIncoReader extends XmlJobs } $extract[$key][] = $extraction; -// $this->extract($value); + // $this->extract($value); continue; } $value = strval($value); @@ -529,7 +665,7 @@ class XmlIncoReader extends XmlJobs } else { $extract[$key] = strlen($value) ? $value : null; } - }//end foreach + } //end foreach return $extract; } } diff --git a/app/Jobs/XmlJobs.php b/app/Jobs/Arcadia/XmlJobs.php similarity index 60% rename from app/Jobs/XmlJobs.php rename to app/Jobs/Arcadia/XmlJobs.php index 2c3d7bc..43933bb 100644 --- a/app/Jobs/XmlJobs.php +++ b/app/Jobs/Arcadia/XmlJobs.php @@ -1,38 +1,32 @@ getDisk(); $this->log('Démarrage du traitement'); + + $this->assertXsdIsHereIfAny(); + $files = $disk->files(); - $progress = 1; - $total = count(array_filter($files, function ($filename) { + $total = count(array_filter($files, function ($filename) { if ($this->fileHasToBeIgnored($filename)) { return false; } return true; })); + $this->startProgressBar($total); + foreach ($files as $filename) { if ($this->fileHasToBeIgnored($filename)) { continue; } - $this->log(sprintf(' traitement %d sur %s ', $progress++, $total)); + $this->incrementProgressBar(); + try { /** @var string $data */ $data = $disk->get($filename); + + $this->validateXmlFile($filename, $data); + try { - $this->validateSchema($data); if ($this->encapsulate) { $data = sprintf('%s', $data); - /** @var SimpleXMLElement|false */ + /** @var \SimpleXMLElement|false */ $xmlContent = simplexml_load_string($data); } else { - /** @var SimpleXMLElement|false */ + /** @var \SimpleXMLElement|false */ $xmlContent = simplexml_load_string($data, null, LIBXML_NOCDATA | LIBXML_PARSEHUGE); } if ($xmlContent === false) { throw new InvalidXmlException('XML is Invalid (simplexml_load_string returned false)'); } - } catch (ErrorException $e) { + } catch (\ErrorException $e) { throw new InvalidXmlException('XML is Invalid', 0, $e); } - $data = $this->extract($xmlContent); - + $data = $this->extract($xmlContent); $lines = []; - foreach ($data[$entityName] as $traca) { - $line = $this->processFields($traca); - if ($callback) { - $callback($xmlContent, $line); + + // * Multiple Entities within file + if (isset($data[$entityName])) { + foreach ($data[$entityName] as $traca) { + $line = $this->processFields($traca); + if ($afterParseCallback) { + $afterParseCallback($xmlContent, $line); + } + $lines[] = $line; + } + } else { + // * Only one entity within file + $line = $this->processFields($data); + if ($afterParseCallback) { + $afterParseCallback($xmlContent, $line); } $lines[] = $line; } - // Mise en base des données + + // * Mise en base des données foreach ($lines as $line) { /** @var array Unique Key columns */ $uniqueColumnsKey = []; foreach ($this->uniqueKeyField as $uField) { $uniqueColumnsKey[$uField] = $line[$uField]; } - forward_static_call_array( - [$modelClass, 'updateOrCreate'], - [$uniqueColumnsKey, $line] - ); - } + if (count($uniqueColumnsKey)) { + if ($saveCallback) { + $saveCallback($uniqueColumnsKey, $line); + } else { + forward_static_call_array( + [$modelClass, 'updateOrCreate'], + [$uniqueColumnsKey, $line] + ); + } + if ($afterSaveCallback) { + $afterSaveCallback($xmlContent, $line); + } + } + } //end foreach } catch (InvalidXmlException $e) { $message = $e->getMessage(); - if (get_class($e) === QueryException::class) { - $message = implode(' ', $e->errorInfo); - } $this->log( sprintf( 'File %s could not be parsed => %s on line %d', @@ -146,19 +173,6 @@ abstract class XmlJobs extends XArcadiaFlux $this->sendInvalidXmlEmail($filename, $e); $this->moveToInvalid($filename); continue; - } catch (ErrorException | TypeError $e) { - $this->log( - sprintf( - 'CRITICAL: File %s could not be parsed => %s on line %d', - $filename, - $e->getMessage(), - $e->getLine() - ), - LogType::critical() - ); - $this->sendErrorParseEmailToServiceWeb($e); - continue; -// ignore the file } catch (AbortXmlImportException $e) { $this->log( sprintf( @@ -169,15 +183,16 @@ abstract class XmlJobs extends XArcadiaFlux ), LogType::error() ); - $this->sendErrorParseEmailToServiceWeb($e); + report($e); continue; -// ignore the file - } catch (FileNotFoundException | QueryException $e) { - // This happens because of concurent xml jobs read + // * ignore the file + } catch (QueryException $e) { + $this->log(implode(' ', $e->errorInfo), LogType::critical()); + report($e); + continue; + } catch (FileNotFoundException $e) { + // This happens because of concurent xml jobs read. $message = $e->getMessage(); - if (get_class($e) === QueryException::class) { - $message = implode(' ', $e->errorInfo); - } $this->log( sprintf( 'ERROR: File %s could not be read (not found) => %s on line %d', @@ -188,13 +203,31 @@ abstract class XmlJobs extends XArcadiaFlux LogType::debug() ); continue; -// ignore the file - }//end try + // * ignore the file + } catch (\Exception | \ErrorException | \TypeError $e) { + $this->log( + sprintf( + 'File %s could not be parsed => %s on line %d', + $filename, + $e->getMessage(), + $e->getLine() + ), + LogType::critical() + ); + report($e); + continue; + // * ignore the file + } //end try $this->moveToArchives($filename); - }//end foreach + } //end foreach - // Clear old archives + // Clear old invalids. + $this->clearOldInvalids(); + + // Clear old archives. $this->clearOldArchives(); + + $this->endProgressBar(); } /** @@ -205,7 +238,7 @@ abstract class XmlJobs extends XArcadiaFlux */ protected function extract($sxe = null): array { - if (!$sxe instanceof SimpleXMLElement) { + if (!$sxe instanceof \SimpleXMLElement) { return array(); } $extract = array(); @@ -229,7 +262,7 @@ abstract class XmlJobs extends XArcadiaFlux * * @param array $fields * @return array - * @throws AbortXmlImportException + * @throws AbortXmlImportException If a field could not be validated. */ protected function processFields(array $fields): array { @@ -242,8 +275,8 @@ abstract class XmlJobs extends XArcadiaFlux throw new AbortXmlImportException(sprintf('The field %s is not allowed to be null', $key)); } if (is_array($field)) { - // Flatten data - $it = new RecursiveIteratorIterator(new RecursiveArrayIterator($field)); + // * Flatten data + $it = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($field)); foreach ($it as $k => $f) { $ko = strtolower(sprintf('%s.%s', $key, $k)); if (!$this->isFieldNullable($ko) && is_null($f)) { @@ -261,7 +294,7 @@ abstract class XmlJobs extends XArcadiaFlux } else { $result[$key] = $field; } - }//end foreach + } //end foreach return $result; } @@ -309,24 +342,6 @@ abstract class XmlJobs extends XArcadiaFlux return in_array($filename, $this->ignoreFiles) or !$this->checkExtension($filename, 'xml'); } - /** - * Validate xml data against xsd file if provided - * - * @return void - */ - protected function validateSchema($data) - { - if ($this->xsdFile) { - // check against XSD - $xsd = storage_path($this->disk . "/" . $this->xsdFile); - $domDocument = new \DOMDocument(); - $domDocument->loadXML($data); - // free some memroy to avoid overload - unset($data); - $domDocument->schemaValidate($xsd); - } - } - /** * Check if the file is an xml file * @@ -342,78 +357,81 @@ abstract class XmlJobs extends XArcadiaFlux return $isXml; } - protected function sendErrorParseEmailToServiceWeb(Throwable $e) - { - Utils::sendEmailErrorManually($e); - } - - protected function sendInvalidXmlEmail(string $filename, Throwable $e) + /** + * Send invalid XML email + * + * @param string $filename + * @param \Throwable $e + * @return void + */ + protected function sendInvalidXmlEmail(string $filename, \Throwable $e) { $message = $e->getPrevious()->getMessage(); - Mail::send('emails.invalidxml', [ + Mail::send('mails.invalidxml', [ 'strError' => sprintf( 'File %s could not be parsed => %s on line %d', $filename, $message, $e->getLine() ), - 'appName' => config('app.name'), 'filename' => $filename, 'exception' => $e->getPrevious() ], function ($m) { $m->to( config('mail.contactsList.invalidInco'), - sprintf('%s Exception Handler', config('app.name')) + sprintf('%s XML Exception Handler', config('app.name')) )->subject('You sent me an invalid XML file, please check its validity before resend ASAP.'); }); } /** - * Move the file to invalid folder + * Assert XSD is readable if configured * - * @param string $filename * @return void + * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException If xsd file cant be read. */ - protected function moveToInvalid(string $filename): void + private function assertXsdIsHereIfAny() { - $newFileName = $filename; - if (!$this->getDisk()->exists($filename)) { - $this->log( - sprintf( - 'File %s could not be moved to invalid (not found)', - $filename - ), - LogType::warning() - ); - return; - } - if ($this->getDisk()->exists(sprintf('%s/%s', $this->invalidFolderName, $filename))) { - $ext = \pathinfo($filename, \PATHINFO_EXTENSION); - $newFileName = \sprintf( - '%s_%d.%s', - \substr($filename, 0, 0 - (strlen($ext) + 1)), - \time(), - $ext - ); - } - try { - if (!$this->getDisk()->move($filename, sprintf('%s/%s', $this->invalidFolderName, $newFileName))) { - $this->log( - sprintf( - 'File %s could not be moved to invalid', - $filename - ), - LogType::warning() - ); - } - } catch (Exception $e) { - $this->log( - sprintf( - 'File %s could not be moved to invalid', - $filename - ), - LogType::warning() - ); + if ($this->xsdSchemaPath) { + $this->xsdSchema = File::get($this->xsdSchemaPath); } } + + /** + * Validate Xml file against XSD if loaded + * + * @param string $fileName + * @param string $fileContent + * @return void + * @throws InvalidXmlException If XML could not be parsed or validated against XSD. + * @phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber + */ + private function validateXmlFile(string $fileName, string $fileContent) + { + if ($this->xsdSchema) { + $domDocument = new \DOMDocument('1.0', 'UTF-8'); + try { + if (!$domDocument->loadXML($fileContent)) { + throw new InvalidXmlException("XML Validation $fileName content could not be loaded to validate."); + } + if (!$domDocument->schemaValidateSource($this->xsdSchema, LIBXML_SCHEMA_CREATE)) { + throw new InvalidXmlException( + "XML Validation $fileName content could not be validated against xsd." + ); + } + } catch (\Exception $e) { + if (\get_class($e) === InvalidXmlException::class) { + throw $e; + } + throw new InvalidXmlException( + "XML Validation $fileName content could not be validated against xsd.", + 0, + 1, + __FILE__, + __LINE__, + $e + ); + } //end try + } //end if + } } diff --git a/app/Jobs/SetServiceStats.php b/app/Jobs/SetServiceStats.php new file mode 100644 index 0000000..87d3b60 --- /dev/null +++ b/app/Jobs/SetServiceStats.php @@ -0,0 +1,81 @@ +service = $service; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + /** @var \App\Models\ServiceStat */ + $serviceStat = ServiceStat::firstOrNew([ + 'date' => date('Y-m-d'), + 'service_id' => $this->service->id, + ]); + if (!$serviceStat->exists) { + $serviceStat->service()->associate($this->service); + } + $serviceStat->nb_calls += 1; + $serviceStat->saveOrFail(); + } + + /** + * Determine the time at which the job should timeout. + * + * @return \DateTime + */ + public function retryUntil() + { + return now()->addMinutes(rand(0, 360)); + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware() + { + return [ + (new WithoutOverlapping(self::KEY)) + ->releaseAfter(rand(90, 180)) + ]; + } +} diff --git a/app/Lib/Helper.php b/app/Lib/Helper.php new file mode 100644 index 0000000..3ddf065 --- /dev/null +++ b/app/Lib/Helper.php @@ -0,0 +1,180 @@ +path(), 'api/v1') === 0 ? 'v1' : null) ?? 'none'; + return strpos($routePrefix, 'v1') === 0; + } + + /** + * Try to get current route name + * + * @param Request $request + * @return string|null + */ + public static function getRouteName(Request $request) + { + return Route::currentRouteName() ?? ($request->route() ? $request->route()->getName() : null); + } + + /** + * Get the bearer token from request + * + * @param Request $request + * @return string|null + */ + public static function getBearerTokenFromRequest(Request $request): ?string + { + $header = $request->header('Authorization', ''); + + if (Str::contains($header, 'Bearer ')) { + $auths = \explode(',', $header); + foreach ($auths as $auth) { + if (Str::contains($auth, 'Bearer ')) { + return Str::substr($auth, 7); + } + } + } + return null; + } + + /** + * Try to get current route prefix + * + * @param Request $request + * @return string|null + */ + public static function getRoutePrefix(Request $request) + { + return (Route::getCurrentRoute() ? Route::getCurrentRoute()->getPrefix() : null) ?? + ($request->route() ? $request->route()->getPrefix() : null); + } + + /** + * Try to get route from request + * + * @param Request $request + * @return \Illuminate\Routing\Route|null + */ + public static function getRoute(Request $request) + { + return Route::getRoutes()->getByName(static::getRouteName($request)); + } + + /** + * Get composer.json infos + * + * @return array + */ + public static function getPackageInfo(): array + { + if (!static::$packageInfos) { + static::$packageInfos = \json_decode(\file_get_contents(__DIR__ . '/../../composer.json'), true); + } + return static::$packageInfos; + } + + /** + * Calculate the request signature + * + * @param string $secret + * @param string $uri + * @param string $method + * @param array $parameters + * @return string request signature + * @throws ApplicationException If an error occurs. + */ + public static function signRequest(string $secret, string $uri, string $method, array $parameters): string + { + try { + $uri = collect(\parse_url($uri)); + $method = \strtolower($method); + + // * Remove signature from parameters to calculate the hashmac. + $signature = collect([ + 'uri' => sprintf( + '%s://%s%s', + $uri->get('scheme'), + $uri->get('host'), + $uri->get('path') + ), + 'method' => $method + ]); + + $flattenRequestArray = function (array $array) use (&$flattenRequestArray) { + $out = []; + foreach ($array as $key => $value) { + if (is_array($value)) { + $subArray = $flattenRequestArray($value); + foreach ($subArray as $subKey => $subValue) { + $out["$key.$subKey"] = $subValue; + } + continue; + } + $out[$key] = $value; + } + return $out; + }; + + $parameters = $flattenRequestArray($parameters); + $parameters = collect($parameters); + + // * Remove signature from parameters. + $parameters->pull('signature'); + + // * Natural sort all parameters. + $parameters = $parameters->sortKeys(\SORT_NATURAL) + ->map(function ($value, $key) { + return sprintf('%s=%s', \rawurlencode($key), \rawurlencode($value)); + }); + + $signature->push($parameters->implode('&')); + + $calculatedHash = base64_encode(hash_hmac( + 'sha256', + $signature->implode('\n'), + Crypt::decryptString($secret), + true + )); + + if (!$calculatedHash) { + throw new ApplicationException('Failed to calculate requests hash'); + } + + return $calculatedHash; + } catch (DecryptException $e) { + throw new ApplicationException('Failed to decrypt service credentials', 0, $e); + } catch (\Exception $e) { + throw new ApplicationException('An error occured when authenticating service', 0, $e); + } //end try + } +} diff --git a/app/Lib/Utils.php b/app/Lib/Utils.php deleted file mode 100644 index 1583128..0000000 --- a/app/Lib/Utils.php +++ /dev/null @@ -1,209 +0,0 @@ - - */ -// phpcs:disable Generic.Files.LineLength.TooLong -class Utils -{ - /** - * Replace stange accents - * - * https://stackoverflow.com/questions/3371697/replacing-accented-characters-php - * @author Jérémy Munsch - * @param string $subject - * @return string - */ - public static function replaceSpecChar(string $subject): string - { - $char_map = array( - "ъ" => "-", "ь" => "-", "Ъ" => "-", "Ь" => "-", - "А" => "A", "Ă" => "A", "Ǎ" => "A", "Ą" => "A", "À" => "A", "Ã" => "A", "Á" => "A", "Æ" => "A", "Â" => "A", "Å" => "A", "Ǻ" => "A", "Ā" => "A", "א" => "A", - "Б" => "B", "ב" => "B", "Þ" => "B", - "Ĉ" => "C", "Ć" => "C", "Ç" => "C", "Ц" => "C", "צ" => "C", "Ċ" => "C", "Č" => "C", "©" => "C", "ץ" => "C", - "Д" => "D", "Ď" => "D", "Đ" => "D", "ד" => "D", "Ð" => "D", - "È" => "E", "Ę" => "E", "É" => "E", "Ë" => "E", "Ê" => "E", "Е" => "E", "Ē" => "E", "Ė" => "E", "Ě" => "E", "Ĕ" => "E", "Є" => "E", "Ə" => "E", "ע" => "E", - "Ф" => "F", "Ƒ" => "F", - "Ğ" => "G", "Ġ" => "G", "Ģ" => "G", "Ĝ" => "G", "Г" => "G", "ג" => "G", "Ґ" => "G", - "ח" => "H", "Ħ" => "H", "Х" => "H", "Ĥ" => "H", "ה" => "H", - "I" => "I", "Ï" => "I", "Î" => "I", "Í" => "I", "Ì" => "I", "Į" => "I", "Ĭ" => "I", "I" => "I", "И" => "I", "Ĩ" => "I", "Ǐ" => "I", "י" => "I", "Ї" => "I", "Ī" => "I", "І" => "I", - "Й" => "J", "Ĵ" => "J", - "ĸ" => "K", "כ" => "K", "Ķ" => "K", "К" => "K", "ך" => "K", - "Ł" => "L", "Ŀ" => "L", "Л" => "L", "Ļ" => "L", "Ĺ" => "L", "Ľ" => "L", "ל" => "L", - "מ" => "M", "М" => "M", "ם" => "M", - "Ñ" => "N", "Ń" => "N", "Н" => "N", "Ņ" => "N", "ן" => "N", "Ŋ" => "N", "נ" => "N", "ʼn" => "N", "Ň" => "N", - "Ø" => "O", "Ó" => "O", "Ò" => "O", "Ô" => "O", "Õ" => "O", "О" => "O", "Ő" => "O", "Ŏ" => "O", "Ō" => "O", "Ǿ" => "O", "Ǒ" => "O", "Ơ" => "O", - "פ" => "P", "ף" => "P", "П" => "P", - "ק" => "Q", - "Ŕ" => "R", "Ř" => "R", "Ŗ" => "R", "ר" => "R", "Р" => "R", "®" => "R", - "Ş" => "S", "Ś" => "S", "Ș" => "S", "Š" => "S", "С" => "S", "Ŝ" => "S", "ס" => "S", - "Т" => "T", "Ț" => "T", "ט" => "T", "Ŧ" => "T", "ת" => "T", "Ť" => "T", "Ţ" => "T", - "Ù" => "U", "Û" => "U", "Ú" => "U", "Ū" => "U", "У" => "U", "Ũ" => "U", "Ư" => "U", "Ǔ" => "U", "Ų" => "U", "Ŭ" => "U", "Ů" => "U", "Ű" => "U", "Ǖ" => "U", "Ǜ" => "U", "Ǚ" => "U", "Ǘ" => "U", - "В" => "V", "ו" => "V", - "Ý" => "Y", "Ы" => "Y", "Ŷ" => "Y", "Ÿ" => "Y", - "Ź" => "Z", "Ž" => "Z", "Ż" => "Z", "З" => "Z", "ז" => "Z", - "а" => "a", "ă" => "a", "ǎ" => "a", "ą" => "a", "à" => "a", "ã" => "a", "á" => "a", "æ" => "a", "â" => "a", "å" => "a", "ǻ" => "a", "ā" => "a", "א" => "a", - "б" => "b", "ב" => "b", "þ" => "b", - "ĉ" => "c", "ć" => "c", "ç" => "c", "ц" => "c", "צ" => "c", "ċ" => "c", "č" => "c", "©" => "c", "ץ" => "c", - "Ч" => "ch", "ч" => "ch", - "д" => "d", "ď" => "d", "đ" => "d", "ד" => "d", "ð" => "d", - "è" => "e", "ę" => "e", "é" => "e", "ë" => "e", "ê" => "e", "е" => "e", "ē" => "e", "ė" => "e", "ě" => "e", "ĕ" => "e", "є" => "e", "ə" => "e", "ע" => "e", - "ф" => "f", "ƒ" => "f", - "ğ" => "g", "ġ" => "g", "ģ" => "g", "ĝ" => "g", "г" => "g", "ג" => "g", "ґ" => "g", - "ח" => "h", "ħ" => "h", "х" => "h", "ĥ" => "h", "ה" => "h", - "i" => "i", "ï" => "i", "î" => "i", "í" => "i", "ì" => "i", "į" => "i", "ĭ" => "i", "ı" => "i", "и" => "i", "ĩ" => "i", "ǐ" => "i", "י" => "i", "ї" => "i", "ī" => "i", "і" => "i", - "й" => "j", "Й" => "j", "Ĵ" => "j", "ĵ" => "j", - "ĸ" => "k", "כ" => "k", "ķ" => "k", "к" => "k", "ך" => "k", - "ł" => "l", "ŀ" => "l", "л" => "l", "ļ" => "l", "ĺ" => "l", "ľ" => "l", "ל" => "l", - "מ" => "m", "м" => "m", "ם" => "m", - "ñ" => "n", "ń" => "n", "н" => "n", "ņ" => "n", "ן" => "n", "ŋ" => "n", "נ" => "n", "ʼn" => "n", "ň" => "n", - "ø" => "o", "ó" => "o", "ò" => "o", "ô" => "o", "õ" => "o", "о" => "o", "ő" => "o", "ŏ" => "o", "ō" => "o", "ǿ" => "o", "ǒ" => "o", "ơ" => "o", - "פ" => "p", "ף" => "p", "п" => "p", - "ק" => "q", - "ŕ" => "r", "ř" => "r", "ŗ" => "r", "ר" => "r", "р" => "r", "®" => "r", - "ş" => "s", "ś" => "s", "ș" => "s", "š" => "s", "с" => "s", "ŝ" => "s", "ס" => "s", - "т" => "t", "ț" => "t", "ט" => "t", "ŧ" => "t", "ת" => "t", "ť" => "t", "ţ" => "t", - "ù" => "u", "û" => "u", "ú" => "u", "ū" => "u", "у" => "u", "ũ" => "u", "ư" => "u", "ǔ" => "u", "ų" => "u", "ŭ" => "u", "ů" => "u", "ű" => "u", "ǖ" => "u", "ǜ" => "u", "ǚ" => "u", "ǘ" => "u", - "в" => "v", "ו" => "v", - "ý" => "y", "ы" => "y", "ŷ" => "y", "ÿ" => "y", - "ź" => "z", "ž" => "z", "ż" => "z", "з" => "z", "ז" => "z", "ſ" => "z", - "™" => "tm", - "@" => "at", - "Ä" => "ae", "Ǽ" => "ae", "ä" => "ae", "æ" => "ae", "ǽ" => "ae", - "ij" => "ij", "IJ" => "ij", - "я" => "ja", "Я" => "ja", - "Э" => "je", "э" => "je", - "ё" => "jo", "Ё" => "jo", - "ю" => "ju", "Ю" => "ju", - "œ" => "oe", "Œ" => "oe", "ö" => "oe", "Ö" => "oe", - "щ" => "sch", "Щ" => "sch", - "ш" => "sh", "Ш" => "sh", - "ß" => "ss", - "Ü" => "ue", - "Ж" => "zh", "ж" => "zh", - ); - return self::mbStrtr($subject, $char_map); - } - - /** - * Multibyte strtr - * - * @param string $str - * @param string|array $from - * @param string|null $to - * @return string - */ - public static function mbStrtr($str, $from, $to = null): string - { - if (is_array($from)) { - $from = array_map('utf8_decode', $from); - $from = array_map('utf8_decode', array_flip($from)); - return utf8_encode(strtr(utf8_decode($str), array_flip($from))); - } - return utf8_encode(strtr(utf8_decode($str), utf8_decode($from), utf8_decode($to))); - } - - /** - * Manually send an exception to team instead of going trough the error handler - * (requires package kwaadpepper/exception-handler) - * - * @param Throwable $e - * @return void - */ - public static function sendEmailErrorManually(Throwable $e) - { - Mail::send([ - 'html' => 'exception-handler::emails.exception', - 'text' => 'exception-handler::emails.exception-plain' - ], [ - 'appName' => config('app.name'), - 'code' => $e->getCode(), - 'line' => $e->getLine(), - 'ename' => \get_class($e), - 'emessage' => self::getAnonymizedMessage($e->getMessage()), - 'file' => str_replace(\realpath(\sprintf('%s/../', \app_path())), '', $e->getFile()), - 'stackTrace' => self::getAnonymizedStackTrace($e), - 'uri' => app()->runningInConsole() ? - 'CONSOLE' : ( - (request() and request()->fullUrl()) ? - request()->fullUrl() : 'none') - ], function ($m) { - /** @var \Illuminate\Mail\Message $m */ - $m->to( - config('mail.contactsList.serviceweb'), - sprintf('%s Exception Handler', config('app.name')) - )->subject('Need help !, an error occured in your application, please check Logs ASAP'); - }); - Log::alert(sprintf('Envoie d\'une erreur par mail : %s', json_encode($e))); - } - - /** - * Anonymize at least Sql queries - * - * @param string $message - * @return string - */ - private static function getAnonymizedMessage(string $message): string - { - $message = \preg_replace('/^(.*)( \(SQL:.*\))$/', '$1', $message) ?? $message; - return $message; - } - - /** - * Anonymize stack trace - * - * @param \Exception $e - * @return string - */ - private static function getAnonymizedStackTrace(Throwable $e): string - { - $projectPath = \realpath(\sprintf('%s/../', \app_path())); - $traceStack = $e->getTrace(); - $traceStr = ''; - $maxStackStage = 0; - foreach ($traceStack as $stackStage => $stack) { - $maxStackStage = $stackStage; - $traceStr .= \sprintf( - '#%s %s(%d): ', - $stackStage, - str_replace($projectPath, '', $stack['file']), - $stack['line'] - ); - $traceStr .= "{$stack['class']}->{$stack['function']}("; - $traceArgs = ''; - if (isset($stack['args'])) { - $argsCount = count($stack['args']); - for ($i = 0; $i < $argsCount; $i++) { - $type = \gettype($stack['args'][$i]); - switch ($type) { - case 'object': - $traceArgs .= sprintf('Object(%s)', \get_class($stack['args'][$i])); - break; - case 'array': - $traceArgs .= sprintf('Array(%d)', \count($stack['args'][$i])); - break; - case 'string': - $traceArgs .= sprintf('String(%d)', \strlen($stack['args'][$i])); - break; - default: - $traceArgs .= \gettype($stack['args'][$i]); - } - $traceArgs .= ($i !== ($argsCount - 1) ? ',' : ''); - } - $traceStr .= $traceArgs; - }//end if - $traceStr .= ")\n"; - }//end foreach - $maxStackStage++; - $traceStr .= "#$maxStackStage {main}"; - return $traceStr; - } -} diff --git a/app/Models/ArcadiaBrand.php b/app/Models/ArcadiaBrand.php new file mode 100644 index 0000000..caf9e33 --- /dev/null +++ b/app/Models/ArcadiaBrand.php @@ -0,0 +1,90 @@ +cacheFor = config('api.arcadiaBrandsDBCache') * 60; + + parent::__construct($attributes); + } + + /** + * Set the base cache tags that will be present + * on all queries. + * + * ! This is used to flush caching + * + * @return array + */ + protected function getCacheBaseTags(): array + { + return [ + 'incollector_arcadia_brands', + ]; + } + + /** + * Perform any actions required after the model boots. + * + * @return void + */ + protected static function booted() + { + static::created(function () { + // * Flush Eloquent cache. + self::flushQueryCache(); + }); + static::updated(function () { + // * Flush Eloquent cache. + self::flushQueryCache(); + }); + static::deleted(function () { + // * Flush Eloquent cache. + self::flushQueryCache(); + }); + } + + // * RELATIONS + + /** + * Service belongsToMany relation + * + * @return \Illuminate\Database\Eloquent\Relations\belongsToMany + */ + public function services() + { + return $this->belongsToMany(Service::class); + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php new file mode 100644 index 0000000..17ecf76 --- /dev/null +++ b/app/Models/Service.php @@ -0,0 +1,224 @@ +cacheFor = config('api.servicesDBCache') * 60; + + parent::__construct($attributes); + } + + /** + * Set the base cache tags that will be present + * on all queries. + * + * ! This is used to flush caching + * + * @return array + */ + protected function getCacheBaseTags(): array + { + return [ + 'incollector_services', + ]; + } + + /** + * Perform any actions required after the model boots. + * + * @return void + */ + protected static function booted() + { + static::creating(function (self $service) { + $service->generateBearerToken(); + $service->generateBearerPassword(); + }); + static::created(function () { + // * Flush Eloquent cache. + self::flushQueryCache(); + }); + static::updated(function () { + // * Flush Eloquent cache. + self::flushQueryCache(); + }); + static::deleted(function () { + // * Flush Eloquent cache. + self::flushQueryCache(); + }); + } + + // * RELATIONS + + /** + * ArcadiaBrand belongsToMany relation + * + * @return \Illuminate\Database\Eloquent\Relations\belongsToMany + */ + public function arcadiaBrands() + { + return $this->belongsToMany(ArcadiaBrand::class); + } + + /** + * ServiceStat hasMany relation + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function servicesStats() + { + return $this->hasMany(ServiceStat::class); + } + + // * METHODS + + /** + * Increment API CRM Stat + * + * @return void + */ + public function incrementStat(): void + { + SetServiceStats::dispatch($this)->afterResponse() + ->onConnection('database')->onQueue('stats'); + } + + /** + * Generates new bearer token + * + * @return string + */ + public function generateBearerToken(): string + { + $this->bearer_token = hash('ripemd160', Str::uuid()); + $this->bearer_token_date = now(); + return $this->bearer_token; + } + + /** + * Generates new bearer password + * It store the crypted password in model + * and returns the plain password + * + * @return string + */ + public function generateBearerPassword(): string + { + $pwd = hash('sha256', Str::uuid()); + $this->bearer_pwd = Crypt::encryptString($pwd); + return $pwd; + } + + /** + * Get Service from bearerToken with its brand + * + * @param string $bearerToken + * @return \App\Models\Service + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException If the token matches no known service. + */ + public static function getFromBearerToken(string $bearerToken): self + { + $services = static::all(); + /** @var \App\Models\Service $service */ + foreach ($services as $service) { + if ($service->bearer_token and $bearerToken === $service->bearer_token) { + return $service->load([ + 'arcadiaBrands' => function ($query) use ($service) { + return $query->cacheTags(["service:{$service->id}:arcadiaBrands"]); + }, + ]); + } + } + throw new ModelNotFoundException('Could not find service from Service Token'); + } + + /** + * Authenticate the request for this service + * + * This will check hash-mac and that the request is wellformed + * + * The request should have the minimum following parameters + * - timestamp using format Y-m-d H:i:s, and must be UTC (Greenwich Mean Time) + * - bearerToken the public token generated by the crm + * - signature the hashmac signature of the request + * + * ! Signature must be calculated with every parameter urlencode once only + * ! All parameters to calculate the signature must be sorted (natural sort asc) + * + * ! Signature should look like + * * httpVerb\n + * * uri\n + * * queryString + * + * ! httpVerb as well as uri must be lower case + * ! uri should include scheme, host and path and nothing else, if path is empty a trailing slash should be added + * ! eg: https://ldc-crm.fr/customer/1 or https://ldc-crm.fr/ + * ! The query string shall be separated with '&', eg param1=value1¶m2=value2 + * ! Key names and values of the query string must be url encoded using RFC 3986 + * ! http://www.faqs.org/rfcs/rfc3986.html + * ! Signature must be hmac sha256 using the private key generatedby the crm + * + * * ! They should then be concat in a string separated by \n + * + * + * + * @param \Illuminate\Http\Request $request + * @return boolean + * @throws \App\Exceptions\Api\V2\CrmException If an error occurs. + */ + public function authenticateApi(Request $request): bool + { + // ! Hack to prevent Laravel to cast empty strings to null or the opposite. + // ! If cannot decode json user legacy all() method. + $data = \json_decode($request->getContent(), true); + $data = (\is_array($data) and \json_last_error() === \JSON_ERROR_NONE) ? + array_replace_recursive($request->all(), $data) : $request->all(); + + $calculatedHash = Helper::signRequest( + $this->bearer_pwd, + $request->getUri(), + $request->getMethod(), + $data + ); + + // ! Très important hash_equals https://fr.wikipedia.org/wiki/Attaque_temporelle + return \hash_equals($calculatedHash, $request->signature); + } +} diff --git a/app/Models/ServiceStat.php b/app/Models/ServiceStat.php new file mode 100644 index 0000000..f424a4a --- /dev/null +++ b/app/Models/ServiceStat.php @@ -0,0 +1,39 @@ + 0 + ]; + + // * RELATIONS + + /** + * Service BelongsTo relation + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function service() + { + return $this->belongsTo(Service::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e77b07f..aa3e11f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\Hash; use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable @@ -42,4 +43,21 @@ class User extends Authenticatable protected $casts = [ 'email_verified_at' => 'datetime', ]; + + /** + * Perform any actions required after the model boots. + * + * @return void + */ + protected static function booted() + { + static::creating(function (self $user) { + $user->password = Hash::make($user->password); + }); + static::updating(function (self $user) { + if (Hash::needsRehash($user->password)) { + $user->password = Hash::make($user->password); + } + }); + } } diff --git a/app/Models/XmlInco.php b/app/Models/XmlInco.php index e8d23f3..c489618 100644 --- a/app/Models/XmlInco.php +++ b/app/Models/XmlInco.php @@ -4,14 +4,26 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Support\Facades\Storage; -use Illuminate\Contracts\Filesystem\Filesystem; -use Illuminate\Support\Collection; +/** + * XmlInco + * + * pictures attribute is organized as following + * [ + * 'checksum' => 'storageFilePath' + * ] + * + * ! checksum includes fullfilepath + data + */ class XmlInco extends Model { use HasFactory; + /** + * The attributes that are mass assignable. + * + * @var array + */ protected $fillable = [ 'id', 'no_art', @@ -28,73 +40,40 @@ class XmlInco extends Model 'lst_ing', 'lib_allergene', 'nutritional_values', - 'picture', - ]; - - protected $casts = [ - 'picture' => 'array', - 'nutritional_values' => 'array', + 'pictures', ]; /** - * The Laravel disk name - * - * @var string - */ - protected $disk = 'inco'; - - protected $imagesPath = 'images'; - - /** - * The files to ignore + * The attributes that should be cast. * * @var array */ - protected $ignoreFiles = [ - '.gitignore', '.gitkeep' + protected $casts = [ + 'pictures' => 'array', + 'nutritional_values' => 'array', ]; - /** - * retrieve a collection of medias - */ - public function getMediasAttribute() - { - /** @var Filesystem */ - $disk = $this->getDisk(); - $files = $disk->files($this->imagesPath); - - $collFiles = new Collection(); - // /** @var File */ - foreach ($files as $file) { - $pi = \pathinfo($file, \PATHINFO_BASENAME); - if (in_array($pi, $this->ignoreFiles)) { - continue; - } - if (!in_array($pi, array($this->id))) { - continue; - } - $image = base64_encode($disk->get($file)); - $element = array( - "filename" => $pi, - "imgdata" => $image - ); - $collFiles->add($element); - } - return $collFiles; - } + // * GETTERS /** - * Get the concerned disk + * Get niceString attribute * - * @return Filesystem + * @return string */ - protected function getDisk(): Filesystem - { - return Storage::disk($this->disk); - } - - public function getNiceStringAttribute() + public function getNiceStringAttribute(): string { return sprintf('%s - %s', $this->id, $this->descriptif); } + + // * RELATIONS + + /** + * ArcadiaBrand belongsTo relation + * + * @return \Illuminate\Database\Eloquent\Relations\belongsTo + */ + public function arcadiaBrand() + { + return $this->belongsTo(ArcadiaBrand::class); + } } diff --git a/app/Policies/ArcadiaBrandPolicy.php b/app/Policies/ArcadiaBrandPolicy.php new file mode 100644 index 0000000..15c78a3 --- /dev/null +++ b/app/Policies/ArcadiaBrandPolicy.php @@ -0,0 +1,52 @@ +policies = [ + 'arcadia_brand' => [ + 'viewAny' => USR::make('everyOne'), + 'view' => USR::make('everyOne'), + 'create' => USR::make('noOne'), + 'update' => USR::make('noOne'), + 'delete' => USR::make('noOne') + ] + ]; + } + + /** + * Check if the customer belongs to the service + * + * @param Service $service + * @param ArcadiaBrand $arcadiaBrand + * @return boolean + */ + public function arcadiaBrandBelongsToService(Service $service, ArcadiaBrand $arcadiaBrand) + { + return $service->arcadiaBrands()->whereHas('services', function ($q) use ($arcadiaBrand) { + $q->whereId($arcadiaBrand->id); + })->exists(); + } +} diff --git a/app/Policies/Rules/UserStaticRules.php b/app/Policies/Rules/UserStaticRules.php new file mode 100644 index 0000000..32d42f7 --- /dev/null +++ b/app/Policies/Rules/UserStaticRules.php @@ -0,0 +1,32 @@ +getClientIp(); + } else { + return request()->server('HTTP_X_FORWARDED_FOR') ?? + request()->getClientIp(); + } + }); + + // * LOCAL FORCE eg: for Carbon + // * Remove numeric to prevent float seperator to breaking numeric transactions + \collect([\LC_TIME, \LC_MESSAGES, \LC_COLLATE, \LC_CTYPE, \LC_MONETARY]) + ->map(function ($contant) { + setlocale( + $contant, + \sprintf( + '%s_%s.UTF-8', + config('app.locale'), + \strtoupper(config('app.locale')) + ) + ); + }); + Carbon::setLocale(config('app.locale')); + + // * Force bootstrap paginator + Paginator::useBootstrap(); + + $appInfos = Cache::remember( + 'composer', + 360, + function () { + return json_decode(File::get(\app_path('../composer.json'))); + } + ); + View::share('name', $appInfos->name); + View::share('version', $appInfos->version); + View::share('license', $appInfos->license); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 9425227..6c62e01 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -2,7 +2,12 @@ namespace App\Providers; +use App\Lib\Helper; +use App\Models\Service; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; class AuthServiceProvider extends ServiceProvider { @@ -11,9 +16,7 @@ class AuthServiceProvider extends ServiceProvider * * @var array */ - protected $policies = [ - // 'App\Models\Model' => 'App\Policies\ModelPolicy', - ]; + protected $policies = []; /** * Register any authentication / authorization services. @@ -23,5 +26,51 @@ class AuthServiceProvider extends ServiceProvider public function boot() { $this->registerPolicies(); + + Auth::viaRequest('api-hmac', function (Request $request) { + + /** + * Un token devrais être fourni pour chaque requete + */ + if (is_null($request->bearer_token)) { + return null; + } + + // * Si la route actuelle n'as pas de nom alors on refuse de la traiter. + if (!(Helper::getRoute($request))) { + // @codeCoverageIgnoreStart + return null; + // @codeCoverageIgnoreEnd + } + + $service = null; + + try { + // * On récupère le service actuel correspondant au token fourni. + $service = Service::getFromBearerToken($request->bearer_token ?? ''); + } catch (ModelNotFoundException $e) { + return null; + } + + // * Preload the arcadiaBrands as we will access it often later. + $service = $service->load('arcadiaBrands'); + + /** + ** All Api routes must pass ArcadiaBrand model to controller action since a ArcadiaBrand owns + ** a service token, otherwise the api cannot be called. + ** This hack is a custom binder instead of passing + ** this to route as a parameter + */ + app()->bind(Service::class, function () use ($service) { + return $service; + }); + + // * On authenticate la request en vérifiant sa signature. + if ($service->authenticateApi($request)) { + return $service; + } + + return null; + }); } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 3bd3c81..6e792a1 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -19,15 +19,6 @@ class RouteServiceProvider extends ServiceProvider */ public const HOME = '/home'; - /** - * The controller namespace for the application. - * - * When present, controller route declarations will automatically be prefixed with this namespace. - * - * @var string|null - */ - // protected $namespace = 'App\\Http\\Controllers'; - /** * Define your route model bindings, pattern filters, etc. * @@ -56,8 +47,16 @@ class RouteServiceProvider extends ServiceProvider */ protected function configureRateLimiting() { + RateLimiter::for('web', function (Request $request) { + // @codeCoverageIgnoreStart + return Limit::perMinute(25) + ->by(optional($request->user())->id ?: app('clientIp')); + // @codeCoverageIgnoreEnd + }); + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass RateLimiter::for('api', function (Request $request) { - return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip()); + return Limit::perMinute(config('api.apiRequestRatePerMinute', 100)) + ->by(app('clientIp')); }); } } diff --git a/composer.json b/composer.json index 85015c8..2d45737 100644 --- a/composer.json +++ b/composer.json @@ -1,19 +1,31 @@ { - "name": "laravel/laravel", + "name": "ldc/incollector", "type": "project", - "description": "The Laravel Framework.", - "keywords": ["framework", "laravel"], + "version": "1.0.0", + "description": "LDC Incollector - A workaround of Arcadia", + "authors": [ + { + "name": "Olivier Legat", + "email": "olivier.legat@ldc.fr" + }, + { + "name": "Jérémy Munsch", + "email": "jeremy.munsch@ldc.fr", + "homepage": "https://jeremydev.ovh" + } + ], "license": "MIT", "require": { "php": "^7.3|^8.0", "fruitcake/laravel-cors": "^2.0", "guzzlehttp/guzzle": "^7.0.1", + "kwaadpepper/crud-policies": "^0.3.5", "kwaadpepper/enum": "^2.1", "kwaadpepper/exception-handler": "^0.0.5", "laravel/framework": "^8.65", "laravel/sanctum": "^2.11", "laravel/tinker": "^2.5", - "squizlabs/php_codesniffer": "^3.6" + "rennokki/laravel-eloquent-query-cache": "^3.3" }, "require-dev": { "facade/ignition": "^2.5", @@ -22,7 +34,8 @@ "mockery/mockery": "^1.4.4", "nunomaduro/collision": "^5.10", "phpunit/phpunit": "^9.5.10", - "slevomat/coding-standard": "^7.1" + "slevomat/coding-standard": "^7.1", + "squizlabs/php_codesniffer": "^3.6" }, "autoload": { "psr-4": { @@ -37,6 +50,16 @@ } }, "scripts": { + "test": [ + "@configclear", + "./test.sh" + ], + "configclear": "@php artisan config:clear", + "runtestmigration": "@php artisan migrate:fresh --env=testing --seed", + "runtest": "@php artisan test --no-coverage --testsuite Api", + "runtestWithCoverage": "@php artisan test --testsuite Api", + "runserve": "@php artisan serve --host=127.0.0.1 --port=8888 --env=testing&", + "killserve": "kill $(ps -f | grep '[1]27.0.0.1:8888' | awk '{print $2}')", "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" diff --git a/composer.lock b/composer.lock index 1b23fa3..bb91026 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "675ecb5599b8e8d3dd09e345d535143b", + "content-hash": "739c10857a26ab983953d6da1124f1b2", "packages": [ { "name": "asm89/stack-cors", @@ -122,6 +122,353 @@ ], "time": "2021-08-15T20:50:18+00:00" }, + { + "name": "doctrine/cache", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/cache.git", + "reference": "331b4d5dbaeab3827976273e9356b3b453c300ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/cache/zipball/331b4d5dbaeab3827976273e9356b3b453c300ce", + "reference": "331b4d5dbaeab3827976273e9356b3b453c300ce", + "shasum": "" + }, + "require": { + "php": "~7.1 || ^8.0" + }, + "conflict": { + "doctrine/common": ">2.2,<2.4" + }, + "require-dev": { + "alcaeus/mongo-php-adapter": "^1.1", + "cache/integration-tests": "dev-master", + "doctrine/coding-standard": "^8.0", + "mongodb/mongodb": "^1.1", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "predis/predis": "~1.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "symfony/cache": "^4.4 || ^5.2 || ^6.0@dev", + "symfony/var-exporter": "^4.4 || ^5.2 || ^6.0@dev" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "Required to use legacy MongoDB driver" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.", + "homepage": "https://www.doctrine-project.org/projects/cache.html", + "keywords": [ + "abstraction", + "apcu", + "cache", + "caching", + "couchdb", + "memcached", + "php", + "redis", + "xcache" + ], + "support": { + "issues": "https://github.com/doctrine/cache/issues", + "source": "https://github.com/doctrine/cache/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache", + "type": "tidelift" + } + ], + "time": "2021-07-17T14:49:29+00:00" + }, + { + "name": "doctrine/dbal", + "version": "3.3.5", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "719663b15983278227669c8595151586a2ff3327" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/719663b15983278227669c8595151586a2ff3327", + "reference": "719663b15983278227669c8595151586a2ff3327", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/cache": "^1.11|^2.0", + "doctrine/deprecations": "^0.5.3", + "doctrine/event-manager": "^1.0", + "php": "^7.3 || ^8.0", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "doctrine/coding-standard": "9.0.0", + "jetbrains/phpstorm-stubs": "2021.1", + "phpstan/phpstan": "1.5.3", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "9.5.16", + "psalm/plugin-phpunit": "0.16.1", + "squizlabs/php_codesniffer": "3.6.2", + "symfony/cache": "^5.2|^6.0", + "symfony/console": "^2.7|^3.0|^4.0|^5.0|^6.0", + "vimeo/psalm": "4.22.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "bin": [ + "bin/doctrine-dbal" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/3.3.5" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2022-04-05T09:50:18+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "v0.5.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "9504165960a1f83cc1480e2be1dd0a0478561314" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/9504165960a1f83cc1480e2be1dd0a0478561314", + "reference": "9504165960a1f83cc1480e2be1dd0a0478561314", + "shasum": "" + }, + "require": { + "php": "^7.1|^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^6.0|^7.0|^8.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0", + "psr/log": "^1.0" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/v0.5.3" + }, + "time": "2021-03-21T12:59:47+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "41370af6a30faa9dc0368c4a6814d596e81aba7f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/41370af6a30faa9dc0368c4a6814d596e81aba7f", + "reference": "41370af6a30faa9dc0368c4a6814d596e81aba7f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/common": "<2.9@dev" + }, + "require-dev": { + "doctrine/coding-standard": "^6.0", + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "lib/Doctrine/Common" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/1.1.x" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2020-05-29T18:28:51+00:00" + }, { "name": "doctrine/inflector", "version": "2.0.4", @@ -418,6 +765,178 @@ ], "time": "2020-12-29T14:50:06+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.13.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "08e27c97e4c6ed02f37c5b2b20488046c8d90d75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/08e27c97e4c6ed02f37c5b2b20488046c8d90d75", + "reference": "08e27c97e4c6ed02f37c5b2b20488046c8d90d75", + "shasum": "" + }, + "require": { + "php": ">=5.2" + }, + "require-dev": { + "simpletest/simpletest": "dev-master#72de02a7b80c6bb8864ef9bf66d41d2f58f826bd" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/master" + }, + "time": "2020-06-29T00:56:53+00:00" + }, + { + "name": "facade/flare-client-php", + "version": "1.9.1", + "source": { + "type": "git", + "url": "https://github.com/facade/flare-client-php.git", + "reference": "b2adf1512755637d0cef4f7d1b54301325ac78ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/facade/flare-client-php/zipball/b2adf1512755637d0cef4f7d1b54301325ac78ed", + "reference": "b2adf1512755637d0cef4f7d1b54301325ac78ed", + "shasum": "" + }, + "require": { + "facade/ignition-contracts": "~1.0", + "illuminate/pipeline": "^5.5|^6.0|^7.0|^8.0", + "php": "^7.1|^8.0", + "symfony/http-foundation": "^3.3|^4.1|^5.0", + "symfony/mime": "^3.4|^4.0|^5.1", + "symfony/var-dumper": "^3.4|^4.0|^5.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.14", + "phpunit/phpunit": "^7.5.16", + "spatie/phpunit-snapshot-assertions": "^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Facade\\FlareClient\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Send PHP errors to Flare", + "homepage": "https://github.com/facade/flare-client-php", + "keywords": [ + "exception", + "facade", + "flare", + "reporting" + ], + "support": { + "issues": "https://github.com/facade/flare-client-php/issues", + "source": "https://github.com/facade/flare-client-php/tree/1.9.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2021-09-13T12:16:46+00:00" + }, + { + "name": "facade/ignition-contracts", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/facade/ignition-contracts.git", + "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267", + "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267", + "shasum": "" + }, + "require": { + "php": "^7.3|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v2.15.8", + "phpunit/phpunit": "^9.3.11", + "vimeo/psalm": "^3.17.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Facade\\IgnitionContracts\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://flareapp.io", + "role": "Developer" + } + ], + "description": "Solution contracts for Ignition", + "homepage": "https://github.com/facade/ignition-contracts", + "keywords": [ + "contracts", + "flare", + "ignition" + ], + "support": { + "issues": "https://github.com/facade/ignition-contracts/issues", + "source": "https://github.com/facade/ignition-contracts/tree/1.0.2" + }, + "time": "2020-10-16T08:27:54+00:00" + }, { "name": "fruitcake/laravel-cors", "version": "v2.2.0", @@ -882,6 +1401,72 @@ ], "time": "2022-03-20T21:55:58+00:00" }, + { + "name": "kwaadpepper/crud-policies", + "version": "0.3.5", + "source": { + "type": "git", + "url": "https://github.com/Kwaadpepper/crud-policies.git", + "reference": "7b7ef4c58839044f96e7862e4e27c945b96e8df2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Kwaadpepper/crud-policies/zipball/7b7ef4c58839044f96e7862e4e27c945b96e8df2", + "reference": "7b7ef4c58839044f96e7862e4e27c945b96e8df2", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.1", + "facade/flare-client-php": "^1.8", + "illuminate/support": "~8", + "kwaadpepper/enum": "^2.0", + "laravel/framework": "~8", + "mews/purifier": "^3.3" + }, + "require-dev": { + "orchestra/testbench": "^6.13", + "phpunit/phpunit": "~9.0", + "slevomat/coding-standard": "^7.0", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Kwaadpepper\\CrudPolicies\\Providers\\CrudPoliciesServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Kwaadpepper\\CrudPolicies\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérémy Munsch", + "email": "github@jeremydev.ovh", + "homepage": "https://jeremydev.ovh" + } + ], + "description": "Makes CRUD easy, make policies easy and everyone happy", + "homepage": "https://github.com/kwaadpepper/crud-policies", + "keywords": [ + "Scafolding", + "crud", + "laravel", + "policies" + ], + "support": { + "issues": "https://github.com/Kwaadpepper/crud-policies/issues", + "source": "https://github.com/Kwaadpepper/crud-policies/tree/0.3.5" + }, + "time": "2022-03-22T09:32:05+00:00" + }, { "name": "kwaadpepper/enum", "version": "2.1.2", @@ -1611,6 +2196,87 @@ ], "time": "2021-11-21T11:48:40+00:00" }, + { + "name": "mews/purifier", + "version": "3.3.7", + "source": { + "type": "git", + "url": "https://github.com/mewebstudio/Purifier.git", + "reference": "9844bc82886e4291b3116afbd9ab5f532856662a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mewebstudio/Purifier/zipball/9844bc82886e4291b3116afbd9ab5f532856662a", + "reference": "9844bc82886e4291b3116afbd9ab5f532856662a", + "shasum": "" + }, + "require": { + "ezyang/htmlpurifier": "4.13.*", + "illuminate/config": "^5.8|^6.0|^7.0|^8.0|^9.0", + "illuminate/filesystem": "^5.8|^6.0|^7.0|^8.0|^9.0", + "illuminate/support": "^5.8|^6.0|^7.0|^8.0|^9.0", + "php": "^7.2|^8.0" + }, + "require-dev": { + "graham-campbell/testbench": "^3.2|^5.5.1", + "mockery/mockery": "^1.3.3", + "phpunit/phpunit": "^8.0|^9.0" + }, + "suggest": { + "laravel/framework": "To test the Laravel bindings", + "laravel/lumen-framework": "To test the Lumen bindings" + }, + "type": "package", + "extra": { + "laravel": { + "providers": [ + "Mews\\Purifier\\PurifierServiceProvider" + ], + "aliases": { + "Purifier": "Mews\\Purifier\\Facades\\Purifier" + } + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Mews\\Purifier\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Muharrem ERİN", + "email": "me@mewebstudio.com", + "homepage": "https://github.com/mewebstudio", + "role": "Developer" + } + ], + "description": "Laravel 5/6/7 HtmlPurifier Package", + "homepage": "https://github.com/mewebstudio/purifier", + "keywords": [ + "Purifier", + "htmlpurifier", + "laravel5 HtmlPurifier", + "laravel5 Purifier", + "laravel5 Security", + "laravel6 HtmlPurifier", + "laravel6 Purifier", + "laravel6 Security", + "security", + "xss" + ], + "support": { + "issues": "https://github.com/mewebstudio/Purifier/issues", + "source": "https://github.com/mewebstudio/Purifier/tree/3.3.7" + }, + "time": "2022-02-12T15:54:11+00:00" + }, { "name": "monolog/monolog", "version": "2.4.0", @@ -1998,6 +2664,55 @@ ], "time": "2021-12-04T23:24:31+00:00" }, + { + "name": "psr/cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/master" + }, + "time": "2016-08-06T20:24:11+00:00" + }, { "name": "psr/container", "version": "1.1.1", @@ -2657,60 +3372,71 @@ "time": "2021-09-25T23:10:38+00:00" }, { - "name": "squizlabs/php_codesniffer", - "version": "3.6.2", + "name": "rennokki/laravel-eloquent-query-cache", + "version": "3.3.0", "source": { "type": "git", - "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "5e4e71592f69da17871dba6e80dd51bce74a351a" + "url": "https://github.com/renoki-co/laravel-eloquent-query-cache.git", + "reference": "00e73a7050bedca315c71bf433d0c283f8a73fbc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/5e4e71592f69da17871dba6e80dd51bce74a351a", - "reference": "5e4e71592f69da17871dba6e80dd51bce74a351a", + "url": "https://api.github.com/repos/renoki-co/laravel-eloquent-query-cache/zipball/00e73a7050bedca315c71bf433d0c283f8a73fbc", + "reference": "00e73a7050bedca315c71bf433d0c283f8a73fbc", "shasum": "" }, "require": { - "ext-simplexml": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": ">=5.4.0" + "illuminate/database": "^8.83|^9.0.1", + "illuminate/support": "^8.83|^9.0.1" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "chelout/laravel-relationship-events": "^1.5", + "laravel/legacy-factories": "^1.3", + "mockery/mockery": "^1.5", + "orchestra/database": "^6.28|^7.0", + "orchestra/testbench": "^6.28|^7.0", + "orchestra/testbench-core": "^6.28|^7.0", + "phpunit/phpunit": "^9.5.13" }, - "bin": [ - "bin/phpcs", - "bin/phpcbf" - ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" + "autoload": { + "psr-4": { + "Rennokki\\QueryCache\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "Apache-2.0" ], "authors": [ { - "name": "Greg Sherwood", - "role": "lead" + "name": "Alex Renoki", + "email": "alex@renoki.org", + "homepage": "https://github.com/rennokki", + "role": "Developer" } ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "description": "Adding cache on your Laravel Eloquent queries' results is now a breeze.", + "homepage": "https://github.com/renoki-co/laravel-eloquent-query-cache", "keywords": [ - "phpcs", - "standards" + "caching", + "eloquent", + "laravel", + "query", + "remember", + "sql" ], "support": { - "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", - "source": "https://github.com/squizlabs/PHP_CodeSniffer", - "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" + "issues": "https://github.com/renoki-co/laravel-eloquent-query-cache/issues", + "source": "https://github.com/renoki-co/laravel-eloquent-query-cache/tree/3.3.0" }, - "time": "2021-12-12T21:44:58+00:00" + "funding": [ + { + "url": "https://github.com/rennokki", + "type": "github" + } + ], + "time": "2022-02-12T19:37:00+00:00" }, { "name": "swiftmailer/swiftmailer", @@ -5400,71 +6126,6 @@ ], "time": "2022-03-03T08:28:38+00:00" }, - { - "name": "facade/flare-client-php", - "version": "1.9.1", - "source": { - "type": "git", - "url": "https://github.com/facade/flare-client-php.git", - "reference": "b2adf1512755637d0cef4f7d1b54301325ac78ed" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/facade/flare-client-php/zipball/b2adf1512755637d0cef4f7d1b54301325ac78ed", - "reference": "b2adf1512755637d0cef4f7d1b54301325ac78ed", - "shasum": "" - }, - "require": { - "facade/ignition-contracts": "~1.0", - "illuminate/pipeline": "^5.5|^6.0|^7.0|^8.0", - "php": "^7.1|^8.0", - "symfony/http-foundation": "^3.3|^4.1|^5.0", - "symfony/mime": "^3.4|^4.0|^5.1", - "symfony/var-dumper": "^3.4|^4.0|^5.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^2.14", - "phpunit/phpunit": "^7.5.16", - "spatie/phpunit-snapshot-assertions": "^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "files": [ - "src/helpers.php" - ], - "psr-4": { - "Facade\\FlareClient\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Send PHP errors to Flare", - "homepage": "https://github.com/facade/flare-client-php", - "keywords": [ - "exception", - "facade", - "flare", - "reporting" - ], - "support": { - "issues": "https://github.com/facade/flare-client-php/issues", - "source": "https://github.com/facade/flare-client-php/tree/1.9.1" - }, - "funding": [ - { - "url": "https://github.com/spatie", - "type": "github" - } - ], - "time": "2021-09-13T12:16:46+00:00" - }, { "name": "facade/ignition", "version": "2.17.5", @@ -5543,59 +6204,6 @@ }, "time": "2022-02-23T18:31:24+00:00" }, - { - "name": "facade/ignition-contracts", - "version": "1.0.2", - "source": { - "type": "git", - "url": "https://github.com/facade/ignition-contracts.git", - "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267", - "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267", - "shasum": "" - }, - "require": { - "php": "^7.3|^8.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^v2.15.8", - "phpunit/phpunit": "^9.3.11", - "vimeo/psalm": "^3.17.1" - }, - "type": "library", - "autoload": { - "psr-4": { - "Facade\\IgnitionContracts\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Freek Van der Herten", - "email": "freek@spatie.be", - "homepage": "https://flareapp.io", - "role": "Developer" - } - ], - "description": "Solution contracts for Ignition", - "homepage": "https://github.com/facade/ignition-contracts", - "keywords": [ - "contracts", - "flare", - "ignition" - ], - "support": { - "issues": "https://github.com/facade/ignition-contracts/issues", - "source": "https://github.com/facade/ignition-contracts/tree/1.0.2" - }, - "time": "2020-10-16T08:27:54+00:00" - }, { "name": "fakerphp/faker", "version": "v1.19.0", @@ -7891,6 +8499,62 @@ ], "time": "2022-03-29T12:44:16+00:00" }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.6.2", + "source": { + "type": "git", + "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", + "reference": "5e4e71592f69da17871dba6e80dd51bce74a351a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/5e4e71592f69da17871dba6e80dd51bce74a351a", + "reference": "5e4e71592f69da17871dba6e80dd51bce74a351a", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "bin": [ + "bin/phpcs", + "bin/phpcbf" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "lead" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", + "source": "https://github.com/squizlabs/PHP_CodeSniffer", + "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" + }, + "time": "2021-12-12T21:44:58+00:00" + }, { "name": "theseer/tokenizer", "version": "1.2.1", diff --git a/config/api.php b/config/api.php new file mode 100644 index 0000000..8f8c4df --- /dev/null +++ b/config/api.php @@ -0,0 +1,30 @@ + Helper::getPackageInfo()['version'], + + /** + * Timestamp timeout in seconds + * Request with timestamp older than 30sec + * will be rejected + */ + 'timestampTimeout' => 30, + + /** + * The number of request + * per user or ip per minutes + */ + 'apiRequestRatePerMinute' => 256, + + /** + * Service requests cache + * time in minutes + */ + 'servicesDBCache' => 10 +]; diff --git a/config/app.php b/config/app.php index be9aed0..91d45c7 100644 --- a/config/app.php +++ b/config/app.php @@ -67,7 +67,7 @@ return [ | */ - 'timezone' => 'UTC', + 'timezone' => 'Europe/Paris', /* |-------------------------------------------------------------------------- @@ -80,7 +80,7 @@ return [ | */ - 'locale' => 'en', + 'locale' => 'fr', /* |-------------------------------------------------------------------------- @@ -106,7 +106,7 @@ return [ | */ - 'faker_locale' => 'en_US', + 'faker_locale' => 'fr_FR', /* |-------------------------------------------------------------------------- @@ -139,6 +139,7 @@ return [ /* * Laravel Framework Service Providers... */ + Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, @@ -169,9 +170,9 @@ return [ /* * Application Service Providers... */ + App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, - // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\RouteServiceProvider::class, @@ -218,7 +219,6 @@ return [ 'Queue' => Illuminate\Support\Facades\Queue::class, 'RateLimiter' => Illuminate\Support\Facades\RateLimiter::class, 'Redirect' => Illuminate\Support\Facades\Redirect::class, - // 'Redis' => Illuminate\Support\Facades\Redis::class, 'Request' => Illuminate\Support\Facades\Request::class, 'Response' => Illuminate\Support\Facades\Response::class, 'Route' => Illuminate\Support\Facades\Route::class, diff --git a/config/auth.php b/config/auth.php index 2c45f30..1f46d3c 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,9 @@ return [ 'driver' => 'session', 'provider' => 'users', ], + 'api' => [ + 'driver' => 'api-hmac' + ], ], /* @@ -63,12 +66,7 @@ return [ 'users' => [ 'driver' => 'eloquent', 'model' => App\Models\User::class, - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], + ] ], /* diff --git a/config/exception-handler.php b/config/exception-handler.php index 83ec149..e6bd69b 100644 --- a/config/exception-handler.php +++ b/config/exception-handler.php @@ -11,5 +11,7 @@ return [ * Put a developper secured mailbox in here * so you can receive email if things go mad */ - 'contactsList' => [] + 'contactsList' => [ + env('MAIL_LIST_SERVICEWEB', 'equipe.web@ldc.fr') + ] ]; diff --git a/config/filesystems.php b/config/filesystems.php index 090cc99..8831119 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -57,6 +57,10 @@ return [ 'driver' => 'local', 'root' => storage_path('inco'), ], + 'arcadia_brand' => [ + 'driver' => 'local', + 'root' => storage_path('marque'), + ], ], /* diff --git a/config/ldcweb.php b/config/ldcweb.php new file mode 100644 index 0000000..7c62d71 --- /dev/null +++ b/config/ldcweb.php @@ -0,0 +1,5 @@ + env('PROJECT_UUID') +]; diff --git a/config/mail.php b/config/mail.php index e61dd6e..1639e8f 100644 --- a/config/mail.php +++ b/config/mail.php @@ -97,8 +97,9 @@ return [ ], 'contactsList' => [ - 'serviceweb' => explode(",", env('MAIL_LIST_SERVICEWEB', 'equipe.web@ldc.fr')), - 'invalidInco' => explode(",", env('MAIL_LIST_INVALIDINCO', 'equipe.web@ldc.fr')), + 'serviceweb' => explode(',', env('MAIL_LIST_SERVICEWEB', 'equipe.web@ldc.fr')), + // phpcs:ignore Generic.Files.LineLength.TooLong + 'invalidInco' => explode(',', env('MAIL_LIST_INVALIDINCO', 'equipe.web@ldc.fr,vincent.pierre@ldc.fr,christelle.manceau@ldc.fr')), ], /* diff --git a/config/trustedproxy.php b/config/trustedproxy.php new file mode 100644 index 0000000..e18fa5d --- /dev/null +++ b/config/trustedproxy.php @@ -0,0 +1,58 @@ + [ + // * HA PROXY prod "Virtual IP". + '192.168.31.9', + // * HA PROXY prod "Virtual IP" redondance. + '192.168.31.10', + // * HA PROXY dev "Virtual IP". + '192.168.34.15' + ], + + /* + * To trust one or more specific proxies that connect + * directly to your server, use an array or a string separated by comma of IP addresses: + */ + + /* + * Or, to trust all proxies that connect + * directly to your server, use a "*" + */ + + /* + * Which headers to use to detect proxy related data (For, Host, Proto, Port) + * + * Options include: + * + * - Illuminate\Http\Request::HEADER_X_FORWARDED_ALL (use all x-forwarded-* headers to establish trust) + * - Illuminate\Http\Request::HEADER_FORWARDED (use the FORWARDED header to establish trust) + * - Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB (If you are using AWS Elastic Load Balancer) + * + * - 'HEADER_X_FORWARDED_ALL' (use all x-forwarded-* headers to establish trust) + * - 'HEADER_FORWARDED' (use the FORWARDED header to establish trust) + * - 'HEADER_X_FORWARDED_AWS_ELB' (If you are using AWS Elastic Load Balancer) + * + * @link https://symfony.com/doc/current/deployment/proxies.html + */ + + 'headers' => Illuminate\Http\Request::HEADER_X_FORWARDED_FOR | + Illuminate\Http\Request::HEADER_X_FORWARDED_HOST | + Illuminate\Http\Request::HEADER_X_FORWARDED_PORT | + Illuminate\Http\Request::HEADER_X_FORWARDED_PROTO, +]; diff --git a/database/factories/ArcadiaBrandFactory.php b/database/factories/ArcadiaBrandFactory.php new file mode 100644 index 0000000..3140078 --- /dev/null +++ b/database/factories/ArcadiaBrandFactory.php @@ -0,0 +1,21 @@ + $this->faker->unique()->numberBetween(1, 999), + 'libelle' => $this->faker->unique()->company() + ]; + } +} diff --git a/database/factories/ServiceFactory.php b/database/factories/ServiceFactory.php new file mode 100644 index 0000000..b01d5b5 --- /dev/null +++ b/database/factories/ServiceFactory.php @@ -0,0 +1,20 @@ + $this->faker->unique()->name() . ' Service' + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 5cdba5b..1cd8cfd 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -18,8 +18,7 @@ class UserFactory extends Factory 'name' => $this->faker->name(), 'email' => $this->faker->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', -// password + 'password' => 'password', 'remember_token' => Str::random(10), ]; } @@ -31,6 +30,7 @@ class UserFactory extends Factory */ public function unverified() { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass return $this->state(function (array $attributes) { return [ 'email_verified_at' => null, diff --git a/database/factories/XmlIncoFactory.php b/database/factories/XmlIncoFactory.php new file mode 100644 index 0000000..d656bb7 --- /dev/null +++ b/database/factories/XmlIncoFactory.php @@ -0,0 +1,105 @@ + $this->faker->unique()->words(rand(2, 3), true), + 'prod_saison' => $this->faker->boolean(), + 'allegations' => $this->faker->boolean(75) ? $this->faker->sentence(7) : null, + 'descriptif' => $this->faker->boolean(75) ? 'descriptif ' . $this->faker->text(150) : null, + 'pictures' => [], + 'astuces' => $this->faker->boolean(75) ? 'astuces ' . $this->faker->text(150) : null, + 'lib_conso' => $this->faker->boolean(75) ? $this->faker->sentence(7) : null, + 'nb_portion' => $this->faker->boolean(75) ? $this->faker->text(20) : null, + 'typ_portion' => $this->faker->boolean(75) ? 'typ_portion ' . $this->faker->text(150) : null, + 'pds_moy_theo' => $this->faker->boolean(75) ? $this->faker->numberBetween(0, 250) : null, + + 'conseil_prepa' => $this->faker->boolean(75) ? 'conseil_prepa ' . $this->faker->text(150) : null, + 'arg_vente' => $this->faker->boolean(75) ? 'arg_vente ' . $this->faker->text(150) : null, + 'lst_ing' => $this->faker->boolean(75) ? 'lst_ing ' . $this->faker->text(150) : null, + 'lib_allergene' => $this->faker->boolean(75) ? 'lib_allergene ' . $this->faker->text(150) : null, + + 'nutritional_values' => [ + 'val_energ_kj_r' => $this->faker->numberBetween(250, 1200), + 'val_energ_kcal_r' => $this->faker->numberBetween(50, 400), + 'mat_grass_r' => $this->faker->numberBetween(6, 30), + 'ags_r' => '5.3', + 'glucides_r' => '2', + 'sucre_r' => '1.4', + 'fib_alim_r' => '0.8', + 'proteine_r' => '17', + 'sel_r' => '0.47', + 'sodium_r' => '188', + 'fruits_r' => '0', + 'fruits_coque_sech_r' => '0', + 'pourcent_apport_ref_e14_r' => '11.4', + 'pourcent_apport_ref_kjo_r' => '11.5', + 'pourcent_apport_ref_fat_r' => '24.7', + 'pourcent_apport_ref_fatsat_r' => '29.8', + 'pourcent_apport_ref_choval_r' => '0.6', + 'pourcent_apport_ref_sugar_r' => '1.7', + 'pourcent_apport_ref_fibtg_r' => null, + 'pourcent_apport_ref_pro_r' => '33', + 'pourcent_apport_ref_salteq_r' => '6.7', + 'taille_port' => null, + 'val_energ_kj_p' => null, + 'val_energ_kcal_p' => null, + 'mat_grass_p' => null, + 'ags_p' => null, + 'glucides_p' => null, + 'sucre_p' => null, + 'fib_alim_p' => null, + 'proteine_p' => null, + 'sodium_p' => null, + 'fruits_p' => null, + 'sel_p' => null, + 'fruits_coque_sech_p' => null, + 'pourcent_apport_ref_e14_p' => null, + 'pourcent_apport_ref_kjo_p' => null, + 'pourcent_apport_ref_fat_p' => null, + 'pourcent_apport_ref_fatsat_p' => null, + 'pourcent_apport_ref_choval_p' => null, + 'pourcent_apport_ref_sugar_p' => null, + 'pourcent_apport_ref_fibtg_p' => null, + 'pourcent_apport_ref_pro_p' => null, + 'pourcent_apport_ref_salteq_p' => null, + 'apport_port' => 'Apport de r\u00e9f\u00e9rence pour un adulte type (8400Kj-2000Kcal)', + 'part_p' => '32', + 'part_g' => '4', + 'part_l' => '63', + 'p_sur_l' => '1.13', + 'pourcent_ags' => '35', + 'pourcent_agi' => '65', + 'pourcent_ss' => '70', + 'pourcent_amidon' => '30', + 'composante_n' => '9', + 'composante_p' => '5', + 'pourcent_fruits' => '0', + 'score_nutri' => '4', + 'classement' => 'C', + 'mat_grass_r_prec' => '=', + 'ags_r_prec' => '=', + 'glucides_r_prec' => '=', + 'sucre_r_prec' => '=', + 'fib_alim_r_prec' => '=', + 'proteine_r_prec' => '=', + 'sel_r_prec' => '=', + 'sodium_r_prec' => null, + 'fruits_r_prec' => null, + 'fruits_coque_sech_r_prec' => null + ] + ]; + } +} diff --git a/database/migrations/2022_04_05_144756_create_arcadia_brands_table.php b/database/migrations/2022_04_05_144756_create_arcadia_brands_table.php new file mode 100644 index 0000000..f42d374 --- /dev/null +++ b/database/migrations/2022_04_05_144756_create_arcadia_brands_table.php @@ -0,0 +1,35 @@ +id(); + + $table->unsignedBigInteger('arcadia_id')->unique(); + $table->string('libelle'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('arcadia_brands'); + } +} diff --git a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php b/database/migrations/2022_04_05_145011_create_services_table.php similarity index 54% rename from database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php rename to database/migrations/2022_04_05_145011_create_services_table.php index 4315e16..ec4835d 100644 --- a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php +++ b/database/migrations/2022_04_05_145011_create_services_table.php @@ -4,7 +4,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -class CreatePersonalAccessTokensTable extends Migration +class CreateServicesTable extends Migration { /** * Run the migrations. @@ -13,13 +13,14 @@ class CreatePersonalAccessTokensTable extends Migration */ public function up() { - Schema::create('personal_access_tokens', function (Blueprint $table) { + Schema::create('services', function (Blueprint $table) { $table->id(); - $table->morphs('tokenable'); + $table->string('name'); - $table->string('token', 64)->unique(); - $table->text('abilities')->nullable(); - $table->timestamp('last_used_at')->nullable(); + $table->string('bearer_token')->unique(); + $table->string('bearer_pwd', 312); + $table->timestamp('bearer_token_date'); + $table->timestamps(); }); } @@ -31,6 +32,6 @@ class CreatePersonalAccessTokensTable extends Migration */ public function down() { - Schema::dropIfExists('personal_access_tokens'); + Schema::dropIfExists('services'); } } diff --git a/database/migrations/2022_04_08_112055_create_arcadia_brand_service_table.php b/database/migrations/2022_04_08_112055_create_arcadia_brand_service_table.php new file mode 100644 index 0000000..a21d73d --- /dev/null +++ b/database/migrations/2022_04_08_112055_create_arcadia_brand_service_table.php @@ -0,0 +1,31 @@ +foreignId('service_id')->constrained('services'); + $table->foreignId('arcadia_brand_id')->constrained('arcadia_brands'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('arcadia_brand_service'); + } +} diff --git a/database/migrations/2021_11_19_102629_create_xml_incos_table.php b/database/migrations/2022_04_19_102629_create_xml_incos_table.php similarity index 88% rename from database/migrations/2021_11_19_102629_create_xml_incos_table.php rename to database/migrations/2022_04_19_102629_create_xml_incos_table.php index fbd3c42..e15767d 100644 --- a/database/migrations/2021_11_19_102629_create_xml_incos_table.php +++ b/database/migrations/2022_04_19_102629_create_xml_incos_table.php @@ -15,13 +15,13 @@ class CreateXmlIncosTable extends Migration public function up() { Schema::create('xml_incos', function (Blueprint $table) { - $table->id(); + $table->id()->comment('Inco ID (NO_ART)'); $table->string('nom')->nullable(); $table->boolean('prod_saison'); $table->string('allegations')->nullable(); $table->text('descriptif')->nullable(); - $table->json('picture'); + $table->json('pictures'); $table->text('astuces')->nullable(); $table->string('lib_conso')->nullable(); $table->string('nb_portion', 20)->nullable(); @@ -35,6 +35,8 @@ class CreateXmlIncosTable extends Migration $table->json('nutritional_values'); + $table->foreignId('arcadia_brand_id')->constrained('arcadia_brands'); + $table->timestamps(); }); } diff --git a/database/migrations/2022_04_19_145010_create_service_stats.php b/database/migrations/2022_04_19_145010_create_service_stats.php new file mode 100644 index 0000000..f2b05f8 --- /dev/null +++ b/database/migrations/2022_04_19_145010_create_service_stats.php @@ -0,0 +1,37 @@ +id(); + + $table->foreignId('service_id')->constrained('services'); + $table->date('date'); + $table->unsignedBigInteger('nb_calls')->default(0); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('service_stats'); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 57b73b5..0332abc 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,7 +2,13 @@ namespace Database\Seeders; +use App\Models\ArcadiaBrand; +use App\Models\Service; +use App\Models\XmlInco; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; +use SplFileInfo; class DatabaseSeeder extends Seeder { @@ -13,6 +19,35 @@ class DatabaseSeeder extends Seeder */ public function run() { - // \App\Models\User::factory(10)->create(); + /** @var \Illuminate\Database\Eloquent\Collection $services */ + $services = Service::factory(5)->create(); + /** @var \Illuminate\Database\Eloquent\Collection $brands */ + $brands = ArcadiaBrand::factory(10)->create(); + + // * Associate brands to services + $services->map(function (Service $service) use (&$brands) { + $pullBrands = $brands->random(rand(1, 2)); + $service->arcadiaBrands()->sync($pullBrands); + $brands = $brands->diff($pullBrands); + }); + + $disk = Storage::disk('inco'); + + $brands->map(function (ArcadiaBrand $brand) use ($disk) { + XmlInco::factory(rand(150, 355))->make() + ->each(function (XmlInco $xmlInco) use ($disk, $brand) { + $xmlInco->pictures = collect([ + new SplFileInfo(resource_path('seeders/xml_inco/fakeimage1.png')), + new SplFileInfo(resource_path('seeders/xml_inco/fakeimage2.png')) + ])->random(rand(0, 2))->map(function (SplFileInfo $image) use ($disk) { + $imgNamePath = $image->getFilename(); + $imageFinalPath = "images/inco/$imgNamePath"; + $disk->put($imgNamePath, File::get($image->getPathname())); + return $imageFinalPath; + })->all(); + $xmlInco->arcadiaBrand()->associate($brand); + $xmlInco->saveOrFail(); + }); + }); } } diff --git a/phpunit.xml b/phpunit.xml index 4ae4d97..97cd15a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,31 +1,31 @@ - + - - ./tests/Unit - - - ./tests/Feature + + ./tests/Api - - - ./app - - + + + ./app + + + + + - - - - - - - - - + + + + + + + + + + + + + diff --git a/storage/inco/.gitkeep b/resources/documents/arcadia/marque.xsd similarity index 100% rename from storage/inco/.gitkeep rename to resources/documents/arcadia/marque.xsd diff --git a/resources/documents/inco/incollector.xsd b/resources/documents/inco/incollector.xsd new file mode 100644 index 0000000..6c9fe89 --- /dev/null +++ b/resources/documents/inco/incollector.xsd @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/seeders/xml_inco/fakeimage1.png b/resources/seeders/xml_inco/fakeimage1.png new file mode 100644 index 0000000000000000000000000000000000000000..98a37a98c10fa3a602c21201e2d4d1881065a3f3 GIT binary patch literal 3118277 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

9@-mLqw1FHbCRxk9vTe+U>le#9+GKwd_a{wtZ!q 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

Y!-qL;&Fhzm2G&~js|Yy+ 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