Compare commits

...
Sign in to create a new pull request.

25 commits

Author SHA1 Message Date
Jérémy Munsch
24604f5c9e
update client submodule 2024-06-05 15:02:14 +02:00
Jérémy Munsch
077ca889b3
updated client 2023-10-05 09:53:45 +02:00
Jérémy Munsch
0ee73c16c9
Inco parse job ok 2023-10-04 14:04:03 +02:00
Jérémy Munsch
caa9c17b8a
Full unit tests ok 2023-10-04 09:45:04 +02:00
Jérémy Munsch
280acef9d1
With client Working 2023-10-03 15:27:16 +02:00
Jérémy Munsch
e6a65cf4a6
add client as submodule 2023-10-02 16:30:02 +02:00
Jérémy Munsch
94f1634791
Units tests for api 2023-10-02 14:58:25 +02:00
Jérémy Munsch
97062e8d91
Remove some useless enums 2023-10-02 12:26:45 +02:00
Jérémy Munsch
c0c1608ad5
Api calls ok 2023-10-02 12:26:41 +02:00
Jérémy Munsch
50d304c6ce
Big bump using base project 2023-09-29 16:33:35 +02:00
Jérémy Munsch
1d07b4fb8f
Tons of corrections + service stats 2023-09-28 16:50:15 +02:00
Jérémy Munsch
70e55f6732
Stats by brand bo Ok 2023-09-28 13:16:56 +02:00
Jérémy Munsch
dd7bbc3b49
Services BO ok 2023-09-28 12:05:50 +02:00
Jérémy Munsch
5c11fa8468
Xml Inco show + bug fixes 2023-09-27 12:12:57 +02:00
Jérémy Munsch
82ca7da7db
Crud brands 2023-09-26 17:59:02 +02:00
Jérémy Munsch
8659827b93
Dashboard ok 2023-09-26 17:59:02 +02:00
Jérémy Munsch
749aff1401
Npm compile 2023-09-26 17:59:01 +02:00
Jérémy Munsch
c3595ad6c0
All data + migration + seeders + models + xml parse OK 2023-09-26 17:58:43 +02:00
Jérémy Munsch
c66a67c889
Xml jobs ok 2023-09-20 17:50:26 +02:00
Jérémy Munsch
f694850e0d
Add migration comments 2023-09-20 14:25:13 +02:00
Jérémy Munsch
37e12669e5
phpcs fix 2023-09-20 14:08:36 +02:00
Jérémy Munsch
a2cae01503
Refactor seeders 2023-09-20 14:05:56 +02:00
Jérémy Munsch
8addafcefd
Reapply exising code on baseproject 2023-09-20 12:30:43 +02:00
Jérémy Munsch
96d2e3960b
Copy base project 2023-09-20 11:42:42 +02:00
Jérémy Munsch
46ea9e24f5
Wipe PROJECT 2023-09-20 11:33:41 +02:00
443 changed files with 177341 additions and 5189 deletions

5
.browserslistrc Normal file
View file

@ -0,0 +1,5 @@
# Browsers that we support
last 3 version
> 0.3%
not dead # no browsers without security updates

View file

@ -3,9 +3,9 @@ root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
@ -16,3 +16,6 @@ indent_size = 2
[docker-compose.yml]
indent_size = 4
[*.vue]
indent_size = 2

View file

@ -17,7 +17,7 @@ DB_PASSWORD=
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DRIVER=local
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
@ -29,17 +29,14 @@ REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
MAIL_LIST_SERVICEWEB=
MAIL_LIST_INVALIDINCO=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
@ -49,7 +46,16 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# Sentry
SENTRY_LARAVEL_DSN=

49
.env.testing Normal file
View file

@ -0,0 +1,49 @@
APP_NAME=Laravel
APP_ENV=testing
APP_KEY=base64:UypKzhOMAs2aDu3ovgw/lINvWZQWicbz9nfUXDpDzeQ=
APP_DEBUG=true
APP_URL="http://127.0.0.25:8888"
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3308
DB_DATABASE=testing_flucollector
DB_USERNAME=root
DB_PASSWORD=:Mysql.Docker
MIGRATIONPAGINATION=20000
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_FROM_ADDRESS=crm@ldc.fr
MAIL_FROM_NAME="${APP_NAME}"
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=
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}"

55
.eslintrc.json Normal file
View file

@ -0,0 +1,55 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:vue/vue3-strongly-recommended",
"@vue/eslint-config-typescript"
],
"overrides": [
{
"files": ["*.ts", "*.vue"],
"rules": {
"no-undef": "off"
}
},
{
"files": ["*.vue"],
"rules": {
"indent": "off"
}
}
],
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["vue", "@typescript-eslint"],
"ignorePatterns": ["webpack.mix.js", "**/vendor/*.js", "**/vendor/**/*.js"],
"rules": {
"indent": ["error", 4, { "SwitchCase": 1 }],
"linebreak-style": ["error", "unix"],
"quotes": ["error", "double"],
"semi": ["error", "always"],
"vue/script-indent": ["error", 2, { "baseIndent": 0, "switchCase": 1 }],
"vue/one-component-per-file": "off"
},
"settings": {
"import/resolver": {
"node": {
"extensions": [".js", ".vue", ".ts", ".d.ts"]
},
"alias": {
"extensions": [".vue", ".js", ".ts", ".scss", ".d.ts"],
"map": [
["~", "./resources/assets"],
["@", "./node_modules"]
]
}
}
}
}

14
.gitattributes vendored
View file

@ -1,5 +1,11 @@
* text=auto
*.css linguist-vendored
*.scss linguist-vendored
*.js linguist-vendored
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

20
.gitignore vendored
View file

@ -1,14 +1,26 @@
.env
.env.backup
.env.production
.phpunit.result.cache
.stylelintcache
/.fleet
/.idea
/.phpunit.cache
/.vscode
/node_modules
/public/build
/public/cache
/public/hot
/public/sitemap.xml
/public/storage
/storage/*.key
/vendor
.env
.env.backup
.phpunit.result.cache
auth.json
docker-compose.override.yml
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/.idea
.npmrc
storage/inco
storage/marque

24
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,24 @@
include:
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'lint/lint.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'security/security.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'build/build.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'test/test.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'deploy/deploy.yml'
stages:
- lint
- security
- build
- test
- deploy

View file

@ -0,0 +1,11 @@
## Comportement constaté
## Comportement attendu
## Solution envisagée
## Étapes pour la reproduction du bug
*

View file

@ -0,0 +1,7 @@
## Présentation du besoin
## Implémentation
## Maquettes, documentations...

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "client"]
path = client
url = git@lab.s-web.fr:ldc-web/projets/flucollector/flucollector-client.git

View file

@ -1,14 +0,0 @@
php:
preset: laravel
version: 8
disabled:
- no_unused_imports
finder:
not-name:
- index.php
- server.php
js:
finder:
not-name:
- webpack.mix.js
css: true

48
.stylelintrc.json Normal file
View file

@ -0,0 +1,48 @@
{
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-sass-guidelines",
"stylelint-config-idiomatic-order"
],
"plugins": [
"stylelint-scss"
],
"files": [
"**/*.css",
"**/*.scss"
],
"ignoreFiles": [
"**/vendor/*.css",
"**/vendor/**/*.css",
"**/vendor/*.scss",
"**/vendor/**/*.scss",
"**/vendor/*.sass",
"**/vendor/**/*.sass",
"**/vendor/*.less",
"**/vendor/**/*.less"
],
"customSyntax": "postcss-scss",
"rules": {
"indentation": 4,
"string-quotes": "double",
"selector-max-id": null,
"color-no-invalid-hex": true,
"order/properties-alphabetical-order": null,
"max-nesting-depth": 5,
"selector-max-compound-selectors": 5,
"declaration-colon-space-after": "always-single-line",
"function-parentheses-space-inside": "never-single-line",
"declaration-colon-newline-after": null,
"value-list-comma-newline-after": null,
"scss/double-slash-comment-empty-line-before": [
"always",
{
"ignore": [
"between-comments",
"stylelint-commands",
"inside-block"
]
}
]
}
}

20
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,20 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"rvest.vs-code-prettier-eslint",
"onecentlin.laravel-extension-pack",
"johnpapa.vscode-peacock",
"xdebug.php-debug",
"neilbrayfield.php-docblocker",
"bmewburn.vscode-intelephense-client",
"valeryanm.vscode-phpsab",
"syler.sass-indented",
"sibiraj-s.vscode-scss-formatter",
"mrmlnc.vscode-scss",
"stylelint.vscode-stylelint",
"vue.vscode-typescript-vue-plugin",
"wscats.vue",
"vue.volar",
"misterj.vue-volar-extention-pack"
]
}

49
.vscode/launch.json vendored
View file

@ -1,49 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Listen for Xdebug",
"type": "php",
"request": "launch",
"hostname": "xdebug-incollector.s-web.test",
"port": 9003
},
{
"name": "Launch currently open script",
"type": "php",
"request": "launch",
"program": "${file}",
"cwd": "${fileDirname}",
"port": 0,
"runtimeArgs": [
"-dxdebug.start_with_request=yes"
],
"env": {
"XDEBUG_MODE": "debug,develop",
"XDEBUG_CONFIG": "client_port=${port}"
}
},
{
"name": "Launch Built-in web server",
"type": "php",
"request": "launch",
"runtimeArgs": [
"-dxdebug.mode=debug",
"-dxdebug.start_with_request=yes",
"-S",
"localhost:0"
],
"program": "",
"cwd": "${workspaceRoot}",
"port": 9003,
"serverReadyAction": {
"pattern": "Development Server \\(http://localhost:([0-9]+)\\) started",
"uriFormat": "http://localhost:%s",
"action": "openExternally"
}
}
]
}

23
.vscode/settings.json vendored
View file

@ -1,23 +1,24 @@
{
"phpcs.executablePath": "vendor/bin/phpcs",
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#a714ac",
"activityBar.activeBorder": "#eb549a",
"activityBar.background": "#a714ac",
"activityBar.activeBackground": "#bf106b",
"activityBar.background": "#bf106b",
"activityBar.foreground": "#e7e7e7",
"activityBar.inactiveForeground": "#e7e7e799",
"activityBarBadge.background": "#eb549a",
"activityBarBadge.foreground": "#15202b",
"sash.hoverBorder": "#a714ac",
"statusBar.background": "#0f437e",
"activityBarBadge.background": "#213b05",
"activityBarBadge.foreground": "#e7e7e7",
"commandCenter.border": "#e7e7e799",
"sash.hoverBorder": "#bf106b",
"statusBar.background": "#900c51",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#a714ac",
"statusBarItem.remoteBackground": "#0f437e",
"statusBarItem.hoverBackground": "#bf106b",
"statusBarItem.remoteBackground": "#900c51",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#0f437e",
"titleBar.activeBackground": "#900c51",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#0f437e99",
"titleBar.inactiveBackground": "#900c5199",
"titleBar.inactiveForeground": "#e7e7e799"
},
"peacock.remoteColor": "#0f437e"
"peacock.remoteColor": "#900c51"
}

6
CHANGELOG.md Normal file
View file

@ -0,0 +1,6 @@
# Changelog
### All notable changes to `Flucollector` will be documented in this file.
# version 1.0.0
Init release

21
LICENCE.md Normal file
View file

@ -0,0 +1,21 @@
MIT License (!! includes CC BY-NC 3.0 parts please read Readme.md)
Copyright (c) 2023
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,16 +1,52 @@
# Incollector
# Comment lancer les tests unitaires ?
## .env
mettre :
```
### Attention, si vous utilisez juste un .env à la place d'un .env.testing votre base de donnée de developpement sera effacée à chaque lancement des tests unitaires
```
Etapes :
- cp .env.testing.example .env.testing (spécial tests unitaires)
- php artisan key:generate
- parameter les BDD dans le .env.testing
- php artisan config:cache
- git submodule init
- git submodule update (récupérer le client)
- composer install
- service start mysql (votre base de donnée accessible sur 127.0.0.1:3306)
- chmod u+x ./test.sh
- php artisan optimize
- composer run test
## Flux
Flux INCO dans :
```
storage/inco
```
Config Supervisor 2 Fichiers :
## Scheduled Jobs
`uwilflucollector-worker.conf`
**Pour les jobs en général**
[program:uwilflucollector-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /srcs/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
startasgroup=true
killasgroup=true
user=www-data
umask=077
numprocs=8
redirect_stderr=true
stdout_logfile=/srcs/storage/logs/worker.log
stopwaitsecs=3600
`uwilflucollector-stats-worker.conf`
**Pour les jobs de stats uniquement**
[program:uwilflucollector-stats-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /srcs/artisan queue:work --queue=stats --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
startasgroup=true
killasgroup=true
user=www-data
umask=077
numprocs=1
redirect_stderr=true
stdout_logfile=/srcs/storage/logs/worker.log
stopwaitsecs=3600

View file

@ -0,0 +1,61 @@
<?php
namespace App\Casts;
use App\Lib\XmlInco\Allegations;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class AllegationCast implements CastsAttributes
{
/**
* Cast the given value.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array<string,mixed> $attributes
* @return mixed
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceBeforeLastUsed
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceAfterLastUsed
*/
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
// phpcs:enable
if (\is_null($value)) {
return $value;
}
if (!\is_string($value)) {
$value = \strval($value);
}
$value = \json_decode($value, true);
/** @var array<string[]> $value */
return new Allegations(...$value);
}
/**
* Prepare the given value for storage.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array<string,mixed> $attributes
* @return mixed
* @throws \RuntimeException If $value is not an intance of PortionAmount.
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceBeforeLastUsed
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceAfterLastUsed
*/
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
// phpcs:enable
if (\is_null($value)) {
return $value;
}
if (\is_object($value) and $value instanceof Allegations) {
return $value->toJson();
}
throw new \RuntimeException("Cannot cast {$key}, need instance of " . Allegations::class);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Casts;
use App\Lib\XmlInco\NutritionalValues;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class NutrionalValuesCast implements CastsAttributes
{
/**
* Cast the given value.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array<string,mixed> $attributes
* @return mixed
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceBeforeLastUsed
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceAfterLastUsed
*/
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
// phpcs:enable
if (\is_null($value)) {
return $value;
}
if (!\is_string($value)) {
$value = \strval($value);
}
$value = \json_decode($value, true);
/** @var array<string,float|integer|null|string> $value */
return new NutritionalValues($value);
}
/**
* Prepare the given value for storage.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array<string,mixed> $attributes
* @return mixed
* @throws \RuntimeException If $value is not an intance of PortionAmount.
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceBeforeLastUsed
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceAfterLastUsed
*/
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
// phpcs:enable
if (\is_null($value)) {
return $value;
}
if (\is_object($value) and $value instanceof NutritionalValues) {
return $value->toJson();
}
throw new \RuntimeException("Cannot cast {$key}, need instance of " . NutritionalValues::class);
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace App\Casts;
use App\Lib\XmlInco\PortionAmount;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class PortionAmountCast implements CastsAttributes
{
/**
* Cast the given value.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array<string,mixed> $attributes
* @return mixed
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceBeforeLastUsed
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceAfterLastUsed
*/
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
// phpcs:enable
if (\is_null($value)) {
return $value;
}
if (\is_numeric($value)) {
$value = \intval($value);
}
return PortionAmount::fromDatabase($value);
}
/**
* Prepare the given value for storage.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array<string,mixed> $attributes
* @return mixed
* @throws \RuntimeException If $value is not an intance of PortionAmount.
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceBeforeLastUsed
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceAfterLastUsed
*/
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
// phpcs:enable
if (\is_null($value)) {
return $value;
}
if (\is_object($value) and $value instanceof PortionAmount) {
return $value->toDatabaseValue();
}
throw new \RuntimeException("Cannot cast {$key}, need instance of " . PortionAmount::class);
}
}

View file

