Compare commits

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

147 commits

Author SHA1 Message Date
K Muller
3ee706c80b fix: 🐛 change pid 2026-10-23 13:24:58 +02:00
K Muller
0c8bcc9937 fix: 🐛 error with composer.lock in master branch 2026-10-23 09:26:13 +02:00
K Muller
93673a9034 fix: 🐛 pass into php 8.5 for patch CVE 2026-10-23 08:59:40 +02:00
Karl M'muller
f8c4c03941 Merge branch '19-url-validation' into 'develop'
fix: 🐛 validate urls as urls instead of strings

See merge request ldc-web/projets/qrcollector/qrcollector-back!28
2026-06-25 12:25:47 +00:00
Noah Lancien
de078eb142 fix: 🐛 validate urls as urls instead of strings 2026-06-25 12:32:32 +02:00
Karl M'muller
14b28365bc Merge branch 'issue-18' into 'develop'
feat: ✨ config front and back when upload image for take SVG

See merge request ldc-web/projets/qrcollector/qrcollector-back!27
2026-06-25 09:43:08 +00:00
Abdel LAMRANI
ac738c02e0 fix: 🐛 overload Helper and trait from package to accept SVG and fix if image not send 2026-06-22 15:58:24 +02:00
Abdel LAMRANI
9d081727bc feat: ✨ config upload image for take SVG 2026-06-19 17:27:21 +02:00
karl.muller
7369d3c820 Merge branch '14-fixes' into 'develop'
#14 feedbacks

See merge request ldc-web/projets/qrcollector/qrcollector-back!26
2026-06-18 15:48:07 +00:00
Noah Lancien
479cb39cc0 feat: ✨ Remove relation between stats and urls 2026-06-18 10:59:44 +02:00
Noah Lancien
5262941eab fix: 🐛 Updated utlstats policies + fixed activitylogs + fixed brand deletion 2026-06-18 10:12:54 +02:00
karl.muller
c7577c9d1b Merge branch '14-marketing-access-fix' into 'develop'
Resolve "Donner l'accès aux services Marketing"

See merge request ldc-web/projets/qrcollector/qrcollector-back!25
2026-06-11 13:38:56 +00:00
karl.muller
bacb71185c Merge branch '16-missing-translations' into 'develop'
fix: 🐛 Added missing translations

See merge request ldc-web/projets/qrcollector/qrcollector-back!23
2026-05-21 07:56:37 +00:00
karl.muller
cb192d46dc Merge branch '12-duplicate-fix' into 'develop'
Resolve "Dupliquer un QR Code"

See merge request ldc-web/projets/qrcollector/qrcollector-back!24
2026-05-21 07:56:28 +00:00
Noah Lancien
a10d9f5979 fix: 🐛 500 on urls create 2026-05-13 12:23:05 +02:00
Noah Lancien
eecbb56d6c fix: 🐛 Added missing script for submit clone button in cruds 2026-05-13 11:33:58 +02:00
Noah Lancien
d367b1d086 fix: 🐛 restrict access in controllers 2026-05-13 11:32:01 +02:00
Noah Lancien
9c09746f79 fix: 🐛 use session for pagination instead of cache to match the package method 2026-05-13 10:55:15 +02:00
Noah Lancien
8488b8a51c fix: 🐛 Added missing button to duplicate url 2026-05-13 10:35:30 +02:00
Noah Lancien
91c42d7dc6 fix: 🐛 Added missing translations 2026-05-13 09:21:17 +02:00
Noah Lancien
57692c4eae ci: 👷 Added @uwil npm config in Dockerfile 2026-04-23 11:43:59 +02:00
Noix
fd82054d0a Merge branch 'bump-php84' into 'develop'
chore: bump php8.4 + laravel12

See merge request ldc-web/projets/qrcollector/qrcollector-back!17
2026-04-23 08:38:13 +00:00
Vincent BRIDIER
f077ae0c28 Merge branch '5-period-to-enums' into 'bump-php84'
Resolve "Migrate Stats perdiods to Enum"

See merge request ldc-web/projets/qrcollector/qrcollector-back!22
2026-04-13 12:32:00 +00:00
Noah Lancien
2c8507829b fix: 🐛 conceptor with no brands couldn't edit an url 2026-04-13 12:42:33 +02:00
Noah Lancien
a87a61386d feat: ✨ replaced string by enums for statis periods 2026-04-13 12:40:53 +02:00
Vincent BRIDIER
078894c577 Merge branch '11-transparency' into 'bump-php84'
Resolve "Création QR code sur fond transparent"

See merge request ldc-web/projets/qrcollector/qrcollector-back!21
2026-04-13 07:02:04 +00:00
Noah Lancien
f0fa683a37 feat: ✨ Added Vue3ColorPicker to others color fields 2026-04-10 17:16:17 +02:00
Noah Lancien
fc49072916 feat: ✨ Added transaprency to qr generator 2026-04-10 17:07:47 +02:00
Vincent BRIDIER
31e585b5a1 Merge branch '14-open-bo' into 'bump-php84'
Resolve "Donner l'accès aux services Marketing"

See merge request ldc-web/projets/qrcollector/qrcollector-back!20
2026-04-08 14:34:14 +00:00
Noah Lancien
04049ea4c3 fix: 🐛 updated some policies 2026-04-08 15:25:29 +02:00
Noah Lancien
43d9d846cf feat: ✨ Added marketing role 2026-04-08 15:25:27 +02:00
Noah Lancien
6f40f7f3a7 feat: ✨ Added admin-secure 2026-04-08 15:16:02 +02:00
noah.lancien
2c86d0094e Merge branch '12-duplicate-qrcode' into 'bump-php84'
Resolve "Dupliquer un QR Code"

See merge request ldc-web/projets/qrcollector/qrcollector-back!18
2026-04-08 13:10:39 +00:00
Noah Lancien
fee8a3ec10 feat: ✨ support duplication for url models 2026-04-03 14:14:40 +02:00
Noah Lancien
f830e07f3e feat: ✨ added commit lint with husky 2026-04-03 14:13:58 +02:00
Noah Lancien
951fa0311e clean: eslint errors 2026-04-02 16:59:24 +02:00
Noah Lancien
dad597ba87 chore: bump php8.4 + laravel12 2026-02-12 16:09:21 +01:00
Noah Lancien
73e6dd5194 ci: added ci variables to define pod resources 2026-01-09 10:41:18 +01:00
Vincent BRIDIER
f21567e028 Fix: revert port on manifet 2025-11-27 09:35:23 +01:00
Vincent BRIDIER
57554f4156 feat: Add DB_HOST and DB_PORT into manifest.yaml 2025-11-27 09:10:26 +01:00
noah.lancien
046695112a Merge branch 'issue-8' into 'develop'
refactor: ♻️ refactoring of all methods to obtain chartjs #8

See merge request ldc-web/projets/qrcollector/qrcollector-back!10
2025-07-16 12:58:20 +00:00
Alexis Gousseau
576bfc8b32
refactor: ♻️ refactoring of all methods to obtain chartjs #8 2025-07-16 14:12:07 +02:00
noah.lancien
13532c05ae Merge branch 'issue-13' into 'develop'
feat: 🔥 remove APP_APP_URL_FRONT reference in manifest

See merge request ldc-web/projets/qrcollector/qrcollector-back!16
2025-02-24 14:54:41 +00:00
Nicolas Demangeat
49cac11412 fix: 🔥 remove all reference to APP_URL_FRONT 2025-02-24 15:52:16 +01:00
Nicolas Demangeat
3f81a0b3f6 feat: 🔥 remove APP_APP_URL_FRONT reference in manifest 2025-02-24 11:03:28 +01:00
Vincent BRIDIER
76cd500aa8 Update manifest 2025-02-07 17:47:46 +00:00
karl.muller
5bbf4e531d Merge branch 'unify-projects' into 'develop'
feat: Moved redirections and stats incrementation from front project

See merge request ldc-web/projets/qrcollector/qrcollector-back!15
2025-02-06 08:43:32 +00:00
Noah Lancien
a0ca50ad4e ci: Updated manifest.yaml 2025-02-05 18:06:00 +01:00
Noah Lancien
058979eb04 clean: use firstOrNew instead of condition 2025-02-05 16:53:19 +01:00
Noah Lancien
483c415d4d feat: Moved redirections and stats incrementation from front project 2025-02-04 11:06:42 +01:00
Karl Muller
737d69e94f fix: last update was in php 8.3, put it back in 7.3 2025-01-14 09:00:09 +00:00
Karl Muller
7adb1d5005 fix: change manifest for cron issue 2025-01-14 08:44:01 +00:00
Thibault Raquet
564de0d2bd Merge branch 'prod-migration' into 'develop'
Prod migration

See merge request ldc-web/projets/qrcollector/qrcollector-back!12
2024-06-27 10:08:59 +00:00
Thibault Raquet
d43c223101 Prod migration 2024-06-27 10:08:59 +00:00
Jérémy Munsch
c0f82c577d
makesure to cast http to https in specific condition for api request signature 2024-04-26 13:56:39 +02:00
Jérémy Munsch
e7f989e392 Merge branch 'fix-signature' into 'develop'
fix signature http/https

See merge request ldc-web/projets/qrcollector/qrcollector-back!11
2024-04-26 11:53:41 +00:00
Noah Lancien
5af7cddaf9 fix signature http/https 2024-04-26 13:52:19 +02:00
Jérémy Munsch
69a51c2f07 Merge branch 'issue-7' into 'develop'
Issue 7

See merge request ldc-web/projets/qrcollector/qrcollector-back!9
2024-04-25 07:19:05 +00:00
Jérémy Munsch
e06d3d7d25 Merge branch 'api' into 'develop'
Api close #1

See merge request ldc-web/projets/qrcollector/qrcollector-back!2
2024-04-25 07:15:28 +00:00
Noah Lancien
e9d09e0693 Fixed replaced files 2024-04-24 16:23:30 +02:00
Noah Lancien
ff0eb7aedb Updated the api autentication 2024-04-23 14:29:01 +02:00
Noah Lancien
221e44fe2f Fixed url stats requests 2024-04-08 16:49:48 +02:00
Alexis Gousseau
4e2038f82a add title on urls - issue #7 2023-12-18 16:37:25 +01:00
Alexis Gousseau
4ea4648d75 add search on brand in urls.index 2023-12-18 16:15:47 +01:00
Vincent BRIDIER
2ddb0de4b7 Merge branch 'k8s' into 'develop'
Added k8s package

See merge request ldc-web/projets/qrcollector/qrcollector-back!8
2023-12-08 08:10:20 +00:00
Noah Lancien
298465b32b Added k8s package 2023-12-08 09:03:53 +01:00
Noah Lancien
95379ae856 Blocked requests that doesn't expect a json response 2023-11-27 13:34:33 +01:00
Noah Lancien
6caeb050c1 Url stats can now be retrieved by period. Brand must be the same as the sent service->brand 2023-11-23 15:33:07 +01:00
Noah Lancien
be3a251894 Api request must now be validated with a generated signature 2023-11-20 16:41:45 +01:00
Noah Lancien
06ca89f74e Resolved conflicts 2023-11-20 12:12:52 +01:00
Vincent BRIDIER
518c18331c
CSP Fix 2023-10-06 14:30:31 +02:00
Vincent BRIDIER
cc74d8d662 Merge branch 'issue-3-stats' into 'develop'
Issue 3 stats

See merge request ldc-web/projets/qrcollector/qrcollector-back!7
2023-10-06 11:02:07 +00:00
noah.lancien
6bbbcd50e5 Issue 3 stats 2023-10-06 11:02:07 +00:00
Vincent BRIDIER
b34151d602 Merge branch 'issue-2-500-on-search' into 'develop'
Fixed error 500 when we searched for a url

Closes #2

See merge request ldc-web/projets/qrcollector/qrcollector-back!6
2023-10-06 06:09:21 +00:00
Noah Lancien
bed2226f2b Fixed error 500 when we searched for a url 2023-09-19 12:08:27 +02:00
Vincent BRIDIER
95376bf592
Update manifest + remove status checker 2023-09-12 10:02:49 +02:00
Vincent BRIDIER
2b9fb68ebb
Update manifest for crons 2023-05-30 13:52:23 +02:00
Vincent BRIDIER
a5fd81c630
Update manifest for cron 2023-05-30 11:31:52 +02:00
Vincent BRIDIER
96e6403930
Merge branch 'trunk' into develop 2023-05-10 15:43:07 +02:00
Vincent BRIDIER
aec99587c8
test laravel cronjob 2023-04-18 08:46:21 +02:00
Vincent BRIDIER
c6e45ad028
test laravel cronjob 2023-04-18 08:37:40 +02:00
Vincent BRIDIER
b6d88d87c1
test laravel cronjob 2023-04-17 18:45:39 +02:00
Vincent BRIDIER
cf093d44ad
Merge branch 'develop' into trunk 2023-04-17 18:39:08 +02:00
Vincent BRIDIER
6aba7629ac
test laravel cronjob 2023-04-17 18:38:37 +02:00
Vincent BRIDIER
064715a06d
Merge branch 'develop' into trunk 2023-04-17 12:38:20 +02:00
Vincent BRIDIER
8c64ddca5e Merge branch 'qrcode-generation-random-key' into 'develop'
Added random string for url key creation