@ -0,0 +1,238 @@
<?php
namespace App\Console\Commands;
use App\Enums\Api\ApiError;
use App\Enums\Api\ApiStatus;
use App\Enums\XmlInco\PortionType;
use App\Exceptions\Api\ApiErrorException;
use App\Exceptions\Api\ApplicationException;
use App\Lib\XmlInco\Allegations;
use App\Lib\XmlInco\NutritionalValues;
use App\Lib\XmlInco\PortionAmount;
use App\Traits\Enums\BaseEnum;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
/**
* @codeCoverageIgnore
*/
class GenerateClient extends Command
{
private const TARGET = 'client/src';
private const CLIENTNAMESPACE = 'UWIL\\Flucollector';
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'client:generate';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate the flucollector client in `client` folder';
/**
* Execute the console command.
*
* @return integer
*/
public function handle(): int
{
$this->copyLibXmlIncoClasses();
$this->copyTraits();
$this->copyEnums();
$this->copyExceptions();
$this->copyRoutesList();
$this->copyApiConfigInfo();
return 0;
}
/**
* Copy config files
*
* @return void
*/
private function copyApiConfigInfo(): void
{
$this->info('Copy Api Config');
$rootTarget = app_path('../' . self::TARGET);
if (File::exists("$rootTarget/../apiConfig.readonly.json")) {
File::delete("$rootTarget/../apiConfig.readonly.json");
}
File::put(
"$rootTarget/../apiConfig.readonly.json",
\json_encode(config('api'), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
}
/**
* Copy routes into a json file
*
* @return void
*/
private function copyRoutesList(): void
{
$this->info('Copy Routes');
$routes = [];
/** @var \Illuminate\Routing\Route */
foreach (Route::getRoutes() as $route) {
if (\strpos($route->getPrefix(), 'api') !== false) {
$routes[$route->getName()] = [
'uri' => $route->uri(),
'methods' => $route->methods()
];
}
}
$rootTarget = app_path('../' . self::TARGET);
if (File::exists("$rootTarget/routes.json")) {
File::delete("$rootTarget/routes.json");
}
File::put(
"$rootTarget/routes.json",
\json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
}
/**
* Copy Exeption classes
*
* @return void
*/
private function copyExceptions(): void
{
$this->info('Copy Exceptions');
$rootTarget = app_path('../' . self::TARGET);
$classes = [
ApplicationException::class,
ApiErrorException::class
];
$this->copyToFolder($classes, 'Exceptions');
foreach ($classes as $class) {
$classBasename = class_basename($class);
$dest = "$rootTarget/Exceptions/$classBasename.php";
$this->replaceInPhpFile($dest, 'App\\', self::CLIENTNAMESPACE . '\\');
}
}
/**
* Copy base enums classes
*
* @return void
*/
private function copyTraits(): void
{
$this->info('Copy Traits');
$rootTarget = app_path('../' . self::TARGET);
$classes = [
BaseEnum::class
];
$this->copyToFolder($classes, 'Traits');
foreach ($classes as $class) {
$classBasename = class_basename($class);
$dest = "$rootTarget/Traits/$classBasename.php";
$this->replaceInPhpFile($dest, 'App\\', self::CLIENTNAMESPACE . '\\');
}
}
/**
* Copy enums classes
*
* @return void
*/
private function copyEnums(): void
{
$this->info('Copy Enums');
$classes = [
ApiError::class,
ApiStatus::class,
PortionType::class,
];
$this->copyToFolder($classes, 'Enums');
$rootTarget = app_path('../' . self::TARGET);
foreach ($classes as $class) {
$classBasename = class_basename($class);
$dest = "{$rootTarget}/Enums/{$classBasename}.php";
$this->replaceInPhpFile($dest, 'App\\', self::CLIENTNAMESPACE . '\\');
}
}
/**
* Copy lib XmlInco classes
*
* @return void
*/
private function copyLibXmlIncoClasses(): void
{
$this->info('Copy Lib XmlInco classes');
$classes = [
Allegations::class,
NutritionalValues::class,
PortionAmount::class,
];
$this->copyToFolder($classes, 'Lib');
$rootTarget = app_path('../' . self::TARGET);
foreach ($classes as $class) {
$classBasename = class_basename($class);
$dest = "{$rootTarget}/Lib/{$classBasename}.php";
$this->replaceInPhpFile($dest, 'App\\', self::CLIENTNAMESPACE . '\\');
}
}
/**
* Copy Classes to specified folder
*
* @param array $classes
* @param string $folder
* @return void
*/
private function copyToFolder(array $classes, string $folder): void
{
$rootTarget = app_path('../' . self::TARGET);
$folderNamespace = \str_replace('/', '\\', $folder);
foreach ($classes as $class) {
$classFileName = \str_replace("App\\$folderNamespace\\", '', sprintf('%s.php', $class));
$classFileName = \str_replace('\\', '/', $classFileName);
$classBasename = class_basename($class);
$source = \app_path("$folder/$classFileName");
$dest = "$rootTarget/$folder/$classBasename.php";
if (File::exists($dest)) {
File::delete($dest);
}
File::copy($source, $dest);
$this->replaceInPhpFile($dest, 'namespace App', 'namespace ' . self::CLIENTNAMESPACE);
}
}
/**
* Replaces a specific string with another on a file
*
* @param string $source
* @param string $search
* @param string $replace
* @return void
*/
private function replaceInPhpFile(string $source, string $search, string $replace): void
{
$fContent = File::get($source);
// Remove trailing \Api .
$fContent = Str::of($fContent)->replaceMatches("/\\\(Api)([\s;\\\])/", '$2');
// Remove trailing \XmlInco .
$fContent = Str::of($fContent)->replaceMatches("/\\\(XmlInco)([\s;\\\])/", '$2');
$fContent = Str::of($fContent)->replaceMatches("/\\\Traits\\\Enums/", '\Traits');
// Replace namespace top level.
$pos = strpos($fContent, $search);
if ($pos !== false) {
File::put($source, \str_replace($search, $replace, $fContent));
}
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class TestMail extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'uwil:mail:test';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Send test email to equipe.web@ldc.fr';
/**
* Execute the console command.
*
* @return void
*/
public function handle()
{
Mail::raw(sprintf('UWIL mail-test from %s %s', \config('app.name'), \config('app.url')), function ($msg) {
$msg->to('equipe.web@ldc.fr')->subject('Test Email');
});
}
}

View file

@ -0,0 +1,231 @@
<?php
namespace App\Console\Commands;
use App\Enums\Users\RoleEnum;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class UserCreate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'uwil:user:create';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a backend admin user';
/**
* The first name of the new user.
*
* @var string
*/
protected $first_name;
/**
* The last name of the new user.
*
* @var string
*/
protected $last_name;
/**
* The email of the new user.
*
* @var string
*/
protected $email;
/**
* The password of the new user.
*
* @var string
*/
protected $password;
/**
* The role of the new user.
*
* @var string
*/
protected $role;
/**
* Execute the console command.
*
* @return integer
*/
public function handle(): int
{
$this->first_name = null;
$this->addFirstName();
$this->last_name = null;
$this->addLastName();
$this->email = null;
$this->addEmail();
$this->password = null;
$this->addPassword();
$this->role = null;
$this->addRole();
$user = new User([
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'email' => $this->email,
'password' => $this->password,
'role' => $this->role,
'published' => true,
'published_at' => Carbon::now(),
]);
$user->saveOrFail();
$this->info('User created 👌');
return 0;
}
/**
* Add a first name to the user.
*
* @return void
*/
private function addFirstName(): void
{
while (is_null($this->first_name)) {
try {
$tmp = $this->ask('Type the wanted user first name', 'Admin');
$validator = Validator::make(['first_name' => $tmp], [
'first_name' => 'required|min:3|max:255'
]);
$validator->validate();
$this->first_name = $tmp;
} catch (ValidationException $e) {
$this->error(sprintf(
'Please provide a valid first name %s',
\implode(',', collect($e->errors())->flatten()->all())
));
continue;
} //end try
}; //end while
}
/**
* Add a last name to the user.
*
* @return void
*/
private function addLastName(): void
{
while (is_null($this->last_name)) {
try {
$tmp = $this->ask('Type the wanted user last name', 'Saïtama');
$validator = Validator::make(['last_name' => $tmp], [
'last_name' => 'required|min:3|max:255'
]);
$validator->validate();
$this->last_name = $tmp;
} catch (ValidationException $e) {
$this->error(sprintf(
'Please provide a valid last_name %s',
\implode(',', collect($e->errors())->flatten()->all())
));
continue;
} //end try
}; //end while
}
/**
* Add an email to the user.
*
* @return void
*/
private function addEmail(): void
{
while (is_null($this->email)) {
try {
$tmp = $this->ask('Type the wanted user email', 'equipe.web@ldc.fr');
$validator = Validator::make(['email' => $tmp], [
'email' => 'required|unique:users,email|email:rfc,dns'
]);
$validator->validate();
$this->email = $tmp;
} catch (ValidationException $e) {
$this->error(sprintf(
'Please provide a valid email %s',
\implode(',', collect($e->errors())->flatten()->all())
));
continue;
} //end try
}; //end while
}
/**
* Add a password to the user.
*
* @return void
*/
private function addPassword(): void
{
while (
($this->password = $this->secret(
(!\is_null($this->password) ? 'Failed to confirm, ' : '') . 'Type the wanted user password'
)) !== $this->secret('Confirm the password')
) {
continue;
}; //end while
}
/**
* Add a role to the user.
*
* @return void
*/
private function addRole(): void
{
while (is_null($this->role)) {
try {
$tmp = $this->choice(
'Select his role',
[RoleEnum::conceptor->name, RoleEnum::admin->name, RoleEnum::visitor->name],
RoleEnum::visitor->name,
$maxAttempts = null,
$allowMultipleSelections = false
);
if ($tmp === RoleEnum::conceptor->name) {
$tmp = RoleEnum::conceptor;
}
if ($tmp === RoleEnum::admin->name) {
$tmp = RoleEnum::admin;
}
if ($tmp === RoleEnum::visitor->name) {
$tmp = RoleEnum::visitor;
}
$validator = Validator::make(['role' => $tmp], [
'role' => 'required'
]);
$validator->validate();
$this->role = $tmp;
} catch (ValidationException $e) {
$this->error(sprintf(
'Please choose from the selection',
\implode(',', collect($e->errors())->flatten()->all())
));
continue;
} //end try
}; //end while
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Jobs\Arcadia\XmlBrandReaderJob;
use Illuminate\Console\Command;
class XmlBrandReader extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'xml:brand';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Run the XmlBrandReaderJob manually';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->info('Running the Job, this could take some time.');
$job = new XmlBrandReaderJob();
$job->console = $this;
$job->handle();
$this->info(' Job completed');
}
}

View file

@ -2,12 +2,11 @@
namespace App\Console\Commands;
use App\Jobs\XmlIncoReader as JobsXmlIncoReader;
use App\Jobs\Arcadia\XmlIncoReaderJob;
use Illuminate\Console\Command;
class XmlIncoReader extends Command
{
/**
* The name and signature of the console command.
*
@ -30,9 +29,9 @@ 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');
$this->info(' Job completed');
}
}

View file

@ -7,25 +7,14 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array
*/
protected $commands = [
//
];
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
// $schedule->command('inspire')->hourly();
//$schedule->job(new XmlIncoReader)->weeklyOn(1, '8:00');
}
/**
@ -35,7 +24,7 @@ class Kernel extends ConsoleKernel
*/
protected function commands()
{
$this->load(__DIR__.'/Commands');
$this->load(__DIR__ . '/Commands');
require base_path('routes/console.php');
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Enums\ActivityLogs;
use App\Traits\Enums\BaseEnum;
enum ActivityLogsEventEnum: int
{
use BaseEnum;
case created = 1;
case updated = 2;
case duplicated = 3;
case deleted = 4;
/**
* Optionnal labels definition
*/
private const LABELS = [
self::created->name => 'Création',
self::updated->name => 'Mise à jour',
self::duplicated->name => 'Duplication',
self::deleted->name => 'Suppression',
];
/**
* Custom added classes definition
*/
private const BOOTSTRAPCLASS = [
self::created->name => 'success',
self::updated->name => 'warning',
self::duplicated->name => 'secondary',
self::deleted->name => 'danger',
];
/**
* Get Class
*
* @return string
*/
public function bootstrapClass(): string
{
return self::BOOTSTRAPCLASS[$this->name];
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Enums\Api;
use App\Traits\Enums\BaseEnum;
enum ApiError: int
{
use BaseEnum;
case modelNotFound = 101;
case validationFailed = 102;
case authFailed = 117;
case apiAuthFailed = 119;
case methodNotAllowed = 150;
case wrongTimeStamp = 151;
case missingParameter = 400;
case authorizationException = 403;
case throttleRequestsException = 429;
/**
* Optionnal labels definition
*/
private const LABELS = [
self::modelNotFound->name => '%s introuvable.',
self::validationFailed->name => 'Impossible de valider les données',
self::authFailed->name => 'Echec de l\'authentification du compte',
self::apiAuthFailed->name => 'Echec de l\'authentification client à l\'Api pour ce service',
self::methodNotAllowed->name => 'This method is not allowed for this request',
// phpcs:ignore Generic.Files.LineLength.TooLong
self::wrongTimeStamp->name => 'Your request is malformed, please check timestamp header and parameter',
self::missingParameter->name => 'The request is missing some parameters',
self::authorizationException->name => 'You are not autorized to access this',
self::throttleRequestsException->name => 'Throttle: too many request have been attempted, please retry later'
];
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Enums\Api;
use App\Traits\Enums\BaseEnum;
enum ApiStatus: int
{
use BaseEnum;
case OK = 1;
case KO = 2;
}

View file

@ -2,20 +2,16 @@
namespace App\Enums;
use Kwaadpepper\Enum\BaseEnumRoutable;
use App\Traits\Enums\BaseEnum;
/**
* This is an enumeration class,
* the following methods are its possible values
*
* @method static self debug()
* @method static self log()
* @method static self warning()
* @method static self info()
* @method static self error()
* @method static self critical()
*
*/
class LogType extends BaseEnumRoutable
enum LogType: int
{
use BaseEnum;
case debug = 1;
case log = 2;
case warning = 3;
case info = 4;
case error = 5;
case critical = 6;
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Enums\Pagination;
use App\Traits\Enums\BaseEnum;
enum ItemsPerPaginationEnum: int
{
use BaseEnum;
case five = 5;
case twelve = 12;
case twentyfive = 25;
case fifty = 50;
case onehundred = 100;
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Enums\Theme;
use App\Traits\Enums\BaseEnum;
enum BootstrapThemeEnum: int
{
use BaseEnum;
case light = 1;
case dark = 2;
/**
* Optionnal labels definition
*/
private const LABELS = [
self::light->name => 'Light',
self::dark->name => 'Dark',
];
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Enums\Users;
use App\Traits\Enums\BaseEnum;
enum RoleEnum: int
{
use BaseEnum;
case conceptor = 1;
case admin = 2;
case visitor = 3;
/**
* Optionnal labels definition.
*/
private const LABELS = [
self::conceptor->name => 'Concepteur',
self::admin->name => 'Administrateur',
self::visitor->name => 'Visiteur',
];
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Enums\XmlInco;
use App\Traits\Enums\BaseEnum;
enum PortionType: int
{
use BaseEnum;
case approximative = 0;
case exact = 1;
/**
* Optionnal labels definition
*/
private const LABELS = [
self::approximative->name => 'approximativement',
self::exact->name => 'exactement'
];
}

View file

@ -2,8 +2,6 @@
namespace App\Exceptions;
use Exception;
class AbortXmlImportException extends Exception
class AbortXmlImportException extends \RuntimeException
{
}

View file

@ -0,0 +1,99 @@
<?php
namespace App\Exceptions\Api;
use App\Enums\Api\ApiError;
use Illuminate\Support\Facades\Log;
/**
* This would be thrown if any of the case
* referenced in \App\Enums\ApiError would
* occur within flucollector
*
* On the client an ApiErrorException would
* also be throw with the ApiError corresponding to
* the situation
*/
class ApiErrorException extends \RuntimeException
{
/**
* Api Error
* This value makes the error more explicit.
* Handling these errors are normal as inputs or
* request may be undesired or refused by flucollector.
*
* @var \App\Enums\Api\ApiError
*/
protected $error;
/**
* This is an explicit error message,
* ou may print this to the front or not depending
* on $error value
*
* @var string
*/
protected $message;
/**
* This is the http code returned by flucollector.
* This value could be used to handle errors.
* But please take in consideration $error first
*
* @var integer
*/
protected $httpCode;
/**
* Create a new exception base on ApiError enum.
*
* @param \App\Enums\Api\ApiError $error
* @param integer $httpCode
* @param string $message
* @param \Throwable|null $previous
*/
public function __construct(ApiError $error, int $httpCode = 400, string $message = '', \Throwable $previous = null)
{
$this->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 \App\Enums\Api\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('FluxCollector API: %s', $this->getMessage()));
// @codeCoverageIgnoreEnd
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Exceptions\Api;
use Illuminate\Support\Facades\Log;
/**
* This is a critical error.
* The request could not be handled. You may dump any value you can
* get before submitting a ticket to https://support.uwil.fr
*/
class ApplicationException extends \RuntimeException
{
/**
* This is used by laravel only.
* It uses Log class to print the error.
*
* @return void
*/
public function report()
{
Log::critical(sprintf('API: %s', $this->getMessage()));
}
}

View file

@ -2,27 +2,39 @@
namespace App\Exceptions;
use Kwaadpepper\ExceptionHandler\Exceptions\ExceptionHandler;
use Throwable;
use App\Enums\Api\ApiError;
use App\Enums\Api\ApiStatus;
use App\Exceptions\Api\ApiErrorException;
use App\Http\Resources\Api\ApiResponse;
use App\Lib\ApiHelper;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
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',
];
@ -32,10 +44,132 @@ class Handler extends ExceptionHandler
*
* @return void
*/
public function register()
public function register(): void
{
$this->reportable(function (Throwable $e) {
//
$this->reportable(function (\Throwable $e) {
if (app()->bound('sentry')) {
app('sentry')->captureException($e);
}
});
}
/**
* Render an exception into an HTTP response.
*
* @param mixed $request
* @param \Throwable $e
* @return \Symfony\Component\HttpFoundation\Response
*/
public function render(mixed $request, \Throwable $e): Response
{
if (ApiHelper::isRequestForApi($request) and $request->isJson()) {
return $this->handleApiException($request, $e);
} //end if
// @codeCoverageIgnoreStart
return parent::render($request, $e);
// @codeCoverageIgnoreEnd
}
/**
* Generate a json response for any apiV4 error case.
*
* @param \Illuminate\Http\Request $request
* @param \Throwable $e
* @return \Illuminate\Http\JsonResponse
*/
private function handleApiException(Request $request, \Throwable $e): JsonResponse
{
switch (get_class($e)) {
case AuthenticationException::class:
/** @var \Illuminate\Auth\AuthenticationException $e */
return $this->getReponse(
$request,
401,
ApiError::apiAuthFailed->value(),
sprintf('%s, %s', ApiError::apiAuthFailed->label(), $e->getMessage())
);
case MethodNotAllowedHttpException::class:
/** @var \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException $e */
return $this->getReponse(
$request,
$e->getStatusCode(),
ApiError::methodNotAllowed->value(),
sprintf('%s, %s', ApiError::methodNotAllowed->label(), $e->getMessage())
);
case ValidationException::class:
/** @var \Illuminate\Validation\ValidationException $e */
return $this->getReponse(
$request,
$e->status,
ApiError::validationFailed->value(),
sprintf(ApiError::validationFailed->label(), $e->getMessage()),
['errors' => $e->errors()]
);
case ModelNotFoundException::class:
/** @var \Illuminate\Database\Eloquent\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 \App\Exceptions\Api\ApiErrorException $e */
return $this->getReponse(
$request,
$e->getStatusCode(),
$e->getCode(),
$e->getMessage()
);
case ThrottleRequestsException::class:
/** @var \Illuminate\Http\Exceptions\ThrottleRequestsException $e */
return $this->getReponse(
$request,
429,
ApiError::throttleRequestsException->value(),
ApiError::throttleRequestsException->label()
);
case AuthorizationException::class:
/** @var \Illuminate\Auth\Access\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, \intval($e->getCode()), $e->getMessage());
// @codeCoverageIgnoreEnd
}
/**
* Generate an Api json response.
*
* @param \Illuminate\Http\Request $request
* @param integer $httpCode
* @param integer $apiCode
* @param string $message
* @param array $data
* @return \Illuminate\Http\JsonResponse
*/
private static function getReponse(
Request $request,
int $httpCode,
int $apiCode,
string $message,
array $data = []
): JsonResponse {
return (new ApiResponse(
(object)array_merge($data, ['message' => $message, 'code' => $apiCode]),
ApiStatus::KO
))->toResponse($request)->setStatusCode($httpCode);
}
}

View file

@ -2,8 +2,6 @@
namespace App\Exceptions;
use Exception;
class InvalidXmlException extends Exception
class InvalidXmlException extends \RuntimeException
{
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class ApiController extends Controller
{
/**
* Just returns HTTP 200 response.
*
* @return \Illuminate\Http\JsonResponse
*/
public function ping(): JsonResponse
{
return response()->json();
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\XmlInco\SearchRequest;
use App\Http\Resources\Api\XmlListResource;
use App\Http\Resources\Api\XmlIncoResource;
use App\Models\Service;
use App\Models\XmlInco;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Support\Carbon;
class XmlIncoController extends Controller
{
/**
* Returns all service questions.
*
* @param \App\Http\Requests\Api\XmlInco\SearchRequest $request
* @param \App\Models\Service $service
* @return \Illuminate\Http\Resources\Json\ResourceCollection
*/
public function search(SearchRequest $request, Service $service): ResourceCollection
{
return new XmlListResource($service->arcadiaBrand->xmlIncos()
->where(fn (Builder $q) => $this->searchQuery(
$q,
$request->search,
null,
'id',
'name'
))->limit(20)->get(['id', 'name']));
}
/**
* Get ids list of updated resources since a specific date
*
* @param \App\Models\Service $service
* @param \Illuminate\Support\Carbon $api_since
* @return \Illuminate\Http\Resources\Json\ResourceCollection
*/
public function getIdListSince(Service $service, Carbon $api_since): ResourceCollection
{
return new XmlListResource(
$service->arcadiaBrand->xmlIncos()
->where('xml_incos.updated_at', '>=', $api_since)->get(['id', 'name']),
null,
true
);
}
/**
* Show Xml Inco resource
*
* @param \App\Models\XmlInco $api_xml_inco
* @return \App\Http\Resources\Api\XmlIncoResource
*/
public function show(XmlInco $api_xml_inco): XmlIncoResource
{
return new XmlIncoResource($api_xml_inco);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Traits\Controllers\ChangesModelOrder;
use App\Traits\Controllers\UpdateModelPublished;
use Illuminate\Http\Request;
class ActivityLogsController extends Controller
{
use ChangesModelOrder;
use UpdateModelPublished;
/**
* Display a listing of the resource.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Contracts\View\View
*/
public function index(Request $request): \Illuminate\Contracts\View\View
{
$this->authorize('isConceptor');
/** @var \Illuminate\Database\Eloquent\Builder $query */
$query = ActivityLog::query()->with('user');
/** @var string $search Search field */
$search = $request->search;
if ($search) {
$this->searchQuery(
$query,
$search,
null,
'model_class',
);
}
$searchFields = \implode(', ', [
trans('validation.custom.target'),
]);
/** Sort columns with a query */
$this->sortQuery($query);
/** Custom pagination */
$activitylogModels = $this->paginate($query);
return view('bo.pages.activity_logs.index', compact('activitylogModels', 'search', 'searchFields'));
}
/**
* Show the form for editing the specified resource.
*
* @param App\Models\ActivityLog $activity_log
* @return \Illuminate\Contracts\View\View
*/
public function show(ActivityLog $activity_log): \Illuminate\Contracts\View\View
{
$this->authorize('isConceptor');
$targetModelClass = $activity_log->model_class;
$targetModel = $activity_log->model_class::where(
(new $targetModelClass())->getRouteKeyName(),
$activity_log->model_id
)->first();
return view('bo.pages.activity_logs.show', [
'activitylogModel' => $activity_log,
'targetModel' => $targetModel,
]);
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Bo\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Support\Facades\Auth;
class LoginController extends Controller
{
use AuthenticatesUsers;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest:backend')->except('logout');
}
/**
* Get the guard to be used during authentication.
*
* @return \Illuminate\Contracts\Auth\StatefulGuard
*/
protected function guard(): StatefulGuard
{
return Auth::guard('backend');
}
/**
* Show the application's login form.
*
* @return \Illuminate\Contracts\View\View
*/
public function showLoginForm(): View
{
return view('bo.auth.login');
}
/**
* Where to redirect users after login.
*
* @return string
*/
protected function redirectTo(): string
{
return response()->redirectToIntended()->getTargetUrl();
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Bo\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Support\Facades\Auth;
class ResetPasswordController extends Controller
{
use ResetsPasswords;
/**
* Get the guard to be used during authentication.
*
* @return \Illuminate\Contracts\Auth\StatefulGuard
*/
protected function guard(): StatefulGuard
{
return Auth::guard('backend');
}
/**
* Where to redirect users after login.
*
* @return string
*/
protected function redirectTo(): string
{
return \route('bo.login');
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Enums\Pagination\ItemsPerPaginationEnum;
use App\Http\Controllers\Controller;
use App\Models\ArcadiaBrand;
use Illuminate\Http\Request;
class BrandController extends Controller
{
/**
* Create the controller instance.
*/
public function __construct()
{
$this->authorizeResource(ArcadiaBrand::class);
}
/**
* Display a listing of the resource.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
/** @var \Illuminate\Database\Eloquent\Builder $query */
$query = ArcadiaBrand::query();
if ($request->search) {
$this->searchQuery(
$query,
$request->search,
null,
'id',
'label',
);
}
$this->sortQuery($query);
$searchFields = trans('Arcadia id, Label');
$brandModels = $this->paginate($query, ItemsPerPaginationEnum::twentyfive);
return view('bo.pages.brands.index', compact('brandModels', 'searchFields'));
}
/**
* Display the specified resource.
*
* @param \App\Models\ArcadiaBrand $arcadiaBrand
* @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
*/
public function show(ArcadiaBrand $arcadiaBrand)
{
return view('bo.pages.brands.show', ['brandModel' => $arcadiaBrand]);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
class DashboardController extends Controller
{
/**
* Dashboard Page
*
* @return \Illuminate\Contracts\View\View
*/
public function index(): View
{
return view('bo.pages.dashboard');
}
}

View file

@ -0,0 +1,204 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Services\StoreServiceRequest;
use App\Http\Requests\Bo\Services\UpdateServiceRequest;
use App\Mail\ServiceCredentialRenewed;
use App\Models\ArcadiaBrand;
use App\Models\Service;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
/**
* @codeCoverageIgnore
*/
class ServiceController extends Controller
{
/**
* Create the controller instance.
*/
public function __construct()
{
$this->authorizeResource(Service::class);
}
/**
* Display a listing of the resource.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Contracts\View\View
*/
public function index(Request $request): \Illuminate\Contracts\View\View
{
/** @var \Illuminate\Database\Eloquent\Builder $query */
$query = Service::query();
if ($request->brand) {
$query = $query->whereHas('arcadiaBrand', $request->brand);
}
if ($request->search) {
$this->searchQuery(
$query,
$request->search,
null,
'name',
);
}
$this->sortQuery($query);
$searchFields = trans('Nom');
$serviceModels = $query->paginate(12);
return view('bo.pages.services.index', compact('serviceModels', 'searchFields'));
}
/**
* Show the form for creating a new resource.
*
* @param \App\Models\Service $service
* @return \Illuminate\Contracts\View\View
*/
public function create(Service $service): \Illuminate\Contracts\View\View
{
$brandModels = ArcadiaBrand::all();
return view('bo.pages.services.create', [
'serviceModel' => $service,
'brandModels' => $brandModels
]);
}
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\Bo\Services\StoreServiceRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function store(StoreServiceRequest $request): \Illuminate\Http\RedirectResponse
{
return DB::transaction(function () use ($request) {
$service = new Service();
$service->fill($request->validated());
$service->generateBearerToken();
$service->saveOrFail();
$this->generateServicePassword($service);
if ($service->saveOrFail()) {
return redirect()->route('bo.services.edit', $service)
->with('success', __('crud.messages.has_been_created', [
'model' => Str::ucfirst(__('models.classes.service'))
]));
}
return redirect()->back()
->with('error', __('crud.messages.cannot_be_created', [
'model' => __('models.classes.service')
]));
});
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\Service $service
* @return \Illuminate\Contracts\View\View
*/
public function edit(Service $service): \Illuminate\Contracts\View\View
{
$brandModels = ArcadiaBrand::all();
return view('bo.pages.services.edit', [
'serviceModel' => $service,
'brandModels' => $brandModels
]);
}
/**
* Update a spacific resource in storage.
*
* @param \App\Http\Requests\Bo\Services\UpdateServiceRequest $request
* @param \App\Models\Service $service
* @return \Illuminate\Http\RedirectResponse
*/
public function update(UpdateServiceRequest $request, Service $service): \Illuminate\Http\RedirectResponse
{
return DB::transaction(function () use ($request, $service) {
$service->fill($request->validated());
if ($service->saveOrFail()) {
return redirect()->route('bo.services.edit', $service->id)
->with('success', __('crud.messages.has_been_updated', [
'model' => Str::ucfirst(__('models.classes.service'))
]));
}
return redirect()->route('bo.services.edit', $service->id)
->with('error', __('crud.messages.cannot_be_updated', [
'model' => Str::ucfirst(__('models.classes.service'))
]));
});
}
/**
* Show the specified resource.
*
* @param \App\Models\Service $service
* @return \Illuminate\Contracts\View\View
*/
public function show(Service $service): \Illuminate\Contracts\View\View
{
return view('bo.pages.services.show', [
'serviceModel' => $service
]);
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\Service $service
* @return \Illuminate\Http\RedirectResponse
*/
public function destroy(Service $service): \Illuminate\Http\RedirectResponse
{
if ($service->deleteOrFail()) {
return redirect()->route('bo.services.index')
->with('success', __('crud.messages.has_been_deleted', [
'model' => __('models.classes.service')
]));
}
return redirect()->back()
->with('error', __('crud.messages.cannot_be_deleted', [
'model' => __('models.classes.service')
]));
}
/**
* Regen service credentials.
*
* @param \App\Models\Service $service
* @return \Illuminate\Http\RedirectResponse
*/
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassAfterLastUsed, Squiz.Commenting.FunctionComment.Missing
public function generateServicePassword(Service $service): \Illuminate\Http\RedirectResponse
{
/** @var \App\Models\User $user */
$user = auth()->user();
// * Special Condition for migration from v2 to v3.
$service->bearer_token = !Hash::needsRehash($service->bearer_token) ?
$service->generateBearerToken() : $service->bearer_token;
$bearerPwd = $service->generateBearerPassword();
$service->saveOrFail();
Mail::send(new ServiceCredentialRenewed($service, $user));
return back()
// phpcs:ignore Generic.Files.LineLength.TooLong
->with('error', trans("ATTENTION : METTEZ À JOUR LE SITE ! \nNouveau token d'API de :service : ':token' et mot de passe ':password'", [
'service' => $service->name,
'token' => $service->bearer_token,
'password' => $bearerPwd,
]));
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\StatsByServiceRequest;
use App\Models\Service;
use App\Models\ServiceStat;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Schema;
use League\Csv\Writer;
/**
* @codeCoverageIgnore
*/
class StatsController extends Controller
{
/**
* Shows stats by service.
*
* @param \App\Http\Requests\Bo\StatsByServiceRequest $request
* @return \Illuminate\Http\Response
*/
public function viewStatsByService(StatsByServiceRequest $request)
{
$stats = null;
if ($request->has('service')) {
/** @var \Illuminate\Database\Eloquent\Collection<\App\Models\ServiceStat> */
$stats = ServiceStat::query()
->with('service')
->whereDate('date', '>=', $request->start)
->whereDate('date', '<=', $request->end)
->whereHas('service', fn (Builder $q) => $q->where('id', $request->service))
->get();
}
return view('bo.pages.stats.view-stats-by-services', \compact('stats'));
}
/**
* Exports stats by service as a csv file.
*
* @param \App\Http\Requests\Bo\StatsByServiceRequest $request
* @return \Illuminate\Http\Response
*/
public function exportByBrandCsv(StatsByServiceRequest $request): Response
{
/** @var \App\Models\Service */
$service = Service::query()->findOrFail($request->service);
/** @var \Illuminate\Database\Eloquent\Collection<\App\Models\ServiceStat> */
$stats = ServiceStat::query()
->with('service.arcadiaBrand')
->whereDate('date', '>=', $request->start)
->whereDate('date', '<=', $request->end)
->whereHas('service', fn (Builder $q) => $q->where('id', $request->service))
->get();
$columns = Schema::getColumnListing((new ServiceStat())->getTable());
$columns = Arr::where($columns, fn ($value) => !in_array($value, ['date', 'service_id']));
/** @var array<string> $columns */
$header = '"date";"service";' . implode(';', $columns);
$w = Writer::createFromString($header . PHP_EOL);
$w->setDelimiter(';');
$w->setEnclosure('"');
$w->setOutputBOM(Writer::BOM_UTF8);
/** @var \App\Models\ServiceStat $stat */
foreach ($stats as $stat) {
/** @var array<string,string> $cols */
$cols = [
$stat->date->isoFormat('YYYY-MM-DD'),
$service->label
];
foreach ($columns as $colname) {
$cols[] = $stat->{$colname};
}
$w->insertOne($cols);
}
return response((string)$w, 200, [
'Content-Encoding' => 'none',
'Content-Type' => 'text/csv; charset=UTF-8',
// phpcs:ignore Generic.Files.LineLength.TooLong
'Content-Disposition' => "attachment; filename=\"Statistiques du {$request->start} au {$request->end} pour {$service->name}.csv\"",
'Content-Description' => 'File Transfer',
]);
}
}

View file

@ -0,0 +1,169 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Users\StoreUserRequest;
use App\Http\Requests\Bo\Users\UpdateUserRequest;
use App\Models\User;
use App\Traits\Controllers\UpdateModelPublished;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class UsersController extends Controller
{
use UpdateModelPublished;
/**
* Create the controller instance.
*/
public function __construct()
{
$this->authorizeResource(User::class);
}
/**
* Display a listing of the resource.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Contracts\View\View
*/
public function index(Request $request): \Illuminate\Contracts\View\View
{
/** @var \Illuminate\Database\Eloquent\Builder $userModels */
$userModels = User::query();
/** @var string $search Search field */
$search = $request->search;
if ($search) {
$this->searchQuery(
$userModels,
$search,
null,
'first_name',
'last_name',
'email',
'role',
);
}
$searchFields = \implode(', ', [
trans('validation.attributes.first_name'),
trans('validation.attributes.last_name'),
trans('validation.attributes.email'),
trans('validation.attributes.role'),
]);
/** Sort columns with a query */
$this->sortQuery($userModels);
/** Custom pagination */
$userModels = $this->paginate($userModels);
return view('bo.pages.users.index', compact('userModels', 'search', 'searchFields'));
}
/**
* Show the form for creating a new resource.
*
* @param \App\Models\User $user
* @return \Illuminate\Contracts\View\View
*/
public function create(User $user): \Illuminate\Contracts\View\View
{
return view('bo.pages.users.create', ['userModel' => $user]);
}
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\Bo\Users\StoreUserRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function store(StoreUserRequest $request): \Illuminate\Http\RedirectResponse
{
return DB::transaction(function () use ($request) {
$user = new User();
$user->fill($request->validated());
if ($user->saveOrFail()) {
return redirect()->route('bo.users.edit', $user)
->with('success', __('crud.messages.has_been_created', [
'model' => Str::ucfirst(__('models.classes.user'))
]));
}
return redirect()->back()
->with('error', __('crud.messages.cannot_be_created', [
'model' => Str::ucfirst(__('models.classes.user'))
]));
});
}
/**
* Show the form for editing the specified resource.
*
* @param App\Models\User $user
* @return \Illuminate\Contracts\View\View
*/
public function edit(User $user): \Illuminate\Contracts\View\View
{
return view('bo.pages.users.edit', ['userModel' => $user]);
}
/**
* Update the specified resource in storage.
*
* @param \App\Http\Requests\Bo\Users\UpdateUserRequest $request
* @param \App\Models\User $user
* @return \Illuminate\Http\RedirectResponse
*/
public function update(UpdateUserRequest $request, User $user): \Illuminate\Http\RedirectResponse
{
return DB::transaction(function () use ($request, $user) {
$user->fill($request->validated());
if ($user->saveOrFail()) {
return redirect()->route('bo.users.edit', $user)
->with('success', __('crud.messages.has_been_updated', [
'model' => Str::ucfirst(__('models.classes.user'))
]));
}
return redirect()->route('bo.users.edit', $user)
->with('error', __('crud.messages.cannot_be_updated', [
'model' => Str::ucfirst(__('models.classes.user'))
]));
});
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\User $user
* @return \Illuminate\Http\RedirectResponse
*/
public function destroy(User $user): \Illuminate\Http\RedirectResponse
{
if ($user->deleteOrFail()) {
return redirect()->route('bo.users.index')
->with('success', __('crud.messages.has_been_deleted', [
'model' => Str::ucfirst(__('models.classes.user'))
]));
}
return redirect()->back()
->with('error', __('crud.messages.cannot_be_deleted', [
'model' => Str::ucfirst(__('models.classes.user'))
]));
}
/**
* Duplicate the specified resource.
*
* @param \App\Models\User $user
* @return \Illuminate\Contracts\View\View
*/
public function duplicate(User $user): \Illuminate\Contracts\View\View
{
return $this->create($user->replicate());
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Enums\Pagination\ItemsPerPaginationEnum;
use App\Http\Controllers\Controller;
use App\Models\XmlInco;
use Illuminate\Http\Request;
class XmlIncoController extends Controller
{
/**
* Create the controller instance.
*/
public function __construct()
{
$this->authorizeResource(XmlInco::class);
}
/**
* Display a listing of the resource.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
/** @var \Illuminate\Database\Eloquent\Builder $query */
$query = XmlInco::query();
if ($request->search) {
$this->searchQuery(
$query,
$request->search,
null,
'id',
'name',
);
}
$this->sortQuery($query);
$searchFields = trans('Arcadia id, Nom');
$xmlIncoModels = $this->paginate($query, ItemsPerPaginationEnum::twentyfive);
return view('bo.pages.xml_incos.index', compact('xmlIncoModels', 'searchFields'));
}
/**
* Display the specified resource.
*
* @param \App\Models\XmlInco $xml_inco
* @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
*/
public function show(XmlInco $xml_inco)
{
return view('bo.pages.xml_incos.show', ['xmlIncoModel' => $xml_inco]);
}
}

View file

@ -2,12 +2,230 @@
namespace App\Http\Controllers;
use App\Enums\Pagination\ItemsPerPaginationEnum;
use App\Enums\Theme\BootstrapThemeEnum;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
use AuthorizesRequests;
use ValidatesRequests;
/**
* Build Search query on specified fields
* splitting search into words or using whole sentence
*
* @param \Illuminate\Database\Eloquent\Builder $query The eloquent query builder.
* @param string $search The query string.
* @param callable $searchQuery The query string.
* @param string ...$fields Either a simple array or a string
* in case of a string the two fields
* would be concatenate to make the search.
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function searchQuery(
Builder $query,
string $search,
callable $searchQuery = null,
string ...$fields
): \Illuminate\Database\Eloquent\Builder {
if (count($fields)) {
// Search using words.
$words = explode(' ', $search);
$query->where(function ($query) use ($searchQuery, $words, $search, $fields) {
foreach ($words as $word) {
if (!strlen($word)) {
continue;
}
$this->searchOnFields($query, $word, $fields);
} //end foreach
// Search whole using whole sentence.
$this->searchOnFields($query, $search, $fields);
if ($searchQuery) {
$searchQuery($query);
}
});
} //end if
return $query;
}
/**
* Build query search on all fields
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $search
* @param array $fields
* @return \Illuminate\Database\Eloquent\Builder
*/
private function searchOnFields(
Builder $query,
string $search,
array $fields
): \Illuminate\Database\Eloquent\Builder {
$table = $query->getModel()->getTable();
foreach ($fields as $field) {
$field = str_replace('?', '\?', $field);
if (is_array($field)) {
$query->orWhereRaw(
sprintf("UPPER(CONCAT(%s)) LIKE UPPER(?)", collect($field)->map(function ($q) use ($table) {
return "`$table`.`$q`";
})->implode(', \' \', ')),
['%' . htmlspecialchars($search) . '%']
);
return $query;
}
// Get models woth enum's label.
if (is_array($this->getSearchValue($query, $search, $field))) {
foreach ($this->getSearchValue($query, $search, $field) as $array) {
$query->orWhereRaw(
"UPPER(`$table`.`$field`) LIKE UPPER(?)",
['%' . htmlspecialchars($array->value) . '%']
);
}
return $query;
}
// Get models with relation's name/label.
if (Str::endsWith($field, '_id') && $query->getModel()->has(Str::remove('_id', $field))) {
$query->orWhereHas(Str::remove('_id', $field), function ($queryRelation) use ($field, $search) {
if (Schema::hasColumn(Str::remove('_id', $field) . 's', 'name')) {
$queryRelation->where('name', 'like', '%' . $search . '%');
} elseif (Schema::hasColumn(Str::remove('_id', $field) . 's', 'label')) {
$queryRelation->where('label', 'like', '%' . $search . '%');
}
});
return $query;
}
$query->orWhereRaw(
"UPPER(`$table`.`$field`) LIKE UPPER(?)",
['%' . htmlspecialchars($this->getSearchValue($query, $search, $field)) . '%']
);
} //end foreach
return $query;
}
/**
* Get the search value (on string or Enum).
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $search
* @param string $field
* @return string|array
*/
protected function getSearchValue(Builder $query, string $search, string $field): string|array
{
if (
count($query->getModel()->getCasts()) and // Check if the model has casts.
isset($query->getModel()->getCasts()[$field]) and // Check if there is cast about specific field.
enum_exists($query->getModel()->getCasts()[$field]) // Check if the cast is an enum.
) {
$cases = collect($query->getModel()->getCasts()[$field]::toArray());
$enum = $cases->filter(function ($item) use ($search) {
return preg_match('/' . Str::lower($search) . '/', Str::lower($item->label));
});
return $enum->toArray();
} else {
return $search;
}
}
/**
* Sort columns with a query.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param array $ignore
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function sortQuery(Builder $query, array $ignore = []): \Illuminate\Database\Eloquent\Builder
{
$rName = request()->route()->getName();
$table = $query->getModel()->getTable();
if (request()->sort_col) {
Session::put("$rName.sort_col", request()->sort_col);
Session::put("$rName.sorted", true);
}
if (request()->sort_way) {
Session::put("$rName.sort_way", request()->sort_way);
Session::put("$rName.sorted", true);
}
if (request()->rst) {
Session::remove("$rName.sorted");
Session::remove("$rName.sort_col");
Session::remove("$rName.sort_way");
}
if (request()->sort_col and request()->sort_way and Schema::hasColumn($table, request()->sort_col)) {
if (
!isset($ignore) or
(isset($ignore) and !in_array(request()->sort_col, $ignore))
) {
$query = $query->orderBy(\sprintf("$table.%s", request()->sort_col), request()->sort_way);
}
} elseif (Schema::hasColumn($table, 'order')) {
$query = $query->orderBy('order', 'ASC');
Session::put("$rName.sort_col", 'order');
Session::put("$rName.sort_way", 'asc');
} elseif (Schema::hasColumn($table, 'updated_at')) {
$query = $query->orderBy('updated_at', 'DESC');
Session::put("$rName.sort_col", 'updated_at');
Session::put("$rName.sort_way", 'desc');
} elseif (Schema::hasColumn($table, 'created_at')) {
$query = $query->orderBy('created_at', 'DESC');
Session::put("$rName.sort_col", 'created_at');
Session::put("$rName.sort_way", 'desc');
}
return $query;
}
/**
* Customize pagination with cache or config (default).
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \App\Enums\Pagination\ItemsPerPaginationEnum|null $pagination
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
protected function paginate(
Builder $query,
ItemsPerPaginationEnum $pagination = null
): \Illuminate\Contracts\Pagination\LengthAwarePaginator {
$currentRoutePath = Str::slug(request()->route()->getName());
$cacheKey = "pagination.{$currentRoutePath}";
$pagination = $pagination ?? ItemsPerPaginationEnum::twelve;
$pagination = ToolboxHelper::getValidatedEnum(
Cache::get($cacheKey, $pagination->value()),
'pagination',
'\App\Enums\Pagination\ItemsPerPaginationEnum',
);
if (Cache::get($cacheKey) !== $pagination) {
Cache::put($cacheKey, $pagination);
}
$query = $query->paginate($pagination);
return $query;
}
/**
* Set the bootstrap theme or light (default).
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse
*/
protected function setTheme(Request $request): \Illuminate\Http\RedirectResponse
{
$theme = ToolboxHelper::getValidatedEnum(
$request->theme ?: Cache::get('theme', BootstrapThemeEnum::light->value()),
'theme',
'\App\Http\Controllers\BootstrapThemeEnum',
);
if (Cache::get("theme") !== $theme) {
Cache::put("theme", $theme);
}
return redirect()->back()->with('success', __('crud.messages.theme_updated'));
}
}

View file

@ -11,37 +11,40 @@ class Kernel extends HttpKernel
*
* These middleware are run during every request to your application.
*
* @var array
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Fruitcake\Cors\HandleCors::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\HtmlSanitizer::class
];
/**
* The application's route middleware groups.
*
* @var array
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\SecureHeaders::class
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\ThrottleRequests::class . ':api',
\App\Http\Middleware\JsonOnly::class,
\App\Http\Middleware\ApiResponse::class,
\App\Http\Middleware\ApiCheckTimeStamps::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
@ -51,16 +54,17 @@ class Kernel extends HttpKernel
*
* These middleware may be assigned to groups or used individually.
*
* @var array
* @var array<string, class-string|string>
*/
protected $routeMiddleware = [
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];

View file

@ -0,0 +1,43 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Api\ApiError;
use App\Exceptions\Api\ApiErrorException;
use App\Lib\ApiHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
/**
* Check Http Header Timestamp with request timestamp
* to prevent replay attacks
* https://restfulapi.net/security-essentials/
*/
class ApiCheckTimeStamps
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
* @throws \App\Exceptions\Api\V4\ApiErrorException With ApiError::wrongTimeStamp
* if timestamp has an incorrect
* format or is timed out.
*/
public function handle(Request $request, \Closure $next): mixed
{
if (ApiHelper::isRequestForApi($request)) {
if (!\is_string($request->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);
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Api\ApiStatus;
use App\Http\Resources\Api\ApiResponse as ApiResponseResource;
use App\Models\Service;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ApiResponse
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
* @throws \ErrorException If the received response is not a JsonResponse.
*/
public function handle(Request $request, \Closure $next): mixed
{
/** @var \Illuminate\Http\JsonResponse $response */
$response = $next($request);
if (\get_class($response) !== JsonResponse::class) {
// @codeCoverageIgnoreStart
throw new \ErrorException('All api responses should be json.');
// @codeCoverageIgnoreEnd
}
// Set no cache header.
$response->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();
/** Check if it's and Api Response. */
if (
gettype($data) === "object" and
isset($data->status) and
isset($data->data) and
isset($data->version) and
in_array($data->status, [ApiStatus::OK->value(), ApiStatus::KO->value()])
) {
/** Return direct ApiResponse */
return $response;
}
$status = intval($response->getStatusCode() / 100) == 2 ? ApiStatus::OK : ApiStatus::KO;
return $response->setData(new ApiResponseResource((object)$data, $status));
}
}

View file

@ -3,19 +3,37 @@
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*
* @param \Illuminate\Http\Request $request
* @param \Illuminate\Http\Request $request
* @return string|null
*/
protected function redirectTo($request)
protected function redirectTo(Request $request): ?string
{
if (! $request->expectsJson()) {
$logins = [
'backend' => route('bo.login')
];
if (!$request->expectsJson()) {
// * Notify must login
Session::flash('warning', trans('Veuillez vous connecter pour continuer'));
foreach ($request->route()->gatherMiddleware() as $middleware) {
if (\is_string($middleware) and strpos($middleware, 'auth:') !== false) {
$route = $logins[str_replace('auth:', '', $middleware)] ?? null;
if ($route) {
return $route;
}
}
}
return route('login');
}
return null;
}
}

View file

@ -9,9 +9,7 @@ class EncryptCookies extends Middleware
/**
* The names of the cookies that should not be encrypted.
*
* @var array
* @var array<int, string>
*/
protected $except = [
//
];
protected $except = [];
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Http\Middleware;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class HtmlSanitizer
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|array ...$params
* @return \Symfony\Component\HttpFoundation\Response
*/
public function handle(Request $request, \Closure $next, string|array ...$params): Response
{
$inputs = $request->all();
/** @var callable|null */
$cleanInput = null;
$cleanInput = function (
null|string|array &$input,
string $name,
?string $rootName = null
) use (
$params,
&$cleanInput
): void {
// * Nested element array
if (is_array($input)) {
array_walk_recursive($input, $cleanInput, $name);
}
if (!is_string($input)) {
return;
}
if (in_array($name, $params) or in_array($rootName ?? $name, $params)) {
// ? Is a json input ?
if ($isJson = \json_decode($input, true) and is_array($isJson)) {
// ! Decode each json field recursive.
\array_walk_recursive($isJson, function (string|array &$item) {
if (\is_string($item)) {
$item = clean($item);
$item = ToolboxHelper::stripCkEditorSchemesOnMediaInputs($item);
}
});
} else {
$input = clean($input);
$input = ToolboxHelper::stripCkEditorSchemesOnMediaInputs($input);
}
} else {
$input = strip_tags($input);
}
};
\array_walk($inputs, $cleanInput);
$request->merge($inputs);
return $next($request);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
class JsonOnly
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, \Closure $next): mixed
{
if (
(!$request->isJson() or
!$request->expectsJson() or
is_null(json_decode($request->getContent())))
) {
abort(400, 'Invalid JSON');
}
return $next($request);
}
}

View file

@ -9,9 +9,7 @@ class PreventRequestsDuringMaintenance extends Middleware
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array
* @var array<int, string>
*/
protected $except = [
//
];
protected $except = [];
}

View file

@ -2,28 +2,39 @@
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null ...$guards
* @return mixed
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param string|null ...$guards
* @return \Symfony\Component\HttpFoundation\Response
* @throws \RuntimeException If guard is unhandled.
* @phpcs:disable Squiz.Commenting.FunctionComment.IncorrectTypeHint
*/
public function handle(Request $request, Closure $next, ...$guards)
public function handle(Request $request, \Closure $next, ...$guards): Response
{
// phpcs:enable phpcs:disable
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
if (auth($guard)->check()) {
switch ($guard) {
case 'frontend':
return \redirect()->route('fo.homepage');
case 'backend':
if (!$request->routeIs('bo.*')) {
continue;
}
return \redirect()->route('bo.dashboard');
default:
throw new \RuntimeException("Unhandled guard `{$guard}` .");
}
}
}

View file

@ -0,0 +1,103 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;
class SecureHeaders
{
/**
* Enumerate headers which you do not want in your application's responses.
* Great starting point would be to go check out @Scott_Helme's:
* https://securityheaders.com/
*
* @var array
*/
private $unwantedHeaderList = [
'X-Powered-By',
'Server',
];
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @return \Symfony\Component\HttpFoundation\Response
* @phpcs:disable Squiz.Commenting.FunctionComment.IncorrectTypeHint
*/
public function handle(Request $request, Closure $next): Response
{
// phpcs:enable
View::share('nonce', $nonce = Vite::useCspNonce());
// * For VueJS dev tool to execute
$jsDev = config('app.debug') ? "'unsafe-eval' 'unsafe-inline'" : "'nonce-{$nonce}' 'strict-dynamic'";
$scheme = config('app.env') === 'local' ? 'http' : 'https';
$reportUri = Route::has('cspReportUri') ?
\sprintf(' report-uri %s ;', \parse_url(route('cspReportUri'), \PHP_URL_PATH)) : '';
$this->removeUnwantedHeaders($this->unwantedHeaderList);
$response = $next($request);
$response->headers->set('Referrer-Policy', 'no-referrer-when-downgrade');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains;');
$cspPolicies = [
"default-src 'none';",
"manifest-src 'self';",
// * CKEDITOR Specific https://ckeditor.com/docs/ckeditor5/latest/installation/advanced/csp.html
"connect-src 'self' blob:;",
"img-src * data: blob:;",
'frame-src ' . implode(' ', [
'https://www.google.com/recaptcha/',
'https://recaptcha.google.com/recaptcha/',
'https://www.google.com/maps/'
]) . ';',
"font-src 'self' data: https://fonts.gstatic.com;",
// ! Using CKEditor will must have unsafe-inline style
// phpcs:ignore Generic.Files.LineLength.TooLong
"style-src 'self' 'unsafe-inline' " . \implode(' ', [
'https://cdnjs.cloudflare.com',
// * File Manager
'https://fonts.googleapis.com',
'https://cdn.jsdelivr.net',
'https://use.fontawesome.com',
'https://fonts.googleapis.com'
]) . ';',
// ! If you dont use ckeditor use this
// "style-src 'self' 'nonce-$nonce';",
// phpcs:ignore Generic.Files.LineLength.TooLong
"script-src 'self' {$jsDev} " . \implode(' ', [
'https: blob:',
'https://www.googletagmanager.com',
'https://cdnjs.cloudflare.com',
"$scheme://www.google-analytics.com",
]) . ';'
];
$response->headers->set(
'Content-Security-Policy',
implode(' ', $cspPolicies) . $reportUri
);
$response->headers->set('Feature-Policy', 'fullscreen \'self\';');
return $response;
}
/**
* Remove unwanted headers
*
* @param array $headerList
* @return void
*/
private function removeUnwantedHeaders(array $headerList): void
{
foreach ($headerList as $header) {
header_remove($header);
}
}
}

View file

@ -9,7 +9,7 @@ class TrimStrings extends Middleware
/**
* The names of the attributes that should not be trimmed.
*
* @var array
* @var array<int, string>
*/
protected $except = [
'current_password',

View file

@ -9,7 +9,7 @@ class TrustHosts extends Middleware
/**
* Get the host patterns that should be trusted.
*
* @return array
* @return array<int, string|null>
*/
public function hosts()
{

View file

@ -10,17 +10,17 @@ class TrustProxies extends Middleware
/**
* The trusted proxies for this application.
*
* @var array|string|null
* @var array<int, string>|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
* @var integer
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |

View file

@ -0,0 +1,15 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Routing\Middleware\ValidateSignature as Middleware;
class ValidateSignature extends Middleware
{
/**
* The names of the query string parameters that should be ignored.
*
* @var array<int, string>
*/
protected $except = [];
}

View file

@ -9,9 +9,9 @@ class VerifyCsrfToken extends Middleware
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array
* @var array<int, string>
*/
protected $except = [
//
'/csp-report'
];
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Api\XmlInco;
use Illuminate\Foundation\Http\FormRequest;
class SearchRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize(): bool
{
return auth('api')->check();
}
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation()
{
$this->merge([
'search' => is_string($this->search) ? strip_tags($this->search) : null
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string,\Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'search' => 'required|string|min:1'
];
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Http\Requests\Bo\Services;
use DragonCode\Support\Facades\Helpers\Arr;
use Illuminate\Foundation\Http\FormRequest;
class StoreServiceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize(): bool
{
return auth()->check();
}
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation()
{
$this->merge([
'arcadia_brand_id' => Arr::get(Arr::first(Arr::wrap($this->arcadiaBrand ?? [])), 'id')
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
'name' => 'required|string|min:5|max:255',
'arcadia_brand_id' => 'required|integer|min:0|exists:arcadia_brands,id',
];
}
/**
* Get custom attributes for validator errors.
*
* @return array
*/
public function attributes(): array
{
return [
'name' => trans('Nom du service'),
'arcadia_brand_id' => trans('Id de la marque'),
];
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Http\Requests\Bo\Services;
class UpdateServiceRequest extends StoreServiceRequest
{
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Http\Requests\Bo;
use Exception;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
/**
* @codeCoverageIgnore
*/
class StatsByServiceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize(): bool
{
return auth()->check();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
* @throws \Illuminate\Validation\ValidationException If given dates are unparsable of period
* is bigger than 1 year.
* @throws \Exception If carbon date processing failed.
*/
public function rules(): array
{
try {
if ((Carbon::parse($this->end)->diffInDays(Carbon::parse($this->start)) + 1) > 365) {
throw ValidationException::withMessages([
'Vous devez sélectionner une période de maximum 1 ans'
]);
}
} catch (Exception $e) {
if (\get_class($e) !== ValidationException::class) {
throw ValidationException::withMessages([
'Les dates sélectionnées ne sembles pas être valides'
]);
}
throw $e;
}
return [
'brand' => 'sometimes|exists:arcadia_brands,id',
'start' => 'required_with:brand|before_or_equal:end|date|date_format:Y-m-d',
'end' => 'required_with:brand|after_or_equal:start|date|date_format:Y-m-d|after_or_equal:start',
];
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Bo;
use Illuminate\Foundation\Http\FormRequest;
class UpdateChangeOrderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize(): bool
{
return auth()->check();
}
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation(): void
{
$this->merge([
'direction' => $this->direction === 'up' ? true : false
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
'direction' => 'required|boolean',
];
}
}

View file

@ -0,0 +1,229 @@
<?php
namespace App\Http\Requests\Bo;
use App\Enums\InputType;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
abstract class UpdateMiniCrudRequest extends FormRequest
{
/**
* The model parameter name
*
* @var string
*/
protected $modelName = null;
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize()
{
return auth('backend')->check();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
* @phpcs:disable Generic.Metrics.NestingLevel.TooHigh
*/
public function rules()
{
// phpcs:enable
$rules = [];
foreach ($this->{$this->modelName}->data as $iptName => $input) {
$input = \json_decode(\json_encode($input), true);
switch ($input['inputType']) {
case InputType::number->value:
$rules = array_merge($rules, [
"data.$iptName.value" => $this->compileValidate('required|numeric', $input)
]);
break;
case InputType::videoYoutube->value:
$rules = array_merge($rules, [
"data.$iptName.value" => $this->compileValidate('regex:%([^"&?\/\s]{11})%i', $input)
]);
break;
case InputType::videoVimeo->value:
$rules = array_merge($rules, [
"data.$iptName.value" => $this->compileValidate('regex:%([0-9]{8,10})%i', $input)
]);
break;
case InputType::video->value:
$rules = array_merge($rules, [
"data.$iptName.value" => $this->compileValidate([
'regex:%([0-9]{8,10}|[^"&?\/\s]{11})%i'
], $input)
]);
break;
case InputType::string->value:
$rules = array_merge($rules, [
"data.$iptName.value" => $this->compileValidate('required|string|min:3', $input)
]);
break;
case InputType::text->value:
$mergeRules = $this->compileValidate('required|string|min:15', $input);
$mergeRules = collect($mergeRules)
->map(function ($rule) {
if (\strpos($rule, 'max:') !== false) {
$max = \intval(str_replace('max:', '', $rule));
return function ($attribute, $value, $fail) use ($max) {
if (\strlen(\strip_tags($value)) > $max) {
$fail("$attribute ne doit pas excéder $max caractères");
}
};
}
return $rule;
});
$rules = array_merge($rules, [
"data.$iptName.value" => $mergeRules
]);
break;
case InputType::image->value:
$required = $this->isUpdate() ? 'sometimes' : 'required';
$ipt = $this->data[$iptName];
$max = isset($ipt['dimensionsMax']);
$dim = \is_string($input['dimensions']) ? collect(explode(
',',
(isset($ipt['dimensionsMax']) and is_string($ipt['dimensionsMax'])) ?
$ipt['dimensionsMax'] : $input['dimensions']
))->mapWithKeys(function ($v) {
$v = explode(' ', $v);
return [$v[0] => $v[1]];
}) : $input['dimensions'];
// * Adapt dimensions
$sizes = (object)[
'minHeight' => $dim['height'],
'maxHeight' => $dim['height'],
'minWith' => $dim['width'],
'maxWith' => $dim['width'],
];
$rules = array_merge($rules, [
"data.$iptName.value" => [
function (string $attribute, $value, callable $fail) use ($sizes) {
return ToolboxHelper::validatePicture($attribute, $value, $fail, $sizes);
}
],
$this->compileValidate(
implode('|', [
$required,
'image',
'mimes:png,jpg,jpeg',
sprintf(
'dimensions:%s=%d,%s=%d',
$max ? 'max_height' : 'height',
$dim['height'],
$max ? 'max_width' : 'width',
$dim['width']
)
]),
$input
),
"data.$iptName.alt" => $this->compileValidate("$required|string|min:5", $input),
"data.$iptName.title" => $this->compileValidate("$required|string|min:5", $input)
]);
break;
case InputType::url->value:
$rules = array_merge($rules, [
"data.$iptName.value" => [
'required', 'url',
// @phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed
function ($attribute, $value, $fail) {
try {
ToolboxHelper::assertNoInternalUrls($attribute, $value);
ToolboxHelper::assertNoInternalBoUrls($attribute, $value);
} catch (ValidationException $e) {
$fail(collect($e->errors())->first()[0]);
}
}
]
]);
break;
case InputType::boolean->value:
$all = $this->all();
$nested = ToolboxHelper::dotStringToNestedArray(
"data.$iptName.value",
$this->input("data.$iptName.value") ? true : false
);
$all = ToolboxHelper::arrayMergeRecursiveDistinct($all, $nested);
$this->merge($all);
$rules = array_merge($rules, [
"data.$iptName.value" => 'boolean'
]);
break;
} //end switch
} //end foreach
return $rules;
}
/**
* Undocumented function
*
* @param string|array $inputTypeValidate
* @param array $input
* @return array
*/
public function compileValidate(string|array $inputTypeValidate, array $input): array
{
if (\is_string($inputTypeValidate)) {
$inputTypeValidate = explode('|', $inputTypeValidate);
}
/** @var array|string */
$inputValidate = \collect($input)->get('validate', $input);
$inputValidate = \is_string($inputValidate) ? explode('|', $inputValidate) : $inputValidate;
foreach ($inputValidate as $validate) {
if (!\in_array($validate, $inputTypeValidate)) {
$inputTypeValidate[] = $validate;
}
}
$test = \collect($inputTypeValidate)->all();
$test = \collect($inputTypeValidate)->sort(function ($valueA, $valueB) {
$valueA = $this->ruleHasToComeFirst($valueA) ? 0 : 2;
$valueB = $this->ruleHasToComeFirst($valueB) ? 1 : 2;
return $valueA - $valueB;
})->unique()->all();
return \collect($inputTypeValidate)->unique()->sort(function ($valueA, $valueB) {
$valueA = $this->ruleHasToComeFirst($valueA) ? 0 : 2;
$valueB = $this->ruleHasToComeFirst($valueB) ? 1 : 2;
return $valueA - $valueB;
})->all();
}
/**
* Does the rulle need to come first ?
*
* @param string $rule
* @return boolean
*/
private function ruleHasToComeFirst(string $rule): bool
{
$rule = \collect(\explode(':', $rule, 1))->filter()->first(null, '');
return \collect([
'nullable', 'present', 'required', 'required_if',
'required_unless', 'required_with', 'required_with_all',
'required_without', 'required_without_all', 'required_with_all',
'missing', 'missing_if', 'missing_unless',
'missing_with', 'missing_with_all',
])->first(fn (string $firstRule) => \strpos($rule, $firstRule) === 0) ? true : false;
}
/**
* Is this request an update request ?
*
* @return boolean
*/
private function isUpdate(): bool
{
return $this->isMethod('PUT');
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace App\Http\Requests\Bo\Users;
use App\Enums\Users\RoleEnum;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize(): bool
{
return auth('backend')->check();
}
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation()
{
$this->merge([
'published' => $this->published ? true : false
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
'first_name' => 'required|string|min:3|max:255',
'last_name' => 'required|string|min:3|max:255',
'email' => 'required|email:rfc,strict,dns,spoof,filter|unique:users,email',
'password' => [
'required', 'required_with:password_confirmation', 'confirmed', 'max:255', Password::min(8)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised()
],
'role' => ['required', new Enum(RoleEnum::class)],
'published' => 'required|boolean',
];
}
/**
* Get custom attributes for validator errors.
*
* @return array
*/
public function attributes(): array
{
return [
'first_name' => trans('validation.attributes.first_name'),
'last_name' => trans('validation.attributes.last_name'),
'email' => trans('validation.attributes.email'),
'password' => trans('validation.attributes.password'),
'role' => trans('validation.attributes.role'),
'published' => trans('validation.custom.publication')
];
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests\Bo\Users;
use Illuminate\Validation\Rules\Password;
class UpdateUserRequest extends StoreUserRequest
{
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation()
{
$this->merge([
'published' => $this->published ? true : false,
'password' => $this->password ? $this->password : $this->route('user')->password,
'password_confirmation' => $this->password_confirmation ?
$this->password_confirmation :
$this->route('user')->password
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return array_merge(parent::rules(), [
'email' => [
'required',
'email:rfc,strict,dns,spoof,filter',
'unique:users,email,' . $this->route('user')->id . ',id',
],
'password' => [
'required', 'required_with:password_confirmation', 'confirmed', 'max:255', Password::min(8)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised()
],
]);
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Http\Resources\Api;
use App\Enums\Api\ApiStatus;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ApiResponse extends JsonResource
{
/** @var \stdClass|null */
protected $data;
/** @var \App\Enums\ApiStatus */
protected $status;
/**
* @param \stdClass|null $data
* @param \App\Enums\Api\ApiStatus $status
*/
public function __construct(\stdClass|null $data = null, ApiStatus $status)
{
$this->status = $status;
$this->data = $data;
}
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
*/
public function toArray(Request $request): array
{
//phpcs:enable
return [
'status' => $this->status->value(),
'data' => (array)($this->data ?? []),
'version' => config('api.version')
];
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Http\Resources\Api;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Arr;
class XmlIncoResource extends JsonResource
{
/**
* The resource instance.
*
* @var \App\Models\XmlInco
*/
public $resource;
/**
* XmlInco columns to keep
*
* @var array
*/
private array $skel = [
'id', 'name', 'description', 'tips',
'preparation_advices', 'sell_arguments',
'allegations', 'ingredient_list', 'allergen_list',
'nutritional_values', 'portion_amount', 'portion_type',
'is_seasonal', 'updated_at'
];
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array<string, mixed>
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
*/
public function toArray(Request $request): array
{
// phpcs:enable
return \array_merge(
Arr::only($this->resource->toArray(), $this->skel),
['updated_at' => $this->resource->updated_at->toIso8601String()]
);
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Http\Resources\Api;
use App\Models\XmlInco;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection as JsonResourceCollection;
use Illuminate\Support\Collection;
class XmlListResource extends JsonResourceCollection
{
/** @var \Illuminate\Support\Collection<\App\Models\XmlInco> */
protected $xmlIncoList = null;
/**
* Send only ids
*
* @var boolean
*/
protected $onlyIds = false;
/**
* Create a new resource instance.
*
* @param mixed $resource
* @param \Illuminate\Support\Collection<\App\Models\XmlInco> $xmlIncoList
* @return void
* @phpcs:disable Squiz.Commenting.FunctionComment.IncorrectTypeHint
*/
public function __construct(mixed $resource, Collection $xmlIncoList = null)
{
//phpcs:enable
$this->xmlIncoList = $xmlIncoList;
parent::__construct($resource);
}
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Support\Collection
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
*/
public function toArray(Request $request): \Illuminate\Support\Collection
{
//phpcs:enable
return $this->collection->map(function (XmlInco $xml_inco) {
return [
'id' => $xml_inco->id,
'name' => $xml_inco->name
];
});
}
}

View file

@ -0,0 +1,372 @@
<?php
namespace App\Jobs\Arcadia;
use App\Enums\LogType;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
abstract class XArcadiaFlux implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* The Laravel disk name
*
* @var string
*/
protected $disk = null;
/**
* This is for Laravel Console usage
*
* @var \Illuminate\Console\Command|null
*/
public $console = null;
/**
* This is for Laravel Console usage
*
* @var \Symfony\Component\Console\Helper\ProgressBar|null
*/
public $progressBar = null;
/**
* invalid xml folder
*
* @var string
*/
protected $invalidFolderName = 'invalid';
/**
* The relative folder where to archive data
*
* @var string
*/
private $archiveFolderName = 'archives';
/**
* The files to ignore
*
* @var array
*/
protected $ignoreFiles = [
'.gitignore', '.gitkeep'
];
/**
* Check if the file extension
*
* @param string $filename
* @param string $extension
* @return boolean
*/
protected function checkExtension(string $filename, string $extension): bool
{
$isExtension = strtolower(pathinfo($filename, \PATHINFO_EXTENSION)) === $extension;
if (!$isExtension) {
Log::warning(sprintf(
'%s : unwanted file type in %s wanted %s',
get_class($this),
$filename,
$extension
));
}
return $isExtension;
}
/**
* Move the file to invalid folder
*
* @param string $filename
* @return void
*/
protected function moveToInvalid(string $filename): void
{
$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
);
}
}
/**
* 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
*
* @param string $filename
* @return void
*/
protected function moveToArchives(string $filename): void
{
$newFileName = $filename;
if (!$this->getDisk()->exists($filename)) {
$this->log(
sprintf(
'File %s could not be moved to archives (not found)',
$filename
),
LogType::warning
);
return;
}
if ($this->getDisk()->exists(sprintf('%s/%s', $this->archiveFolderName, $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->archiveFolderName, $newFileName))) {
$this->log(
sprintf(
'File %s could not be moved to archives',
$filename
),
LogType::warning
);
}
} catch (\Exception $e) {
$this->log(
sprintf(
'File %s could not be moved to archives',
$filename
),
LogType::warning
);
}
}
/**
* Suppression des fichiers dans
* archives qui ont plus de 2j
*
* @return void
*/
protected function clearOldArchives(): void
{
$disk = $this->getDisk();
$archives = $disk->files($this->archiveFolderName);
// /** @var File */
foreach ($archives as $archive) {
$pi = \pathinfo($archive, \PATHINFO_BASENAME);
if (in_array($pi, $this->ignoreFiles)) {
continue;
}
if ($disk->lastModified($archive) < strtotime(Carbon::now()->subDays(2))) {
if (!$disk->delete($archive)) {
Log::critical(sprintf('%s : Could not delete file %s', get_class($this), $archive));
};
}
}
}
/**
* Get the concerned disk
*
* @return \Illuminate\Contracts\Filesystem\Filesystem
*/
protected function getDisk(): Filesystem
{
return Storage::disk($this->disk);
}
/**
* Logs data
*
* @param string $message
* @param LogType $logtype
* @return void
* @throws \RuntimeException If logtype is not handled.
*/
protected function log(string $message, LogType $logtype = null): void
{
if (!$logtype) {
$logtype = LogType::debug;
}
$message = sprintf('%s : %s', get_class($this), $message);
switch ($logtype) {
case LogType::debug:
Log::debug($message);
break;
case LogType::info:
Log::info($message);
break;
case LogType::log:
Log::log($message);
break;
case LogType::warning:
Log::warning($message);
break;
case LogType::error:
Log::error($message);
break;
case LogType::critical:
Log::critical($message);
break;
default:
throw new \RuntimeException(sprintf('%s : unhandled log type %s', get_class($this), $logtype->label));
} //end switch
$this->consolePrint($message, $logtype);
}
/**
* Prints to the console
*
* @param string $message
* @param \App\Enums\LogType $logtype
* @return void
* @throws \RuntimeException If logtype is not handled.
*/
protected function consolePrint(string $message, LogType $logtype): void
{
$this->clearProgressBarFromLine();
if (!$this->console) {
return;
}
switch ($logtype) {
case LogType::debug:
case LogType::info:
case LogType::log:
$this->console->info($message);
break;
case LogType::warning:
$this->console->comment($message);
break;
case LogType::error:
case LogType::critical:
$this->console->error($message);
break;
default:
throw new \RuntimeException(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);
$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();
}
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Jobs\Arcadia;
use App\Models\ArcadiaBrand;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class XmlBrandReaderJob extends XmlJobs
{
/**
* The xsd file path used to validate files
*
* @var string|null
*/
protected $xsdSchemaPath = 'documents/arcadia/brand.xsd';
/**
* Disk name from config/filesystems.php
*
* @var string
*/
protected $disk = 'arcadia_brand';
/**
* @var array Columns to take from the xml.
*/
protected $takeColumns = [
'codMarq' => 'id',
'libMarq' => 'label',
];
/**
* Fields that are allowed to be null
*
* @var array
*/
protected $nullableFields = [];
/**
* Unique keys for updating rows
*
* @var array
*/
protected $uniqueKeyField = ['id'];
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
ArcadiaBrand::flushQueryCache();
$this->xsdSchemaPath = \resource_path($this->xsdSchemaPath);
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed
$this->parseData('dam', ArcadiaBrand::class, function (\SimpleXMLElement $xmlContent, array &$line) {
Arr::set($line, 'id', intval(Arr::get($line, 'id')));
Arr::set($line, 'label', (string)Str::of(Arr::get($line, 'label'))->squish()->title());
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,38 +1,31 @@
<?php
namespace App\Jobs;
namespace App\Jobs\Arcadia;
use App\Enums\LogType;
use App\Exceptions\AbortXmlImportException;
use App\Exceptions\InvalidXmlException;
use App\Lib\Utils;
use ErrorException;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use RecursiveArrayIterator;
use RecursiveIteratorIterator;
use SimpleXMLElement;
use Throwable;
use TypeError;
abstract class XmlJobs extends XArcadiaFlux
{
/**
* invalid xml folder
* The xsd file path used to validate files
*
* @var string
* @var string|null
*/
protected $invalidFolderName = 'invalid';
protected $xsdSchemaPath = null;
/**
* xsd file location
* The xsd file content used to validate files
*
* @var string
* @var string|null
*/
protected $xsdFile = '';
protected $xsdSchema = null;
/**
* The columns to take from the XML
@ -57,83 +50,117 @@ abstract class XmlJobs extends XArcadiaFlux
*/
protected $encapsulate = false;
/**
* Unique keys for updating rows
*
* @var array
*/
protected $uniqueKeyField = [];
/**
* Parse Data from the XML and gives it to the callback
*
* @param string $entityName
* @param string $modelClass
* @param string $entityName
* @param string $modelClass
* @param callable $afterParseCallback After parse is done.
* @param callable $saveCallback Instead of updateOrCreate, execute $saveCallback.
* @param callable $afterSaveCallback After save is done.
* @return void
* @throws \App\Exceptions\InvalidXmlException If xml file is not valid.
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.MaxExceeded
*/
// phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh
protected function parseData(string $entityName, string $modelClass, callable $callback = null): void
{
/** @var Filesystem */
protected function parseData(
string $entityName,
string $modelClass,
callable $afterParseCallback = null,
callable $saveCallback = null,
callable $afterSaveCallback = null
): void {
$disk = $this->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;
}));
if (count($files)) {
$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('<Root>%s</Root>', $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);
/** @var array<array<string,string>> */
$lines = [];
foreach ($data[$entityName] as $traca) {
// * Unwrap if neceessary.
$data = isset($data[$entityName]) ? $data[$entityName] : $data;
foreach ($data as $traca) {
$line = $this->processFields($traca);
if ($callback) {
$callback($xmlContent, $line);
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',
@ -141,24 +168,11 @@ abstract class XmlJobs extends XArcadiaFlux
$message,
$e->getLine()
),
LogType::critical()
LogType::critical
);
$this->sendInvalidXmlEmail($filename, $e);
$this->reportInvalidXmlSentryAlert($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(
@ -167,17 +181,18 @@ abstract class XmlJobs extends XArcadiaFlux
$e->getMessage(),
$e->getLine()
),
LogType::error()
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',
@ -185,16 +200,34 @@ abstract class XmlJobs extends XArcadiaFlux
$message,
$e->getLine()
),
LogType::debug()
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;
}
@ -306,24 +339,8 @@ abstract class XmlJobs extends XArcadiaFlux
*/
protected function fileHasToBeIgnored(string $filename): bool
{
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);
}
return in_array($filename, $this->ignoreFiles) or
!$this->checkExtension($filename, 'xml');
}
/**
@ -341,77 +358,78 @@ abstract class XmlJobs extends XArcadiaFlux
return $isXml;
}
protected function sendErrorParseEmailToServiceWeb(Throwable $e)
/**
* Report Invalid Xml using sentry
*
* @param string $filename
* @param \Throwable $e
* @return void
*/
protected function reportInvalidXmlSentryAlert(string $filename, \Throwable $e)
{
Utils::sendEmailErrorManually($e);
}
protected function sendInvalidXmlEmail(string $filename, Throwable $e)
{
$message = $e->getPrevious()->getMessage();
Mail::send('emails.invalidxml', [
'strError' => sprintf(
\report(new \RuntimeException(
sprintf(
'File %s could not be parsed => %s on line %d',
$filename,
$message,
$e->getPrevious()->getMessage(),
$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'))
)->subject('You sent me an invalid XML file, please check its validity before resend ASAP.');
});
$e->getCode(),
$e
));
}
/**
* 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.
* @throws \RuntimeException If schame is empty.
* @phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
*/
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()
);
// phpcs:enable
if (!$this->xsdSchemaPath) {
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
);
if (empty($this->xsdSchema = File::get($this->xsdSchemaPath))) {
throw new \RuntimeException('xsdSchema is empty');
};
}
/**
* Validate Xml file against XSD if loaded
*
* @param string $fileName
* @param string $fileContent
* @return void
* @throws \App\Exceptions\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) {
return;
}
$domDocument = new \DOMDocument('1.0', 'UTF-8');
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()
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) {
$this->log(
sprintf(
'File %s could not be moved to invalid',
$filename
),
LogType::warning()
} 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,
$e
);
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace App\Jobs;
use App\Models\Service;
use App\Models\ServiceStat;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
/**
* @codeCoverageIgnore
*/
class SetServiceStats implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
private const KEY = '2202020';
/** @var \App\Models\Service */
protected $service;
/**
* Create a new job instance.
*
* @param Service $service
* @return void
*/
public function __construct(Service $service)
{
$this->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))
];
}
}

View file

@ -1,223 +0,0 @@
<?php
namespace App\Jobs;
use App\Enums\LogType;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
abstract class XArcadiaFlux implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* The Laravel disk name
*
* @var string
*/
protected $disk = null;
/**
* This is for Laravel Console usage
*/
public $console = null;
/**
* The relative folder where to archive data
*
* @var string
*/
private $archiveFolderName = 'archives';
/**
* The files to ignore
*
* @var array
*/
protected $ignoreFiles = [
'.gitignore', '.gitkeep'
];
/**
* Check if the file extension
*
* @param string $filename
* @return boolean
*/
protected function checkExtension(string $filename, string $extension): bool
{
$isExtension = strtolower(pathinfo($filename, \PATHINFO_EXTENSION)) === $extension;
if (!$isExtension) {
Log::warning(sprintf(
'%s : unwanted file type in %s wanted %s',
get_class($this),
$filename,
$extension
));
}
return $isExtension;
}
/**
* Move the file to archives
*
* @param string $filename
* @return void
*/
protected function moveToArchives(string $filename): void
{
$newFileName = $filename;
if (!$this->getDisk()->exists($filename)) {
$this->log(
sprintf(
'File %s could not be moved to archives (not found)',
$filename
),
LogType::warning()
);
return;
}
if ($this->getDisk()->exists(sprintf('%s/%s', $this->archiveFolderName, $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->archiveFolderName, $newFileName))) {
$this->log(
sprintf(
'File %s could not be moved to archives',
$filename
),
LogType::warning()
);
}
} catch (Exception $e) {
$this->log(
sprintf(
'File %s could not be moved to archives',
$filename
),
LogType::warning()
);
}
}
/**
* Suppression des fichiers dans
* archives qui ont plus de 2j
*
* @return void
*/
protected function clearOldArchives(): void
{
$disk = $this->getDisk();
$archives = $disk->files($this->archiveFolderName);
// /** @var File */
foreach ($archives as $archive) {
$pi = \pathinfo($archive, \PATHINFO_BASENAME);
if (in_array($pi, $this->ignoreFiles)) {
continue;
}
if ($disk->lastModified($archive) < strtotime(Carbon::now()->subDays(2))) {
if (!$disk->delete($archive)) {
Log::critical(sprintf('%s : Could not delete file %s', get_class($this), $archive));
};
}
}
}
/**
* Get the concerned disk
*
* @return Filesystem
*/
protected function getDisk(): Filesystem
{
return Storage::disk($this->disk);
}
/**
* Logs data
*
* @param string $message
* @param LogType $logtype
* @return void
*/
protected function log(string $message, LogType $logtype = null): void
{
if (!$logtype) {
$logtype = LogType::debug();
}
$message = sprintf('%s : %s', get_class($this), $message);
switch ($logtype->value) {
case LogType::debug()->value:
Log::debug($message);
break;
case LogType::info()->value:
Log::info($message);
break;
case LogType::log()->value:
Log::log($message);
break;
case LogType::warning()->value:
Log::warning($message);
break;
case LogType::error()->value:
Log::error($message);
break;
case LogType::critical()->value:
Log::critical($message);
break;
default:
throw new Exception(sprintf('%s : unhandled log type %s', get_class($this), $logtype->label));
}//end switch
$this->consolePrint($message, $logtype);
}
/**
* Prints to the console
*
* @param string $message
* @param LogType $logtype
* @return void
*/
protected function consolePrint(string $message, LogType $logtype): void
{
if ($this->console) {
switch ($logtype->value) {
case LogType::debug()->value:
case LogType::info()->value:
case LogType::log()->value:
$this->console->info($message);
break;
case LogType::warning()->value:
$this->console->comment($message);
break;
case LogType::error()->value:
case LogType::critical()->value:
$this->console->error($message);
break;
default:
throw new Exception(sprintf('%s : unhandled log type %s', get_class($this), $logtype->label));
}
}
}
}

View file

@ -1,523 +0,0 @@
<?php
namespace App\Jobs;
use App\Exceptions\AbortXmlImportException;
use App\Models\XmlInco;
use SimpleXMLElement;
class XmlIncoReader extends XmlJobs
{
protected $disk = 'inco';
protected $imagesPath = 'images';
protected $xsdFile = 'xsd/incollector.xsd';
/**
* éléments à la racine : nom du noeud
* sous éléments d'un noeud PARENT :
* d'abord:
* 'PARENT' => 'parent', // Consider this sub array
* puis:
* "PARENT.LEAF" => 'leaf', // extract this data in the line
* Note : Xmljobs iterates the PARENT with RecursiveIteratorIterator::LEAVES_ONLY
* intermediate nodes are therefore ignored
* example : nodes : <EXPLOITANT><SOCIETE><ID_SOCIETE /><NOM_SOCIETE /></SOCIETE></EXPLOITANT>
* how to extract leaves :
* 'EXPLOITANT' => 'exploitant', // Consider this sub array
* 'EXPLOITANT.ID_SOCIETE' => 'id_societe',
* 'EXPLOITANT.NOM_SOCIETE' => 'nom_societe',
*/
protected $takeColumns = [
'NO_ART' => 'id', // primary key on model
//this wil not return anything cf doc above
'ID_SOCIETE' => 'id_societe',
'NOM_SOCIETE' => 'nom_societe',
'PROD_SAISON' => 'prod_saison',
'NOM' => 'nom',
'NUMERO' => 'numero',
'TAILLE_REF' => 'taille_ref', //$poidsRef censé être dans INCO_COMPO (et généralement 100g)
'ALLEGATIONS' => 'allegations',
'CONSEIL_PREPA' => 'conseil_prepa',
'ASTUCES' => 'astuces',
'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
// list alergene
'LST_ALLERGENE' => 'lst_allergene', // Consider this sub array
'LST_ALLERGENE.LIB_ALLERGENE' => 'lib_allergene',
// Allegations
'ALLEGAT_SANTE' => 'allegat_sante',
'ALLEGAT_NUTRIT' => 'allegat_nutrit',
'ALLEGAT_AUTRE' => 'allegat_autre',
'INCO_COMPO' => 'inco_compo', // 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
'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',
'INCO_COMPO.MAT_GRASS_NUT' => 'mat_grass_nut',
'INCO_COMPO.AGS_R' => 'ags_r',
'INCO_COMPO.AGS_NUT' => 'ags_nut',
'INCO_COMPO.GLUCIDES_R' => 'glucides_r',
'INCO_COMPO.GLUCIDES_NUT' => 'glucides_nut',
'INCO_COMPO.SUCRE_NUT' => 'sucre_nut',
'INCO_COMPO.SUCRE_R' => 'sucre_r',
'INCO_COMPO.FIB_ALIM_NUT' => 'fib_alim_nut',
'INCO_COMPO.FIB_ALIM_R' => 'fib_alim_r',
'INCO_COMPO.PROTEINE_R' => 'proteine_r',
'INCO_COMPO.PROTEINE_NUT' => 'proteine_nut',
'INCO_COMPO.SEL_R' => 'sel_r',
'INCO_COMPO.SEL_NUT' => 'sel_nut',
'INCO_COMPO.SODIUM_R' => 'sodium_r',
'INCO_COMPO.ENER_E14_R' => 'ener_e14_r',
'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
'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',
'INCO_COMPO.FATSAT_R' => 'fatsat_r',
'INCO_COMPO.POURCENT_APPORT_REF_FATSAT_R' => 'pourcent_apport_ref_fatsat_r',
'INCO_COMPO.CHOVAL_R' => 'choval_r',
'INCO_COMPO.POURCENT_APPORT_REF_CHOVAL_R' => 'pourcent_apport_ref_choval_r',
'INCO_COMPO.SUGAR_R' => 'sugar_r',
'INCO_COMPO.POURCENT_APPORT_REF_SUGAR_R' => 'pourcent_apport_ref_sugar_r',
'INCO_COMPO.FIBTG_R' => 'fibtg_r',
'INCO_COMPO.POURCENT_APPORT_REF_FIBTG_R' => 'pourcent_apport_ref_fibtg_r',
'INCO_COMPO.PRO_R' => 'pro_r',
'INCO_COMPO.POURCENT_APPORT_REF_PRO_R' => 'pourcent_apport_ref_pro_r',
'INCO_COMPO.SALTEQ_R' => 'salteq_r',
'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
'INCO_COMPO.VAL_ENERG_KJ_P' => 'val_energ_kj_p', //_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',
'INCO_COMPO.GLUCIDES_P' => 'glucides_p',
'INCO_COMPO.SUCRE_P' => 'sucre_p',
'INCO_COMPO.FIB_ALIM_P' => 'fib_alim_p',
'INCO_COMPO.PROTEINE_P' => 'proteine_p',
'INCO_COMPO.SODIUM_P' => 'sodium_p',
'INCO_COMPO.FRUITS_P' => 'fruits_p',
'INCO_COMPO.SEL_P' => 'sel_p',
'INCO_COMPO.FRUITS_COQUE_SECH_P' => 'fruits_coque_sech_p',
'INCO_COMPO.ENER_E14_P' => 'ener_e14_p',
'INCO_COMPO.POURCENT_APPORT_REF_E14_P' => 'pourcent_apport_ref_e14_p',
'INCO_COMPO.ENER_KJO_P' => 'ener_kjo_p',
'INCO_COMPO.POURCENT_APPORT_REF_KJO_P' => 'pourcent_apport_ref_kjo_p',
'INCO_COMPO.FAT_P' => 'fat_p',
'INCO_COMPO.POURCENT_APPORT_REF_FAT_P' => 'pourcent_apport_ref_fat_p',
'INCO_COMPO.FATSAT_P' => 'fatsat_p',
'INCO_COMPO.POURCENT_APPORT_REF_FATSAT_P' => 'pourcent_apport_ref_fatsat_p',
'INCO_COMPO.CHOVAL_P' => 'CHOVAL_P',
'INCO_COMPO.POURCENT_APPORT_REF_CHOVAL_P' => 'pourcent_apport_ref_choval_p',
'INCO_COMPO.SUGAR_P' => 'SUGAR_P',
'INCO_COMPO.POURCENT_APPORT_REF_SUGAR_P' => 'pourcent_apport_ref_sugar_p',
'INCO_COMPO.FIBTG_P' => 'fibtg_p',
'INCO_COMPO.POURCENT_APPORT_REF_FIBTG_P' => 'pourcent_apport_ref_fibtg_p',
'INCO_COMPO.PRO_P' => 'pro_p',
'INCO_COMPO.POURCENT_APPORT_REF_PRO_P' => 'pourcent_apport_ref_pro_p',
'INCO_COMPO.SALTEQ_P' => 'salteq_p',
'INCO_COMPO.POURCENT_APPORT_REF_SALTEQ_P' => 'pourcent_apport_ref_salteq_p',
'INCO_COMPO.APPORT_PORT' => 'apport_port',
'INCO_COMPO.PART_P' => 'part_p',
'INCO_COMPO.PART_G' => 'part_g',
'INCO_COMPO.PART_L' => 'part_l',
'INCO_COMPO.P_SUR_L' => 'p_sur_l',
'INCO_COMPO.POURCENT_AGS' => 'pourcent_ags',
'INCO_COMPO.POURCENT_AGI' => 'pourcent_agi',
'INCO_COMPO.POURCENT_SS' => 'pourcent_ss',
'INCO_COMPO.POURCENT_AMIDON' => 'pourcent_amidon',
'INCO_COMPO.COMPOSANTE_N' => 'composante_n',
'INCO_COMPO.COMPOSANTE_P' => 'composante_p',
'INCO_COMPO.POURCENT_FRUITS' => 'pourcent_fruits',
'INCO_COMPO.SCORE_NUTRI' => 'score_nutri', //du coup nutriscore ?
'INCO_COMPO.CLASSEMENT' => 'classement', //$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',
'INCO_COMPO.SUCRE_R_PREC' => 'sucre_r_prec',
'INCO_COMPO.FIB_ALIM_R_PREC' => 'fib_alim_r_prec',
'INCO_COMPO.PROTEINE_R_PREC' => 'proteine_r_prec',
'INCO_COMPO.SEL_R_PREC' => 'sel_r_prec',
'INCO_COMPO.SODIUM_R_PREC' => 'sodium_r_prec',
'INCO_COMPO.FRUITS_R_PREC' => 'fruits_r_prec',
'INCO_COMPO.FRUITS_COQUE_SECH_R_PREC' => 'fruits_coque_sech_r_prec',
];
protected $nullableFields = [
'taille_ref',
'conseil_prepa',
'typ_portion',
'nb_portion',
'descriptif',
'prod_saison',
'allegations',
'astuces', 'arg_vente',
'pds_moy_theo',
'lib_conso', 'lst_ing', 'lst_allergene',
'allegat_sante', 'allegat_nutrit', 'allegat_autre',
'inco_compo',
'inco_compo.nom',
'inco_compo.numero',
'inco_compo.taille',
'inco_compo.taille_ref',
'inco_compo.val_energ_kj_nut',
'inco_compo.val_energ_kj_r',
'inco_compo.val_energ_kcal_r',
'inco_compo.val_energ_kcal_nut',
'inco_compo.mat_grass_r',
'inco_compo.mat_grass_nut',
'inco_compo.ags_r',
'inco_compo.ags_nut',
'inco_compo.glucides_r',
'inco_compo.glucides_nut',
'inco_compo.sucre_nut',
'inco_compo.sucre_r',
'inco_compo.fib_alim_nut',
'inco_compo.fib_alim_r',
'inco_compo.proteine_r',
'inco_compo.proteine_nut',
'inco_compo.sel_r',
'inco_compo.sel_nut',
'inco_compo.sodium_r',
'inco_compo.ener_e14_r',
'inco_compo.fruits_r',
'inco_compo.fruits_coque_sech_r',
'inco_compo.pourcent_apport_ref_e14_r',
'inco_compo.ener_kjo_r',
'inco_compo.pourcent_apport_ref_kjo_r',
'inco_compo.pourcent_apport_ref_fat_r',
'inco_compo.fatsat_r',
'inco_compo.pourcent_apport_ref_fatsat_r',
'inco_compo.choval_r',
'inco_compo.pourcent_apport_ref_choval_r',
'inco_compo.sugar_r',
'inco_compo.pourcent_apport_ref_sugar_r',
'inco_compo.fibtg_r',
'inco_compo.pourcent_apport_ref_fibtg_r',
'inco_compo.pro_r',
'inco_compo.pourcent_apport_ref_pro_r',
'inco_compo.salteq_r',
'inco_compo.pourcent_apport_ref_salteq_r',
'inco_compo.apport_ref',
'inco_compo.taille_port',
'inco_compo.val_energ_kj_p',
'inco_compo.val_energ_kcal_p',
'inco_compo.mat_grass_p',
'inco_compo.ags_p',
'inco_compo.glucides_p',
'inco_compo.sucre_p',
'inco_compo.fib_alim_p',
'inco_compo.proteine_p',
'inco_compo.sodium_p',
'inco_compo.fruits_p',
'inco_compo.sel_p',
'inco_compo.fruits_coque_sech_p',
'inco_compo.ener_e14_p',
'inco_compo.pourcent_apport_ref_e14_p',
'inco_compo.ener_kjo_p',
'inco_compo.pourcent_apport_ref_kjo_p',
'inco_compo.fat_p',
'inco_compo.pourcent_apport_ref_fat_p',
'inco_compo.fatsat_p',
'inco_compo.pourcent_apport_ref_fatsat_p',
'inco_compo.choval_p',
'inco_compo.pourcent_apport_ref_choval_p',
'inco_compo.sugar_p',
'inco_compo.pourcent_apport_ref_sugar_p',
'inco_compo.fibtg_p',
'inco_compo.pourcent_apport_ref_fibtg_p',
'inco_compo.pro_p',
'inco_compo.pourcent_apport_ref_pro_p',
'inco_compo.salteq_p',
'inco_compo.pourcent_apport_ref_salteq_p',
'inco_compo.apport_port',
'inco_compo.part_p',
'inco_compo.part_g',
'inco_compo.part_l',
'inco_compo.p_sur_l',
'inco_compo.pourcent_ags',
'inco_compo.pourcent_agi',
'inco_compo.pourcent_ss',
'inco_compo.pourcent_amidon',
'inco_compo.composante_n',
'inco_compo.composante_p',
'inco_compo.pourcent_fruits',
'inco_compo.score_nutri',
'inco_compo.classement',
'inco_compo.mat_grass_r_prec',
'inco_compo.ags_r_prec',
'inco_compo.glucides_r_prec',
'inco_compo.sucre_r_prec',
'inco_compo.fib_alim_r_prec',
'inco_compo.proteine_r_prec',
'inco_compo.sel_r_prec',
'inco_compo.sodium_r_prec',
'inco_compo.fruits_r_prec',
'inco_compo.fruits_coque_sech_r_prec'
];
protected $uniqueKeyField = ['id'];
protected $mergeColumns = [
'allegations' => [
'allegat_sante',
'allegat_nutrit',
'allegat_autre'
],
'nutritional_values' => [
'taille_ref',
'val_energ_kj_nut',
'val_energ_kj_r',
'val_energ_kcal_r',
'val_energ_kcal_nut',
'mat_grass_r',
'mat_grass_nut',
'ags_r',
'ags_nut',
'glucides_r',
'glucides_nut',
'sucre_nut',
'sucre_r',
'fib_alim_nut',
'fib_alim_r',
'proteine_r',
'proteine_nut',
'sel_r',
'sel_nut',
'sodium_r',
'ener_e14_r',
'fruits_r',
'fruits_coque_sech_r',
'pourcent_apport_ref_e14_r',
'ener_kjo_r',
'pourcent_apport_ref_kjo_r',
'pourcent_apport_ref_fat_r',
'fatsat_r',
'pourcent_apport_ref_fatsat_r',
'choval_r',
'pourcent_apport_ref_choval_r',
'sugar_r',
'pourcent_apport_ref_sugar_r',
'fibtg_r',
'pourcent_apport_ref_fibtg_r',
'pro_r',
'pourcent_apport_ref_pro_r',
'salteq_r',
'pourcent_apport_ref_salteq_r',
'apport_ref',
'taille_port',
'val_energ_kj_p',
'val_energ_kcal_p',
'mat_grass_p',
'ags_p',
'glucides_p',
'sucre_p',
'fib_alim_p',
'proteine_p',
'sodium_p',
'fruits_p',
'sel_p',
'fruits_coque_sech_p',
'ener_e14_p',
'pourcent_apport_ref_e14_p',
'ener_kjo_p',
'pourcent_apport_ref_kjo_p',
'fat_p',
'pourcent_apport_ref_fat_p',
'fatsat_p',
'pourcent_apport_ref_fatsat_p',
'choval_p',
'pourcent_apport_ref_choval_p',
'sugar_p',
'pourcent_apport_ref_sugar_p',
'fibtg_p',
'pourcent_apport_ref_fibtg_p',
'pro_p',
'pourcent_apport_ref_pro_p',
'salteq_p',
'pourcent_apport_ref_salteq_p',
'apport_port',
'part_p',
'part_g',
'part_l',
'p_sur_l',
'pourcent_ags',
'pourcent_agi',
'pourcent_ss',
'pourcent_amidon',
'composante_n',
'composante_p',
'pourcent_fruits',
'score_nutri',
'classement',
'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',
'fruits_r_prec',
'fruits_coque_sech_r_prec',
]
];
public function handle()
{
$this->parseData('INCO', XmlInco::class, function (SimpleXMLElement $xmlData, array &$line) {
// merge columns
foreach ($this->mergeColumns as $key => $values) {
$line[$key] = [];
// 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];
unset($line[$kValue]);
}
}, $values);
if ($key == 'allegations') {
$line[$key] = implode(',', array_filter($line[$key]));
$line[$key] = strlen($line[$key]) ? $line[$key] : null;
}
//json column cast into array in model => no need for encode
// if ($key == 'nutritional_values') {
// $line[$key] = json_encode($line[$key], \JSON_FORCE_OBJECT);
// }
}
// Boolean cast prod_saison
$line['prod_saison'] = isset($line['prod_saison']) ?
(strtolower($line['prod_saison']) == 'non' ? false : true) : false;
$arImgPaths = $this->parseImage($xmlData);
$line['picture'] = $arImgPaths;
});
}
private function parseImage(SimpleXMLElement $xmlData)
{
$images = $xmlData->INCO->MEDIA_VISUEL_EAI->MEDIA_VISUEL_EAI;
$arImgPaths = array();
$index = 1;
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(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));
}
$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
}
}
}
return $arImgPaths;
}
/**
* Recursive extract to array
* introduce sort of Hook to transform
* <TYP_PORTION>
* <code><![CDATA[2]]></code>
* <libelle><![CDATA[Approximatif]]></libelle>
* </TYP_PORTION>
* extracted as "TYP_PORTION" => array:1 [
* 0 => array:2 [
* "code" => "2"
* "libelle" => "Approximatif"
* ]
* ]
* into this
* <TYP_PORTION><![CDATA[Approximatif]]></TYP_PORTION>
* extracted as "TYP_PORTION" => "Approximatif"
*
*
* @param \SimpleXMLElement|false $sxe
* @return array
*/
protected function extract($sxe = null): array
{
if (!$sxe instanceof SimpleXMLElement) {
return array();
}
$extract = array();
foreach ($sxe->children() as $key => $value) {
if ($value->count()) {
$extraction = $this->extract($value);
if(count($extraction)
&& array_key_exists("code",$extraction)
&& array_key_exists("libelle",$extraction)) {
$val = strval($extraction['libelle']);
$extract[$key] = strlen($val) ? $val : null;
continue;
}
$extract[$key][] = $extraction; //$this->extract($value);
continue;
}
$value = strval($value);
if (array_key_exists($key, $extract) and is_array($extract[$key])) {
$extract[$key][] = strlen($value) ? $value : null;
} else {
$extract[$key] = strlen($value) ? $value : null;
}
}
return $extract;
}
}

184
app/Lib/ApiHelper.php Normal file
View file

@ -0,0 +1,184 @@
<?php
namespace App\Lib;
use App\Exceptions\Api\ApplicationException;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
/**
* Api Helper Class
*/
class ApiHelper
{
/**
* Package infos
*
* @var array
*/
private static $packageInfos;
/**
* Tells if the request is within api routes.
*
* @param \Illuminate\Http\Request $request
* @return boolean
*/
public static function isRequestForApi(Request $request): bool
{
// * Small hack to ensure to know if route is From APIv2.
$routeName = static::getRouteName($request) ??
(strpos($request->path(), 'api') === 0 ? 'api.' : null) ?? 'none';
return strpos($routeName, 'api.') === 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(
#[\SensitiveParameter] 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
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace App\Lib\Helpers;
use Illuminate\Container\Container;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
class CollectionHelper
{
/**
* Paginate a collection
*
* @param \Illuminate\Support\Collection $results
* @param integer $pageSize
* @return \Illuminate\Pagination\LengthAwarePaginator
*/
public static function paginate(Collection $results, int $pageSize): LengthAwarePaginator
{
$page = Paginator::resolveCurrentPage('page');
return self::paginateManually($results, $pageSize, $page);
}
/**
* Paginate a collection
*
* @param \Illuminate\Support\Collection $results
* @param integer $pageSize
* @param integer $page
* @return \Illuminate\Pagination\LengthAwarePaginator
*/
public static function paginateManually(Collection $results, int $pageSize, int $page = 1): LengthAwarePaginator
{
$total = $results->count();
return self::paginator($results->forPage($page, $pageSize), $total, $pageSize, $page, [
'path' => Paginator::resolveCurrentPath(),
'pageName' => 'page',
]);
}
/**
* Create a new length-aware paginator instance.
*
* @param \Illuminate\Support\Collection $items
* @param integer $total
* @param integer $perPage
* @param integer $currentPage
* @param array $options
* @return \Illuminate\Pagination\LengthAwarePaginator
*/
protected static function paginator(Collection $items, int $total, int $perPage, int $currentPage, array $options)
{
return Container::getInstance()->makeWith(LengthAwarePaginator::class, [
'items' => $items,
'total' => $total,
'perPage' => $perPage,
'currentPage' => $currentPage,
'options' => $options
]);
}
}

View file

@ -0,0 +1,190 @@
<?php
namespace App\Lib\Helpers;
class ExceptionsHelper
{
/**
* Handle sentry event
*
* @param \Sentry\Event $event
* @return null|\Sentry\Event
*/
public static function sentryEventHandler(\Sentry\Event $event): ?\Sentry\Event
{
// Remove unwanted reports.
$event->setModules([]);
$event->setOsContext(null);
/** @var \Sentry\ExceptionDataBag[] */
$exceptionBag = [];
foreach ($event->getExceptions() as $exceptionDataBag) {
$exceptionBag[] = self::getAnonymizedSentryException($exceptionDataBag);
}
$event->setExceptions($exceptionBag);
return $event;
}
/**
* Anonimize Sentry Exception bag
*
* @param \Sentry\ExceptionDataBag $exceptionDataBag
* @return \Sentry\ExceptionDataBag
*/
private static function getAnonymizedSentryException(
\Sentry\ExceptionDataBag $exceptionDataBag
): \Sentry\ExceptionDataBag {
$exceptionDataBag->setValue(self::getAnonymizedMessage($exceptionDataBag->getValue()));
if ($stackTrace = $exceptionDataBag->getStacktrace()) {
$exceptionDataBag->setStacktrace(self::getAnonymizedSentryStackTrace($stackTrace));
}
return $exceptionDataBag;
}
/**
* Anonymise sentry stacktrace
*
* @param \Sentry\Stacktrace $stackTrace
* @return \Sentry\Stacktrace
*/
private static function getAnonymizedSentryStackTrace(\Sentry\Stacktrace $stackTrace): \Sentry\Stacktrace
{
/** @var \Sentry\Frame[] */
$frames = \array_reverse($stackTrace->getFrames(), true);
$i = \count($frames) - 1;
// * Remove all but keep first (index.php).
while ($i-- > 0) {
$stackTrace->removeFrame(count($stackTrace->getFrames()) - 1);
}
foreach ($frames as $index => $frame) {
$frame->setPostContext([]);
$frame->setPreContext([]);
$frame->setContextLine('');
$frame->setVars(self::getAnonymizedSentryParams($frame->getVars()));
$stackTrace->addFrame($frame);
if ($index === 0) {
$stackTrace->removeFrame(count($stackTrace->getFrames()) - 1);
}
}
return $stackTrace;
}
/**
* Get anonymized sentry params
*
* @param mixed[] $params
* @return string[]
*/
private static function getAnonymizedSentryParams(array $params): array
{
foreach ($params as &$param) {
$type = \strtolower(\gettype($param));
if (\is_string($param)) {
$type = \explode(' ', \trim(strtolower($param)))[0] ?? null;
}
switch (true) {
case $type === 'lambda':
case $type === 'object':
continue 2;
case $type === 'array' and \is_array($param):
$param = sprintf('Array(%d)', \count($param));
continue 2;
default:
if (\is_numeric($param)) {
$param = \ucfirst($type);
continue 2;
}
if (\is_string($param)) {
$param = sprintf('String(%d)', \strlen($param));
continue 2;
}
} //end switch
} //end foreach
return $params;
}
/**
* Anonymize at least Sql queries
*
* @param string $message
* @return string
*/
public static function getAnonymizedMessage(string $message): string
{
$message = \preg_replace('/^(.*)( \(SQL:.*\))$/', '$1', $message) ?? $message;
if ($sqlPos = \strpos($message, 'to use near')) {
$message = \mb_strcut($message, 0, $sqlPos);
}
return $message;
}
/**
* Anonymize stack trace
*
* @param \Throwable $e
* @return string
*/
public 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'] ?? ''
);
if (isset($stack['class'])) {
$traceStr .= "{$stack['class']}->{$stack['function']}(";
if (isset($stack['args'])) {
$traceStr .= self::handleArgs($stack);
}
$traceStr .= ")\n";
} elseif (isset($stack['function'])) {
$traceStr .= "{$stack['function']}(";
if (isset($stack['args'])) {
$traceStr .= self::handleArgs($stack);
}
$traceStr .= ")\n";
}
} //end foreach
$maxStackStage++;
$traceStr .= "#$maxStackStage {main}";
return $traceStr;
}
/**
* Handle Args
*
* @param array $stack
* @return string
*/
public static function handleArgs(array $stack): string
{
$traceArgs = '';
$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) ? ',' : '');
}
return $traceArgs;
}
}

View file

@ -0,0 +1,454 @@
<?php
namespace App\Lib\Helpers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* This class is used to handle files in LDC Laravel stack
*
* * The storage strategy will be using date.
* The file name may contain sufix timestamp highresolution time (hrtime or microtime) if is duplicate.
* (function() { return time() . ' ' . Str::slug(microtime(true)) . ' ' . hrtime(true); })();
*
* posts/ - The model table name
* 10/ - Year
* 04/ - Month
* 19/ - Day
* 5f/ - First two letters of md5 file
* black-cat-under-red-car.png - Slugified file name
* black-cat-under-red-car-286071690833400.png - Slugified file name that is duplicated
*
* The final path will be storage/modelfiles/posts/10/04/19/5f/black-cat-under-red-car-286071690833400.png
*
* ! No limits of file per folder will be handled.
*
* @author Munsch Jérémy <jeremy.munsch@ldc.fr>
*/
class FileStorageHelper
{
/**
* Destroy the file associate with the attribute.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $attribute
* @return void
*/
public static function removeFile(Model $model, string $attribute): void
{
$path = \ltrim(\urldecode($model->{$attribute}), '/');
if (\strpos($path, 'storage') !== 0) {
return;
}
$path = ToolboxHelper::mbReplace('storage/', '', $path);
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
/**
* Destroy the file associate with the attribute old value (before updated to DB).
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $attribute
* @param boolean $onlyIfUpdated
* @return void
*/
public static function removeOldFile(Model $model, string $attribute, bool $onlyIfUpdated = true): void
{
// * Ignore If removal has to be done only if the field has changed and it actually hasnt.
if ($onlyIfUpdated and $model->{$attribute} === ToolboxHelper::getOriginal($model, $attribute)) {
return;
}
$originalPath = \ltrim(\urldecode(ToolboxHelper::getOriginal($model, $attribute)), '/');
if (\strpos($originalPath, 'storage') !== 0) {
return;
}
$originalPath = ToolboxHelper::mbReplace('storage/', '', $originalPath);
if (Storage::disk('public')->exists($originalPath)) {
Storage::disk('public')->delete($originalPath);
}
}
/**
* Store a file using optimized storage strategy
*
* You can use filename param to overload file name.
*
* @param \Illuminate\Database\Eloquent\Model $model The model that will hold the picture.
* @param \Illuminate\Http\UploadedFile|\SplFileInfo|string $file The file, will just return itself is string.
* @param boolean $slugify Force filename slugification.
* @param string|null $filename This string may be used to store the file.
* @param boolean $private To store the file in a private space.
* @return string
* @throws \RuntimeException If file copy fails.
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
public static function storeFile(
Model $model,
$file,
bool $slugify = false,
string $filename = null,
bool $private = false
): string {
// phpcs:enable
$tableName = $model->getTable();
if (\is_object($file) and $file instanceof \Illuminate\Http\UploadedFile) {
/** @var \Illuminate\Http\UploadedFile $file */
$filename = $filename ?: $file->getClientOriginalName();
$filename = $slugify ? self::slugifyFileName($filename) : $filename;
$newPath = self::prepareMoveFile($tableName, $filename, $private);
$newPath = str_replace(storage_path('app'), '', $newPath);
if (!$private) {
// * Store public file.
$file->storePubliclyAs(
pathinfo($newPath, \PATHINFO_DIRNAME),
pathinfo($newPath, \PATHINFO_BASENAME)
);
return 'storage' . str_replace('/public', '', $newPath);
}
$file->storeAs(
pathinfo($newPath, \PATHINFO_DIRNAME),
pathinfo($newPath, \PATHINFO_BASENAME)
);
return str_replace('/private/', '', $newPath);
} //end if
if (\is_object($file) and $file instanceof \SplFileInfo) {
/** @var \SplFileInfo $file */
$filename = $filename ?: $file->getBaseName();
$filename = $slugify ? self::slugifyFileName($filename) : $filename;
$newPath = self::prepareMoveFile($tableName, $filename, $private);
if (!File::copy($file->getRealPath(), $newPath)) {
throw new \RuntimeException(sprintf(
'Failed to copy file %s to %s',
$file->getRealPath(),
$newPath
));
}
return $private ?
\ltrim(str_replace(\storage_path('app/private'), '/storage/app/private', $newPath), '/') :
\ltrim(str_replace(\storage_path('app/public'), '/storage', $newPath), '/');
}
return strval($file);
}
/**
* Store all files from string attribute html
* If oldHtmlString is passed, a diff is done with new and old files
* to keep only files that are present in the new html string.
*
* @param \Illuminate\Database\Eloquent\Model $model The model that will hold the picture.
* @param string $htmlString The html string to parse and modify files.
* @param string|null $oldHtmlString The old html string with alreadyParsed
* files using storeModelFilesFromHtml.
* @return string Html with files pathes replaced using storage strategy.
*/
public static function storeModelFilesFromHtml(
Model $model,
string $htmlString,
string $oldHtmlString = null
): string {
\collect(self::onlyUploadedFilesWithFileManager(self::findFilesInHtml($htmlString)))
->each(function (string $path) use ($model, &$htmlString) {
/** @var \Illuminate\Filesystem\FilesystemAdapter */
$disk = Storage::disk('uploads');
$fileInfo = new \SplFileInfo($disk->path(
ToolboxHelper::mbReplace('/uploads/', '', \urldecode($path))
));
$htmlString = ToolboxHelper::mbReplace(
$path,
'/' . self::storeFile($model, $fileInfo, true),
$htmlString
);
});
if ($oldHtmlString) {
self::removeOldModelFilesFromHtml($htmlString, $oldHtmlString);
}
return $htmlString;
}
/**
* Remove all old files from string attribute html that are stored in public that
* are nove present anymore on new html string
*
* @param string $newHtmlString The html string with alreadyParsed files using storeModelFilesFromHtml.
* @param string $oldHtmlString The old html string with alreadyParsed files using storeModelFilesFromHtml.
* @return void
*/
public static function removeOldModelFilesFromHtml(string $newHtmlString, string $oldHtmlString): void
{
self::removeOldFiles(self::findFilesInHtml($oldHtmlString), self::findFilesInHtml($newHtmlString));
}
/**
* Remove all old files that are stored in public that
* are not present anymore on new array files
*
* @param string[] $oldFiles The old array files.
* @param string[] $newFiles The new array files.
* @return void
*/
public static function removeOldFiles(array $oldFiles, array $newFiles): void
{
$oldFiles = \collect(self::onlyUploadedFilesWithFileStorageHelper($oldFiles));
$filesToKeep = \collect(self::onlyUploadedFilesWithFileStorageHelper($newFiles));
$filesToRemove = $oldFiles->diff($filesToKeep);
$filesToRemove->each(function (string $publicStorageFilePath) {
$path = ToolboxHelper::mbReplace('storage/', '', \ltrim(\urldecode($publicStorageFilePath), '/'));
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
});
}
/**
* Remove all files from string attribute html that are stored in public
*
* @param string $htmlString The html string with alreadyParsed files using storeModelFilesFromHtml.
* @return void
*/
public static function removeModelFilesOfHtml(string $htmlString): void
{
$filesToRemove = \collect(self::onlyUploadedFilesWithFileStorageHelper(self::findFilesInHtml($htmlString)));
$filesToRemove->each(function (string $publicStorageFilePath) {
$path = ToolboxHelper::mbReplace('storage/', '', \ltrim(\urldecode($publicStorageFilePath), '/'));
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
});
}
/**
* Find all occurence of files in html
*
* @param string $html
* @return array
*/
public static function findFilesInHtml(string $html): array
{
preg_match_all('/href="(?P<href>.*)"|src="(?P<src>.*)"|url="(?P<url>.*)"/Um', $html, $m, PREG_SET_ORDER, 0);
return collect($m)->pluck('href')->filter()
->concat(collect($m)->pluck('url')->filter())
->concat(collect($m)->pluck('src')->filter())
->all();
}
/**
* Filter array to get only uploaded files that exists
* on disk 'public' (using helper storage strategy)
*
* @param array $urls
* @return array
*/
public static function onlyUploadedFilesWithFileStorageHelper(array $urls): array
{
return \collect($urls)->unique()
// * Filter to get only /uploads and using app url or empty
->filter(function (string $url) {
$path = \parse_url($url, \PHP_URL_PATH);
return \strpos(\ltrim($path, '/'), 'storage/modelfiles') === 0;
})->filter(function (string $url) {
$path = \parse_url($url, \PHP_URL_PATH);
return Storage::disk('public')
->exists(ToolboxHelper::mbReplace('storage/', '', \ltrim(\urldecode($path), '/')));
})->all();
}
/**
* Filter array to get only uploaded files that exists
* on disk 'uploads'
*
* @param array $urls
* @return array
*/
private static function onlyUploadedFilesWithFileManager(array $urls): array
{
return \collect($urls)->unique()
// * Filter to get only /uploads and using app url or empty
->filter(function (string $url) {
$host = \parse_url($url, \PHP_URL_HOST);
$path = \parse_url($url, \PHP_URL_PATH);
return ($host === \parse_url(config('app.url'), PHP_URL_HOST) or \is_null($host)) and
\strpos(\ltrim($path, '/'), 'uploads') === 0;
})->filter(function (string $url) {
$path = \parse_url($url, \PHP_URL_PATH);
return Storage::disk('uploads')
->exists(ToolboxHelper::mbReplace('uploads/', '', \ltrim(\urldecode($path), '/')));
})->all();
}
/**
* Prepare storage path and return
* the file full path
*
* @param string $tableName Model table name.
* @param string $filename The basename for the file.
* @param boolean $private May the file be stored in a private dir.
* @return string
*/
private static function prepareMoveFile(string $tableName, string $filename, bool $private = false): string
{
// * Security filter
$filename = \htmlspecialchars($filename);
// * Security filter + beautifier
$filename = self::sanitizeFileName($filename);
// * Slugify filename
$ext = \pathinfo($filename, \PATHINFO_EXTENSION);
$filename = Str::slug(\pathinfo($filename, \PATHINFO_FILENAME)) .
(\strlen($ext) ? ".{$ext}" : '');
$folderPath = self::getStoragePath(
$private ? 'app/private/modelfiles' : 'app/public/modelfiles',
$tableName,
$filename,
$private
);
$filename = self::getFileUniqueName($folderPath, $filename);
return "{$folderPath}/{$filename}";
}
/**
* Create sub folders to store files using pattern
* storage/modelfiles/posts/10/04/19/5f
* year/month/day/md5 first 2 letters
*
* @param string $storage_dir
* @param string $table
* @param string $filename
* @return string
*/
private static function getStoragePath(string $storage_dir, string $table, string $filename): string
{
$filenameHash = \md5($filename);
$pathFolder = \storage_path(sprintf(
'%s/%s/%s/%s/%s/%s',
$storage_dir,
$table,
gmdate('y'),
gmdate('m'),
gmdate('d'),
substr($filenameHash, 0, 2)
));
if (!File::exists($pathFolder)) {
File::makeDirectory($pathFolder, 0755, true);
}
return $pathFolder;
}
/**
* Get unique filename if the path is already occupied
*
* @param string $folderPath
* @param string $filename
* @return string
* @throws \RuntimeException If a unique filename cannot be obtained.
*/
private static function getFileUniqueName(string $folderPath, string $filename): string
{
$attempts = 0;
$maxAttempts = 20;
while (File::exists("{$folderPath}/{$filename}")) {
if ($attempts++ >= $maxAttempts) {
throw new \RuntimeException("Failed to have a unique filename for {$folderPath}/{$filename}");
}
$filename = pathinfo($filename, \PATHINFO_FILENAME) .
\uniqid('-') . '.' .
pathinfo($filename, \PATHINFO_EXTENSION);
}
return $filename;
}
/**
* Slugify the filename
*
* @param string $filename
* @return string
*/
public static function slugifyFileName(string $filename): string
{
return Str::slug(pathinfo($filename, \PATHINFO_FILENAME)) . '.' . pathinfo($filename, \PATHINFO_EXTENSION);
}
/**
* Sanitize File name for uploaded files
*
* @param string $filename The original uploaded client file name.
* @param boolean $beautify Beautify the filename.
* @return string The sanitized file name.
*/
public static function sanitizeFileName(string $filename, bool $beautify = true): string
{
// * Sanitize filename
// phpcs:disable Generic.Files.LineLength.TooLong
$filename = preg_replace(
'~
[<>:"/\\\|?*]| # file system reserved https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
[\x00-\x1F]| # control characters http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx
[\x7F\xA0\xAD]| # non-printing characters DEL, NO-BREAK SPACE, SOFT HYPHEN
[#\[\]@!$&\'()+,;=]| # URI reserved https://tools.ietf.org/html/rfc3986#section-2.2
[{}^\~`] # URL unsafe characters https://www.ietf.org/rfc/rfc1738.txt
~x',
'-',
$filename
);
// phpcs:enable
// * Avoids '.' '..' or '.hiddenFiles' .
$filename = Str::of($filename)->ltrim('.-');
// * Optional beautification
if ($beautify) {
$filename = static::beautifyFilename($filename);
}
// * Maximize filename length to 255 bytes http://serverfault.com/a/9548/44086
/** @var string */
$extension = pathinfo($filename, \PATHINFO_EXTENSION);
/** @var string */
$filename = pathinfo($filename, \PATHINFO_FILENAME);
$encoding = mb_detect_encoding($filename);
$encoding = $encoding ? $encoding : null;
return \mb_strcut(
$filename,
0,
255 - (!empty($extension) ? strlen($extension) + 1 : 0),
$encoding
) . (!empty($extension) ? ".$extension" : '');
}
/**
* Beautify a filename
*
* @param string $filename
* @return string
*/
private static function beautifyFilename(string $filename): string
{
// Reduce consecutive characters.
$filename = preg_replace([
// * "file name.zip" becomes 'file-name.zip'
'/ +/',
// * "file___name.zip" becomes 'file-name.zip'
'/_+/',
// * "file---name.zip" becomes 'file-name.zip'
'/-+/'
], '-', $filename);
$filename = preg_replace([
// * "file--.--.-.--name.zip" becomes "file.name.zip"
'/-*\.-*/',
// * "file...name..zip" becomes "file.name.zip"
'/\.{2,}/'
], '.', $filename);
// Lowercase for windows/unix interoperability http://support.microsoft.com/kb/100625 .
$filename = mb_strtolower($filename, mb_detect_encoding($filename));
// * ".file-name.-" becomes "file-name"
$filename = trim($filename, '.-');
return $filename;
}
}

View file

@ -0,0 +1,601 @@
<?php
namespace App\Lib\Helpers;
use Carbon\Carbon;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Finder\SplFileInfo;
class ToolboxHelper
{
/**
* Multibyte strtok
*
* @param string $delimiters
* @param string $str
* @return string|false
*/
public static function mbStrtok(string $delimiters, string $str = null): string|false
{
// * Keep track of the position on the string for each subsequent call.
static $pos = 0;
static $string = "";
// * If a new string is passed, reset the static parameters.
if ($str != null) {
$pos = 0;
$string = $str;
}
// * Initialize the token.
$token = "";
while ($pos < mb_strlen($string)) {
$char = mb_substr($string, $pos, 1);
$pos++;
if (mb_strpos($delimiters, $char) === false) {
$token .= $char;
} else {
// * Don't return empty strings.
if ($token != "") {
return $token;
}
}
}
// * Check whether there is a last token to return.
if ($token != "") {
return $token;
} else {
return false;
}
}
/**
* Multibyte sub str replace
*
* @param string $original
* @param string $replacement
* @param integer $position
* @param integer $length
* @return string
*/
public static function mbSubstrReplace(string $original, string $replacement, int $position, int $length): string
{
$startString = mb_substr($original, 0, $position, "UTF-8");
$endString = mb_substr($original, $position + $length, mb_strlen($original), "UTF-8");
return $startString . $replacement . $endString;
}
/**
* @param array<int|string, mixed> $array1
* @param array<int|string, mixed> $array2
*
* @return array<int|string, mixed>
*/
public static function arrayMergeRecursiveDistinct(array &$array1, array &$array2): array
{
$merged = $array1;
foreach ($array2 as $key => &$value) {
if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
$merged[$key] = self::arrayMergeRecursiveDistinct($merged[$key], $value);
} else {
$merged[$key] = $value;
}
}
return $merged;
}
/**
* Validate recaptcha
*
* @param \Illuminate\Http\Request $request
* @param boolean $isV3
* @return void
*/
public static function validateRecaptcha(Request $request, bool $isV3 = true)
{
Validator::validate($request->all(), [
'g-recaptcha-response' => [
'required', 'string',
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
function ($attribute, $value, $fail) use ($isV3) {
try {
$response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [
'secret' => config('services.google.recaptcha.private'),
'response' => \strval($value),
'remoteip' => app('clientIp')
]);
if ($e = $response->toException()) {
throw $e;
}
if ($isV3) {
if (
!$response->json('success') or
!$response->json('challenge_ts') or
Carbon::parse($response->json('challenge_ts'))->lessThan(\now('utc')->subMinutes(5)) or
$response->json('hostname') !== \parse_url(config('app.url'), \PHP_URL_HOST) or
!$response->json('score')
) {
throw new \RuntimeException();
}
if (\floatval($response->json('score')) < 0.8) {
$fail("Vous n'avez pas obtenu un score assez élevé lors de la validation du recaptcha");
}
return;
}
if (
!$response->json('success') or
!$response->json('challenge_ts') or
Carbon::parse($response->json('challenge_ts'))->lessThan(\now('utc')->subMinutes(5)) or
$response->json('hostname') !== \parse_url(config('app.url'), \PHP_URL_HOST)
) {
throw new \RuntimeException();
}
} catch (\Exception $e) {
$fail('Erreur lors de la validation du formulaire veuillez réssayer');
} //end try
}
]
], [], ['g-recaptcha-response' => 'reCaptcha']);
}
/**
* Strip scheme (https) on oembed urls
* Only embed allowed are for MP4 self hosted videos right now
*
* https is force by ckeditor MediaElements which natively supports
* only cloud hosted medias (youtube, spotify etc..)
*
* @param string $input
* @return string
*/
public static function stripCkEditorSchemesOnMediaInputs(string $input): string
{
$input = \preg_replace('/(?<=<oembed url=")(https:\/\/)(?=.*?">)/m', '', $input);
return $input;
}
/**
* Assert That no Back Office url is present on txt
*
* @param string $field
* @param string $text
* @return string
* @throws \Illuminate\Validation\ValidationException If url is a bo url.
*/
public static function assertNoInternalBoUrls(string $field, string $text): string
{
$routes = Route::getRoutes();
// * Url replace and email replace.
$text = preg_replace_callback(
// phpcs:ignore Generic.Files.LineLength.TooLong
'/(?P<url>(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/',
function ($matches) use ($routes, $field) {
$match = collect($matches)->first() ?? '';
$url = $match;
try {
$request = Request::create($url);
// If a route is match then it do not throws exception.
if (
\parse_url(config('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST) &&
trim($routes->match($request)->getPrefix() ?? '', '/') === 'bo'
) {
// Removes the matched url.
throw ValidationException::withMessages([
$field => trans(
'crud.libs.toolbox.assertNoInternalBoUrls',
['attribute' => $field]
)
]);
}
return $match;
} catch (NotFoundHttpException $e) {
return $match;
}
},
$text
);
return $text;
}
/**
* Assert That only external url is present on txt
*
* @param string $field
* @param string $text
* @return string
* @throws \Illuminate\Validation\ValidationException If url is a bo url.
*/
public static function assertNoInternalUrls(string $field, string $text): string
{
self::assertNoInternalBoUrls($field, $text);
$routes = Route::getRoutes();
// * Url replace and email replace.
$text = preg_replace_callback(
// phpcs:ignore Generic.Files.LineLength.TooLong
'/(?P<url>(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/',
function ($matches) use ($routes, $field) {
$match = collect($matches)->first() ?? '';
$url = $match;
try {
$request = Request::create($url);
// If a route is match then it do not throws exception.
if (
\parse_url(config('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST) &&
$routes->match($request)
) {
// Removes the matched url.
throw ValidationException::withMessages([
$field => trans(
'crud.libs.toolbox.assertNoInternalUrls',
['attribute' => $field, 'appname' => config('app.name')]
)
]);
}
return $match;
} catch (NotFoundHttpException $e) {
return $match;
}
},
$text
);
return $text;
}
/**
* Mutltibyte string replace
*
* @param array|string $search
* @param array|string $replace
* @param array|string $subject
* @param integer $count
* @return array|string
*/
public static function mbReplace(
array|string $search,
array|string $replace,
array|string $subject,
int &$count = 0
): array|string {
if (!is_array($search) && is_array($replace)) {
return false;
}
if (is_array($subject)) {
// Call mb_replace for each single string in $subject .
foreach ($subject as &$string) {
$string = &self::mbReplace($search, $replace, $string, $count);
}
} elseif (is_array($search)) {
if (!is_array($replace)) {
foreach ($search as &$string) {
$subject = self::mbReplace($string, $replace, $subject, $count);
}
} else {
$n = max(count($search), count($replace));
while ($n--) {
$subject = self::mbReplace(current($search), current($replace), $subject, $count);
next($search);
next($replace);
}
}
} else {
$parts = mb_split(preg_quote($search), $subject);
$count = count($parts) - 1;
$subject = implode($replace, $parts);
} //end if
return $subject;
}
/**
* Asserts that the field is unique
*
* @param string $table
* @param string $field
* @param string|integer|float|boolean|null|array $value
* @param integer|null $id
* @param callable|null $query
* @return void
* @throws \Illuminate\Validation\ValidationException If the field is not unique.
*/
public static function assertFieldIsUnique(
string $table,
string $field,
string|int|float|bool|null|array $value,
?int $id = null,
?callable $query = null
) {
if (!$query) {
Validator::make([$field => $value], [
self::mbReplace('.', '\.', $field) => Rule::unique($table, $field)->ignore($id),
], [
self::mbReplace('.', '\.', "{$field}.unique")
=> trans('crud.libs.toolbox.assertFieldIsUnique', ['attribute' => $field, 'value' => $value])
])->validate();
return;
}
Validator::make([$field => $value], [
$field => Rule::unique($table)->where($query)->ignore($id),
], [
$field . '.unique' => trans(
'crud.libs.toolbox.assertFieldIsUnique',
['attribute' => $field, 'value' => $value]
)
])->validate();
}
/**
* Get original polyfill with autotranslate behavior
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $attribute
* @return mixed
*/
public static function getOriginal(Model $model, string $attribute): mixed
{
if (\in_array('Spatie\Translatable\HasTranslations', \class_uses_recursive($model))) {
return $model->getTranslation(
$attribute,
$model->getLocale(),
$model->useFallbackLocale()
);
}
return $model->getOriginal($attribute);
}
/**
* Dynamic model query
*
* @param string $defaultLocale
* @param \Illuminate\Contracts\Database\Eloquent\Builder $q
* @param string $column
* @param string $value
* @return \Illuminate\Contracts\Database\Eloquent\Builder
*/
public static function queryColumnWithLocales(
string $defaultLocale,
Builder $q,
string $column,
string $value
): Builder {
$currentLocale = app()->currentLocale();
$locales = collect(config('app.locales'))
->mapWithKeys(fn (string $locale) => [$locale => $locale]);
if (!$locales->has($defaultLocale)) {
// * Remove default locale.
$locales->pull($defaultLocale);
}
if (!$locales->has($currentLocale)) {
// * Remove current locale.
$locales->pull($currentLocale);
}
return $q->where(function (Builder $q) use ($defaultLocale, $currentLocale, $locales, $column, $value) {
// * Query default locale
$q->whereRaw("JSON_EXTRACT({$column}, '$.{$defaultLocale}') = '{$value}'");
if ($defaultLocale !== $currentLocale) {
// * Query current locale
$q->whereRaw("JSON_EXTRACT({$column}, '$.{$currentLocale}') = '{$value}'");
}
// * Query any other locale
$locales->each(fn ($locale) => $q->orWhereRaw(
"JSON_EXTRACT({$column}, '$.{$locale}') = '{$value}'"
));
});
}
/**
* Generates an array from a dot separated string
*
* @param string $path
* @param mixed $value
* @param string $separator
* @return array
*/
public static function dotStringToNestedArray(string $path, mixed $value, string $separator = '.'): array
{
$arr = $value;
$keys = array_reverse(explode($separator, $path));
foreach ($keys as $key) {
$arr = [$key => $arr];
}
return $arr;
}
/**
* Convert deeply an object to array
*
* @param array|object $input
* @return array
* @throws \RuntimeException If $input var is a scalar value.
*/
public static function objectToArray($input): array
{
// Only process if it's an object or array being passed to the function.
if (is_object($input) or is_array($input)) {
$ret = (array)$input;
foreach ($ret as &$item) {
// Recursively process EACH element only if not scalar.
$item = (is_object($item) or is_array($item)) ?
self::objectToArray($item) : $item;
}
return $ret;
} else {
throw new \RuntimeException('Cannot handle $input of type ' . \gettype($input));
}
}
/**
* Convert deeply an array to object
*
* @param array|object $input
* @return object
* @throws \RuntimeException If $input var is a scalar value.
*/
public static function arrayToObject($input): object
{
// Only process if it's an object or array being passed to the function.
if (is_object($input) or is_array($input)) {
$ret = (object)$input;
foreach ($ret as &$item) {
// Recursively process EACH element only if not scalar.
$item = (is_object($item) or is_array($item)) ?
self::arrayToObject($item) : $item;
}
return $ret;
} else {
throw new \RuntimeException('Cannot handle $input of type ' . \gettype($input));
}
}
/**
* Validate picture
*
* @param string $attribute
* @param string|null $value
* @param callback $fail
* @param object $sizes
* @return void
*/
public static function validatePicture(string $attribute, string|null $value, callable $fail, object $sizes)
{
if (!($useStorage = Storage::exists($value)) and !File::exists($value)) {
$fail(trans(':attribute n\'existe pas dans le système de fichier', [
'attribute' => $attribute
]));
return;
}
/** @var string */
$fileFullPath = self::getUploadedFileFullPath($value, $useStorage);
if (
!\in_array(self::getUploadedFileMimeType($value, $useStorage), [
'image/jpeg', 'image/jpg', 'image/png', 'image/gif'
])
) {
$fail(trans(':attribute doit être une image de type \'image/jpeg,image/jpg,image/png,image/gif\'', [
'attribute' => $attribute
]));
}
if (!($dimensions = \getimagesize($fileFullPath)) or !isset($dimensions[0]) or !isset($dimensions[1])) {
$fail(trans(':attribute impossible de valider les dimensions', [
'attribute' => $attribute
]));
} else {
$width = intval($dimensions[0]);
$height = intval($dimensions[1]);
$xMessage = trans(
":attribute la largeur doit être comprise entre {$sizes->minWith}px et {$sizes->maxWith}px"
);
if ($sizes->minWith === $sizes->maxWith) {
$xMessage = trans(":attribute la largeur doit être de {$sizes->minWith}px");
}
$yMessage = trans(
":attribute la hauteur doit être comprise entre {$sizes->minWith}px et {$sizes->maxWith}px"
);
if ($sizes->minHeight === $sizes->maxHeight) {
$yMessage = trans(":attribute la hauteur doit être de {$sizes->minWith}px");
}
if (($width < $sizes->minWith) or ($width > $sizes->maxWith)) {
$fail($xMessage);
}
if (($height < $sizes->minHeight) or ($height > $sizes->maxHeight)) {
$fail($yMessage);
}
} //end if
}
/**
* Get the file full path
*
* @param string $value
* @param boolean $useStorage
* @return string
*/
private static function getUploadedFileFullPath(string $value, bool $useStorage): string
{
/** @var \Illuminate\Filesystem\FilesystemAdapter */
$disk = Storage::disk();
return !$useStorage ? (realpath($value) ?: $value) : $disk->path($value);
}
/**
* Get the file mimetype
*
* @param string $value
* @param boolean $useStorage
* @return string|false
*/
private static function getUploadedFileMimeType(string $value, bool $useStorage)
{
return !$useStorage ? File::mimeType($value) : Storage::mimeType($value);
}
/**
* Fetch class list from a specific namespace
*
* @param string $namespace
* @return array<string> An array of classe namespaces
*/
public static function getClassesUsingNamespace(string $namespace): array
{
return collect(File::files(\app_path(Str::of($namespace)->replaceFirst('\App', ''))))
->filter(fn (SplFileInfo $splFileInfo) => $splFileInfo->getExtension() === 'php')
->map(fn (SplFileInfo $splFileInfo) => Str::of($splFileInfo->getBasename())
->replace('.' . $splFileInfo->getExtension(), '')->prepend("{$namespace}\\"))->all();
}
/**
* Get all classes from a specific namespace that implement a specific interface
*
* @param string $namespace
* @param string $interface
* @return array<string> An array of classe namespaces
*/
public static function getClassesUsingInterface(string $namespace, string $interface): array
{
return Arr::where(
static::getClassesUsingNamespace($namespace),
fn (string $class_namespace) => \collect(class_implements($class_namespace, true))->filter()
->contains(fn (string $class_interface) => $class_interface === $interface)
);
}
/**
* Get validated pagination from request.
*
* @param integer $default
* @param string $field
* @param string $enumPath
* @return integer
*/
public static function getValidatedEnum(int $default, string $field, string $enumPath): int
{
try {
return \intval(Validator::make(
[$field => \request()->get($field)],
[$field => new Enum($enumPath)]
)->validated()[$field]);
} catch (ValidationException $e) {
return $default;
}
}
}

View file

@ -1,210 +0,0 @@
<?php
namespace App\Lib;
use Exception;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Some utilities methods
*
* @author Jérémy Munsch <jeremy.munsch@ldc.fr>
*/
// 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 <jeremy.munsch@jeremydev.ovh>
* @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;
}
}

View file

@ -0,0 +1,132 @@
<?php
namespace App\Lib\XmlInco;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
/**
* Des listes d'allégations
*
* @property-read string[] $health
* @property-read string[] $nutrition
* @property-read string[] $other
*
* @package App\Lib
*/
class Allegations implements \Stringable, \JsonSerializable, Jsonable, Arrayable
{
/**
* Valeur minimale, ou exacte
*
* @var string[]
*/
private array $health;
/**
* Valeur minimale, ou exacte
*
* @var string[]
*/
private array $nutrition;
/**
* Valeur minimale, ou exacte
*
* @var string[]
*/
private array $other;
/**
* Allegation
*
* ! All array are filtered keeping only strings.
*
* @param string[] $health Health allegations.
* @param string[] $nutrition Nutrition allegations.
* @param string[] $other Other allegations.
* @return void
*/
public function __construct(array $health = [], array $nutrition = [], array $other = [])
{
$this->health = \array_filter($health, fn ($el) => \is_string($el));
$this->nutrition = \array_filter($nutrition, fn ($el) => \is_string($el));
$this->other = \array_filter($other, fn ($el) => \is_string($el));
}
// * Methods
/**
* Getter
*
* @param string $name
* @return array
* @throws \Error If property access is unhandled.
*/
public function __get(string $name): array
{
if (\in_array($name, ['health', 'nutrition', 'other'])) {
return $this->{$name};
}
$class = __CLASS__;
throw new \Error("Undefined property: {$class}::{$name}");
}
/**
* Get all
*
* @return array<string,string[]>
*/
public function all(): array
{
return $this->toArray();
}
// * Implements Interfaces
/**
* Convert the object to its JSON representation.
*
* @param integer $options
* @return string
* @phpcs:disable Squiz.Commenting.FunctionComment.ScalarTypeHintMissing
*/
public function toJson($options = 0)
{
// phpcs:enable
return \json_encode($this->jsonSerialize(), $options);
}
/**
* Convert the object into something JSON serializable.
*
* @return array<string,string[]>
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return $this->toArray();
}
/**
* Get the instance as an array.
*
* @return array<string,string[]>
*/
public function toArray()
{
return [
'health' => $this->health,
'nutrition' => $this->nutrition,
'other' => $this->other
];
}
/**
* @return string
*/
public function __toString()
{
return \implode("\n", \array_map(fn (array $arr) => \implode(', ', $arr), $this->toArray()));
}
}

View file

@ -0,0 +1,281 @@
<?php
namespace App\Lib\XmlInco;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
/**
* Les valeurs nutritionelles
*
* @property-read array<string,float|string|null> $values
*
* @package App\Lib
*/
class NutritionalValues implements \Stringable, \JsonSerializable, Jsonable, Arrayable
{
/**
* Liste des valeurs nutritionelles
*
* @var array<string,float|integer|null|string>
*/
private array $values;
/**
* Nutritional values properties
*
* @var string[]
*/
private static $properties = [
'contribution_for',
'nutritional_score',
'ranking',
'energy',
'energy_kj',
'protein',
'carbohydrates',
'sugar',
'fat',
'saturated_fatty_acids',
'dietary_fiber',
'salt',
'energy_percent',
'energy_kj_percent',
'protein_percent',
'carbohydrates_percent',
'sugar_percent',
'fat_percent',
'saturated_fatty_acids_percent',
'dietary_fiber_percent',
'salt_percent'
];
/**
* Nutritional values properties units mesure
*
* @var array<string,string|null>
*/
private static $propertiesUnits = [
'contribution_for' => null,
'nutritional_score' => null,
'ranking' => null,
'energy' => 'Kcal',
'energy_kj' => 'Kj',
'protein' => 'g',
'carbohydrates' => 'g',
'sugar' => 'g',
'fat' => 'g',
'saturated_fatty_acids' => 'g',
'dietary_fiber' => 'g',
'salt' => 'g',
'energy_percent' => '%',
'energy_kj_percent' => '%',
'protein_percent' => '%',
'carbohydrates_percent' => '%',
'sugar_percent' => '%',
'fat_percent' => '%',
'saturated_fatty_acids_percent' => '%',
'dietary_fiber_percent' => '%',
'salt_percent' => '%'
];
/**
* Translation values of inco unreadable keys
*
* @var string[]
*/
private static $cleanValues = [
'apport_port' => 'contribution_for',
'score_nutri' => 'nutritional_score',
'classement' => 'ranking',
'val_energ_kcal_r' => 'energy',
'val_energ_kcal_p' => 'energy_percent',
'val_energ_kj_r' => 'energy_kj',
'pourcent_apport_ref_kjo_r' => 'energy_kj_percent',
'proteine_r' => 'protein',
'pourcent_apport_ref_pro_r' => 'protein_percent',
'sucre_r' => 'sugar',
'pourcent_apport_ref_sugar_r' => 'sugar_percent',
'mat_grass_r' => 'fat',
'pourcent_apport_ref_fat_r' => 'fat_percent',
'fib_alim_r' => 'dietary_fiber',
'pourcent_apport_ref_fibtg_r' => 'dietary_fiber_percent',
'glucides_r' => 'carbohydrates',
'glucides_p' => 'carbohydrates_percent',
'ags_r' => 'saturated_fatty_acids',
'pourcent_ags' => 'saturated_fatty_acids_percent',
'sel_r' => 'salt',
'pourcent_apport_ref_salteq_r' => 'salt_percent'
];
/**
* Human readable translations for inco translation
*
* @var string[]
*/
private static $humanReadable = [
'Pour' => 'contribution_for',
'Classement' => 'ranking',
'Nutriscore' => 'nutritional_score',
'Énergie' => 'energy',
'Énergie kJ' => 'energy_kj',
'Protéines' => 'protein',
'Glucides' => 'carbohydrates',
'Sucres' => 'sugar',
'Matière grasse' => 'fat',
'Acides Gras Saturés' => 'saturated_fatty_acids',
'Fibres alimentaires' => 'dietary_fiber',
'Sel' => 'salt'
];
/**
* Allegation
*
* ! All array are filtered keeping only strings.
*
* @param string[] $values Nutritional values.
* @return void
*/
public function __construct(array $values = [])
{
$emptySkel = \array_map(fn () => null, \array_flip(self::$properties));
$values = \array_map(fn ($value) => \is_null($value) ?
$value : (\is_numeric($value) ? \floatval($value) : \strval($value)), $values);
$this->values = \array_merge($emptySkel, $values);
}
// * Methods
/**
* Getter
*
* @param string $name
* @return array|float|string|null
* @throws \Error If property access is unhandled.
*/
public function __get(string $name): array|float|string|null
{
if (\in_array($name, self::$properties)) {
return $this->values[$name];
}
if ($name === 'values') {
return $this->values;
}
$class = __CLASS__;
throw new \Error("Undefined property: {$class}::{$name}");
}
/**
* Get human readable values
*
* @return array<string,float|string|null>
*/
public function humanReadableValues(): array
{
$o = [];
foreach (self::$humanReadable as $k => $t) {
$o[$k] = $this->values[$t];
}
return $o;
}
/**
* Get units
*
* @param boolean $forHumanReadable
* @return array
*/
public static function getUnits(bool $forHumanReadable = false): array
{
if ($forHumanReadable) {
$out = [];
$humanReadable = array_flip(self::$humanReadable);
foreach (self::$propertiesUnits as $propertyIndex => $unit) {
if (\array_key_exists($propertyIndex, $humanReadable)) {
$out[$humanReadable[$propertyIndex]] = $unit;
}
}
return $out;
}
return self::$propertiesUnits;
}
// * Static Methods
/**
* Convert Xml Inco values into proper Nutritional values
*
* @param array $incoNutritionalValues
* @return self
*/
public static function makeNutritionalValues(array $incoNutritionalValues): self
{
$incoNutritionalValues = \array_map(fn ($value) => \is_null($value) ?
$value : (\is_numeric($value) ? \floatval($value) : \strval($value)), $incoNutritionalValues);
$o = [];
foreach (self::$cleanValues as $k => $t) {
$o[$t] = $incoNutritionalValues[$k] ?? null;
}
return new self($o);
}
// * Implements Interfaces
/**
* Convert the object to its JSON representation.
*
* @param integer $options Json_encode php options.
* @param boolean $fullData Serialize all properties or ignore empty one. Default is false (ignore).
* @return string
* @phpcs:disable Squiz.Commenting.FunctionComment.ScalarTypeHintMissing
*/
public function toJson($options = 0, bool $fullData = false)
{
// phpcs:enable
return \json_encode($this->jsonSerialize($fullData), $options);
}
/**
* Convert the object into something JSON serializable.
*
* @param boolean $fullData Serialize all properties or ignore empty one. Default is false (ignore).
* @return array<string,float|string|null>
*/
#[\ReturnTypeWillChange]
public function jsonSerialize(bool $fullData = false)
{
if ($fullData) {
return $this->toArray();
}
// * Save BDD space dont serialize empty values.
return array_filter($this->toArray(), fn ($value) => !\is_null($value));
}
/**
* Get the instance as an array.
*
* @return array<string,float|string|null>
*/
public function toArray()
{
return $this->values;
}
/**
* @return string
*/
public function __toString()
{
$values = $this->humanReadableValues();
$units = self::getUnits(true);
return \implode("\n", \array_map(
fn (float|string|null $value, string $index) =>
"{$index} : {$value}" . ($units[$index] ? ' ' . $units[$index] : ''),
$values,
\array_keys($values)
));
}
}

View file

@ -0,0 +1,260 @@
<?php
namespace App\Lib\XmlInco;
use App\Enums\XmlInco\PortionType;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
/**
* Si le type de portion est exact, seul la partie basse est utilisée,
* Si le type de portion est environ, la partie basse seule ou les deux peuvent
* etre utilisés.
*
* @property-read integer $low_part
* @property-read integer $high_part
*
* @package App\Lib
*/
class PortionAmount implements \Stringable, \JsonSerializable, Jsonable, Arrayable
{
/**
* Valeur minimale, ou exacte
*
* @var integer
*/
private int $low_part;
/**
* Valeur maximale
*
* @var integer
*/
private int $high_part;
/**
* The portion is approximative
*
* @var boolean
*/
private bool $approximative;
/**
* The number of decimals allowed, this should not change
*
* @var integer
*/
private static int $allowedDecimalSize = 2;
/**
* Binary masks to store and retrieve values
*
* @var array<string,integer>
*/
private static $masks = [
'low_part' => ((1 << 15) - 1),
'high_part' => (1 << 31) - (1 << 16),
'approximative' => 1 << 31,
];
/**
* NbPortion
*
* @param float $low_part Min value is 0, max depends on low_part mask.
* @param float $high_part Min value is 0, max depends on low_part mask.
* @param boolean $approximative
* @return void
* @throws \RuntimeException If low_part part does not fit in.
* @throws \RuntimeException If high_part part does not fit in.
*/
public function __construct(float $low_part, float $high_part = 0, bool $approximative = false)
{
$maxMask = self::$masks['low_part'];
$maxValue = \bcdiv($maxMask, pow(10, self::$allowedDecimalSize), self::$allowedDecimalSize);
if (($maxValue - $low_part) < 0 or $low_part < 0) {
throw new \RuntimeException("low_part has to be a value between 0 and {$maxValue}, given is {$low_part}");
}
if (($maxValue - $high_part) < 0 or $high_part < 0) {
throw new \RuntimeException("high_part has to be a value between 0 and {$maxValue}, given is {$high_part}");
}
$this->low_part = \bcmul($low_part, pow(10, self::$allowedDecimalSize), self::$allowedDecimalSize);
$this->high_part = \bcmul($high_part, pow(10, self::$allowedDecimalSize), self::$allowedDecimalSize);
$this->approximative = $approximative;
}
// * Casts
/**
* To database value
*
* @return integer
*/
public function toDatabaseValue(): int
{
return $this->low_part |
$this->high_part << 16 |
($this->approximative ? self::$masks['approximative'] : 0);
}
/**
* Make NbPortion from database value
*
* @param integer $databaseValue
* @return \App\Lib\XmlInco\PortionAmount
*/
public static function fromDatabase(int $databaseValue): self
{
$low_part = \bcdiv(
($databaseValue & self::$masks['low_part']),
pow(10, self::$allowedDecimalSize),
self::$allowedDecimalSize
);
$high_part = \bcdiv(
($databaseValue & self::$masks['high_part']) >> 16,
pow(10, self::$allowedDecimalSize),
self::$allowedDecimalSize
);
$approximative = $databaseValue & self::$masks['approximative'];
return new self(
$low_part,
$high_part,
$approximative,
);
}
/**
* Make NbPortion from json value
*
* @param array<string,integer|boolean> $jsonArray
* @return \App\Lib\XmlInco\PortionAmount
*/
public static function fromJson(array $jsonArray): self
{
$low_part = \bcdiv(
$jsonArray['low_part'],
pow(10, self::$allowedDecimalSize),
self::$allowedDecimalSize
);
$high_part = \bcdiv(
$jsonArray['high_part'],
pow(10, self::$allowedDecimalSize),
self::$allowedDecimalSize
);
$approximative = $jsonArray['approximative'];
return new self(
$low_part,
$high_part,
$approximative,
);
}
// * STATIC Methods
/**
* Get mask for database query
*
* @return array
*/
public static function getMasks(): array
{
return self::$masks;
}
// * Methods
/**
* Get low part
*
* @return float
*/
public function getLowValue(): float
{
return \bcdiv(
$this->low_part,
pow(10, self::$allowedDecimalSize),
self::$allowedDecimalSize
);
}
/**
* Get high part
*
* @return float
*/
public function getHighValue(): float
{
return \bcdiv(
$this->high_part,
pow(10, self::$allowedDecimalSize),
self::$allowedDecimalSize
);
}
/**
* Get Portion type
*
* @return \App\Enums\XmlInco\PortionType
*/
public function getType(): PortionType
{
return match ($this->approximative) {
true => PortionType::approximative,
false => PortionType::exact
};
}
// * Implements Interfaces
/**
* Convert the object to its JSON representation.
*
* @param integer $options
* @return string
* @phpcs:disable Squiz.Commenting.FunctionComment.ScalarTypeHintMissing
*/
public function toJson($options = 0)
{
// phpcs:enable
return \json_encode($this->jsonSerialize(), $options);
}
/**
* Convert the object into something JSON serializable.
*
* @return array
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return $this->toArray();
}
/**
* Get the instance as an array.
*
* @return array<TKey,TValue>
*/
public function toArray()
{
return [
'low_part' => $this->low_part,
'high_part' => $this->high_part,
'approximative' => $this->approximative
];
}
/**
* @return string
*/
public function __toString()
{
if ($this->high_part) {
return \trans('entre :low_part et :high_part', [
'low_part' => $this->getLowValue(),
'high_part' => $this->getHighValue()
]);
}
return $this->approximative ?
trans('environ :amount:', ['amount' => $this->getLowValue()]) : $this->getLowValue();
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace App\Mail;
use App\Models\Service;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ServiceCredentialRenewed extends Mailable
{
use Queueable;
use SerializesModels;
/**
* The concerned service.
*
* @var \App\Models\Service
*/
public $service;
/**
* The user who did the action.
*
* @var \App\Models\User
*/
public $user;
/**
* Create a new message instance.
*
* @param \App\Models\Service $service
* @param \App\Models\User $user
*/
public function __construct(Service $service, User $user)
{
$this->service = $service;
$this->user = $user;
$this->service->load('arcadiaBrand');
}
/**
* Get the message envelope.
*
* @return \Illuminate\Mail\Mailables\Envelope
*/
public function envelope(): Envelope
{
return new Envelope(
subject: '!!! Warning !!!, New service credential have been generated !',
to: [new Address(
config('mail.contactsList.serviceweb', 'equipe.web@ldc.fr'),
sprintf('%s service warning', config('app.name'))
)]
);
}
/**
* Get the message content definition.
*
* @return \Illuminate\Mail\Mailables\Content
*/
public function content(): Content
{
return new Content(
markdown: 'emails.service-renewed',
with: [
'appName' => config('app.name'),
'service' => $this->service,
'brand' => $this->service->arcadiaBrand,
'by' => sprintf('%s %s (%s)', $this->user->first_name, $this->user->last_name, $this->user->email),
]
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

133
app/Models/ActivityLog.php Normal file
View file

@ -0,0 +1,133 @@
<?php
namespace App\Models;
use App\Enums\ActivityLogs\ActivityLogsEventEnum;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Schema;
/**
* @property string $model Target model.
* @property string $model_id Id of the target model.
* @property string $event Event of this activity (ActivityLogsEventEnum).
* @property string $data Changes.
* @property-read \Illuminate\Support\Carbon $created_at Created date.
*
* @method public static function addActivity($model, $eventEnum) Add new activity to the activity logs list.
* @method public static function getValuesChanged($activity, $model, $eventEnum)
* Get old, new and type of values changed.
*
* @property-read \App\Models\User $user User BelongsTo relation.
*/
class ActivityLog extends Model
{
use HasFactory;
use Notifiable;
/**
* Indicates if the model should be timestamped.
*
* @var boolean
*/
public $timestamps = false;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'is_anonymous',
'model_class',
'model_id',
'event',
'data',
'created_at',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'is_anonymous' => 'bool',
'event' => ActivityLogsEventEnum::class,
'data' => 'json',
'created_at' => 'datetime',
];
// * METHODS
/**
* Add new activity to the activity logs list.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param \App\Enums\ActivityLogs\ActivityLogsEventEnum $eventEnum
* @return void
*/
public static function addActivity(Model $model, ActivityLogsEventEnum $eventEnum): void
{
$isAnonymous = true;
/** @var \Illuminate\Database\Eloquent\Model|null */
$userModel = auth('backend')->user();
if (is_object($userModel)) {
$userModel = $userModel->getKeyForSelectQuery();
$isAnonymous = false;
}
$activity = new self();
$activity->user_id = $userModel;
$activity->is_console = app()->runningInConsole();
$activity->is_anonymous = $isAnonymous;
$activity->model_class = \get_class($model);
$activity->model_id = $model->getKey();
$activity->event = $eventEnum;
$activity->data = static::getChangedColumns($activity, $model, $eventEnum);
$activity->created_at = Carbon::now();
$activity->saveOrFail();
}
/**
* Get old, new columns that has changed.
*
* @param \self $activity
* @param \Illuminate\Database\Eloquent\Model $model
* @param \App\Enums\ActivityLogs\ActivityLogsEventEnum $eventEnum
* @return array|null
*/
public static function getChangedColumns(self $activity, Model $model, ActivityLogsEventEnum $eventEnum): array|null
{
$modelClassName = $activity->model_class;
/** Get type of each fields of the target model */
$targetModel = $activity->model_class::where(
(new $modelClassName())->getRouteKeyName(),
$activity->model_id
)->first();
if ($targetModel != null) {
// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed
$targetModelTypes = collect($targetModel->toArray())
->map(function ($field, $fieldIndex) use ($targetModel) {
return Schema::getColumnType($targetModel->getTable(), $fieldIndex);
})->toArray();
// phpcs:enable
}
/** Return old, new and type of values changed */
return ($eventEnum === ActivityLogsEventEnum::updated) ?
array_intersect_key($targetModelTypes, $model->getChanges()) : null;
}
// * RELATIONS
/**
* User BelongsTo relation.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
}

118
app/Models/ArcadiaBrand.php Normal file
View file

@ -0,0 +1,118 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Rennokki\QueryCache\Traits\QueryCacheable;
/**
* @property-read integer $id The arcadia brand id.
* @property-read string $label The arcadia brand name.
*
* @property-read \Illuminate\Database\Eloquent\Collection<\App\Models\Service> $services
* The Services for the ArcadiaBrand.
* @property-read \Illuminate\Database\Eloquent\Collection<\App\Models\XmlInco> $xmlIncos
* The XmlIncos for the ArcadiaBrand.
* @package App\Models
*/
class ArcadiaBrand extends Model
{
use HasFactory;
use QueryCacheable;
/**
* Indicates if the IDs are auto-incrementing.
*
* @var boolean
*/
public $incrementing = false;
/**
* The timeout cache for this model
*
* @var integer
*/
protected $cacheFor = 0;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'id',
'label'
];
/**
* ArcadiaBrand
*
* @param array $attributes
*/
public function __construct(array $attributes = [])
{
$this->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
/**
* Get all of the Services for the ArcadiaBrand
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function services(): HasMany
{
return $this->hasMany(Service::class);
}
/**
* Get all of the xmlIncos for the ArcadiaBrand
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function xmlIncos(): HasMany
{
return $this->hasMany(XmlInco::class);
}
}

Some files were not shown because too many files have changed in this diff Show more