See merge request ldc-web/projets/qrcollector/qrcollector-back!5
2023-04-17 10:24:17 +00:00
Noah Lancien
ebfb106781 Added random string for url key creation 2023-04-17 10:14:45 +00:00
Vincent BRIDIER
c22b78c68b
Merge branch 'develop' into trunk 2023-04-17 11:45:59 +02:00
Vincent BRIDIER
0cbf94e9c4 Merge branch 'fix-qrcode-editing' into 'develop'
Fixed : Editing a qrcode didn't get the full url

See merge request ldc-web/projets/qrcollector/qrcollector-back!4
2023-04-17 09:45:23 +00:00
Noah Lancien
1d3efb4187 Fixed : Editing a qrcode didn't get the full url 2023-04-17 09:34:32 +00:00
Vincent BRIDIER
d556761e72
Merge branch 'develop' into trunk 2023-04-17 10:53:26 +02:00
Vincent BRIDIER
4c3c69fea0 add app url front 2023-04-17 08:50:49 +00:00
Vincent BRIDIER
2fb5b4c421
Merge branch 'develop' into trunk 2023-04-17 10:36:27 +02:00
Vincent BRIDIER
656dd92a70 Merge branch 'fix-qrcode-url' into 'develop'
Fix qrcode url

See merge request ldc-web/projets/qrcollector/qrcollector-back!3
2023-04-17 08:35:54 +00:00
noah.lancien
f140ef582b Fix qrcode url 2023-04-17 08:35:54 +00:00
Noah Lancien
c8f4d918eb Started to implement authentication 2023-04-07 06:58:47 +00:00
Vincent BRIDIER
d28c5bd2a5
Merge branch 'develop' into trunk 2023-03-31 12:01:20 +02:00
Vincent BRIDIER
0ba52c6f61
Update manifest for run with www-data 2023-03-31 12:00:47 +02:00
Vincent BRIDIER
a20c4a2b1a
Merge branch 'develop' into trunk 2023-03-30 19:07:19 +02:00
Vincent BRIDIER
30d230905a
Update Dockerfile 2023-03-30 19:07:07 +02:00
Vincent BRIDIER
f02e040a64
Merge branch 'develop' into trunk 2023-03-30 18:18:03 +02:00
Vincent BRIDIER
e312e299e6
Add chmod storage 2023-03-30 18:17:51 +02:00
Vincent BRIDIER
ee5810e129
Merge branch 'develop' into trunk 2023-03-30 18:10:05 +02:00
Vincent BRIDIER
a2a2903db1
Add chmod storage 2023-03-30 18:09:51 +02:00
Vincent BRIDIER
0abde46789
Merge branch 'develop' into trunk 2023-03-30 16:38:05 +02:00
Vincent BRIDIER
de2e0cf84a
Remove storage link 2023-03-30 16:37:56 +02:00
Vincent BRIDIER
35b3eee576
Merge branch 'develop' into trunk 2023-03-30 16:31:10 +02:00
Vincent BRIDIER
03ad609648
Add storage link 2023-03-30 16:31:02 +02:00
Vincent BRIDIER
b08fe1d32d
Merge branch 'develop' into trunk 2023-03-30 16:22:05 +02:00
Vincent BRIDIER
80cf95e7c1
Add storage link 2023-03-30 16:21:54 +02:00
Vincent BRIDIER
10d399a798
Merge branch 'develop' into trunk 2023-03-30 16:04:32 +02:00
Vincent BRIDIER
8e789ca6e6
Update Dockerfile 2023-03-30 16:04:19 +02:00
Vincent BRIDIER
bda991087e
Merge branch 'develop' into trunk 2023-03-30 13:48:54 +02:00
Vincent BRIDIER
923ac45252
Update for 443 use 2023-03-30 13:48:41 +02:00
Noah Lancien
d2b77f6143 Created Service CRUD + access id and pwd generation 2023-03-30 11:44:13 +00:00
Vincent BRIDIER
a17b86466b
Merge branch 'develop' into trunk 2023-03-30 12:31:24 +02:00
Vincent BRIDIER
0097f3699b
Update for 443 use 2023-03-30 12:30:15 +02:00
Noah Lancien
ef14493201 Merge remote-tracking branch 'origin/develop' into api 2023-03-30 11:01:24 +02:00
Vincent BRIDIER
4961bb0593
block index by bots 2023-03-29 18:56:23 +02:00
Vincent BRIDIER
e897e969ce
Merge branch 'develop' into trunk 2023-03-29 18:27:56 +02:00
Vincent BRIDIER
a351189ab3
Update manifest 2023-03-29 18:27:36 +02:00
Vincent BRIDIER
ce0fdf9efc
Merge branch 'develop' into trunk 2023-03-29 17:53:41 +02:00
Vincent BRIDIER
37f9cf0793
Fix CSP + errors pages + login redirect 2023-03-29 17:53:29 +02:00
Noah Lancien
093413abf8 Merge branch 'develop' into api 2023-03-29 14:29:29 +02:00
Vincent BRIDIER
b761da48f7
Merge branch 'develop' into trunk 2023-03-29 12:20:21 +02:00
Vincent BRIDIER
231ba7eaf7
ESLint + Stylelint fixes 2023-03-29 11:26:28 +02:00
Vincent BRIDIER
7f4ad281c0
Update manifest and .env.example 2023-03-29 11:08:40 +02:00
Vincent BRIDIER
7a9d265940 Merge branch 'qrcode-generation' into 'develop'
Qrcode generation

See merge request ldc-web/projets/qrcollector/qrcollector-back!1
2023-03-28 14:37:49 +00:00
noah.lancien
15e1c98f0d Qrcode generation 2023-03-28 14:37:49 +00:00
Noah Lancien
25f975d4aa Limit the stats list to 1 year and added parameters in url 2023-03-27 08:26:20 +00:00
Noah Lancien
6e598e8043 Limit the history list to 1 year and add parameters in url 2023-03-27 08:22:02 +00:00
Noah Lancien
bafe9503cd added paginatino on index routes 2023-03-24 16:16:02 +00:00
Noah Lancien
727d16373c Created api base 2023-03-24 16:09:27 +00:00
Noah Lancien
3d46d570f2 Disabled key edition 2023-03-24 14:11:58 +00:00
Noah Lancien
a771c1817b Added condition to create history only when url changed 2023-03-24 14:01:32 +00:00
Noah Lancien
8ce5d76faa Fixed key length validation 2023-03-24 13:52:19 +00:00
Noah Lancien
e3c3757acd Fixed saving logo path in settings 2023-03-24 13:49:43 +00:00
Noah Lancien
fb56dbba66 Added Vue component to generate QRCode 2023-03-24 10:42:19 +00:00
Noah Lancien
58517aa01d added qrcode-styling package 2023-03-24 10:40:59 +00:00
Noah Lancien
071677f665 Updated blade files 2023-03-24 10:15:00 +00:00
Noah Lancien
d097ef0ef5 Fixed url deletion 2023-03-20 13:27:41 +00:00
Noah Lancien
1c34b2aba0 Slugified the url key 2023-03-20 13:27:17 +00:00
Noah Lancien
ea89408d91 Created Url CRUD (including stats and history) 2023-03-20 13:05:14 +00:00
Noah Lancien
9727d59a18 Fixed an issue with table name for Url History 2023-03-20 13:02:26 +00:00
Noah Lancien
a94370a4d9 Created BrandController and routes to interact with brands datas 2023-03-20 09:23:42 +00:00
Noah Lancien
e5f8d4865f Created models and migrations 2023-03-20 09:19:26 +00:00
Noah Lancien
f9114e9a01 Created project with LBP 2023-03-20 09:18:48 +00:00
436 changed files with 49474 additions and 0 deletions

21
.dockerignore Normal file
View file

@ -0,0 +1,21 @@
.git
.git/
.gitlab/
.gitlab-ci.yml
*.md
node_modules/
npm-debug.log
.npmrc
tests/
Dockerfile*
.dockerignore
.git
.gitignore
README.md
LICENSE
.vscode
public/build/
auth.json
manifest.yaml
production-resources.yaml
staging-resources.yaml

21
.editorconfig Normal file
View file

@ -0,0 +1,21 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.vue]
indent_size = 2
[docker-compose.yml]
indent_size = 4

37
.env.example Normal file
View file

@ -0,0 +1,37 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_HOST=127.0.0.1
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=null
MAIL_FROM_NAME="${APP_NAME}"
# SENTRY
SENTRY_LARAVEL_DSN=
# UWIL ADMIN SECURE
# - LIST SEPARATED WITH ',' (Required)
UWIL_ADMIN_SECURE_CIDR_LIST=""
# - LIST SEPARATED WITH ',' (Optional)
UWIL_ADMIN_SECURE_CIDR_PROJECT_ALLOWED_LIST=""
# SAML
SAML2_DEFAULT_TENANT_UUID=
SAML2_SP_CERT_x509=
SAML2_SP_CERT_PRIVATEKEY=
SAML2_CONTACT_TECHNICAL_EMAIL=
SAML2_CONTACT_SUPPORT_EMAIL=

62
.eslintrc.json Normal file
View file

@ -0,0 +1,62 @@
{
"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",
"**/*.php",
"**/*.svg",
"**/vendor/*.js",
"**/vendor/**/*.js",
"**/vendor/**/*.scss"
],
"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"]
]
}
}
}
}

10
.gitattributes vendored Normal file
View file

@ -0,0 +1,10 @@
* text=auto
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore

21
.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
/node_modules
/public/hot
/public/storage
/public/build
/public/medias
/public/uploads
/public/cache
/public/sitemap.xml
/storage/*.key
/vendor
.env
.phpunit.result.cache
docker-compose.override.yml
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/.idea
/.vscode
auth.json
.npmrc

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/docker-multistage.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

3
.husky/commit-msg Normal file
View file

@ -0,0 +1,3 @@
#!/bin/sh
npx --no -- commitlint --config node_modules/@uwil/husky-config/commitlint.config.cjs --edit $1

14
.styleci.yml Normal file
View file

@ -0,0 +1,14 @@
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

45
.stylelintrc.json Normal file
View file

@ -0,0 +1,45 @@
{
"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": {
"declaration-property-value-no-unknown": true,
"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-newline-after": null,
"value-list-comma-newline-after": null,
"scss/double-slash-comment-empty-line-before": [
"always",
{
"ignore": [
"between-comments",
"stylelint-commands",
"inside-block"
]
}
]
}
}

47
Dockerfile Normal file
View file

@ -0,0 +1,47 @@
FROM composer:2.9.2 AS vendor
ARG GITLAB_USER
ARG GITLAB_PASSWORD
COPY database/ database/
COPY composer.json composer.json
COPY composer.lock composer.lock
RUN composer config gitlab-token.lab.s-web.fr ${GITLAB_USER} ${GITLAB_PASSWORD}
RUN composer install \
--ignore-platform-reqs \
--no-interaction \
--no-plugins \
--no-scripts \
--no-dev \
--prefer-dist
FROM node:20.18 AS frontend
ARG GITLAB_PASSWORD
RUN mkdir -p /app/public/build
COPY package*.json tsconfig.json vite.config.ts .eslintrc.json .stylelintrc.json /app/
COPY resources/ /app/resources/
WORKDIR /app
RUN npm config set @ldc-web:registry https://lab.s-web.fr/api/v4/packages/npm/
RUN npm config set -- '//lab.s-web.fr/api/v4/packages/npm/:_authToken' "${GITLAB_PASSWORD}"
RUN npm config set @uwil:registry=https://lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/
RUN npm config set -- '//lab.s-web.fr/api/v4/groups/ldc-web/-/packages/npm/:_authToken' "${GITLAB_PASSWORD}"
RUN npm ci --no-audit --no-fund --loglevel=error && npm run prod
FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/uwil-production-8.5:${PHP_8_5_TAG}
ARG USER=www-data
COPY --chown=${USER}:${USER} . /app/srcs
COPY --chown=${USER}:${USER} ./resources/favicon /app/srcs/public
COPY --from=vendor --chown=${USER}:${USER} /app/vendor/ /app/srcs/vendor/
COPY --from=frontend --chown=${USER}:${USER} /app/public/build/ /app/srcs/public/build/
USER root
RUN chmod -R 755 /app/srcs/storage
USER ${USER}

View file

@ -0,0 +1,13 @@
# Nexurl
## Installation
```
cp .env.example .env
php artisan key:generate
php artisan migrate
php artisan storage:link
npm i && npm run prod
```
Création d'un administrateur :
```
php artisan user:create
```

View file

@ -0,0 +1,202 @@
<?php
namespace App\Console\Commands;
use App\Enums\Users\RoleEnum;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str;
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|null
*/
protected $first_name;
/**
* The last name of the new user.
*
* @var string|null
*/
protected $last_name;
/**
* The email of the new user.
*
* @var string|null
*/
protected $email;
/**
* The role of the new user.
*
* @var string|null
*/
protected $role;
/**
* Execute the console command.
*
* @return void
*/
public function handle(): void
{
$this->first_name = null;
$this->addFirstName();
$this->last_name = null;
$this->addLastName();
$this->email = null;
$this->addEmail();
$this->role = null;
$this->addRole();
$user = new User([
'firstname' => $this->first_name,
'lastname' => $this->last_name,
'email' => $this->email,
'role' => $this->role,
]);
$user->saveOrFail();
$this->info('User created 👌');
}
/**
* 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 role to the user.
*
* @return void
*/
private function addRole(): void
{
while (is_null($this->role)) {
try {
$tmp = $this->choice(
'Select his role',
[
Str::ucFirst(RoleEnum::conceptor->label()),
Str::ucFirst(RoleEnum::admin->label()),
Str::ucFirst(RoleEnum::visitor->label())
],
Str::ucFirst(RoleEnum::visitor->label()),
);
if ($tmp === Str::ucFirst(RoleEnum::conceptor->label())) {
$tmp = RoleEnum::conceptor;
}
if ($tmp === Str::ucFirst(RoleEnum::admin->label())) {
$tmp = RoleEnum::admin;
}
if ($tmp === Str::ucFirst(RoleEnum::visitor->label())) {
$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 %s',
\implode(',', collect($e->errors())->flatten()->all())
));
continue;
} //end try
}; //end while
}
}

31
app/Console/Kernel.php Normal file
View file

@ -0,0 +1,31 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
}
/**
* Register the commands for the application.
*
* @return void
*/
protected function commands()
{
$this->load(__DIR__ . '/Commands');
require base_path('routes/console.php');
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Enums\ActivityLogs;
use Uwil\LaravelCommon\Traits\Enums\BaseEnum;
enum ActivityLogsEventEnum: int
{
use BaseEnum;
case created = 1;
case updated = 2;
case duplicated = 3;
case deleted = 4;
/**
* Optionnal labels definition
*
* @phpstan-ignore-next-line
*/
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,25 @@
<?php
namespace App\Enums\Api;
use Uwil\LaravelCommon\Traits\Enums\BaseEnum;
enum ApiPeriodEnum: int
{
use BaseEnum;
case day = 1;
case week = 2;
case month = 3;
/**
* Optionnal labels definition.
*
* @phpstan-ignore-next-line
*/
private const LABELS = [
self::day->name => 'jour',
self::week->name => 'semaine',
self::month->name => 'mois',
];
}

View file

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

25
app/Enums/PeriodEnum.php Normal file
View file

@ -0,0 +1,25 @@
<?php
namespace App\Enums;
use Uwil\LaravelCommon\Traits\Enums\BaseEnum;
enum PeriodEnum: int
{
use BaseEnum;
case day = 1;
case week = 2;
case month = 3;
/**
* Optionnal labels definition.
*
* @phpstan-ignore-next-line
*/
private const LABELS = [
self::day->name => 'jour',
self::week->name => 'semaine',
self::month->name => 'mois',
];
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Enums\Users;
use Uwil\LaravelCommon\Traits\Enums\BaseEnum;
enum RoleEnum: int
{
use BaseEnum;
case conceptor = 1;
case admin = 2;
case marketing = 3;
case visitor = 99;
/**
* Optionnal labels definition.
*
* @phpstan-ignore-next-line
*/
private const LABELS = [
self::conceptor->name => 'enums.user-role.conceptor',
self::admin->name => 'enums.user-role.admin',
self::marketing->name => 'enums.user-role.marketing',
self::visitor->name => 'enums.user-role.visitor',
];
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* A list of exception types with their corresponding custom log levels.
*
* @var array<class-string<\Throwable>, \Psr\Log\LogLevel::*>
*/
protected $levels = [];
/**
* A list of the exception types that are not reported.
*
* @var array<int, class-string<\Throwable>>
*/
protected $dontReport = [];
/**
* A list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*
* @return void
*/
public function register()
{
$this->reportable(function (Throwable $e) {
if (app()->bound('sentry')) {
app('sentry')->captureException($e);
}
});
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\BrandCollection;
use App\Http\Resources\BrandResource;
use App\Http\Resources\UrlCollection;
use App\Models\Brand;
use App\Models\Service;
class BrandController extends Controller
{
/**
* Returns a listing of the brands.
*
* @return \App\Http\Resources\BrandCollection
*/
public function index()
{
return new BrandCollection(Brand::paginate(25));
}
/**
* Returns the specified brand.
*
* @param \Illuminate\Http\Request $request
* @param Brand $brand
* @return \App\Http\Resources\BrandResource
*
* @throws \Exception If the brand isn't the brand associated to the service.
*/
public function show(\Illuminate\Http\Request $request, Brand $brand)
{
$service = Service::query()->where('bearer_token', $request->bearer_token)->firstOrFail();
if ($brand != $service->brand) {
throw new \Exception('Invalid brand.');
};
return new BrandResource($brand);
}
/**
* Returns the urls for a given brand.
*
* @param \Illuminate\Http\Request $request
* @param Brand $brand
* @return \App\Http\Resources\UrlCollection
*
* @throws \Exception If the brand isn't the brand associated to the service.
*/
public function getUrls(\Illuminate\Http\Request $request, Brand $brand)
{
$service = Service::query()->where('bearer_token', $request->bearer_token)->firstOrFail();
if ($brand != $service->brand) {
throw new \Exception('Invalid brand.');
};
return new UrlCollection($brand->urls);
}
}

View file

@ -0,0 +1,151 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\Api\ApiPeriodEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\GetStatsRequest;
use App\Http\Resources\UrlCollection;
use App\Http\Resources\UrlHistoryCollection;
use App\Http\Resources\UrlResource;
use App\Models\Url;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use DateTime;
use Illuminate\Http\Request;
class UrlController extends Controller
{
/**
* Returns a listing of the urls.
*
* @return \App\Http\Resources\UrlCollection
*/
public function index()
{
return new UrlCollection(Url::paginate(25));
}
/**
* Returns the specified url.
*
* @param Url $url
* @return \App\Http\Resources\UrlResource
*/
public function show(Url $url)
{
return new UrlResource($url);
}
/**
* Returns the history for a given url.
*
* @param Url $url
* @param Request $request
* @return \Illuminate\Http\Response|\App\Http\Resources\UrlHistoryCollection
*/
public function getHistory(Url $url, Request $request)
{
// Set start date with request or now - 1 year.
if ($request->start) {
$start = $request->start;
$start = DateTime::createFromFormat('dmY', $start);
} else {
$start = now()->modify('-1 year');
}
// Set end date with request or now.
if ($request->end) {
$end = $request->end;
$end = DateTime::createFromFormat('dmY', $end)->modify('+1 day');
} else {
$end = now();
}
// Check if the difference bewteen end and start is < than 1 year.
if ($start->diff($end)->days > 366) {
return response('Date interval too long', 403);
};
$history = $url->history()
->where('start_date', '>', $start)
->where('end_date', '<', $end)
->get();
return new UrlHistoryCollection($history);
}
/**
* Returns the stats for the given url.
*
* @param \App\Http\Requests\Api\GetStatsRequest $request
* @param Url $url
* @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse
*/
public function getStats(GetStatsRequest $request, Url $url)
{
$period = ApiPeriodEnum::make($request->period, 'name');
$start = Carbon::make($request->start);
$end = Carbon::make($request->end);
// Check if the difference bewteen end and start is < than 1 year.
if ($start->diff($end)->days > 366) {
return response('Date interval too long', 403);
};
$stats = [];
switch ($period->label()) {
case \App\Enums\Api\ApiPeriodEnum::day->label():
$carbonPeriod = CarbonPeriod::create(
$start->format('Y-m-d'),
$end->format('Y-m-d')
)->toArray();
$dbStats = $url->stats()
->whereDate('date', '>', $start)
->whereDate('date', '<=', $end)
->get(['date', 'calls']);
foreach ($carbonPeriod as $day) {
$stats[] = $dbStats->where('date', $day)->isNotEmpty() ?
$dbStats->where('date', $day)->first()->calls :
0;
}
break;
case \App\Enums\Api\ApiPeriodEnum::week->label():
// Create a Carbon Period for weeks to make only one query per week.
$carbonPeriod = CarbonPeriod::create(
$start->startOfWeek()->format('Y-m-d'),
$end->endOfWeek()->format('Y-m-d')
)->weeks()->toArray();
foreach ($carbonPeriod as $week) {
$stats[] = $url->stats()
->whereIn(
'date',
CarbonPeriod::create(
$week->startOfWeek(Carbon::MONDAY)->format('Y-m-d'),
$week->endOfWeek(Carbon::SUNDAY)->format('Y-m-d')
)->toArray()
)
->sum('calls');
}
break;
case \App\Enums\Api\ApiPeriodEnum::month->label():
// Create a Carbon Period for months to make only one query per month.
$carbonPeriod = CarbonPeriod::create(
$start->startOfMonth()->format('Y-m-d'),
$end->endOfMonth()->format('Y-m-d')
)->months()->toArray();
foreach ($carbonPeriod as $month) {
$stats[] = $url->stats()
->whereIn(
'date',
CarbonPeriod::create(
$month->startOfMonth()->format('Y-m-d'),
$month->endOfMonth()->format('Y-m-d')
)->toArray()
)
->sum('calls');
}
break;
} //end switch
return \response()->json($stats);
}
}

View file

@ -0,0 +1,79 @@
<?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 ActivityLogController extends Controller
{
/**
* 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 \App\Models\User|null $userModel */
$userModel = $request->route()->parameter('user');
if ($userModel) {
$query->where('user_id', $userModel->getKey());
}
/** @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',
'userModel'
));
}
/**
* 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,51 @@
<?php
namespace App\Http\Controllers\Bo\Auth;
use App\Http\Controllers\Controller;
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()
{
return Auth::guard('backend');
}
/**
* Show the application's login form.
*
* @return \Illuminate\View\View
*/
public function showLoginForm()
{
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,31 @@
<?php
namespace App\Http\Controllers\Bo\Auth;
use App\Http\Controllers\Controller;
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()
{
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,178 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Brands\GetBrandUrlsRequest;
use App\Http\Requests\Bo\Brands\StoreBrandRequest;
use App\Http\Requests\Bo\Brands\UpdateBrandRequest;
use App\Models\Brand;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class BrandController extends Controller
{
/**
* Create the controller instance.
*/
public function __construct()
{
$this->authorizeResource(Brand::class);
}
/**
* Display a listing of the resource.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
*/
public function index(Request $request)
{
/** @var \Illuminate\Database\Eloquent\Builder $brands */
$brands = Brand::query();
if ($request->search) {
$this->searchQuery(
$brands,
$request->search,
null,
'name',
);
}
$this->sortQuery($brands);
$searchFields = trans('Nom');
$brands = $this->paginate($brands);
return view('bo.pages.brands.index', compact('brands', 'searchFields'));
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
*/
public function create()
{
return view('bo.pages.brands.create');
}
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\Bo\Brands\StoreBrandRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function store(StoreBrandRequest $request)
{
return DB::transaction(function () use ($request) {
$brand = new Brand();
$brand->fill($request->validated());
if ($brand->saveOrFail()) {
return redirect()
->route('bo.brands.edit', $brand)
->with('success', trans(
':model a été créé',
['model' => Str::singular(trans('models.classes.brands'))]
));
}
return redirect()
->back()
->with('error', trans(
':model n\'a pas été créé',
['model' => Str::singular(trans('models.classes.brands'))]
));
});
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\Brand $brand
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
*/
public function edit(Brand $brand)
{
return view('bo.pages.brands.edit', [
'brandModel' => $brand,
]);
}
/**
* Update the specified resource in storage.
*
* @param \App\Http\Requests\Bo\Brands\UpdateBrandRequest $request
* @param \App\Models\Brand $brand
* @return \Illuminate\Http\RedirectResponse
*/
public function update(UpdateBrandRequest $request, Brand $brand)
{
return DB::transaction(function () use ($request, $brand) {
$brand->fill($request->validated());
if ($brand->saveOrFail()) {
return redirect()
->route('bo.brands.edit', $brand)
->with('success', trans(
':model a été mis à jour',
['model' => Str::singular(trans('models.classes.brands'))]
));
}
return redirect()
->back()
->with('error', trans(
':model n\'a pas été mis à jour',
['model' => Str::singular(trans('models.classes.brands'))]
));
});
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\Brand $brand
* @return \Illuminate\Http\RedirectResponse
*/
public function destroy(Brand $brand)
{
if ($brand->deleteOrFail()) {
return redirect()
->route('bo.brands.index')
->with('warning', trans(
':model a bien été supprimée',
['model' => Str::singular(trans('models.classes.brands'))]
));
}
return redirect()
->back()
->with('error', trans(
':model n\'a pas été supprimée',
['model' => Str::singular(trans('models.classes.brands'))]
));
}
/**
* Get urls from a given brand.
*
* @param \App\Http\Requests\Bo\Brands\GetBrandUrlsRequest $request
* @param \App\Models\Brand $brand
* @return \Illuminate\Http\JsonResponse
*/
public function getUrls(GetBrandUrlsRequest $request, Brand $brand)
{
if (!$request->wantsJson()) {
return abort(404);
}
return response()
->json($brand->urls);
}
}

View file

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

View file

@ -0,0 +1,200 @@
<?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\Models\Brand;
use App\Models\Service;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
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\Factory|\Illuminate\Contracts\View\View
*/
public function index(Request $request)
{
/** @var \Illuminate\Database\Eloquent\Builder $services */
$services = Service::query();
if ($request->search) {
$this->searchQuery(
$services,
$request->search,
null,
'name',
);
}
$this->sortQuery($services);
$searchFields = trans('Nom');
$services = $this->paginate($services);
return view('bo.pages.services.index', compact('services', 'searchFields'));
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
*/
public function create()
{
$brandModels = Brand::orderBy('name', 'ASC')->get();
return view('bo.pages.services.create', compact('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)
{
return DB::transaction(function () use ($request) {
$service = new Service();
$service->fill($request->validated());
if ($service->saveOrFail()) {
$this->generateServicePassword($service);
return redirect()
->route('bo.services.edit', $service)
->with('success', trans(
':model a été créé',
['model' => Str::singular(trans('models.classes.services'))]
))
->with(
'warning',
"ATTENTION : METTEZ À JOUR LES SITES UTILISANT L'API ! \n
Nouvel id de marque pour le service $service->name : $request->brand_id"
);
}
return redirect()
->back()
->with('error', trans(
':model n\'a pas été créé',
['model' => Str::singular(trans('models.classes.services'))]
));
});
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\Service $service
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
*/
public function edit(Service $service)
{
$brandModels = Brand::orderBy('name', 'ASC')->get();
return view('bo.pages.services.edit', [
'serviceModel' => $service,
'brandModels' => $brandModels
]);
}
/**
* Update the specified 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)
{
return DB::transaction(function () use ($request, $service) {
$oldBrandId = $service->brand->id;
$service->fill($request->validated());
if ($service->saveOrFail()) {
if ($request->bearer_pwd === 'on') {
$this->generateServicePassword($service);
}
if ($oldBrandId != $request->brand_id) {
$changeBrandMessage = "ATTENTION : METTEZ À JOUR LES SITES UTILISANT L'API ! \n
Nouvel id de marque pour le service $service->name : $request->brand_id";
};
return redirect()
->route('bo.services.edit', $service)
->with('success', trans(
':model a été mis à jour',
['model' => Str::singular(trans('models.classes.services'))]
))
->with(
'warning',
$changeBrandMessage ?? ''
);
}
return redirect()
->back()
->with('error', trans(
':model n\'a pas été mis à jour',
['model' => Str::singular(trans('models.classes.services'))]
));
});
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\Service $service
* @return \Illuminate\Http\RedirectResponse
*/
public function destroy(Service $service)
{
if ($service->deleteOrFail()) {
return redirect()
->route('bo.services.index')
->with('warning', trans(
':model a bien été supprimée',
['model' => Str::singular(trans('models.classes.services'))]
));
}
return redirect()
->back()
->with('error', trans(
':model n\'a pas été supprimée',
['model' => Str::singular(trans('models.classes.services'))]
));
}
/**
* Regen service credentials
*
* @param Service $service
* @return \Illuminate\Http\RedirectResponse
*/
private function generateServicePassword(Service $service)
{
$bearerPwd = $service->generateBearerPassword();
$service->bearer_token_date = now();
$service->saveOrFail();
return redirect()->route('bo.services.edit', $service)
->with(
'error',
"ATTENTION : METTEZ À JOUR LES SITES UTILISANT L'API ! \n
Nouveau token d'API de $service->name : '$service->bearer_token' et mot de passe '$bearerPwd'"
);
}
}

View file

@ -0,0 +1,279 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Enums\PeriodEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\StatsRequest;
use App\Lib\Helpers\ChartJsHelper;
use App\Models\Brand;
use App\Models\Url;
use App\Models\UrlStats;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Colors\RandomColor;
use Illuminate\Database\Eloquent\Model;
class StatsController extends Controller
{
/**
* Create the controller instance.
*/
public function __construct()
{
$this->authorizeResource(UrlStats::class);
}
/** @var array Colors used in charts */
private $colors = [];
/**
* Display a resource.
*
* @param \App\Http\Requests\Bo\StatsRequest $request
* @return \Illuminate\Contracts\View\View
*/
public function index(StatsRequest $request): \Illuminate\Contracts\View\View
{
/** @var \App\Models\User $user */
$user = auth('backend')->user();
$brandModels = $user->getRelatedBrands();
// Check if it's a post request or not to get data.
if ($request->post()) {
$requestValidated = $request->validated();
$start = $requestValidated['start'] ? Carbon::parse($requestValidated['start']) : null;
$end = $requestValidated['end'] ? Carbon::parse($requestValidated['end']) : null;
$chart = $this->getChart($requestValidated);
}
return view('bo.pages.stats', [
'brandModels' => $brandModels,
'chart' => $chart ?? null,
'start' => $start ?? null,
'end' => $end ?? null,
]);
}
/**
* Get a chart from ChartJs.
*
* @param array $requestValidated
* @return \IcehouseVentures\LaravelChartjs\Builder
*/
private function getChart(array $requestValidated): \IcehouseVentures\LaravelChartjs\Builder
{
// Get PeriodEnum from Request. Default = PeriodEnum::day.
$periodEnum = PeriodEnum::tryFrom($requestValidated['period']) ?? PeriodEnum::day;
$periodInterval = $this->getDateInterval(
$requestValidated['start'],
$requestValidated['end'],
$periodEnum,
);
// Get all labels contained in the interval period.
$labels = [];
foreach ($periodInterval as $date) {
$labels[] = $this->getChartLabels($date, $periodEnum);
}
/**
* Get data from an entity :
* - total of all urls from a brand
* - total of specific url from a brand
* - total of all urls for each brand
*/
$datasets = match (true) {
!$requestValidated['brand'] => $this->buildDatasets(
Brand::all(),
$periodInterval,
$periodEnum,
fn($brand, $date, $period) => $this->getCallsSum($brand, $date, $period),
fn($brand) => $brand->name
),
!$requestValidated['url'] => $this->buildDatasets(
Brand::find($requestValidated['brand'])->urls,
$periodInterval,
$periodEnum,
fn($url, $date, $period) => $this->getCallsSum($url, $date, $period),
fn($url) => $url->value
),
default => $this->buildDatasets(
[Url::find($requestValidated['url'])],
$periodInterval,
$periodEnum,
fn($url, $date, $period) => $this->getCallsSum($url, $date, $period),
fn($url) => $url->value
),
}; //end match
// Get the type of graph when there are several data or only one.
$chartType = count(Arr::first($datasets)['data'] ?? []) > 1 ? 'line' : 'bar';
return (new ChartJsHelper())
->name('stats')
->type($chartType)
->labels($labels)
->datasets($datasets);
}
/**
* Get data from an entity.
*
* @param iterable $entities
* @param \Carbon\CarbonPeriod $periodInterval
* @param \App\Enums\PeriodEnum $period
* @param callable $getStats
* @param callable $getLabel
* @return array
*/
private function buildDatasets(
iterable $entities,
CarbonPeriod $periodInterval,
PeriodEnum $period,
callable $getStats,
callable $getLabel
): array {
$datasets = [];
foreach ($entities as $entity) {
$data = [];
foreach ($periodInterval as $date) {
$data[] = $getStats($entity, $date, $period);
}
$color = $this->getUniqueColor();
$datasets[] = [
'label' => $getLabel($entity),
'backgroundColor' => $color,
'borderColor' => $color,
'pointBorderColor' => $color,
'pointBackgroundColor' => $color,
'data' => $data,
'lineTension' => 0.4,
];
}
return $datasets;
}
/**
* Get the sum of all calls.
*
* @param \Illuminate\Database\Eloquent\Model $entity
* @param \Carbon\Carbon $date
* @param \App\Enums\PeriodEnum $period
* @return integer
*/
private function getCallsSum(Model $entity, Carbon $date, PeriodEnum $period): int
{
$urlKeys = method_exists($entity, 'urls') && $entity->urls()->exists()
? $entity->urls()->pluck('key')
: collect([$entity->key]);
return UrlStats::query()
->when(
$period === PeriodEnum::day,
fn($query) => $query->whereDate('date', $date)
)
->when(
$period === PeriodEnum::week,
fn($query) => $query->whereBetween('date', [
$date->copy()->startOfWeek(),
$date->copy()->endOfWeek()
])
)
->when(
$period === PeriodEnum::month,
fn($query) => $query->whereBetween('date', [
$date->copy()->startOfMonth(),
$date->copy()->endOfMonth()
])
)
->whereIn('url_key', $urlKeys)
->sum('calls');
}
/**
* Get the CarbonPeriod for the given period.
*
* @param string $start
* @param string $end
* @param \App\Enums\PeriodEnum $period
* @return \Carbon\CarbonPeriod
*/
private function getDateInterval(string $start, string $end, PeriodEnum $period): \Carbon\CarbonPeriod
{
$startDate = Carbon::parse($start);
$endDate = Carbon::parse($end);
/**
* Set config for each enum names.
* methods: specific methods needed.
* interval: specific interval defined.
*/
$unitMap = [
PeriodEnum::day->value => [
'methods' => null,
'interval' => null
],
PeriodEnum::week->value => [
'methods' => ['startOfWeek', 'endOfWeek'],
'interval' => '1 week'
],
PeriodEnum::month->value => [
'methods' => ['startOfMonth', 'endOfMonth'],
'interval' => '1 month'
],
];
// Check if config exist for the given period, get the day config by default.
$config = $unitMap[$period->value] ?? $unitMap[PeriodEnum::day->value];
// Execute configuration methods where available.
if ($config['methods']) {
[$startMethod, $endMethod] = $config['methods'];
$startDate = $startDate->$startMethod();
$endDate = $endDate->$endMethod();
}
return CarbonPeriod::create($startDate, $config['interval'], $endDate);
}
/**
* Get labels for charts.
*
* @param \Carbon\Carbon $date
* @param \App\Enums\PeriodEnum $period
* @return array
*/
private function getChartLabels(Carbon $date, PeriodEnum $period): array
{
// Match given period with enum names.
return match ($period) {
PeriodEnum::week => array_unique([sprintf(
'Du %s au %s',
$date->startOfWeek()->format('Y-m-d'),
$date->endOfWeek()->format('Y-m-d')
)]),
PeriodEnum::month => array_unique([
Str::ucfirst($date->monthName)
]),
default => array_unique([$date->format('d-m-Y')]),
};
}
/**
* Get unique color.
*
* @return string
*/
private function getUniqueColor(): string
{
do {
$color = RandomColor::one();
// Check if color is already used.
} while (in_array($color, $this->colors));
$this->colors[] = $color;
return $color;
}
}

View file

@ -0,0 +1,244 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Enums\Users\RoleEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Urls\StoreUrlRequest;
use App\Http\Requests\Bo\Urls\UpdateUrlRequest;
use App\Models\Url;
use App\Models\UrlHistory;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Uwil\LaravelCommon\Helpers\FileHelper;
class UrlController extends Controller
{
/**
* Create the controller instance.
*/
public function __construct()
{
$this->authorizeResource(Url::class);
}
/**
* Display a listing of the resource.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
*/
public function index(Request $request)
{
/** @var \App\Models\User $user */
$user = auth('backend')->user();
/** @var \Illuminate\Database\Eloquent\Builder $urls */
$urls = $user->role->equals(RoleEnum::conceptor) ?
Url::query() :
Url::query()->whereIn('brand_id', $user->brands->pluck('id'));
if ($request->search) {
$this->searchQuery(
$urls,
$request->search,
null,
'key',
'value',
'title',
'brand_id'
);
}
$this->sortQuery($urls);
$searchFields = trans('Clé, Redirection, Titre, Marque');
$urls = $this->paginate($urls);
return view('bo.pages.urls.index', compact('urls', 'searchFields'));
}
/**
* Show the form for creating a new resource.
*
* @param \App\Models\Url $url
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
*/
public function create(Url $url)
{
/** @var \App\Models\User $user */
$user = auth('backend')->user();
$brandModels = $user->getRelatedBrands();
return view('bo.pages.urls.create', [
'brands' => $brandModels,
'urlModel' => $url
]);
}
/**
* Store a newly created resource in storage.
*
* @param StoreUrlRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function store(StoreUrlRequest $request)
{
return DB::transaction(function () use ($request) {
$url = new Url();
$url->fill($request->validated());
// When duplicated, $url->logo is the path of the logo from the original model.
$url->logo = is_string($url->logo) ?
new UploadedFile(
FileHelper::getPublicAbsolutePath($url->logo),
sprintf(
'%s.%s',
Str::uuid(),
new \SplFileInfo(asset($url->logo))->getExtension()
)
) :
$url->logo;
$url->saveOrFail();
// Reassign the logo in image settings to save only the path.
// Do it after the first saveOrFail to get the real logo path, not tmp.
$settings = json_decode($request->validated('image_settings'));
$settings->image = $url->logo;
$url->image_settings = $settings;
if ($url->saveOrFail()) {
return redirect()
->route('bo.urls.edit', $url)
->with('success', trans(
':model a été créé',
['model' => Str::singular(trans('models.classes.urls'))]
));
}
return redirect()
->back()
->with('error', trans(
':model n\'a pas été créé',
['model' => Str::singular(trans('models.classes.urls'))]
));
});
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\Url $url
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
*/
public function edit(Url $url)
{
/** @var \App\Models\User $user */
$user = auth('backend')->user();
$brandModels = $user->getRelatedBrands();
return view('bo.pages.urls.edit', [
'urlModel' => $url,
'brands' => $brandModels
]);
}
/**
* Update the specified resource in storage.
*
* @param UpdateUrlRequest $request
* @param Url $url
* @return \Illuminate\Http\RedirectResponse
*/
public function update(UpdateUrlRequest $request, Url $url)
{
return DB::transaction(function () use ($request, $url) {
$oldUrl = $url->value;
$data = $request->validated();
// Reassign the logo in image settings to save only the path.
// Do it after the first saveOrFail to get the real logo path, not tmp.
$settings = json_decode($request->validated('image_settings'));
if (empty($settings->image)) {
$url->logo = null;
}
$url->fill($data);
$url->saveOrFail();
if ($oldUrl !== $url->value) {
$this->saveUrlHistory($url, $oldUrl);
}
$settings->image = $url->logo;
$url->image_settings = $settings;
if ($url->saveOrFail()) {
return redirect()
->route('bo.urls.edit', $url)
->with('success', trans(
':model a été mis à jour',
['model' => Str::singular(trans('models.classes.urls'))]
));
}
return redirect()
->back()
->with('error', trans(
':model n\'a pas été mis à jour',
['model' => Str::singular(trans('models.classes.urls'))]
));
});
}
/**
* Remove the specified resource from storage.
*
* @param Url $url
* @return \Illuminate\Http\RedirectResponse
*/
public function destroy(Url $url)
{
foreach ($url->history as $history) {
$history->deleteOrFail();
}
$url->deleteOrFail();
return redirect()->route('bo.urls.index')
->with(['warning' => 'L\'url pour la clé ' . $url->key . ' a bien été supprimée.']);
}
/**
* Duplicate the specified resource.
*
* @param \App\Models\Url $url
* @return \Illuminate\Contracts\View\View
*/
public function duplicate(Url $url): \Illuminate\Contracts\View\View
{
$duplicatedUrl = $url->replicate(['key']);
return $this->create($duplicatedUrl);
}
/**
* Save url history when updating url.
* @param Url $url
* @param string $oldValue
* @return \App\Models\UrlHistory
*/
private function saveUrlHistory(Url $url, string $oldValue)
{
$history = new UrlHistory();
$history->value = $oldValue;
$history->start_date = $url->updated_at;
$history->end_date = now();
$history->url_id = $url->id;
$history->saveOrFail();
return $history;
}
}

View file

@ -0,0 +1,175 @@
<?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\Brand;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class UserController extends Controller
{
/**
* 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\Factory|\Illuminate\Contracts\View\View
*/
public function index(Request $request)
{
/** @var \Illuminate\Database\Eloquent\Builder $users */
$users = User::query();
if ($request->search) {
$this->searchQuery(
$users,
$request->search,
null,
'firstname',
'lastname',
'email'
);
}
$this->sortQuery($users);
$searchFields = \implode(', ', [
trans('validation.custom.firstname'),
trans('validation.custom.lastname'),
trans('validation.custom.email'),
]);
$users = $this->paginate($users);
return view('bo.pages.users.index', compact('users', 'searchFields'));
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
*/
public function create()
{
$brandModels = Brand::query()->get();
return view('bo.pages.users.create', compact('brandModels'));
}
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\Bo\Users\StoreUserRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function store(StoreUserRequest $request)
{
return DB::transaction(function () use ($request) {
$user = new User();
$user->fill($request->validated());
if ($user->saveOrFail()) {
$user->brands()->sync(collect($request->brands)->pluck('id'));
return redirect()
->route('bo.users.edit', $user)
->with('success', trans(
':model a été créé',
['model' => trans('models.classes.user')]
));
}
return redirect()
->back()
->with('error', trans(
':model n\'a pas été créé',
['model' => trans('models.classes.user')]
));
});
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\User $user
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
*/
public function edit(User $user)
{
$brandModels = Brand::query()->get();
return view('bo.pages.users.edit', [
'userModel' => $user,
'brandModels' => $brandModels
]);
}
/**
* 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)
{
return DB::transaction(function () use ($request, $user) {
$user->fill($request->validated());
$user->brands()->sync(collect($request->brands)->pluck('id'));
if ($user->saveOrFail()) {
return redirect()
->route('bo.users.edit', $user)
->with('success', trans(
':model a été mis à jour',
['model' => trans('models.classes.user')]
));
}
return redirect()
->back()
->with('error', trans(
':model n\'a pas été mis à jour',
['model' => trans('models.classes.user')]
));
});
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\User $user
* @return \Illuminate\Http\RedirectResponse
*/
public function destroy(User $user)
{
return DB::transaction(function () use ($user) {
$user->brands()->detach();
if ($user->deleteOrFail()) {
return redirect()
->route('bo.users.index')
->with('warning', trans(
':model a bien été supprimé',
['model' => trans('models.classes.user')]
));
}
return redirect()
->back()
->with('error', trans(
':model n\'a pas été supprimé',
['model' => trans('models.classes.user')]
));
});
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Uwil\LaravelCommon\Controllers\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests;
use DispatchesJobs;
use ValidatesRequests;
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Fo;
use App\Http\Controllers\Controller;
class HomeController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Contracts\View\View
*/
public function index()
{
return view('fo.pages.home');
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Fo;
use App\Http\Controllers\Controller;
use App\Jobs\IncrementUrlStat;
use App\Models\Url;
class UrlController extends Controller
{
/**
* Redirect an url to its value.
*
* @param \App\Models\Url $url
*
* @return \Illuminate\Http\RedirectResponse
*/
public function redirect(Url $url)
{
IncrementUrlStat::dispatch($url)->onConnection('database')->onQueue('database');
return \redirect($url->value, 301)
->header('Cache-Control', 'no-store, no-cache, must-revalidate');
}
}

76
app/Http/Kernel.php Normal file
View file

@ -0,0 +1,76 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array<int, class-string|string>
*/
protected $middleware = [
\App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::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,
];
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
'htmlsanitize',
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\SecureHeaders::class
],
'saml' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
],
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\JsonOnly::class,
\App\Http\Middleware\ApiCheckTimeStamps::class,
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array<string, class-string|string>
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::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,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'htmlsanitize' => \App\Http\Middleware\HtmlSanitizer::class,
];
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Http\Middleware;
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 \Symfony\Component\HttpKernel\Exception\HttpException If timestamp has an incorrect
* format or is timed out.
*/
public function handle(Request $request, \Closure $next)
{
if (
!\is_string($request->input('timestamp')) or
!Carbon::hasFormat($request->input('timestamp'), 'Y-m-d H:i:s')
) {
abort(401, 'Timestamp isn\'t correctly formatted');
}
if (
Carbon::parse($request->input('timestamp'), 'utc')
->isBefore(Carbon::now('UTC')->subSeconds(config('api.timestampTimeout', 10)))
) {
abort(401, 'Timestamp is too old');
}
return $next($request);
}
}

View file

@ -0,0 +1,39 @@
<?php
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
* @return string|null
*/
protected function redirectTo(Request $request)
{
$logins = [
// Dans tous les cas bo.login.
'frontend' => route('bo.login'),
'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('bo.login');
}
abort(401);
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array<int, string>
*/
protected $except = [
];
}

View file

@ -0,0 +1,53 @@
<?php
namespace App\Http\Middleware;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Http\Request;
class HtmlSanitizer
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|array ...$params
* @return mixed
*/
public function handle(Request $request, \Closure $next, string|array ...$params)
{
$inputs = $request->all();
/** @var callable|null */
$cleanInput = null;
$cleanInput = function (&$input, $name, $rootName = null) use ($params, &$cleanInput) {
// * 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 (&$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,26 @@
<?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)
{
if (
(!($request->isJson() or $request->expectsJson()))
) {
abort(400, 'Invalid JSON');
}
return $next($request);
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array<int, string>
*/
protected $except = [];
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @phpcs:disable Generic.Files.LineLength.TooLong
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse) $next
* @param string|null ...$guards
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
* @throws \RuntimeException If guard is unhandled.
* @phpcs:enable
*/
public function handle(Request $request, Closure $next, ...$guards)
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
switch ($guard) {
case 'frontend':
return \redirect()->route('home');
case 'backend':
return \redirect()->route('bo.dashboard');
default:
throw new \RuntimeException("Unhandled guard `{$guard}` .");
}
}
}
return $next($request);
}
}

View file

@ -0,0 +1,107 @@
<?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;
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 $next
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
*/
public function handle(Request $request, Closure $next)
{
// * For Vite DEV
$host = \parse_url(\config('app.url'), \PHP_URL_HOST);
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: data: " . implode(' ', [
// * VITE DEV
config('app.debug') ? "wss://{$host}:4443" : '',
config('app.debug') ? "https://{$host}:4443" : '',
]) . ';',
"img-src 'self' data: blob: " . implode(' ', [
// * VITE DEV
config('app.debug') ? "https://{$host}:4443" : '',
]) . ';',
"object-src 'self' " . implode(' ', []) . ';',
'frame-src ' . implode(' ', []) . ';',
"font-src 'self' data: " . \implode(' ', [
// * VITE DEV
config('app.debug') ? "https://{$host}:4443" : '',
]) . ';',
// ! Using CKEditor will must have unsafe-inline style
// phpcs:ignore Generic.Files.LineLength.TooLong
"style-src 'self' 'unsafe-inline' " . \implode(' ', [
// * File Manager
'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://cdnjs.cloudflare.com',
// * VITE DEV
config('app.debug') ? "https://{$host}:4443" : '',
]) . ';'
];
$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)
{
foreach ($headerList as $header) {
header_remove($header);
}
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array<int, string>
*/
protected $except = [
'current_password',
'password',
'password_confirmation',
];
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array<int, string|null>
*/
public function hosts()
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array<int, string>|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var integer
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = ['/csp-report'];
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\Api;
use Carbon\Carbon;
use Illuminate\Foundation\Http\FormRequest;
class GetStatsRequest extends FormRequest
{
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation(): void
{
$this->merge([
'period' => $this->input('period') ? $this->input('period') : 'day',
'start' => $this->input('start') ? Carbon::create($this->input('start')) : now()->modify('-1 year'),
'end' => $this->input('end') ? Carbon::create($this->input('end')) : now(),
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Bo\Brands;
use Illuminate\Foundation\Http\FormRequest;
class GetBrandUrlsRequest extends FormRequest
{
/**
* 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
*/
public function rules()
{
return [];
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Bo\Brands;
use Illuminate\Foundation\Http\FormRequest;
class StoreBrandRequest extends FormRequest
{
/**
* 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
*/
public function rules()
{
return [
'name' => 'string|max:255|required|unique:brands,name'
];
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Bo\Brands;
use Illuminate\Validation\Rule;
class UpdateBrandRequest extends StoreBrandRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'name' => [
'string',
'max:255',
'required',
Rule::unique('brands', 'name')->ignore($this->brand->id)
]
];
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Bo\Services;
use Illuminate\Foundation\Http\FormRequest;
class StoreServiceRequest extends FormRequest
{
/**
* 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
*/
public function rules()
{
return [
'name' => 'string|max:255|required',
'brand_id' => 'required|exists:brands,id'
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Requests\Bo\Services;
class UpdateServiceRequest extends StoreServiceRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'name' => 'string|max:255|required',
'brand_id' => 'required|exists:brands,id'
];
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Http\Requests\Bo;
use App\Enums\PeriodEnum;
use App\Models\Brand;
use App\Models\Url;
use Carbon\Carbon;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StatsRequest 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(): void
{
$now = Carbon::now();
$urlId = $this->input('url');
$brandId = $this->input('brand');
$startDate = Carbon::parse($this->input('start'));
$endDate = Carbon::parse($this->input('end'));
$adjustedEndDate = $endDate->greaterThan($now) ? $now : $endDate;
$period = PeriodEnum::tryFrom($this->input('period'));
// Get start & end dates by period.
[$adjustedStart, $adjustedEnd] = match ($period) {
PeriodEnum::week => [$startDate->copy()->startOfWeek(), $endDate->copy()->endOfWeek()],
PeriodEnum::month => [$startDate->copy()->startOfMonth(), $endDate->copy()->endOfMonth()],
default => [$startDate, $adjustedEndDate],
};
$this->merge([
'url' => Url::find($urlId) ? $urlId : null,
'brand' => Brand::find($brandId) ? $brandId : null,
'start' => $adjustedStart->toDateString(),
'end' => $adjustedEnd->toDateString(),
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return (request()->post()) ? [
'period' => [
'required',
'string',
Rule::enum(PeriodEnum::class)
],
'start' => 'required|date|date_format:Y-m-d|before_or_equal:today|after_or_equal:'
. Carbon::parse($this->input('end'))->modify('-1 year'),
'end' => 'required|date|date_format:Y-m-d|after_or_equal:start',
'brand' => 'nullable|integer|exists:App\Models\Brand,id',
'url' => 'nullable|integer|exists:App\Models\Url,id'
] : [];
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Http\Requests\Bo\Urls;
use App\Enums\Users\RoleEnum;
use App\Traits\Requests\HasPictureWithSvgSupport;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Uwil\LaravelCommon\Traits\Requests\HasPicture;
class StoreUrlRequest extends FormRequest
{
use HasPicture;
use HasPictureWithSvgSupport;
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize()
{
return auth('backend')->check();
}
/**
* Prepare the data for validation.
* @return void
*/
protected function prepareForValidation(): void
{
$this->mergePicture('logo');
$this->merge([
'key' => Str::slug($this->key),
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
$user = auth('backend')->user();
$rules = [
'key' => 'required|string|max:45|unique:App\Models\Url,key',
'value' => 'required|url:http,https|max:255',
'title' => 'required|string|min:3|max:255',
'brand_id' => [
'required',
'integer',
'exists:App\Models\Brand,id',
!$user->role->equals(RoleEnum::conceptor) ? Rule::in($user->brands->pluck('id')) : ''
],
'image_settings' => 'required|json',
];
return \array_merge(
$rules,
$this->pictureRulesWithSvgSupport('logo', 100, 100, 500, 500, true),
);
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\Bo\Urls;
use Illuminate\Validation\Rule;
class UpdateUrlRequest extends StoreUrlRequest
{
/**
* Prepare the data for validation.
* @return void
*/
protected function prepareForValidation(): void
{
$this->merge([
'key' => $this->url->key,
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
$rules = [
'key' => [
'required',
'string',
'max:45',
Rule::unique('urls', 'key')->ignore($this->url->id)
],
];
return \array_merge(
parent::rules(),
$rules,
);
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Http\Requests\Bo\Users;
use App\Enums\Users\RoleEnum;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rules\Enum;
class StoreUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize()
{
return Gate::check('create', User::class);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'firstname' => 'required|string|min:3|max:255',
'lastname' => 'required|string|min:3|max:255',
'email' => 'required|email:rfc,strict,dns,spoof,filter|unique:users,email',
'role' => ['required', new Enum(RoleEnum::class)],
'brands' => 'nullable|array|min:1',
'brands.*' => 'nullable|array',
'brands.*.id' => 'nullable|exists:\App\Models\Brand,id',
];
}
/**
* Get custom attributes for validator errors.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'first_name' => trans('validation.attributes.first_name'),
'last_name' => trans('validation.attributes.last_name'),
'email' => trans('validation.attributes.email'),
'role' => trans('validation.attributes.role'),
];
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests\Bo\Users;
use App\Enums\Users\RoleEnum;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
class UpdateUserRequest extends StoreUserRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize(): bool
{
return Gate::check('update', $this->route('user'));
}
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation(): void
{
/** @var \App\Models\User $user */
$user = $this->route('user');
if (!$user->role->equals(RoleEnum::conceptor)) {
$this->merge(['email' => $user->email]);
}
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
/** @var \App\Models\User $user */
$user = $this->route('user');
return array_merge(parent::rules(), [
'email' => [
'required',
'email:rfc,strict,dns,spoof,filter',
Rule::unique('users', 'email')->ignore($user->getRouteKey()),
]
]);
}
}

View file

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
class BrandCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.TypeHintMissing
*/
public function toArray($request)
{
return [
'data' => $this->collection
];
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\Brand
*/
class BrandResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.TypeHintMissing
*/
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
class UrlCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.TypeHintMissing
*/
public function toArray($request)
{
return [
'data' => $this->collection
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
class UrlHistoryCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.TypeHintMissing
*/
public function toArray($request)
{
return [
'data' => $this->collection
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\UrlHistory
*/
class UrlHistoryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.TypeHintMissing
*/
public function toArray($request)
{
return [
'id' => $this->id,
'value' => $this->value,
'start' => $this->start_date,
'end' => $this->end_date,
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\Url
*/
class UrlResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.TypeHintMissing
*/
public function toArray($request)
{
return [
'id' => $this->id,
'key' => $this->key,
'value' => $this->value,
'brand' => new BrandResource($this->brand)
];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
class UrlStatsCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.TypeHintMissing
*/
public function toArray($request)
{
// phpcs:enable
return [
'data' => $this->collection
];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\UrlStats
*/
class UrlStatsResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass, Squiz.Commenting.FunctionComment.TypeHintMissing
*/
public function toArray($request)
{
return [
'id' => $this->id,
'calls' => $this->calls,
'date' => $this->date,
];
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace App\Jobs;
use App\Models\Url;
use App\Models\UrlStats;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
class IncrementUrlStat implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
/**
* To be serialized by jobs system
*
* @var Url
* */
private $url;
/**
* Create a new job instance.
*
* @param \App\Models\Url $url
* @return void
*/
public function __construct(Url $url)
{
$this->url = $url;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$urlStat = UrlStats::query()
->whereDate('date', now())
->firstOrNew(
[
'url_key' => $this->url->key,
'url' => $this->url->value,
],
);
$urlStat->calls += 1;
$urlStat->saveOrFail();
Log::debug(sprintf('Stats incremented for URL : %s', $this->url->key));
}
}

View file

@ -0,0 +1,182 @@
<?php
namespace App\Lib\Helpers;
use IcehouseVentures\LaravelChartjs\Builder;
use Illuminate\Support\Arr;
class ChartJsHelper extends Builder
{
// phpcs:disable
/**
* @var array
*/
public $charts = [];
/**
* @var string
*/
public $name;
/**
* @var array
*/
private $defaults = [
'datasets' => [],
'labels' => [],
'type' => 'line',
'options' => [],
'size' => ['width' => null, 'height' => null]
];
/**
* @var array
*/
private $types = [
'bar',
'horizontalBar',
'bubble',
'scatter',
'doughnut',
'line',
'pie',
'polarArea',
'radar'
];
/**
* @param $name
*
* @return $this|Builder
*/
public function name($name)
{
$this->name = $name;
$this->charts[$name] = $this->defaults;
return $this;
}
/**
* @param $element
*
* @return Builder
*/
public function element($element)
{
return $this->set('element', $element);
}
/**
* @param array $labels
*
* @return Builder
*/
public function labels(array $labels)
{
return $this->set('labels', $labels);
}
/**
* @param array $datasets
*
* @return Builder
*/
public function datasets(array $datasets)
{
return $this->set('datasets', $datasets);
}
/**
* @param $type
*
* @return Builder
*/
public function type($type)
{
if (!in_array($type, $this->types)) {
throw new \InvalidArgumentException('Invalid Chart type.');
}
return $this->set('type', $type);
}
/**
* @param array $size
*
* @return Builder
*/
public function size($size)
{
return $this->set('size', $size);
}
/**
* @param array $options
*
* @return $this|Builder
*/
public function options(array $options)
{
foreach ($options as $key => $value) {
$this->set('options.' . $key, $value);
}
return $this;
}
/**
*
* @param string|array $optionsRaw
* @return $this
*/
public function optionsRaw($optionsRaw)
{
if (is_array($optionsRaw)) {
$this->set('optionsRaw', json_encode($optionsRaw));
return $this;
}
$this->set('optionsRaw', $optionsRaw);
return $this;
}
/**
* @return mixed
*/
public function render()
{
$chart = $this->charts[$this->name];
return view('vendor.chart-template')
->with('datasets', $chart['datasets'])
->with('element', $this->name)
->with('labels', $chart['labels'])
->with('options', isset($chart['options']) ? $chart['options'] : '')
->with('optionsRaw', isset($chart['optionsRaw']) ? $chart['optionsRaw'] : '')
->with('type', $chart['type'])
->with('size', $chart['size']);
}
/**
* @param $key
*
* @return mixed
*/
public function get($key)
{
return Arr::get($this->charts[$this->name], $key);
}
/**
* @param $key
* @param $value
*
* @return $this|Builder
*/
public function set($key, $value)
{
Arr::set($this->charts[$this->name], $key, $value);
return $this;
}
// phpcs:enable
}

View file

@ -0,0 +1,85 @@
<?php
namespace App\Lib\Helpers;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Uwil\LaravelCommon\Helpers\ToolboxHelper as BaseToolboxHelper;
class ToolboxHelper extends BaseToolboxHelper
{
/**
* Calculate the request signature
*
* @param string $secret
* @param string $uri
* @param string $method
* @param array $parameters
* @return string request signature
* @throws \RuntimeException If an error occurs.
*/
public static function signRequest(string $secret, string $uri, string $method, array $parameters): string
{
try {
$uri = collect(\parse_url($uri));
$method = \strtolower($method);
// * Remove signature from parameters to calculate the hashmac.
$signature = collect([
'uri' => sprintf(
'%s://%s%s',
$uri->get('scheme'),
$uri->get('host'),
$uri->get('path')
),
'method' => $method
]);
$flattenRequestArray = function (array $array) use (&$flattenRequestArray) {
$out = [];
foreach ($array as $key => $value) {
if (is_array($value)) {
$subArray = $flattenRequestArray($value);
foreach ($subArray as $subKey => $subValue) {
$out["$key.$subKey"] = $subValue;
}
continue;
}
$out[$key] = $value;
}
return $out;
};
$parameters = $flattenRequestArray($parameters);
$parameters = collect($parameters);
// * Remove signature from parameters.
$parameters->pull('signature');
// * Natural sort all parameters.
$parameters = $parameters->sortKeys(\SORT_NATURAL)
->map(function ($value, $key) {
return sprintf('%s=%s', \rawurlencode($key), \rawurlencode($value));
});
$signature->push($parameters->implode('&'));
$calculatedHash = base64_encode(hash_hmac(
'sha256',
$signature->implode('\n'),
Crypt::decryptString($secret),
true
));
if (!$calculatedHash) {
throw new \RuntimeException('Failed to calculate requests hash');
}
return $calculatedHash;
} catch (DecryptException $e) {
throw new \RuntimeException('Failed to decrypt service credentials', 0, $e);
} catch (\Exception $e) {
throw new \RuntimeException('An error occured when authenticating service', 0, $e);
} //end try
}
}

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

@ -0,0 +1,176 @@
<?php
namespace App\Models;
use App\Enums\ActivityLogs\ActivityLogsEventEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/**
* @property string $model Target model.
* @property string $model_id Id of the target model.
* @property \App\Enums\ActivityLogs\ActivityLogsEventEnum $event Event of this activity (ActivityLogsEventEnum).
* @property array $data Changes.
* @property-read \Illuminate\Support\Carbon $created_at Created date.
*
* @property-read \App\Models\User $user User BelongsTo relation.
*/
class ActivityLog extends Model
{
use HasFactory;
/**
* 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' => 'boolean',
'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);
// @phpstan-ignore-next-line
$activity->created_at = 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
*/
public static function getChangedColumns(self $activity, Model $model, ActivityLogsEventEnum $eventEnum): array
{
$modelClassName = $activity->model_class;
/** Get type of each fields of the target model */
/** @var \Illuminate\Database\Eloquent\Model|null $targetModel */
$targetModel = $activity->model_class::where(
(new $modelClassName())->getRouteKeyName(),
$activity->model_id
)->first();
if (\is_null($targetModel)) {
return [];
}
$targetModelTypes = collect($targetModel->toArray())
->map(fn ($value) => self::getValueType($value))
->toArray();
/** Return old, new and type of values changed */
return match ($eventEnum) {
ActivityLogsEventEnum::created => array_intersect_key($targetModelTypes, $model->toArray()),
ActivityLogsEventEnum::duplicated => array_intersect_key($targetModelTypes, $model->toArray()),
ActivityLogsEventEnum::updated => array_intersect_key($targetModelTypes, $model->getChanges()),
default => []
};
}
/**
* Get a value type as a string
* ! Use this for display purpose only
*
* @param mixed $value
* @return string
* @throws \RuntimeException If type is unhlanded.
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.MaxExceeded
*/
private static function getValueType(mixed $value): string
{
// phpcs:enable
$type = \gettype($value);
switch ($type) {
// phpcs:ignore PSR2.ControlStructures.SwitchDeclaration.TerminatingComment
case 'string':
return match (true) {
Str::length(\strip_tags($value)) !== Str::length($value) => 'html',
Str::startsWith($value, 'storage/modelfiles') => 'file',
Str::isUuid($value) => 'uuid',
Str::isUlid($value) => 'ulid',
Str::isUrl($value) => 'url',
Str::isMatch('/^[0-9A-Za-z_-]{10}[048AEIMQUYcgkosw]$/', $value) => 'youtube_video_id',
\is_numeric($value) => 'numeric',
Str::isJson($value) and
(Str::startsWith($value, '[') or Str::startsWith($value, '{')) => 'json',
default => 'string'
};
case 'NULL':
case 'object':
case 'array':
case 'integer':
case 'boolean':
return $type;
case 'double':
return is_float($value) ? 'float' : 'double';
case 'resource (closed)':
case 'resource':
throw new \RuntimeException('Laravel model properties should not contain resources !');
case 'unknown type':
default:
throw new \RuntimeException('Unhandled Type ' . \gettype($value));
} //end switch
}
// * RELATIONS
/**
* User BelongsTo relation.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class);
}
}

71
app/Models/Brand.php Normal file
View file

@ -0,0 +1,71 @@
<?php
namespace App\Models;
use App\Traits\Models\ActivityLog;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Brand extends Model
{
use HasFactory;
use ActivityLog;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name'
];
/**
* The "booted" method of the model.
*
* @return void
*/
protected static function boot(): void
{
parent::boot();
static::deleting(function (self $model) {
$model->users()->detach();
});
}
// * RELATIONSHIPS
/**
* Get the urls for the brand.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\Url, $this>
*/
public function urls()
{
return $this->hasMany(Url::class);
}
/**
* Get the urls for the brand.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\Service, $this>
*/
public function services()
{
return $this->hasMany(Service::class);
}
/**
* The users that belong to the brand.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\App\Models\User,
* $this,
* \Illuminate\Database\Eloquent\Relations\Pivot>
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
}

156
app/Models/Service.php Normal file
View file

@ -0,0 +1,156 @@
<?php
namespace App\Models;
use App\Lib\Helpers\ToolboxHelper;
use App\Traits\Models\ActivityLog;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
class Service extends Model
{
use HasFactory;
use ActivityLog;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'bearer_token',
'bearer_pwd',
'brand_id'
];
// * EVENTS
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function booted()
{
static::creating(function (self $model) {
self::assertFieldIsUnique('name', $model->name);
$model->generateBearerToken();
$model->generateBearerPassword();
});
}
// * METHODS
/**
* Asserts using validation that the field is unique.
*
* @param string $nameField
* @param string $valueField
* @param integer|null $id
* @return void
*/
private static function assertFieldIsUnique(string $nameField, string $valueField, ?int $id = null)
{
$table = (new self())->getTable();
ToolboxHelper::assertFieldIsUnique($table, $nameField, $valueField, $id);
}
/**
* Generates new bearer token
*
* @return string
*/
public function generateBearerToken(): string
{
$this->bearer_token = hash('ripemd160', Str::uuid());
$this->bearer_token_date = now();
return $this->bearer_token;
}
/**
* Generates new bearer password
* It store the crypted password in model
* and returns the plain password
*
* @return string
*/
public function generateBearerPassword(): string
{
$pwd = hash('sha256', Str::uuid());
$this->bearer_pwd = Crypt::encryptString($pwd);
return $pwd;
}
/**
* Authenticate the request for this service
*
* This will check hash-mac and that the request is wellformed
*
* The request should have the minimum following parameters
* - timestamp using format Y-m-d H:i:s, and must be UTC (Greenwich Mean Time)
* - bearerToken the public token generated by the crm
* - signature the hashmac signature of the request
*
* ! Signature must be calculated with every parameter urlencode once only
* ! All parameters to calculate the signature must be sorted (natural sort asc)
*
* ! Signature should look like
* * httpVerb\n
* * uri\n
* * queryString
*
* ! httpVerb as well as uri must be lower case
* ! uri should include scheme, host and path and nothing else, if path is empty a trailing slash should be added
* ! eg: https://ldc-crm.fr/customer/1 or https://ldc-crm.fr/
* ! The query string shall be separated with '&', eg param1=value1&param2=value2
* ! Key names and values of the query string must be url encoded using RFC 3986
* ! http://www.faqs.org/rfcs/rfc3986.html
* ! Signature must be hmac sha256 using the private key generatedby the crm
*
* * ! They should then be concat in a string separated by \n
*
*
*
* @param \Illuminate\Http\Request $request
* @return boolean
* @throws \RuntimeException If an error occurs.
*/
public function authenticateApi(Request $request): bool
{
// ! Hack to prevent Laravel to cast empty strings to null or the opposite.
// ! If cannot decode json user legacy all() method.
$data = \json_decode($request->getContent(), true);
$data = (\is_array($data) and \json_last_error() === \JSON_ERROR_NONE) ?
array_replace_recursive($request->all(), $data) : $request->all();
$uri = $request->getUri();
if (app()->environment(['staging', 'production']) and Str::of($uri)->startsWith('http:')) {
$uri = (string)Str::of($uri)->replaceFirst('http:', 'https:');
}
$calculatedHash = ToolboxHelper::signRequest(
$this->bearer_pwd,
$uri,
$request->getMethod(),
$data
);
// ! Très important hash_equals https://fr.wikipedia.org/wiki/Attaque_temporelle
return \hash_equals($calculatedHash, $request->signature);
}
// * RELATIONSHIPS
/**
* Get the brand that owns the service.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\App\Models\Brand, $this>
*/
public function brand()
{
return $this->belongsTo(Brand::class);
}
}

79
app/Models/Url.php Normal file
View file

@ -0,0 +1,79 @@
<?php
namespace App\Models;
use App\Traits\Models\ActivityLog;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Uwil\LaravelCommon\Helpers\FileHelper;
class Url extends Model
{
use HasFactory;
use ActivityLog;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'key',
'value',
'title',
'image_settings',
'logo',
'brand_id'
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'image_settings' => 'array',
];
/**
* The "booted" method of the model.
*
* @return void
*/
protected static function boot(): void
{
static::creating(function (self $model) {
$model->logo = FileHelper::storeFile($model, $model->logo, true);
});
static::updating(function (self $model) {
$model->logo = FileHelper::storeFile($model, $model->logo, true);
});
static::updated(function (self $model) {
FileHelper::removeOldFile($model, 'logo');
});
static::deleted(function (self $model) {
FileHelper::removeFile($model, 'logo');
});
parent::boot();
}
/**
* Get the brand that owns the url.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\App\Models\Brand, $this>
*/
public function brand()
{
return $this->belongsTo(Brand::class);
}
/**
* Get the history for the url.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\UrlHistory, $this>
*/
public function history()
{
return $this->hasMany(UrlHistory::class);
}
}

32
app/Models/UrlHistory.php Normal file
View file

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UrlHistory extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'value',
'start_date',
'end_date'
];
/**
* Get the url that owns the stats.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\App\Models\Url, $this>
*/
public function url()
{
return $this->belongsTo(Url::class);
}
}

45
app/Models/UrlStats.php Normal file
View file

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UrlStats extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'calls',
'date',
'url_key',
'url',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'date' => 'date',
];
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function booted(): void
{
static::creating(function (self $model) {
$model->calls = 1;
$model->date = now();
});
}
}

107
app/Models/User.php Normal file
View file

@ -0,0 +1,107 @@
<?php
namespace App\Models;
use App\Enums\Users\RoleEnum;
use App\Traits\Models\ActivityLog;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use HasFactory;
use Notifiable;
use ActivityLog;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'firstname',
'lastname',
'email',
'role',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'remember_token',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'role' => RoleEnum::class,
'last_connection' => 'date',
];
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function booted()
{
static::creating(function (self $model) {
self::checkElevationPrivileges($model);
});
static::updating(function (self $model) {
self::checkElevationPrivileges($model);
});
}
/**
* Prevent user elevation privileges.
*
* @param self $user
* @return void
*/
private static function checkElevationPrivileges(self $user): void
{
throw_if(
auth('backend')->user() and auth('backend')->user()->role->value() > $user->role->value(),
AuthorizationException::class
);
}
/**
* Get related brands or all the brands if the user is consceptor.
*
* @return \Illuminate\Database\Eloquent\Collection<int, \App\Models\Brand>|
* \Illuminate\Database\Eloquent\Relations\BelongsToMany<\App\Models\Brand,
* $this,
* \Illuminate\Database\Eloquent\Relations\Pivot>
*/
public function getRelatedBrands()
{
return $this->role->equals(RoleEnum::conceptor) ?
Brand::all() :
$this->brands;
}
// * RELATIONSHIPS
/**
* The brands that belong to the user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\App\Models\Brand,
* $this,
* \Illuminate\Database\Eloquent\Relations\Pivot>
*/
public function brands(): BelongsToMany
{
return $this->belongsToMany(Brand::class);
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Observers;
use App\Enums\ActivityLogs\ActivityLogsEventEnum;
use App\Models\ActivityLog;
use Illuminate\Database\Eloquent\Model;
class ModelObserver
{
/**
* Handle the model "created" event.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @return void
*/
public static function created(Model $model): void
{
ActivityLog::addActivity($model, ActivityLogsEventEnum::created);
}
/**
* Handle the model "updated" event.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @return void
*/
public static function updated(Model $model): void
{
ActivityLog::addActivity($model, ActivityLogsEventEnum::updated);
}
/**
* Handle the model "duplicated" event.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @return void
*/
public static function duplicated(Model $model): void
{
ActivityLog::addActivity($model, ActivityLogsEventEnum::duplicated);
}
/**
* Handle the model "deleted" event.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @return void
*/
public static function deleted(Model $model): void
{
ActivityLog::addActivity($model, ActivityLogsEventEnum::deleted);
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Policies;
use App\Enums\Users\RoleEnum;
use App\Models\User;
use App\Policies\Rules\UserStaticRules;
class BrandPolicy
{
/**
* Determine whether the user can view any models.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function viewAny(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can view the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function view(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can create a new model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function create(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can update the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function update(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can delete the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function delete(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Policies\Rules;
use App\Enums\Users\RoleEnum;
use Illuminate\Database\Eloquent\Model;
/**
* Userlevel Policiy Static Rules
*
* @codeCoverageIgnore
*/
abstract class UserStaticRules
{
/**
* Allow everyone.
*
* @return boolean
*/
public static function everyOne(): bool
{
return true;
}
/**
* Allow no one.
*
* @return boolean
*/
public static function noOne(): bool
{
return false;
}
/**
* At least a minimum role
*
* @param \Illuminate\Database\Eloquent\Model $authModel
* @param \App\Enums\Users\RoleEnum $minRole
* @return boolean
*/
public static function atLeastRole(Model $authModel, RoleEnum $minRole): bool
{
// phpcs:enable
return isset($authModel->role) && $authModel->role->value() <= $minRole->value();
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Policies;
use App\Enums\Users\RoleEnum;
use App\Models\User;
use App\Policies\Rules\UserStaticRules;
class ServicePolicy
{
/**
* Determine whether the user can view any models.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function viewAny(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can view the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function view(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can create a new model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function create(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can update the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function update(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can delete the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function delete(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Policies;
use App\Enums\Users\RoleEnum;
use App\Models\Url;
use App\Models\User;
use App\Policies\Rules\UserStaticRules;
class UrlPolicy
{
/**
* Determine whether the user can view any models.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function viewAny(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::marketing);
}
/**
* Determine whether the user can view the model.
*
* @param \App\Models\User $authUser
* @param \App\Models\Url $url
* @return boolean
*/
public function view(User $authUser, Url $url): bool
{
return (UserStaticRules::atLeastRole($authUser, RoleEnum::marketing) &&
\collect($authUser->brands->pluck('id'))->contains($url->brand->id)) ||
UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can create a new model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function create(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::marketing);
}
/**
* Determine whether the user can update the model.
*
* @param \App\Models\User $authUser
* @param \App\Models\Url $url
* @return boolean
*/
public function update(User $authUser, Url $url): bool
{
return (UserStaticRules::atLeastRole($authUser, RoleEnum::marketing) &&
\collect($authUser->brands->pluck('id'))->contains($url->brand->id)) ||
UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can duplicate the model.
*
* @param \App\Models\User $authUser
* @param \App\Models\Url $url
* @return boolean
*/
public function duplicate(User $authUser, Url $url): bool
{
return (UserStaticRules::atLeastRole($authUser, RoleEnum::marketing) &&
\collect($authUser->brands->pluck('id'))->contains($url->brand->id)) ||
UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can delete the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function delete(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Policies;
use App\Enums\Users\RoleEnum;
use App\Models\User;
use App\Policies\Rules\UserStaticRules;
class UrlStatsPolicy
{
/**
* Determine whether the user can view any models.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function viewAny(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can view the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function view(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can create a new model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function create(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can update the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function update(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can delete the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function delete(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Policies;
use App\Enums\Users\RoleEnum;
use App\Models\User;
use App\Policies\Rules\UserStaticRules;
class UserPolicy
{
/**
* Determine whether the user can view any models.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function viewAny(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can view the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function view(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can create a new model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function create(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can update the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function update(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can delete the model.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function delete(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Providers;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
// * HTTPS FIX
URL::forceScheme('https');
// * HA PROXY FIX
App::singleton('clientIp', function () {
if (config('app.env') === 'local') {
return request()->getClientIp();
} else {
return request()->server('HTTP_X_FORWARDED_FOR') ??
request()->getClientIp();
}
});
// * LOCAL FORCE eg: for Carbon
setlocale(LC_ALL, \sprintf(
'%s_%s.UTF-8',
config('app.locale'),
\strtoupper(config('app.locale'))
));
Carbon::setLocale(config('app.locale'));
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Providers;
use App\Enums\Users\RoleEnum;
use App\Models\Service;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
/**
* Register any authentication / authorization services.
*
* @return void
*/
public function boot()
{
$this->registerPolicies();
Gate::define('isConceptor', function (Model $authModel) {
return isset($authModel->role) && $authModel->role === RoleEnum::conceptor;
});
Auth::viaRequest('api-hmac', function (Request $request) {
$service = null;
try {
// * On récupère le service actuel correspondant au token fourni.
$service = Service::query()->where('bearer_token', $request->input('bearer_token'))->firstOrFail();
} catch (ModelNotFoundException $e) {
return null;
}
// * On authenticate la request en vérifiant sa signature.
if ($service->authenticateApi($request)) {
return $service;
}
return null;
});
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Session;
use Slides\Saml2\Events\SignedIn;
class EventServiceProvider extends ServiceProvider
{
/**
* The event listener mappings for the application.
*
* @var array<class-string, array<int, class-string>>
*/
protected $listen = [
Registered::class => [
SendEmailVerificationNotification::class,
],
];
/**
* Determine if events and listeners should be automatically discovered.
*
* @return boolean
*/
public function shouldDiscoverEvents(): bool
{
return false;
}
/**
* Register any events for your application.
*
* @return void
*/
public function boot()
{
Event::listen('Slides\Saml2\Events\SignedIn', function (SignedIn $event) {
$messageId = $event->getAuth()->getLastMessageId();
if (Cache::get(config('cache.prefix') . "_saml_message_id_" . $messageId)) {
logger()->error(
'SAML MESSAGE ID HANDLER',
['error' => 'Message ID : ' . $messageId . " replayed !"]
);
abort(403);
} else {
// Cache for 8h.
Cache::put(config('cache.prefix') . "_saml_message_id_" . $messageId, $messageId, 28800);
}
$user = $event->getSaml2User();
$email = $user->getNameId();
$lUser = User::query()->where('email', $email)->first();
if (!$lUser) {
abort(403);
}
Auth::guard('backend')->login($lUser);
session(['sessionIndex' => $user->getSessionIndex()]);
$lUser->last_connection = now();
$lUser->saveQuietly();
});
Event::listen('Slides\Saml2\Events\SignedOut', function () {
Auth::guard('backend')->logout();
Session::save();
});
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace App\Providers;
use App\Models\Service;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
/**
* The path to the "home" route for your application.
*
* This is used by Laravel authentication to redirect users after login.
*
* @var string
*/
public const HOME = '/';
/**
* Define your route model bindings, pattern filters, etc.
*
* @return void
*/
public function boot()
{
$this->configureRateLimiting();
$this->cspReportRoute();
$this->routes(function () {
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/web-bo.php'));
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/web-fo.php'));
Route::middleware('api')
->namespace($this->namespace)
->group(base_path('routes/api.php'));
});
Route::model('service', Service::class);
}
/**
* Configure the rate limiters for the application.
*
* @return void
*/
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(150)->by(optional($request->user())->id ?: app('clientIp'));
});
RateLimiter::for('web', function (Request $request) {
return Limit::perMinute(150)->by(optional($request->user())->id ?: app('clientIp'));
});
RateLimiter::for('credentials', function (Request $request) {
return Limit::perMinute(30)->by(optional($request->user())->id ?: $request->ip());
});
}
/**
* * Register CSP report URI
*
* Put this function inside RouteServiceProvider,
* and call it in RouteServiceProvider::boot
*
* @return void
*/
private function cspReportRoute()
{
// * CSP REPORT URI, Please add /csp-report to VerifyCsrfToken $except middleware
\Illuminate\Support\Facades\Route::post('/csp-report', function () {
$isCspRequest = request()->header('Content-Type') === 'application/csp-report';
if (!$isCspRequest or !request()->json('csp-report') or !is_array(request()->json('csp-report'))) {
abort(401);
}
// * Flatten if needed
$cspReport = collect(request()->json('csp-report'))
->flatMap(function ($v, $k) {
return [$k => is_array($v) ? collect($v)->flatten()->implode(', ') : $v];
})->all();
// * Filter array keys
$cspReport = collect(array_intersect_key($cspReport, [
'blocked-uri' => '', 'document-uri' => '', 'original-policy' => '',
'referrer' => '', 'violated-directive' => ''
]));
\Illuminate\Support\Facades\Log::warning(sprintf(
"CSP REPORT for %s : \n %s",
$cspReport->get('document-uri'),
$cspReport->implode("\n")
));
})->middleware('throttle:5,2')
->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class)
->name('cspReportUri');
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Providers;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\Finder\SplFileInfo;
class TranslationServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application services.
*
* @return void
*/
public function boot()
{
foreach ($this->collectLocalesStrings() as $locale) {
// * Suported locales
Cache::rememberForever(sprintf('translations.%s', $locale), function () use ($locale) {
$translations = [
'php' => $this->phpTranslations($locale),
'json' => $this->jsonTranslations($locale),
];
return $translations;
});
}
}
/**
* Gather provided langs checking from files
*
* @return Collection
*/
private function collectLocalesStrings(): Collection
{
return collect(File::allFiles(resource_path('lang/')))->flatMap(function (SplFileInfo $file) {
if ($file->getRelativePath() and \strpos($file->getRelativePath(), 'vendor') !== 0) {
return [$file->getRelativePath() => ''];
}
})->keys();
}
/**
* Gather all php translations
*
* @param string $locale
* @return array
*/
private function phpTranslations(string $locale): array
{
$path = resource_path("lang/$locale");
return collect(File::allFiles($path))->flatMap(function ($file) use ($locale) {
$translation = $file->getBasename('.php');
return [$translation => trans($translation, [], $locale)];
})->all();
}
/**
* Gather json translations
*
* @param string $locale
* @return array
*/
private function jsonTranslations(string $locale): array
{
$path = resource_path("lang/$locale.json");
if (!File::exists($path) or !File::isReadable($path)) {
return [];
}
return json_decode(File::get($path), true) ?? [];
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Providers;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\ServiceProvider;
class ViewServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
if (!app()->runningInConsole()) {
// * FORCE BOOTSTRAP PAGINATOR (or custom if in front)
// Early boot, request wont be filled as expected.
switch (collect(explode('/', \request()->getPathInfo()))->get(1)) {
case 'bo':
Paginator::defaultView('bo.modules.pagination');
break;
default:
Paginator::useBootstrapFive();
break;
}
} //end if
}
}

View file

View file

View file

@ -0,0 +1,36 @@
<?php
namespace App\Traits\Models;
use App\Observers\ModelObserver;
use App\Models\ActivityLog as ActivityLogModel;
/**
* If there is a static function in a trait (named boot[TraitName]),
* it will be executed as the boot() function would on an Eloquent model.
* Futhermore, event in the Model observer doesn't erase actual events in the model,
* but adds them.
*/
trait ActivityLog
{
/**
*
* @return void
*/
public static function bootActivityLog(): void
{
static::observe(ModelObserver::class);
}
// * RELATIONS
/**
* Activities logs HasMany relationship.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function activityLogs(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(ActivityLogModel::class, 'model_id')->with('user');
}
}

View file

View file

@ -0,0 +1,175 @@
<?php
namespace App\Traits\Requests;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\File;
trait HasPictureWithSvgSupport
{
/**
* Minimum width
*
* @var integer
*/
private $minWidth = 400;
/**
* Maximum width
*
* @var integer
*/
private $maxWidth = 1800;
/**
* Minimum height
*
* @var integer
*/
private $minHeight = 300;
/**
* Maximum height
*
* @var integer
*/
private $maxHeight = 1800;
/**
* Image rules
*
* @param string $name
* @param integer|null $minWidth
* @param integer|null $minHeight
* @param integer|null $maxWidth
* @param integer|null $maxHeight
* @param boolean $nullable
* @param integer|null $size
* @param string|null $requiredWith
* @param string|null $requiredWithout
* @return array
*/
public function pictureRulesWithSvgSupport(
string $name = 'picture',
?int $minWidth = null,
?int $minHeight = null,
?int $maxWidth = null,
?int $maxHeight = null,
bool $nullable = false,
?int $size = null,
?string $requiredWith = null,
?string $requiredWithout = null,
): array {
$dimensions = (object)[
'minWidth' => $minWidth ?? $this->minWidth,
'minHeight' => $minHeight ?? $this->minHeight,
'maxWidth' => $maxWidth ?? $this->maxWidth,
'maxHeight' => $maxHeight ?? $this->maxHeight
];
return [
$name => [
!$nullable ? 'required' : 'nullable',
(strlen($requiredWith)) ? 'required_with:' . $requiredWith : '',
(strlen($requiredWithout)) ? 'required_without:' . $requiredWithout : '',
($size) ? 'max:' . $size : '',
function (string $attribute, $value, callable $fail) use ($dimensions) {
self::validatePicture($attribute, $value, $fail, $dimensions);
}
]
];
}
/**
* Validate picture
*
* @param string $attribute
* @param string|null $value
* @param callable $fail
* @param object $sizes
* @return void
*/
public static function validatePicture(string $attribute, string|null $value, callable $fail, object $sizes): void
{
if (!($useStorage = Storage::exists($value)) && !File::exists($value)) {
$fail(trans(':attribute n\'existe pas dans le système de fichier', [
'attribute' => $attribute
]));
return;
}
$fileFullPath = self::getUploadedFileFullPath($value, $useStorage);
if (
!\in_array(self::getUploadedFileMimeType($value, $useStorage), [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/svg+xml'
])
) {
$fail(trans(':attribute doit être une image de type \'image/jpeg,image/jpg,image/png,image/gif\',image/svg\'', [
'attribute' => $attribute
]));
return;
}
if (self::getUploadedFileMimeType($value, $useStorage) !== "image/svg+xml") {
if (!($dimensions = \getimagesize($fileFullPath))) {
$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->minWidth}px et {$sizes->maxWidth}px"
);
if ($sizes->minWidth === $sizes->maxWidth) {
$xMessage = trans(":attribute la largeur doit être de {$sizes->minWidth}px");
}
$yMessage = trans(
":attribute la hauteur doit être comprise entre {$sizes->minHeight}px et {$sizes->maxHeight}px"
);
if ($sizes->minHeight === $sizes->maxHeight) {
$yMessage = trans(":attribute la hauteur doit être de {$sizes->minHeight}px");
}
if (($width < $sizes->minWidth) || ($width > $sizes->maxWidth)) {
$fail($xMessage);
}
if (($height < $sizes->minHeight) || ($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);
}
}

53
artisan Executable file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env php
<?php
define('LARAVEL_START', microtime(true));
/*
|--------------------------------------------------------------------------
| Register The Auto Loader
|--------------------------------------------------------------------------
|
| Composer provides a convenient, automatically generated class loader
| for our application. We just need to utilize it! We'll require it
| into the script here so that we do not have to worry about the
| loading of any of our classes manually. It's great to relax.
|
*/
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
/*
|--------------------------------------------------------------------------
| Run The Artisan Application
|--------------------------------------------------------------------------
|
| When we run the console application, the current CLI command will be
| executed in this console and the response sent back to a terminal
| or another output device for the developers. Here goes nothing!
|
*/
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$status = $kernel->handle(
$input = new Symfony\Component\Console\Input\ArgvInput,
new Symfony\Component\Console\Output\ConsoleOutput
);
/*
|--------------------------------------------------------------------------
| Shutdown The Application
|--------------------------------------------------------------------------
|
| Once Artisan has finished running, we will fire off the shutdown events
| so that any final work may be done by the application before we shut
| down the process. This is the last thing to happen to the request.
|
*/
$kernel->terminate($input, $status);
exit($status);

55
bootstrap/app.php Normal file
View file

@ -0,0 +1,55 @@
<?php
/*
|--------------------------------------------------------------------------
| Create The Application
|--------------------------------------------------------------------------
|
| The first thing we will do is create a new Laravel application instance
| which serves as the "glue" for all the components of Laravel, and is
| the IoC container for the system binding all of the various parts.
|
*/
$app = new Illuminate\Foundation\Application(
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
);
/*
|--------------------------------------------------------------------------
| Bind Important Interfaces
|--------------------------------------------------------------------------
|
| Next, we need to bind some important interfaces into the container so
| we will be able to resolve them when needed. The kernels serve the
| incoming requests to this application from both the web and CLI.
|
*/
$app->singleton(
Illuminate\Contracts\Http\Kernel::class,
App\Http\Kernel::class
);
$app->singleton(
Illuminate\Contracts\Console\Kernel::class,
App\Console\Kernel::class
);
$app->singleton(
Illuminate\Contracts\Debug\ExceptionHandler::class,
App\Exceptions\Handler::class
);
/*
|--------------------------------------------------------------------------
| Return The Application
|--------------------------------------------------------------------------
|
| This script returns the application instance. The instance is given to
| the calling script so we can separate the building of the instances
| from the actual running of the application and sending responses.
|
*/
return $app;

2
bootstrap/cache/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

1
commitlint.config.cjs Normal file
View file

@ -0,0 +1 @@
module.exports = require('@uwil/husky-config/commitlint.config.cjs');

85
composer.json Normal file
View file

@ -0,0 +1,85 @@
{
"name": "uwil/qrcollector-back",
"version": "1.0.0",
"type": "project",
"description": "Administration interface for QRCollector",
"license": "MIT",
"require": {
"php": "^8.5",
"24slides/laravel-saml2": "^2.4",
"diglactic/laravel-breadcrumbs": "^10.0",
"guzzlehttp/guzzle": "^7.5.0",
"icehouse-ventures/laravel-chartjs": "^4.2",
"kwaadpepper/image-resizer": "^3.0",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.5",
"laravel/ui": "^4.0",
"mews/purifier": "^3.4",
"mistic100/randomcolor": "^1.0",
"sentry/sentry-laravel": "^4.20",
"uwil/admin-secure-laravel": "^5.0",
"uwil/k8s-laravel": "^2.0",
"uwil/laravel-common": "^4.1"
},
"require-dev": {
"fakerphp/faker": "^1.9.1",
"larastan/larastan": "^3.9",
"laravel/sail": "^1.18",
"mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^8.1",
"phpunit/phpunit": "^11.0",
"slevomat/coding-standard": "^8.10"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
"php-http/discovery": true
},
"gitlab-domains": [
"lab.s-web.fr"
]
},
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": {
"lab.s-web.fr": {
"type": "composer",
"url": "https://lab.s-web.fr/api/v4/group/23/-/packages/composer/packages.json"
}
}
}

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