Compare commits

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

83 commits
trunk ... main

Author SHA1 Message Date
Vincent BRIDIER
972d5b5214 Merge branch 'trunk' 2026-10-23 17:13:33 +02:00
Vincent BRIDIER
c40a622581 Merge branch 'develop' into trunk 2026-10-23 17:13:18 +02:00
Vincent BRIDIER
a2ce48e52d feat: Change Apache PID 2026-10-23 17:13:04 +02:00
Noah Lancien
47a92783d7 Merge branch 'trunk' 2026-10-22 18:03:17 +02:00
Noah Lancien
23273bd65f Merge branch 'develop' into trunk 2026-10-22 18:02:55 +02:00
Noix
0b4f9ed7dd Merge branch 'bump-phpdep' into 'develop'
chore: update dependencies for improved stability

See merge request ldc-web/projets/showroom/showroom-bo!18
2026-10-22 16:00:40 +00:00
ca76365537
chore(Dockerfile, manifest.yaml): update PHP version and database config
- Updated PHP version tag in Dockerfile.
- Changed database host and port variables in manifest.yaml for better clarity.
2026-10-22 17:37:41 +02:00
fdad334d39
chore: update dependencies for improved stability
Updated various dependencies to their latest versions to enhance project stability and security.
2026-10-22 17:37:32 +02:00
Vincent BRIDIER
b5ac47d6cd Merge branch '14-rgpd' into 'develop'
docs: 📝 Added rgpd.md

See merge request ldc-web/projets/showroom/showroom-bo!14
2026-10-12 15:43:33 +02:00
K Muller
d4c12731db Merge branch 'trunk' 2026-10-08 11:02:01 +02:00
K Muller
323eeb80f9 Merge branch 'develop' into trunk 2026-10-08 11:01:48 +02:00
Karl M'muller
4bf49b356a Merge branch 'issue-15' into 'develop'
fix: 🐛 check zip close result and ensure export dir exists #15

See merge request ldc-web/projets/showroom/showroom-bo!15
2026-10-08 09:00:27 +00:00
Alexis Gousseau
a2de399fd6
fix: 🐛 check zip close result and ensure export dir exists #15 2026-10-02 16:52:56 +02:00
Noah Lancien
1ba9927042 docs: 📝 Added rgpd.md 2026-06-05 17:14:03 +02:00
Noah Lancien
1bcfabfeaa Merge branch 'trunk' 2026-01-29 14:07:27 +01:00
Noah Lancien
5050e07d94 Merge branch 'develop' into trunk 2026-01-29 14:07:05 +01:00
Noah Lancien
d73701912e ci: limit jobs history to 1 2026-01-29 14:06:47 +01:00
Noah Lancien
208b186e93 Merge branch 'trunk' 2026-01-12 11:51:47 +01:00
Noah Lancien
ef1bc6f4b1 Merge branch 'develop' into trunk 2026-01-12 11:51:24 +01:00
Noah Lancien
6c536efcd4 feat: updated admin-secure 2026-01-12 11:49:49 +01:00
Noah Lancien
b5167ef0bc Merge branch 'develop' into trunk 2026-01-09 11:10:19 +01:00
Noah Lancien
aa29e62ba4 ci: added ci variables to define pod resources + added staging-resources.yaml 2026-01-09 11:10:01 +01:00
Noah Lancien
1548ff4462 Merge branch 'trunk' 2025-07-10 10:35:51 +02:00
Noah Lancien
2e2c64ed87 Merge branch 'develop' into trunk 2025-07-10 10:35:32 +02:00
Noah Lancien
bc4aff38ab fix: update Settings model to manage 'locales' default 2025-07-10 10:35:06 +02:00
Noah Lancien
0ba59fa271 Merge branch 'trunk' 2025-07-10 10:20:30 +02:00
Noah Lancien
da99be8e5c Merge branch 'develop' into trunk 2025-07-10 10:20:13 +02:00
Noah Lancien
f6425ae6ef fix: update last migration to make it compatible with mysql database 2025-07-10 10:19:55 +02:00
Noah Lancien
dce60e4763 Merge branch 'trunk' 2025-07-10 10:04:18 +02:00
Noah Lancien
08eed01bce Merge branch 'develop' into trunk 2025-07-10 10:04:00 +02:00
noah.lancien
6dc49edad6 Merge branch 'update' into 'develop'
feat: ✨ add locales in settings and export

See merge request ldc-web/projets/showroom/showroom-bo!13
2025-07-10 08:02:42 +00:00
Alexis Gousseau
5226bb57c9
feat: ✨ add locales in settings and export 2025-07-09 15:28:33 +02:00
Noah Lancien
fa3666973a Merge branch 'trunk' 2025-06-23 11:59:54 +02:00
Noah Lancien
b57c3ce2e0 Merge branch 'develop' into trunk 2025-06-20 14:20:57 +02:00
Noah Lancien
c84c2baeeb ci: update image version + replace 443 port by 80 in manifest 2025-06-20 14:20:14 +02:00
Noah Lancien
a8e4bd2893 Merge branch 'trunk' 2025-06-10 10:42:58 +02:00
Noah Lancien
c4758c65c2 Merge branch 'develop' into trunk 2025-06-10 10:24:26 +02:00
Noah Lancien
d3b001ad4b Merge remote-tracking branch 'origin/fix-imageinput' into develop 2025-06-10 10:24:08 +02:00
Karl
c88b82d5c2 fix: 🐛 error ts on custom event 2025-06-10 10:21:37 +02:00
Noah Lancien
ff466982ec Merge branch 'develop' into trunk 2025-06-10 09:54:35 +02:00
noah.lancien
64414fb2fa Merge branch 'issue-9' into 'develop'
fix: 🐛 delete the shelf on schema.json + settings logo was not correctly saved in db

See merge request ldc-web/projets/showroom/showroom-bo!12
2025-06-10 07:32:54 +00:00
Karl
d83b07b967 fix: 🐛 delete the shelf on schema.json + settings logo was not correctly saved in db 2025-06-04 10:49:05 +02:00
karl.muller
aa767eff32 Merge branch 'issue-4' into 'develop'
feat: delete the shelf in range and brand models

See merge request ldc-web/projets/showroom/showroom-bo!11
2025-06-03 15:11:44 +00:00
Karl
25843893ad fix: resolve conflict 2025-06-03 17:09:41 +02:00
Vincent BRIDIER
ce185ad7ca Merge branch 'issue-6' into 'develop'
feat: ✨ add conditionnal image size validation with width enum

See merge request ldc-web/projets/showroom/showroom-bo!10
2025-06-03 15:02:23 +00:00
Karl
1d5659b202 feat: delete the shelf in range and brand models 2025-06-03 16:45:12 +02:00
Karl
8e608a9a9c feat: ✨ add conditionnal image size validation with width enum 2025-06-03 11:10:29 +02:00
Vincent BRIDIER
a05179b658 Merge branch 'issue-7' into 'develop'
feat: add width column in ranges index

See merge request ldc-web/projets/showroom/showroom-bo!9
2025-06-02 14:45:56 +00:00
Karl
aea005049f feat: add width column in ranges index 2025-06-02 16:42:24 +02:00
Vincent BRIDIER
406a88993a Merge branch 'issue-8' into 'develop'
fix: 🐛 add uuid as file name for avoid error on long name

See merge request ldc-web/projets/showroom/showroom-bo!8
2025-06-02 14:35:20 +00:00
Karl
9c8b1c31b2 fix: 🐛 add uuid as file name for avoid error on long name 2025-06-02 16:31:43 +02:00
Vincent BRIDIER
91d85912fc
Merge branch 'trunk' 2025-05-30 17:36:11 +02:00
Vincent BRIDIER
2e94466e4f
Merge branch 'develop' into trunk 2025-05-30 17:31:26 +02:00
Vincent BRIDIER
56c104a62a
fix: Fix default tenancy database connection 2025-05-30 17:31:15 +02:00
Vincent BRIDIER
67511218b3 feat: remove www middleware and prefix 2025-05-30 13:14:39 +00:00
Vincent BRIDIER
0fcdbbe562 Merge branch '3-multitenant' into 'main'
Resolve "Rendre le projet multi tenant"

See merge request ldc-web/projets/showroom/showroom-bo!7
2025-05-30 13:09:58 +00:00
Noah Lancien
863a65e3c8 ci: Added ci/cd 2025-05-30 11:39:14 +02:00
Noah Lancien
d14e9ca90a feat: send mail when a tenant is deleted 2025-05-30 11:38:28 +02:00
Noah Lancien
351f7f34bc fix: review feedbacks 2025-05-28 17:20:29 +02:00
Noah Lancien
6aa2907d8b fix: many fixes related to tenant management 2025-05-27 09:11:43 +02:00
Noah Lancien
ee8106e679 feat: assign many users to many tenants + tenant creation 2025-05-21 12:56:59 +02:00
Noah Lancien
0eed72ed89 feat: add multi-tenant to the project 2025-05-20 12:51:38 +02:00
Vincent BRIDIER
ad130f54fc Merge branch '2-laravel-12' into 'main'
Bump Laravel 12

See merge request ldc-web/projets/showroom/showroom-bo!6
2025-05-15 12:04:05 +00:00
Noah Lancien
0d09b01a2d chore: phpcs + phpstan 2025-05-15 11:47:41 +02:00
Noah Lancien
b61fd77a6d chore: bump project to laravel 12 2025-05-14 17:17:44 +02:00
Noah Lancien
6e15a24301 Merge branch 'fix-export' 2024-10-08 09:38:44 +02:00
Noah Lancien
7044faf64c chore: bump app version to 2.3.3 2024-10-08 09:36:29 +02:00
Vincent BRIDIER
8613f0d21a
Bump app version to 2.3.2 2024-10-08 09:27:20 +02:00
Vincent BRIDIER
25d794f9fa
fix: Fix missing folders for export in k8s env 2024-10-08 09:26:47 +02:00
Vincent BRIDIER
7cae4ec111
fix: Fix local env SSL Error 2024-10-08 08:27:56 +02:00
noah.lancien
832e896aee Merge branch 'add-es-lang' into 'main'
Add es lang

See merge request ldc-web/projets/showroom/showroom-bo!5
2024-09-13 07:14:11 +00:00
Noah Lancien
838a2b3ba3 feat: Added DE lang 2024-09-12 17:59:09 +02:00
Noah Lancien
a28383d6aa fix: Removed unexisting fields in UserFactory 2024-09-12 16:37:23 +02:00
Noah Lancien
9471aa236c feat: Added ES lang 2024-09-12 16:36:53 +02:00
Vincent BRIDIER
3cede74888
fix: 💚 remove vendor/ from dockerignore 2024-09-12 10:16:24 +02:00
Vincent BRIDIER
b91554df1d
ci: 👷 🔧 - Update Dockerfile and CI files 2024-09-04 12:15:14 +02:00
Vincent BRIDIER
1ff60e1cd7
feat: 🍱 Change project logo and favicon 2024-09-03 19:07:36 +02:00
Vincent BRIDIER
e82d79362d
feat: 🛂 Change BO Authentication for SAML 2024-09-03 18:53:53 +02:00
Vincent BRIDIER
5a5d36da31
feat: ➕📦 Add Admin Secure 2024-09-03 16:00:40 +02:00
Vincent BRIDIER
5df55a81b3
Merge branch 'develop' 2024-09-03 10:29:11 +02:00
Vincent BRIDIER
709a60bae7
chore: 💥📦⬆️ Bump PHP to 8.3 + Bump Laravel to 11 2024-09-03 10:24:31 +02:00
Vincent BRIDIER
7e4436f2b3 Merge branch 'stage2-retours-mail' into 'develop'
Retours mail 30/01/24 evo UX/Features

See merge request ldc-web/projets/showroom/showroom-bo!1
2024-09-03 07:25:58 +00:00
Noah Lancien
427dd82e78 Updated manifest.yaml 2024-08-07 11:33:27 +02:00
192 changed files with 10285 additions and 7503 deletions

View file

@ -1,2 +1,16 @@
.git
*.md
node_modules/
npm-debug.log
.npmrc
tests/
Dockerfile*
.dockerignore
.git
.gitignore
README.md
LICENSE
.vscode
vendor/
public/build/
auth.json

View file

@ -3,7 +3,6 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
ASSET_URL=http://localhost
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
@ -37,26 +36,22 @@ MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
MAIL_TO_ADDRESS=
MAIL_TO_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=

View file

@ -1,24 +1,23 @@
include:
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'lint/lint.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'security/security.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'build/build.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'test/test.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'deploy/deploy.yml'
- 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
- lint
- security
- build
- test
- deploy

View file

@ -1,5 +1,49 @@
FROM registry.s-web.fr/ldc-web/projets/ldc-web-images-docker/ldcweb-production-8.2:latest
COPY --chown=www-data:www-data . /app/srcs
ARG PHP_8_5_TAG=latest
FROM composer:2.8 AS vendor
WORKDIR /app
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 ci && npm run prod
ARG PHP_8_5_TAG
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 www-data
USER ${USER}

View file

@ -1,66 +1,5 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
# Showroom BO
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## Usage
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains over 2000 video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the Laravel [Patreon page](https://patreon.com/taylorotwell).
### Premium Partners
- **[Vehikl](https://vehikl.com/)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Cubet Techno Labs](https://cubettech.com)**
- **[Cyber-Duck](https://cyber-duck.co.uk)**
- **[Many](https://www.many.co.uk)**
- **[Webdock, Fast VPS Hosting](https://www.webdock.io/en)**
- **[DevSquad](https://devsquad.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
- **[OP.GG](https://op.gg)**
- **[WebReinvent](https://webreinvent.com/?utm_source=laravel&utm_medium=github&utm_campaign=patreon-sponsors)**
- **[Lendio](https://lendio.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
Dans la stack, avec l'env `local` il faut mettre l'url du service Traefik (ex: `http://172.18.0.2:443`) dans la variable APP_URL

13
RGPD.md Normal file
View file

@ -0,0 +1,13 @@
# RGPD
## Site
### En base de données
| Table | Colonne | Contenu | Utilisation |
| ----- | ------- | ------- | ----------- |
| activity_logs | user_id | Identifiant de l'utilisateur (back) | Tracer les modifications dans le BO |
| --- | --- | --- | --- |
| users | first_name | Prénom | Affichage à différents endroits du BO |
| users | last_name | Nom | Affichage à différents endroits du BO |
| users | email | Adresse mail | Identifiant, sert à la connexion au BO |

View file

@ -3,9 +3,14 @@
namespace App\Console\Commands;
use App\Jobs\ShowroomExport;
use App\Models\Tenant;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\PromptsForMissingInput;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use RuntimeException;
class ExportShowRoom extends Command
class ExportShowRoom extends Command implements PromptsForMissingInput
{
/**
* The name and signature of the console command.
@ -21,13 +26,57 @@ class ExportShowRoom extends Command
*/
protected $description = 'Export showroom data : a zip file with json data and associated pictures';
/**
* The tenant_id of the tenant that contains the datas to export.
*
* @var string|null
*/
protected $tenantId;
/**
* Execute the console command.
*
* @return void
*
* @throws RuntimeException If tenantId isn't a string. Should never happen but it makes intelephense happy.
*/
public function handle(): void
{
\dispatch_sync(new ShowroomExport());
$this->tenantId = null;
$this->addTenantId();
if (!$this->tenantId) {
throw new RuntimeException('\'tenantId\' must be a valid string.');
}
\dispatch_sync(new ShowroomExport($this->tenantId));
}
/**
* Add a role to the user.
*
* @return void
*/
private function addTenantId(): void
{
while (is_null($this->tenantId)) {
try {
$tmp = $this->choice(
'Select a tenant',
Tenant::all()->pluck('id')->toArray(),
Tenant::query()->first()->id,
);
$validator = Validator::make(['tenant_id' => $tmp], [
'tenant_id' => 'required'
]);
$validator->validate();
$this->tenantId = $tmp;
} catch (ValidationException $e) {
$this->error(sprintf(
'Please choose from the selection %s',
\implode(',', collect($e->errors())->flatten()->all())
));
continue;
} //end try
}; //end while
}
}

View file

@ -46,13 +46,6 @@ class UserCreate extends Command
*/
protected $email;
/**
* The password of the new user.
*
* @var string|null
*/
protected $password;
/**
* The role of the new user.
*
@ -76,9 +69,6 @@ class UserCreate extends Command
$this->email = null;
$this->addEmail();
$this->password = null;
$this->addPassword();
$this->role = null;
$this->addRole();
@ -86,7 +76,6 @@ class UserCreate extends Command
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'email' => $this->email,
'password' => $this->password,
'role' => $this->role,
'published' => true,
'published_at' => Carbon::now(),
@ -173,22 +162,6 @@ class UserCreate extends Command
}; //end while
}
/**
* Add a password to the user.
*
* @return void
*/
private function addPassword(): void
{
while (
($this->password = $this->secret(
(!\is_null($this->password) ? 'Failed to confirm, ' : '') . 'Type the wanted user password'
)) !== $this->secret('Confirm the password')
) {
continue;
}; //end while
}
/**
* Add a role to the user.
*

67
app/Enums/Width.php Normal file
View file

@ -0,0 +1,67 @@
<?php
namespace App\Enums;
use App\Traits\Enums\BaseEnum;
enum Width: int
{
use BaseEnum;
case half = 1;
case full = 2;
/**
* Optionnal labels definition.
*
* @phpstan-ignore-next-line
*/
private const LABELS = [
self::half->name => 'enums.width.half',
self::full->name => 'enums.width.full',
];
/**
* Custom percent width value
*/
private const PERCENTVALUE = [
self::half->name => '50 %',
self::full->name => '100 %'
];
/**
* Get percent width value
*
* @return string
*/
public function percentValue(): string
{
return self::PERCENTVALUE[$this->name];
}
/**
* Custom dimension validation for the enum
*/
private const DIMVALIDATION = [
self::half->name => [
'width' => 600,
'height' => 500
],
self::full->name => [
'width' => 1300,
'height' => 500
],
];
/**
* Get percent width value
*
* @return array
*/
public function dimValidation(): array
{
return self::DIMVALIDATION[$this->name];
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Helpers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Uwil\LaravelCommon\Helpers\FileHelper;
use Illuminate\Support\Str;
class ShowroomFileHelper
{
/**
* Store file using UUID as name.
*
* @param \Illuminate\Http\UploadedFile $file
* @param Model $model
* @phpstan-param Model&object{tenant_id: string|int} $model
* @return string
*/
public static function storeFileWithUuidName(UploadedFile $file, Model $model): string
{
$extension = $file->getClientOriginalExtension();
$uuidName = (string) Str::uuid() . ($extension ? '.' . $extension : '');
return FileHelper::storeFile(
model: $model,
file: $file,
slugify: false,
filename: $uuidName,
prefix: $model->tenant_id
);
}
}

View file

@ -33,9 +33,10 @@ class BrandController extends Controller
{
/** @var \Illuminate\Database\Eloquent\Builder $query */
$query = Brand::query()->withCount([
'ranges', 'products',
'ranges as published_ranges' => fn (Builder $q) => $q->where('ranges.status', true),
'products as published_products' => fn (Builder $q) => $q->where('products.status', true),
'ranges',
'products',
'ranges as published_ranges' => fn(Builder $q) => $q->where('ranges.status', true),
'products as published_products' => fn(Builder $q) => $q->where('products.status', true),
]);
/** @var string $search Search field */
@ -84,7 +85,7 @@ class BrandController extends Controller
$brand->fill($request->validated());
if ($brand->saveOrFail()) {
return redirect()->route('bo.brands.edit', $brand)
return redirect()->route('bo.brands.edit', ['tenant' => \tenant(), 'brand' => $brand])
->with('success', __('crud.messages.has_been_created', [
'model' => Str::ucfirst(\trans_choice('models.classes.brand', 1))
]));
@ -105,9 +106,10 @@ class BrandController extends Controller
public function edit(Brand $brand): View
{
$brand->loadCount([
'ranges', 'products',
'ranges as published_ranges' => fn (Builder $q) => $q->where('ranges.status', true),
'products as published_products' => fn (Builder $q) => $q->where('products.status', true),
'ranges',
'products',
'ranges as published_ranges' => fn(Builder $q) => $q->where('ranges.status', true),
'products as published_products' => fn(Builder $q) => $q->where('products.status', true),
]);
return view('bo.pages.brands.edit', ['brandModel' => $brand]);
}
@ -125,12 +127,12 @@ class BrandController extends Controller
$brand->fill($request->validated());
if ($brand->saveOrFail()) {
return redirect()->route('bo.brands.edit', $brand)
return redirect()->route('bo.brands.edit', ['tenant' => \tenant(), 'brand' => $brand])
->with('success', __('crud.messages.has_been_updated', [
'model' => Str::ucfirst(\trans_choice('models.classes.brand', 1))
]));
}
return redirect()->route('bo.brands.edit', $brand)
return redirect()->route('bo.brands.edit', ['tenant' => \tenant(), 'brand' => $brand])
->with('error', __('crud.messages.cannot_be_updated', [
'model' => Str::ucfirst(\trans_choice('models.classes.brand', 1))
]));
@ -149,7 +151,7 @@ class BrandController extends Controller
$previousQueries = [];
\parse_str(\parse_url(url()->previous(), \PHP_URL_QUERY), $previousQueries);
if ($brand->deleteOrFail()) {
return redirect()->route('bo.brands.index', $previousQueries)
return redirect()->route('bo.brands.index', array_merge(['tenant' => \tenant()], $previousQueries))
->with('success', __('crud.messages.has_been_deleted', [
'model' => Str::ucfirst(\trans_choice('models.classes.brand', 1))
]));

View file

@ -5,7 +5,6 @@ namespace App\Http\Controllers\Bo;
use App\Http\Controllers\Controller;
use App\Jobs\ShowroomExport;
use Illuminate\Support\Facades\File;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ExportController extends Controller
{
@ -14,11 +13,17 @@ class ExportController extends Controller
*
* @return \Symfony\Component\HttpFoundation\StreamedResponse
*/
public function exportToZip(): StreamedResponse
public function exportToZip(): \Symfony\Component\HttpFoundation\StreamedResponse
{
$exportedFiles = File::allFiles(storage_path('app/export'));
\dispatch_sync(new ShowroomExport());
$exportedFile = collect(File::allFiles(storage_path('app/export')))->diff($exportedFiles)->first();
$exportFolder = storage_path('app/export');
if (!File::exists($exportFolder)) {
File::makeDirectory($exportFolder, 0700, true);
}
$exportedFiles = File::allFiles($exportFolder);
\dispatch_sync(new ShowroomExport(\tenant('id')));
$exportedFile = collect(File::allFiles($exportFolder))->diff($exportedFiles)->first();
if (!$exportedFile) {
response()->redirectToRoute('bo.dashboard')->withErrors([
'Export failed to catch the exported file'

View file

@ -40,7 +40,7 @@ class FontController extends Controller
$query,
$search,
null,
'name',
'font_name',
);
}
$searchFields = \implode(', ', [
@ -80,7 +80,7 @@ class FontController extends Controller
$font->fill($request->validated());
if ($font->saveOrFail()) {
return redirect()->route('bo.fonts.edit', $font)
return redirect()->route('bo.fonts.edit', ['tenant' => \tenant(), 'font' => $font])
->with('success', __('crud.messages.has_been_created', [
'model' => Str::ucfirst(\trans_choice('models.classes.font', 1))
]));
@ -120,12 +120,12 @@ class FontController extends Controller
$font->fill($request->validated());
if ($font->saveOrFail()) {
return redirect()->route('bo.fonts.edit', $font)
return redirect()->route('bo.fonts.edit', ['tenant' => \tenant(), 'font' => $font])
->with('success', __('crud.messages.has_been_updated', [
'model' => Str::ucfirst(\trans_choice('models.classes.font', 1))
]));
}
return redirect()->route('bo.fonts.edit', $font)
return redirect()->route('bo.fonts.edit', ['tenant' => \tenant(), 'font' => $font])
->with('error', __('crud.messages.cannot_be_updated', [
'model' => Str::ucfirst(\trans_choice('models.classes.font', 1))
]));
@ -141,7 +141,7 @@ class FontController extends Controller
public function destroy(Font $font): RedirectResponse
{
if ($font->deleteOrFail()) {
return redirect()->route('bo.fonts.index')
return redirect()->route('bo.fonts.index', ['tenant' => \tenant()])
->with('success', __('crud.messages.has_been_deleted', [
'model' => Str::ucfirst(\trans_choice('models.classes.font', 1))
]));

View file

@ -34,9 +34,8 @@ class ProductController extends Controller
{
/** @var \Illuminate\Database\Eloquent\Builder $query */
$query = Product::query()->with(['range', 'brand'])->withCount([
'range as parent_published' => fn (Builder $q) => $q->where('ranges.status', true),
'range as parent_shelf' => fn (Builder $q) => $q->where('ranges.shelf', true),
'brand as parent_parent_published' => fn (Builder $q) => $q->where('brands.status', true),
'range as parent_published' => fn(Builder $q) => $q->where('ranges.status', true),
'brand as parent_parent_published' => fn(Builder $q) => $q->where('brands.status', true),
]);
/** @var string $search Search field */
@ -57,7 +56,7 @@ class ProductController extends Controller
$this->sortQuery($query);
if ($request->input('range_id')) {
$query->whereHas('range', fn (Builder $q) => $q->where('id', $request->input('range_id')));
$query->whereHas('range', fn(Builder $q) => $q->where('id', $request->input('range_id')));
}
/** Custom pagination */
@ -96,7 +95,7 @@ class ProductController extends Controller
$product->fill($request->validated());
if ($product->saveOrFail()) {
return redirect()->route('bo.products.edit', $product)
return redirect()->route('bo.products.edit', ['tenant' => tenant(), 'product' => $product])
->with('success', __('crud.messages.has_been_created', [
'model' => Str::ucfirst(\trans_choice('models.classes.product', 1))
]));
@ -117,11 +116,12 @@ class ProductController extends Controller
public function edit(Product $product): View
{
$product->load([
'range', 'brand'
'range',
'brand'
]);
$product->loadCount([
'range as parent_published' => fn (Builder $q) => $q->where('ranges.status', true),
'brand as parent_parent_published' => fn (Builder $q) => $q->where('brands.status', true),
'range as parent_published' => fn(Builder $q) => $q->where('ranges.status', true),
'brand as parent_parent_published' => fn(Builder $q) => $q->where('brands.status', true),
]);
$rangeModelList = Range::all();
return view('bo.pages.products.edit', [
@ -143,12 +143,12 @@ class ProductController extends Controller
$product->fill($request->validated());
if ($product->saveOrFail()) {
return redirect()->route('bo.products.edit', $product)
return redirect()->route('bo.products.edit', ['tenant' => tenant(), 'product' => $product])
->with('success', __('crud.messages.has_been_updated', [
'model' => Str::ucfirst(\trans_choice('models.classes.product', 1))
]));
}
return redirect()->route('bo.products.edit', $product)
return redirect()->route('bo.products.edit', ['tenant' => tenant(), 'product' => $product])
->with('error', __('crud.messages.cannot_be_updated', [
'model' => Str::ucfirst(\trans_choice('models.classes.product', 1))
]));
@ -167,7 +167,7 @@ class ProductController extends Controller
/** @var array<string,string> */
$previousQueries = [];
\parse_str(\parse_url(url()->previous(), \PHP_URL_QUERY), $previousQueries);
return redirect()->route('bo.products.index', $previousQueries)
return redirect()->route('bo.products.index', array_merge(['tenant' => \tenant()], $previousQueries))
->with('success', __('crud.messages.has_been_deleted', [
'model' => Str::ucfirst(\trans_choice('models.classes.product', 1))
]));

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Bo;
use App\Enums\Width;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Ranges\StoreRangeRequest;
use App\Http\Requests\Bo\Ranges\UpdateRangeRequest;
@ -39,9 +40,8 @@ class RangeController extends Controller
/** @var \Illuminate\Database\Eloquent\Builder $query */
$query = Range::query()->with(['brand', 'font'])->withCount([
'products',
'products as published_products' => fn (Builder $q) => $q->where('products.status', true),
'brand as parent_shelf' => fn (Builder $q) => $q->where('shelf', true),
'brand as parent_published' => fn (Builder $q) => $q->where('status', true),
'products as published_products' => fn(Builder $q) => $q->where('products.status', true),
'brand as parent_published' => fn(Builder $q) => $q->where('status', true),
]);
/** @var string $search Search field */
@ -62,7 +62,7 @@ class RangeController extends Controller
$this->sortQuery($query);
if ($request->input('brand_id')) {
$query->whereHas('brand', fn (Builder $q) => $q->where('id', $request->input('brand_id')));
$query->whereHas('brand', fn(Builder $q) => $q->where('id', $request->input('brand_id')));
}
/** Custom pagination */
@ -88,7 +88,8 @@ class RangeController extends Controller
$fontModelList = Font::all();
return view('bo.pages.ranges.create', [
'brandModelList' => $brandModelList,
'fontModelList' => $fontModelList
'fontModelList' => $fontModelList,
'widths' => Width::toArray()
]);
}
@ -105,7 +106,7 @@ class RangeController extends Controller
$range->fill($request->validated());
if ($range->saveOrFail()) {
return redirect()->route('bo.ranges.edit', $range)
return redirect()->route('bo.ranges.edit', ['tenant' => tenant(), 'range' => $range])
->with('success', __('crud.messages.has_been_created', [
'model' => Str::ucfirst(\trans_choice('models.classes.range', 1))
]));
@ -127,15 +128,16 @@ class RangeController extends Controller
{
$range->loadCount([
'products',
'products as published_products' => fn (Builder $q) => $q->where('products.status', true),
'brand as parent_published' => fn (Builder $q) => $q->where('status', true),
'products as published_products' => fn(Builder $q) => $q->where('products.status', true),
'brand as parent_published' => fn(Builder $q) => $q->where('status', true),
]);
$brandModelList = Brand::all();
$fontModelList = Font::all();
return view('bo.pages.ranges.edit', [
'brandModelList' => $brandModelList,
'fontModelList' => $fontModelList,
'rangeModel' => $range
'rangeModel' => $range,
'widths' => Width::toArray()
]);
}
@ -152,12 +154,12 @@ class RangeController extends Controller
$range->fill($request->validated());
if ($range->saveOrFail()) {
return redirect()->route('bo.ranges.edit', $range)
return redirect()->route('bo.ranges.edit', ['tenant' => tenant(), 'range' => $range])
->with('success', __('crud.messages.has_been_updated', [
'model' => Str::ucfirst(\trans_choice('models.classes.range', 1))
]));
}
return redirect()->route('bo.ranges.edit', $range)
return redirect()->route('bo.ranges.edit', ['tenant' => tenant(), 'range' => $range])
->with('error', __('crud.messages.cannot_be_updated', [
'model' => Str::ucfirst(\trans_choice('models.classes.range', 1))
]));
@ -176,7 +178,7 @@ class RangeController extends Controller
$previousQueries = [];
\parse_str(\parse_url(url()->previous(), \PHP_URL_QUERY), $previousQueries);
if ($range->deleteOrFail()) {
return redirect()->route('bo.ranges.index', $previousQueries)
return redirect()->route('bo.ranges.index', array_merge(['tenant' => \tenant()], $previousQueries))
->with('success', __('crud.messages.has_been_deleted', [
'model' => Str::ucfirst(\trans_choice('models.classes.range', 1))
]));

View file

@ -46,12 +46,12 @@ class SettingsController extends Controller
$settings->fill($request->validated());
if ($settings->saveOrFail()) {
return redirect()->route('bo.settings.edit', $settings)
return redirect()->route('bo.settings.edit', ['tenant' => \tenant(), 'settings' => $settings])
->with('success', __('crud.messages.has_been_updated', [
'model' => Str::ucfirst(\trans('models.classes.settings'))
]));
}
return redirect()->route('bo.settings.edit', $settings)
return redirect()->route('bo.settings.edit', ['tenant' => \tenant(), 'settings' => $settings])
->with('error', __('crud.messages.cannot_be_updated', [
'model' => Str::ucfirst(\trans('models.classes.settings'))
]));

View file

@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Tenants\StoreTenantRequest;
use App\Models\Font;
use App\Models\Settings;
use App\Models\Tenant;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class TenantController extends Controller
{
/**
* Create the controller instance.
*/
public function __construct()
{
$this->authorizeResource(Tenant::class);
}
/**
* Display a listing of the resource.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Contracts\View\View
*/
public function index(Request $request): View
{
/** @var \Illuminate\Database\Eloquent\Builder $query */
$query = Tenant::query();
/** @var string $search Search field */
$search = $request->input('search');
if ($search) {
$this->searchQuery(
$query,
$search,
null,
'name',
);
}
$searchFields = \implode(', ', [
trans('validation.attributes.name'),
]);
/** Sort columns with a query */
$this->sortQuery($query);
/** Custom pagination */
$tenantModelList = $this->paginate($query);
return view('bo.pages.tenants.index', compact('tenantModelList', 'search', 'searchFields'));
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Contracts\View\View
*/
public function create(): \Illuminate\Contracts\View\View
{
return view('bo.pages.tenants.create');
}
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\Bo\Tenants\StoreTenantRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function store(StoreTenantRequest $request): RedirectResponse
{
return DB::transaction(function () use ($request) {
$tenant = new Tenant();
$tenant->fill($request->validated());
if ($tenant->saveOrFail()) {
$this->createTenantRelatedMandatoryModels($tenant);
return redirect()->route('bo.tenants.index')
->with('success', __('crud.messages.has_been_created', [
'model' => Str::ucfirst(\trans_choice('models.classes.tenant', 1))
]));
}
return redirect()->back()
->with('error', __('crud.messages.cannot_be_created', [
'model' => Str::ucfirst(\trans_choice('models.classes.tenant', 1))
]));
});
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\Tenant $tenant
* @return \Illuminate\Http\RedirectResponse
*/
public function destroy(Tenant $tenant): RedirectResponse
{
/** @var array<string,string> */
$previousQueries = [];
\parse_str(\parse_url(url()->previous(), \PHP_URL_QUERY), $previousQueries);
if ($tenant->deleteOrFail()) {
Storage::disk('public')->deleteDirectory("modelfiles/{$tenant->id}");
return redirect()->route('bo.tenants.index', $previousQueries)
->with('success', __('crud.messages.has_been_deleted', [
'model' => Str::ucfirst(\trans_choice('models.classes.tenant', 1))
]));
}
return redirect()->back()
->with('error', __('crud.messages.cannot_be_deleted', [
'model' => Str::ucfirst(\trans_choice('models.classes.tenant', 1))
]));
}
/**
* Create mandatory models related to a new tenant.
*
* @param \App\Models\Tenant $tenant
*
* @return void
*/
private function createTenantRelatedMandatoryModels(Tenant $tenant): void
{
Settings::createNewData($tenant->id);
$fontSerif = new Font(['font_name' => 'serif', 'tenant_id' => $tenant->id]);
$fontSansSerif = new Font(['font_name' => 'sans-serif', 'tenant_id' => $tenant->id]);
$fontSerif->saveOrFail();
$fontSansSerif->saveOrFail();
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Bo;
use App\Enums\Users\RoleEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Users\StoreUserRequest;
use App\Http\Requests\Bo\Users\UpdateUserRequest;
@ -32,7 +33,7 @@ class UserController extends Controller
public function index(Request $request): \Illuminate\Contracts\View\View
{
/** @var \Illuminate\Database\Eloquent\Builder $userModels */
$userModels = User::query();
$userModels = User::query()->with('tenants');
/** @var string $search Search field */
$search = $request->search;
@ -84,10 +85,12 @@ class UserController extends Controller
{
return DB::transaction(function () use ($request) {
$user = new User();
$user->fill($request->validated());
$user->fill($request->safe()->except(['tenants']));
if ($user->saveOrFail()) {
return redirect()->route('bo.users.edit', $user)
$user->tenants()->sync(collect($request->validated('tenants'))->pluck('id'));
return redirect()->route('bo.users.edit', ['user' => $user])
->with('success', __('crud.messages.has_been_created', [
'model' => Str::ucfirst(__('models.classes.user'))
]));
@ -120,15 +123,19 @@ class UserController extends Controller
public function update(UpdateUserRequest $request, User $user): \Illuminate\Http\RedirectResponse
{
return DB::transaction(function () use ($request, $user) {
$user->fill($request->validated());
$user->fill($request->safe()->except('tenants'));
if (RoleEnum::conceptor->equals(\auth('backend')->user()->role)) {
$user->tenants()->sync(collect($request->validated('tenants'))->pluck('id'));
}
if ($user->saveOrFail()) {
return redirect()->route('bo.users.edit', $user)
return redirect()->route('bo.users.edit', ['user' => $user])
->with('success', __('crud.messages.has_been_updated', [
'model' => Str::ucfirst(__('models.classes.user'))
]));
}
return redirect()->route('bo.users.edit', $user)
return redirect()->route('bo.users.edit', ['user' => $user])
->with('error', __('crud.messages.cannot_be_updated', [
'model' => Str::ucfirst(__('models.classes.user'))
]));

View file

@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use App\Enums\Pagination\ItemsPerPaginationEnum;
use App\Enums\Theme\BootstrapThemeEnum;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
@ -14,6 +13,7 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use App\Lib\Helpers\ToolboxHelper;
class Controller extends BaseController
{
@ -26,7 +26,7 @@ class Controller extends BaseController
*
* @param \Illuminate\Database\Eloquent\Builder $query The eloquent query builder.
* @param string $search The query string.
* @param callable $searchQuery The query string.
* @param callable|null $searchQuery The query string.
* @param string ...$fields Either a simple array or a string
* in case of a string the two fields
* would be concatenate to make the search.
@ -35,7 +35,7 @@ class Controller extends BaseController
protected function searchQuery(
Builder $query,
string $search,
callable $searchQuery = null,
callable|null $searchQuery = null,
string ...$fields
): \Illuminate\Database\Eloquent\Builder {
if (count($fields)) {
@ -200,7 +200,7 @@ class Controller extends BaseController
*/
protected function paginate(
Builder $query,
ItemsPerPaginationEnum $pagination = null
ItemsPerPaginationEnum|null $pagination = null
): \Illuminate\Contracts\Pagination\LengthAwarePaginator {
$currentRoutePath = Str::slug(request()->route()->getName());
$cacheKey = "pagination.{$currentRoutePath}";

View file

@ -38,7 +38,13 @@ class Kernel extends HttpKernel
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\Lang::class,
\App\Http\Middleware\SecureHeaders::class
\App\Http\Middleware\SecureHeaders::class,
],
'saml' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
],
'api' => [
@ -55,16 +61,17 @@ class Kernel extends HttpKernel
* @var array<string, class-string|string>
*/
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'htmlsanitize' => \App\Http\Middleware\HtmlSanitizer::class,
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'restrict_tenant_access' => \App\Http\Middleware\RestrictTenantAccess::class,
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'htmlsanitize' => \App\Http\Middleware\HtmlSanitizer::class,
];
}

View file

@ -2,9 +2,9 @@
namespace App\Http\Middleware;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Lib\Helpers\ToolboxHelper;
class HtmlSanitizer
{

View file

@ -21,7 +21,7 @@ class Lang
{
// phpcs:enable
$prefix = Route::current()->action['prefix'];
if ($prefix === '/bo') {
if (\in_array($prefix, ['/{tenant}/bo', '/bo'])) {
app()->setLocale(session()->get('lang', config('app.locale')));
return $next($request);
}

View file

@ -0,0 +1,73 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Users\RoleEnum;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RestrictTenantAccess
{
/** @var list<string> $routesWithoutTenant List of routes without tenant in the url. */
private $routesWithoutTenant = [
'bo.dashboard',
'bo.users.*',
'bo.tenants.*'
];
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @return \Symfony\Component\HttpFoundation\Response
* @phpcs:disable Squiz.Commenting.FunctionComment.IncorrectTypeHint
*/
public function handle(Request $request, Closure $next): Response
{
$user = \auth('backend')->user();
if ($user && Tenant::count()) {
$this->throwNotFoundIfTenantDoesntExist($request);
if ($request->routeIs($this->routesWithoutTenant)) {
$tenant = ($user->tenants->count()) ? $user->tenants->first() : Tenant::first()->getKey();
tenancy()->initialize($request->tenant ?? $tenant);
return $next($request);
}
if (
!\in_array($request->tenant, $user->tenants->pluck('id')->toArray()) &&
!(RoleEnum::conceptor->equals($user->role))
) {
abort(403);
}
if (RoleEnum::conceptor->equals($user->role)) {
tenancy()->initialize($request->tenant ?? Tenant::query()->first()->id);
return $next($request);
}
} //end if
return $next($request);
}
/**
* Throw NotFoundException if the tenant doesn't exist,
* instead of Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException.
*
* @param \Illuminate\Http\Request $request
*
* @return void
*/
private function throwNotFoundIfTenantDoesntExist(Request $request)
{
if (
!$request->routeIs($this->routesWithoutTenant) &&
$request->tenant &&
!Tenant::query()->find($request->tenant)
) {
abort(404);
}
}
}

View file

@ -6,6 +6,7 @@ use App\Models\Brand;
use App\Traits\Requests\HasPicture;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;
use App\Rules\Hex;
class StoreBrandRequest extends FormRequest
{
@ -30,7 +31,6 @@ class StoreBrandRequest extends FormRequest
{
$this->mergePicture('picture');
$this->merge([
'shelf' => $this->boolean('shelf'),
'status' => $this->boolean('status')
]);
}
@ -44,8 +44,7 @@ class StoreBrandRequest extends FormRequest
{
$rules = [
'name' => 'required|min:2|max:60',
'color' => 'required|color_hex',
'shelf' => 'present|boolean',
'color' => ['required', new Hex()],
'status' => 'present|boolean'
];
@ -65,7 +64,6 @@ class StoreBrandRequest extends FormRequest
return [
'color' => trans('validation.custom.color'),
'status' => \trans('validation.custom.status'),
'shelf' => \trans('validation.custom.shelf'),
];
}
}

View file

@ -27,11 +27,11 @@ class StoreProductRequest extends FormRequest
*
* @return void
*/
protected function prepareForValidation()
protected function prepareForValidation(): void
{
$this->merge([
'description' => (string)Str::of($this->input('description', '')),
'status' => $this->boolean('status')
'description' => (string) Str::of($this->input('description', '')),
'status' => $this->boolean('status')
]);
$this->mergePicture('picture');
}
@ -44,10 +44,10 @@ class StoreProductRequest extends FormRequest
public function rules(): array
{
$rules = [
'name' => 'required|min:2|max:130',
'name' => 'required|min:2|max:130',
'description' => 'present',
'status' => 'present|boolean',
'range_id' => 'required|exists:ranges,id'
'status' => 'present|boolean',
'range_id' => 'required|exists:ranges,id'
];
return \array_merge(
@ -64,8 +64,10 @@ class StoreProductRequest extends FormRequest
public function attributes(): array
{
return [
'range_id' => \trans_choice('models.classes.range', 1),
'status' => \trans('validation.custom.status'),
'name' => \trans('validation.attributes.name'),
'description' => \trans('validation.attributes.description'),
'status' => \trans('validation.custom.status'),
'range_id' => \trans_choice('models.classes.range', 1),
];
}
}

View file

@ -2,10 +2,13 @@
namespace App\Http\Requests\Bo\Ranges;
use App\Enums\Width;
use App\Models\Range;
use App\Traits\Requests\HasPicture;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;
use App\Rules\Hex;
use Illuminate\Validation\Rule;
class StoreRangeRequest extends FormRequest
{
@ -30,7 +33,6 @@ class StoreRangeRequest extends FormRequest
{
$this->mergePicture('picture');
$this->merge([
'shelf' => $this->boolean('shelf'),
'status' => $this->boolean('status')
]);
}
@ -42,19 +44,27 @@ class StoreRangeRequest extends FormRequest
*/
public function rules(): array
{
$rules = [
'name' => 'required|min:2|max:90',
'brand_id' => 'required|exists:brands,id',
'font_id' => 'required|exists:fonts,id',
'color' => 'required|color_hex',
'width' => 'required|numeric|min:1|max:2',
'shelf' => 'present|boolean',
'color' => ['required', new Hex()],
'width' => ['required', Rule::enum(Width::class)],
'status' => 'present|boolean',
];
$widthEnum = Width::make($this->input('width'));
return \array_merge(
$rules,
$this->pictureRules('picture', 600, 500, 1300, 500, false)
$this->pictureRules(
'picture',
$widthEnum->dimValidation()['width'],
$widthEnum->dimValidation()['height'],
$widthEnum->dimValidation()['width'],
$widthEnum->dimValidation()['height'],
false
)
);
}
@ -71,7 +81,6 @@ class StoreRangeRequest extends FormRequest
'color' => \trans('validation.custom.color'),
'width' => \trans('validation.custom.width'),
'status' => \trans('validation.custom.status'),
'shelf' => \trans('validation.custom.shelf'),
];
}
}

View file

@ -5,6 +5,7 @@ namespace App\Http\Requests\Bo\Settings;
use App\Traits\Requests\HasPicture;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;
use App\Rules\Hex;
class UpdateSettingsRequest extends FormRequest
{
@ -25,7 +26,7 @@ class UpdateSettingsRequest extends FormRequest
*
* @return void
*/
protected function prepareForValidation()
protected function prepareForValidation(): void
{
$this->mergePicture('logo');
}
@ -37,11 +38,15 @@ class UpdateSettingsRequest extends FormRequest
*/
public function rules(): array
{
$rules = [
'sub_title' => 'nullable|min:2|max:110',
'footer_phrase' => 'required|min:2|max:110',
'color' => 'required|color_hex',
'home_timeout' => 'required|numeric|min:20|max:900',
$numberOfLocales = count(config('app.locales'));
$rules = [
'locales' => "required|array|between:1,$numberOfLocales",
'sub_title' => "required|array|between:0,$numberOfLocales",
'sub_title.*' => 'nullable|min:2|max:110',
'footer_phrase' => "required|array|between:1,$numberOfLocales",
'footer_phrase.*' => 'required|min:2|max:110',
'color' => ['required', new Hex()],
'home_timeout' => 'required|numeric|min:20|max:900',
];
return \array_merge(
@ -58,11 +63,12 @@ class UpdateSettingsRequest extends FormRequest
public function attributes(): array
{
return [
'color' => \trans('validation.custom.color'),
'logo' => \trans('validation.custom.logo'),
'home_timeout' => \trans('validation.custom.home_timeout'),
'sub_title' => \trans('validation.custom.sub_title'),
'footer_phrase' => \trans('validation.custom.footer_phrase'),
'locales' => \trans('validation.custom.locales'),
'color' => \trans('validation.custom.color'),
'logo' => \trans('validation.custom.logo'),
'home_timeout' => \trans('validation.custom.home_timeout'),
'sub_title.*' => \trans('validation.custom.sub_title'),
'footer_phrase.*' => \trans('validation.custom.footer_phrase'),
];
}
}

View file

@ -26,7 +26,7 @@ class UpdateStaticPageRequest extends FormRequest
public function prepareForValidation(): void
{
$this->merge([
'content' => (string)Str::of($this->input('content'))
'content' => (string) Str::of($this->input('content'))
]);
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Bo\Tenants;
use App\Models\Tenant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;
class StoreTenantRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize(): bool
{
return Gate::allows('create', Tenant::class);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'name' => 'required|min:2|max:255',
];
}
}

View file

@ -3,9 +3,9 @@
namespace App\Http\Requests\Bo;
use App\Enums\InputType;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
use App\Lib\Helpers\ToolboxHelper;
abstract class UpdateMiniCrudRequest extends FormRequest
{
@ -99,7 +99,7 @@ abstract class UpdateMiniCrudRequest extends FormRequest
}) : $input['dimensions'];
// * Adapt dimensions
$sizes = (object)[
$sizes = (object) [
'minHeight' => $dim['height'],
'maxHeight' => $dim['height'],
'minWith' => $dim['width'],
@ -133,7 +133,8 @@ abstract class UpdateMiniCrudRequest extends FormRequest
case InputType::url->value:
$rules = array_merge($rules, [
"data.$iptName.value" => [
'required', 'url',
'required',
'url',
// @phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed
function ($attribute, $value, $fail) {
try {
@ -208,12 +209,22 @@ abstract class UpdateMiniCrudRequest extends FormRequest
{
$rule = \collect(\explode(':', $rule, 1))->filter()->first(null, '');
return \collect([
'nullable', 'present', 'required', 'required_if',
'required_unless', 'required_with', 'required_with_all',
'required_without', 'required_without_all', 'required_with_all',
'missing', 'missing_if', 'missing_unless',
'missing_with', 'missing_with_all',
])->first(fn (string $firstRule) => \strpos($rule, $firstRule) === 0) ? true : false;
'nullable',
'present',
'required',
'required_if',
'required_unless',
'required_with',
'required_with_all',
'required_without',
'required_without_all',
'required_with_all',
'missing',
'missing_if',
'missing_unless',
'missing_with',
'missing_with_all',
])->first(fn(string $firstRule) => \strpos($rule, $firstRule) === 0) ? true : false;
}
/**

View file

@ -7,7 +7,6 @@ use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest
{
@ -41,19 +40,13 @@ class StoreUserRequest extends FormRequest
public function rules(): array
{
return [
'first_name' => 'required|string|min:3|max:255',
'last_name' => 'required|string|min:3|max:255',
'email' => 'required|email:rfc,strict,dns,spoof,filter|unique:users,email',
'password' => [
'required', 'required_with:password_confirmation', 'confirmed', 'max:255', Password::min(8)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised()
],
'role' => ['required', new Enum(RoleEnum::class)],
'published' => 'required|boolean',
'first_name' => 'required|string|min:3|max:255',
'last_name' => 'required|string|min:3|max:255',
'email' => 'required|email:rfc,strict,dns,spoof,filter|unique:users,email',
'role' => ['required', new Enum(RoleEnum::class)],
'published' => 'required|boolean',
'tenants' => 'nullable|array',
'tenants.*.id' => 'required|exists:tenants,id',
];
}
@ -68,7 +61,6 @@ class StoreUserRequest extends FormRequest
'first_name' => trans('validation.attributes.first_name'),
'last_name' => trans('validation.attributes.last_name'),
'email' => trans('validation.attributes.email'),
'password' => trans('validation.attributes.password'),
'role' => trans('validation.attributes.role'),
'published' => trans('validation.custom.publication')
];

View file

@ -4,7 +4,6 @@ namespace App\Http\Requests\Bo\Users;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class UpdateUserRequest extends StoreUserRequest
{
@ -26,11 +25,7 @@ class UpdateUserRequest extends StoreUserRequest
protected function prepareForValidation(): void
{
$this->merge([
'published' => $this->boolean('published'),
'password' => $this->input('password') ??
\optional($this->route('user'))->password,
'password_confirmation' => $this->input('password_confirmation') ??
\optional($this->route('user'))->password
'published' => $this->boolean('published'),
]);
}
@ -45,16 +40,9 @@ class UpdateUserRequest extends StoreUserRequest
'email' => [
'required',
'email:rfc,strict,dns,spoof,filter',
Rule::unique('users', 'email')->ignore(\optional($this->route('user'))->getRouteKey())
],
'password' => [
'required', 'required_with:password_confirmation', 'confirmed', 'max:255', Password::min(8)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised()
],
Rule::unique('users', 'email')
->ignore(\optional($this->route('user'))->getRouteKey())
]
]);
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Jobs\Mails;
use App\Mail\TenantDeleted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Mail;
class SendTenantDeletedMail implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
/**
* Name of the deleted tenant.
*
* @var string
* */
private $name;
/**
* Create a new job instance.
*
* @param string $name
* @return void
*/
public function __construct(string $name)
{
$this->name = $name;
}
/**
* Execute the job.
*
* @return void
*/
public function handle(): void
{
Mail::to(config('mail.to.address'))->send(new TenantDeleted($this->name));
}
}

View file

@ -2,12 +2,13 @@
namespace App\Jobs;
use App\Lib\Helpers\ToolboxHelper;
use App\Models\Brand;
use App\Models\Font;
use App\Models\Product;
use App\Models\Range;
use App\Models\Settings;
use App\Models\Tenant;
use Carsdotcom\JsonSchemaValidation\SchemaValidator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Contracts\Queue\ShouldBeUnique;
@ -22,7 +23,7 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use sixlive\Laravel\JsonSchemaAssertions\SchemaAssertion;
use RuntimeException;
class ShowroomExport implements ShouldQueue, ShouldBeUnique
{
@ -45,6 +46,13 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
*/
private string $tempFolder = 'app/temp/export';
/**
* Working dir
*
* @var string
*/
private string $tenantId = '';
/**
* The number of seconds after which the job's unique lock will be released.
*
@ -54,40 +62,33 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
/**
* Create a new job instance.
*
* @param string $tenantId
*
* @throws RuntimeException If there's no Tenant for the given id.
*/
public function __construct()
public function __construct(string $tenantId)
{
if (!Tenant::query()->where('id', $tenantId)->first()) {
throw new RuntimeException(\sprintf('No Tenant found for id : %s', $tenantId));
}
$this->exportFolder = \storage_path($this->exportFolder);
$this->tempFolder = \storage_path($this->tempFolder);
}
/**
* Get the unique ID for the job.
*
* @return string
*/
public function uniqueId(): string
{
return "9999999";
$this->tenantId = $tenantId;
}
/**
* Execute the job.
*
* @return void
* @throws \RuntimeException If a schema could not be found or data could not be validated against schema.
* Or Time stamp on file could not be fetched.
* @throws \Illuminate\Contracts\Filesystem\FileNotFoundException If a schema file could not be found.
*/
public function handle(): void
{
$data = $this->getData();
$associatedFileList = $this->getAssociatedFileList($data);
$fileContent = $this->getFile($data);
$schemaContent = ToolboxHelper::getJsonSchema('app.schema.json');
if ($schemaContent === false) {
throw new \RuntimeException("The schema could not be found 'app'");
}
$this->assertSchemaIsValid($schemaContent, json_encode($fileContent));
$this->assertSchemaIsValid('app.schema.json', json_encode($fileContent));
$filesToZip = $this->preparePicturesForZip($associatedFileList);
$dataFilePath = "{$this->tempFolder}/data.json";
@ -131,25 +132,36 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
*/
protected function makeZipFile(array $filesToZip, string $dataFilePath): string
{
File::ensureDirectoryExists($this->exportFolder);
$zip = new \ZipArchive();
$zipFileName = Str::of('showroom-bo-export')->append('_')
->append(now()->isoFormat('DD-MM-YYYY_HH-mm-ss'))->append('.zip');
$zipFilePath = Str::of($this->exportFolder)->append("/{$zipFileName}");
if ($zip->open($zipFilePath, \ZipArchive::CREATE) !== true) {
$zipFileName = 'showroom-bo-export_' . now()->isoFormat('DD-MM-YYYY_HH-mm-ss') . '.zip';
$zipFilePath = "{$this->exportFolder}/{$zipFileName}";
if ($zip->open($zipFilePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException("Failed to open zip file {$zipFilePath}");
}
foreach ($filesToZip as $file) {
if (! is_file($file)) {
throw new \RuntimeException("File to zip does not exist: {$file}");
}
$fileToAdd = Str::of($file)->replace($this->tempFolder, '')
->start('/')->explode('/')->filter()->implode('/');
if (!$zip->addFile($file, $fileToAdd)) {
if (! $zip->addFile($file, $fileToAdd)) {
throw new \RuntimeException("Failed to add file {$file} into zip");
}
}
if (!$zip->addFile($dataFilePath, \pathinfo($dataFilePath, \PATHINFO_BASENAME))) {
if (! is_file($dataFilePath) || ! $zip->addFile($dataFilePath, basename($dataFilePath))) {
throw new \RuntimeException("Failed to add file {$dataFilePath} into zip");
}
$zip->close();
if (! $zip->close()) {
throw new \RuntimeException(
"Failed to write zip {$zipFilePath}: " . $zip->getStatusString()
);
}
return $zipFilePath;
}
@ -157,33 +169,38 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
/**
* Assert schema is valid
*
* @param array $schema
* @param string $schemaFileName
* @param string $fileContent
* @return void
* @throws \PHPUnit\Framework\AssertionFailedError I case of failure.
* @throws \Carsdotcom\JsonSchemaValidation\Exceptions\JsonSchemaValidationException In case of failure.
*/
protected function assertSchemaIsValid(array $schema, string $fileContent): void
protected function assertSchemaIsValid(string $schemaFileName, string $fileContent): void
{
$assertion = new SchemaAssertion($schema);
$assertion->assert($fileContent);
SchemaValidator::validateEncodedStringOrThrow($fileContent, $schemaFileName);
}
/**
* Get associated files list to export
*
* @param array<string,array<int,\App\Models\Brand>|array<int,\App\Models\Font>> $data
* @return array<'settings'|'brands'|'fonts'|'ranges'|'products',string|array<string,string>>
* @return array{
* settings: string,
* brands: array<int, string>,
* fonts: array<int, string>,
* ranges: array<int, string>,
* products: array<int, string>
* }
*/
protected function getAssociatedFileList(array $data): array
{
$groupBy = fn (array $path) => Str::of(Arr::get($path, 0))->explode('/')->get(2);
$items = collect(Arr::get($data, 'brands'))->map(fn (Brand $brand) => [
$groupBy = fn(array $path) => Str::of(Arr::get($path, 0))->explode('/')->get(2);
$items = collect(Arr::get($data, 'brands'))->map(fn(Brand $brand) => [
$brand->picture,
// @phpstan-ignore-next-line
$brand->ranges->map(fn (Range $range) => [
$brand->ranges->map(fn(Range $range) => [
$range->picture,
// @phpstan-ignore-next-line
$range->products->map(fn (Product $product) => [
$range->products->map(fn(Product $product) => [
$product->picture
])->collect()->groupBy($groupBy)
])->collect()->groupBy($groupBy)
@ -196,16 +213,16 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
/** @var \Illuminate\Support\Collection<int, string> $brandList */
$brandList = $items->pluck(0);
/** @var \Illuminate\Support\Collection<int, string> $rangeList */
$rangeList = $items->pluck(1)->map(fn (Collection $ranges) => $ranges
->map(fn (Collection $range) => $range->pluck(0)))->flatten();
$rangeList = $items->pluck(1)->map(fn(Collection $ranges) => $ranges
->map(fn(Collection $range) => $range->pluck(0)))->flatten();
/** @var \Illuminate\Support\Collection<int, string> $productList */
$productList = $items->pluck(1)->map(fn (Collection $ranges) => $ranges
->map(fn (Collection $range) => $range->pluck(1)))->flatten();
$productList = $items->pluck(1)->map(fn(Collection $ranges) => $ranges
->map(fn(Collection $range) => $range->pluck(1)))->flatten();
return [
'settings' => $logo,
'brands' => $brandList->all(),
'fonts' => $fonts,
'ranges' => $rangeList->all(),
'brands' => $brandList->all(),
'fonts' => $fonts,
'ranges' => $rangeList->all(),
'products' => $productList->all(),
];
}
@ -237,14 +254,19 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
}),
'items' => Arr::map(Arr::get($data, 'brands'), function (Brand $brand) {
$this->setPictureExportPath($brand);
$brand->setRelation('ranges', $brand->ranges->map(function (Range $range) {
$this->setPictureExportPath($range);
$range->setRelation('products', $range->products->map(function (Product $product) {
$this->setPictureExportPath($product);
return $product;
}));
return $range;
}));
$brand->setRelation(
'ranges',
// @phpstan-ignore argument.type
$brand->ranges->map(function (Range $range) {
$this->setPictureExportPath($range);
// @phpstan-ignore argument.type
$range->setRelation('products', $range->products->map(function (Product $product) {
$this->setPictureExportPath($product);
return $product;
}));
return $range;
})
);
return $brand;
})
];
@ -255,7 +277,8 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
/**
* Get the files that should be zipped
*
* @param array<'brands'|'fonts'|'products'|'ranges'|'settings',array<string,string>|string> $filesToZip
* @phpcs:ignore
* @param array{settings: string,brands: array<int, string>,fonts: array<string>,ranges: array<int, string>,products: array<int, string>} $filesToZip
* @return array<string>
* @throws \RuntimeException If temp folder caanot be cleared in order to work.
*/
@ -270,7 +293,7 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
throw new \RuntimeException("{$this->tempFolder} is not empty, refuse to prepare export");
}
$copyPath = fn (string $targetPath) => function (string $path) use ($targetPath): string {
$copyPath = fn(string $targetPath) => function (string $path) use ($targetPath): string {
$targetFilePath = Str::of($targetPath)->append('/')->append(\pathinfo($path, \PATHINFO_BASENAME));
File::copy(\public_path($path), $targetFilePath);
return $targetFilePath;
@ -285,7 +308,7 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
$targetPath = Str::of($this->tempFolder)->append("/fonts");
File::makeDirectory($targetPath, 0700, true);
$newPaths['fonts'] = collect($filesToZip['fonts'])->map(function ($path) use ($targetPath) {
$targetFilePath = (string)$targetPath->append('/')->append(\pathinfo($path, \PATHINFO_BASENAME));
$targetFilePath = (string) $targetPath->append('/')->append(\pathinfo($path, \PATHINFO_BASENAME));
File::copy(\public_path($path), $targetFilePath);
return $targetFilePath;
})->all();
@ -324,7 +347,7 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
*/
private function getSettings(): Settings
{
return Settings::getCached()->first();
return Settings::getFromCache($this->tenantId);
}
/**
@ -335,16 +358,22 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
private function getData(): array
{
return [
'brands' => Brand::query()->where('status', true)
'brands' => Brand::query()
->where([['status', true], ['tenant_id', $this->tenantId]])
->whereHas('ranges')
->with('ranges', function (Builder $q) {
$q->where('status', true)
return $q->where('status', true)
->whereHas('products')
->with('products', function (Builder $q) {
$q->where('status', true);
return $q->where('status', true);
});
})->get()->all(),
'fonts' => Font::get()->all()
})
->get()
->all(),
'fonts' => Font::query()
->where('tenant_id', $this->tenantId)
->get()
->all()
];
}
}

View file

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

View file

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

View file

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

View file

@ -2,67 +2,23 @@
namespace App\Lib\Helpers;
use Carbon\Carbon;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Finder\SplFileInfo;
use Uwil\LaravelCommon\Helpers\ToolboxHelper as CommonToolboxHelper;
class ToolboxHelper
class ToolboxHelper extends CommonToolboxHelper
{
/**
* Get the contrast for any color
*
* @param string $hexcolor An HTML Hex color.
* @return string "#000000" or "#FFFFFF".
*/
public static function getContrastYiq(string $hexcolor): string
{
$r = intval(\substr($hexcolor, 1, 2), 16);
$g = intval(\substr($hexcolor, 3, 2), 16);
$b = intval(\substr($hexcolor, 5, 2), 16);
$yiq = (($r * 299) + ($g * 587) + ($b * 114)) / 1000;
return ($yiq >= 128) ? "#000000" : "#FFFFFF";
}
/**
* Convert HTML Hex color to RGBA
*
* @param string $hexcolor An HTML Hex color.
* @param integer $alpha The transparency to apply.
* @return string RGBA string color.
*/
public static function toRgba(string $hexcolor, int $alpha = 1): string
{
$r = intval(\substr($hexcolor, 1, 3), 16);
$g = intval(\substr($hexcolor, 3, 5), 16);
$b = intval(\substr($hexcolor, 5, 7), 16);
return "rgba({$r}, {$g}, {$b}, {$alpha})";
}
/**
* Get a specific json schema
*
* @depends `sixlive/laravel-json-schema-assertions`
*
* @param string $schema
* @return array|false Returns false if the schema cannot be found.
* @throws \RuntimeException If the schema does not exists.
*/
public static function getJsonSchema(string $schema): array|false
{
$schemaPath = Str::of(config('json-schema-assertions.schema_base_path'))->finish("/{$schema}");
$schemaPath = Str::of(config('json-schema.local_base_prefix'))->finish("/{$schema}");
if (!File::exists($schemaPath)) {
return false;
}
@ -71,557 +27,11 @@ class ToolboxHelper
throw new \RuntimeException("{$schemaPath} is not a json schema .");
}
$schemaContent = json_decode($schemaContent, true);
Arr::set($schemaContent, '$id', route('fo.schema', ['schema' => $schema]));
$schemaRoute = route('fo.schema', ['schema' => $schema]);
if (app()->environment('local')) {
$schemaRoute = Str::replace('https', 'http', $schemaRoute);
}
Arr::set($schemaContent, '$id', $schemaRoute);
return $schemaContent;
}
/**
* Run a terminal command and gives the output
*
* @param array|string $command The command to execute on terminal.
* @param boolean $throw Wether to throw en esception on failure or not.
* @return string|false Return false on failure only if $throw is false.
* @phpcs:disable Squiz.Commenting.FunctionComment.EmptyThrows
* @phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
* @throws \Illuminate\Process\Exceptions\ProcessTimedOutException
* @throws \Symfony\Component\Process\Exception\LogicException
* @throws \Symfony\Component\Process\Exception\InvalidArgumentException
* @throws \Symfony\Component\Process\Exception\RuntimeException
* @throws \LogicException
* @throws \RuntimeException If the command fails.
*/
public static function runConsoleCommand(array|string $command, bool $throw = true): string | false
{
// phpcs:enable
$process = \Illuminate\Support\Facades\Process::command($command)->run();
if ($process->failed()) {
$code = $process->exitCode();
$ouput = $process->errorOutput();
if ($throw) {
throw new \RuntimeException("Failed to run command exit code {$code} with output \n{$ouput}");
}
return false;
}
return $process->output();
}
/**
* @param array<int|string, mixed> $array1
* @param array<int|string, mixed> $array2
*
* @return array<int|string, mixed>
*/
public static function arrayMergeRecursiveDistinct(array &$array1, array &$array2): array
{
$merged = $array1;
foreach ($array2 as $key => &$value) {
if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
$merged[$key] = self::arrayMergeRecursiveDistinct($merged[$key], $value);
} else {
$merged[$key] = $value;
}
}
return $merged;
}
/**
* Validate recaptcha
*
* @param \Illuminate\Http\Request $request
* @param boolean $isV3
* @return void
*/
public static function validateRecaptcha(Request $request, bool $isV3 = true)
{
Validator::validate($request->all(), [
'g-recaptcha-response' => [
'required', 'string',
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
function ($attribute, $value, $fail) use ($isV3) {
try {
$response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [
'secret' => config('services.google.recaptcha.private'),
'response' => \strval($value),
'remoteip' => app('clientIp')
]);
if ($e = $response->toException()) {
throw $e;
}
if ($isV3) {
if (
!$response->json('success') or
!$response->json('challenge_ts') or
Carbon::parse($response->json('challenge_ts'))->lessThan(\now('utc')->subMinutes(5)) or
$response->json('hostname') !== \parse_url(config('app.url'), \PHP_URL_HOST) or
!$response->json('score')
) {
throw new \RuntimeException();
}
if (\floatval($response->json('score')) < 0.8) {
$fail(trans('recaptcha.not_enough_score'));
}
return;
}
if (
!$response->json('success') or
!$response->json('challenge_ts') or
Carbon::parse($response->json('challenge_ts'))->lessThan(\now('utc')->subMinutes(5)) or
$response->json('hostname') !== \parse_url(config('app.url'), \PHP_URL_HOST)
) {
throw new \RuntimeException();
}
} catch (\Exception $e) {
$fail(trans('recaptcha.error'));
} //end try
}
]
], [], ['g-recaptcha-response' => 'reCaptcha']);
}
/**
* Strip scheme (https) on oembed urls
* Only embed allowed are for MP4 self hosted videos right now
*
* https is force by ckeditor MediaElements which natively supports
* only cloud hosted medias (youtube, spotify etc..)
*
* @param string $input
* @return string
*/
public static function stripCkEditorSchemesOnMediaInputs(string $input): string
{
$input = \preg_replace('/(?<=<oembed url=")(https:\/\/)(?=.*?">)/m', '', $input);
return $input;
}
/**
* Assert That no Back Office url is present on txt
*
* @param string $field
* @param string $text
* @return string
* @throws \Illuminate\Validation\ValidationException If url is a bo url.
*/
public static function assertNoInternalBoUrls(string $field, string $text): string
{
$routes = Route::getRoutes();
// * Url replace and email replace.
$text = preg_replace_callback(
// phpcs:ignore Generic.Files.LineLength.TooLong
'/(?P<url>(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/',
function ($matches) use ($routes, $field) {
$match = collect($matches)->first() ?? '';
$url = $match;
try {
$request = Request::create($url);
// If a route is match then it do not throws exception.
if (
\parse_url(config('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST) &&
trim($routes->match($request)->getPrefix() ?? '', '/') === 'bo'
) {
// Removes the matched url.
throw ValidationException::withMessages([
$field => trans(
'crud.libs.toolbox.assertNoInternalBoUrls',
['attribute' => $field]
)
]);
}
return $match;
} catch (NotFoundHttpException $e) {
return $match;
}
},
$text
);
return $text;
}
/**
* Assert That only external url is present on txt
*
* @param string $field
* @param string $text
* @return string
* @throws \Illuminate\Validation\ValidationException If url is a bo url.
*/
public static function assertNoInternalUrls(string $field, string $text): string
{
self::assertNoInternalBoUrls($field, $text);
$routes = Route::getRoutes();
// * Url replace and email replace.
$text = preg_replace_callback(
// phpcs:ignore Generic.Files.LineLength.TooLong
'/(?P<url>(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/',
function ($matches) use ($routes, $field) {
$match = collect($matches)->first() ?? '';
$url = $match;
try {
$request = Request::create($url);
// Assert route matches existing routes.
$routes->match($request);
// ? Is url internal ?
if (\parse_url(config('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST)) {
// Removes the matched url.
throw ValidationException::withMessages([
$field => trans(
'crud.libs.toolbox.assertNoInternalUrls',
['attribute' => $field, 'appname' => config('app.name')]
)
]);
}
return $match;
} catch (NotFoundHttpException $e) {
return $match;
}
},
$text
);
return $text;
}
/**
* Mutltibyte string replace
*
* @param array|string $search
* @param array|string $replace
* @param array|string $subject
* @param integer $count
* @return array|string|false
*/
public static function mbReplace(
array|string $search,
array|string $replace,
array|string $subject,
int &$count = 0
): array|string|false {
if (!is_array($search) && is_array($replace)) {
return false;
}
if (is_array($subject)) {
// Call mb_replace for each single string in $subject .
foreach ($subject as &$string) {
$string = &self::mbReplace($search, $replace, $string, $count);
}
} elseif (is_array($search)) {
if (!is_array($replace)) {
foreach ($search as &$string) {
$subject = self::mbReplace($string, $replace, $subject, $count);
}
} else {
$n = max(count($search), count($replace));
while ($n--) {
$subject = self::mbReplace(current($search), current($replace), $subject, $count);
next($search);
next($replace);
}
}
} else {
$parts = mb_split(preg_quote($search), $subject);
$count = count($parts) - 1;
$subject = implode($replace, $parts);
} //end if
return $subject;
}
/**
* Asserts that the field is unique
*
* @param string $table
* @param string $field
* @param string|integer|float|boolean|null|array $value
* @param integer|null $id
* @param callable|null $query
* @return void
* @throws \Illuminate\Validation\ValidationException If the field is not unique.
*/
public static function assertFieldIsUnique(
string $table,
string $field,
string|int|float|bool|null|array $value,
?int $id = null,
?callable $query = null
) {
if (!$query) {
Validator::make([$field => $value], [
self::mbReplace('.', '\.', $field) => Rule::unique($table, $field)->ignore($id),
], [
self::mbReplace('.', '\.', "{$field}.unique")
=> trans('crud.libs.toolbox.assertFieldIsUnique', ['attribute' => $field, 'value' => $value])
])->validate();
return;
}
Validator::make([$field => $value], [
$field => Rule::unique($table)->where($query)->ignore($id),
], [
$field . '.unique' => trans(
'crud.libs.toolbox.assertFieldIsUnique',
['attribute' => $field, 'value' => $value]
)
])->validate();
}
/**
* Get original polyfill with autotranslate behavior
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $attribute
* @return mixed
*/
public static function getOriginal(Model $model, string $attribute): mixed
{
if (
\in_array('Spatie\Translatable\HasTranslations', \class_uses_recursive($model)) and
// @phpstan-ignore-next-line
$model->isTranslatableAttribute($attribute)
) {
// @phpstan-ignore-next-line
return $model->getTranslation(
$attribute,
// @phpstan-ignore-next-line
$model->getLocale(),
// @phpstan-ignore-next-line
$model->useFallbackLocale()
);
}
return $model->getOriginal($attribute);
}
/**
* Dynamic model query
*
* @param string $defaultLocale
* @param \Illuminate\Contracts\Database\Eloquent\Builder $q
* @param string $column
* @param string $value
* @return \Illuminate\Contracts\Database\Eloquent\Builder
*/
public static function queryColumnWithLocales(
string $defaultLocale,
Builder $q,
string $column,
string $value
): Builder {
$currentLocale = app()->currentLocale();
$locales = collect(config('app.locales'))
->mapWithKeys(fn (string $locale) => [$locale => $locale]);
if (!$locales->has($defaultLocale)) {
// * Remove default locale.
$locales->pull($defaultLocale);
}
if (!$locales->has($currentLocale)) {
// * Remove current locale.
$locales->pull($currentLocale);
}
return $q->where(function (Builder $q) use ($defaultLocale, $currentLocale, $locales, $column, $value) {
// * Query default locale
$q->whereRaw("JSON_EXTRACT({$column}, '$.{$defaultLocale}') = '{$value}'");
if ($defaultLocale !== $currentLocale) {
// * Query current locale
$q->whereRaw("JSON_EXTRACT({$column}, '$.{$currentLocale}') = '{$value}'");
}
// * Query any other locale
$locales->each(fn ($locale) => $q->orWhereRaw(
"JSON_EXTRACT({$column}, '$.{$locale}') = '{$value}'"
));
});
}
/**
* Generates an array from a dot separated string
*
* @param string $path
* @param mixed $value
* @param string $separator
* @return array
*/
public static function dotStringToNestedArray(string $path, mixed $value, string $separator = '.'): array
{
$arr = $value;
$keys = array_reverse(explode($separator, $path));
foreach ($keys as $key) {
$arr = [$key => $arr];
}
return $arr;
}
/**
* Convert deeply an object to array
*
* @param mixed $input
* @return array
* @throws \RuntimeException If $input var is a scalar value.
*/
public static function objectToArray(mixed $input): array
{
// Only process if it's an object or array being passed to the function.
if (!(is_object($input) or is_array($input))) {
throw new \RuntimeException('Cannot handle $input of type ' . \gettype($input));
}
$ret = (array)$input;
foreach ($ret as &$item) {
// Recursively process EACH element only if not scalar.
$item = (is_object($item) or is_array($item)) ?
self::objectToArray($item) : $item;
}
return $ret;
}
/**
* Convert deeply an array to object
*
* @param mixed $input
* @return object
* @throws \RuntimeException If $input var is a scalar value.
*/
public static function arrayToObject(mixed $input): object
{
// Only process if it's an object or array being passed to the function.
if (!(is_object($input) or is_array($input))) {
throw new \RuntimeException('Cannot handle $input of type ' . \gettype($input));
}
$ret = (object)$input;
foreach ($ret as &$item) {
// Recursively process EACH element only if not scalar.
$item = (is_object($item) or is_array($item)) ?
self::arrayToObject($item) : $item;
}
return $ret;
}
/**
* 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)) and !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'
])
) {
$fail(trans(':attribute doit être une image de type \'image/jpeg,image/jpg,image/png,image/gif\'', [
'attribute' => $attribute
]));
}
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->minWidth}px et {$sizes->maxWidth}px"
);
if ($sizes->minHeight === $sizes->maxHeight) {
$yMessage = trans(":attribute la hauteur doit être de {$sizes->minHeight}px");
}
if (($width < $sizes->minWidth) or ($width > $sizes->maxWidth)) {
$fail($xMessage);
}
if (($height < $sizes->minHeight) or ($height > $sizes->maxHeight)) {
$fail($yMessage);
}
} //end if
}
/**
* Get the file full path
*
* @param string $value
* @param boolean $useStorage
* @return string
*/
private static function getUploadedFileFullPath(string $value, bool $useStorage): string
{
/** @var \Illuminate\Filesystem\FilesystemAdapter */
$disk = Storage::disk();
return !$useStorage ? (realpath($value) ?: $value) : $disk->path($value);
}
/**
* Get the file mimetype
*
* @param string $value
* @param boolean $useStorage
* @return string|false
*/
private static function getUploadedFileMimeType(string $value, bool $useStorage)
{
return !$useStorage ? File::mimeType($value) : Storage::mimeType($value);
}
/**
* Fetch class list from a specific namespace
*
* @param string $namespace
* @return array<string> An array of classe namespaces
*/
public static function getClassesUsingNamespace(string $namespace): array
{
return collect(File::files(\app_path(Str::of($namespace)->replaceFirst('\App', ''))))
->filter(fn (SplFileInfo $splFileInfo) => $splFileInfo->getExtension() === 'php')
->map(fn (SplFileInfo $splFileInfo) => Str::of($splFileInfo->getBasename())
->replace('.' . $splFileInfo->getExtension(), '')->prepend("{$namespace}\\"))->all();
}
/**
* Get all classes from a specific namespace that implement a specific interface
*
* @param string $namespace
* @param string $interface
* @return array<string> An array of classe namespaces
*/
public static function getClassesUsingInterface(string $namespace, string $interface): array
{
return Arr::where(
static::getClassesUsingNamespace($namespace),
fn (string $class_namespace) => \collect(class_implements($class_namespace, true))->filter()
->contains(fn (string $class_interface) => $class_interface === $interface)
);
}
/**
* Get validated pagination from request.
*
* @param integer $default
* @param string $field
* @param string $enumPath
* @return integer
*/
public static function getValidatedEnum(int $default, string $field, string $enumPath): int
{
try {
return \intval(Validator::make(
[$field => \request()->get($field)],
[$field => new Enum($enumPath)]
)->validated()[$field]);
} catch (ValidationException $e) {
return $default;
}
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class TenantDeleted extends Mailable
{
use Queueable;
use SerializesModels;
/** @var string $name */
private $name;
/**
* Create a new message instance.
*
* @param string $name
* @return void
*/
public function __construct(string $name)
{
$this->name = $name;
}
/**
* Build the message.
*
* @return mixed
*/
public function build(): mixed
{
return $this
->from(config('mail.from.address'), config('mail.from.name'))
->subject(sprintf('Suppression du tenant %s', $this->name))
->markdown('bo.mails.tenant-deleted', ['name' => $this->name]);
}
}

View file

@ -7,12 +7,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
/**
* @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 string $tenant_id Id of the related tenant.
* @property-read \Illuminate\Support\Carbon $created_at Created date.
*
* @property-read \App\Models\User $user User BelongsTo relation.
@ -21,6 +23,7 @@ class ActivityLog extends Model
{
use HasFactory;
use Notifiable;
use BelongsToTenant;
/**
* Indicates if the model should be timestamped.
@ -32,7 +35,7 @@ class ActivityLog extends Model
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
* @var list<string>
*/
protected $fillable = [
'is_anonymous',
@ -55,6 +58,29 @@ class ActivityLog extends Model
'created_at' => 'datetime',
];
/**
* Models that are not related to a tenant.
*
* @var list<string>
*/
private $nonTenantRelatedModels = [
User::class
];
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function booted(): void
{
static::creating(function (self $model) {
$model->tenant_id = !\in_array($model->model_class, $model->nonTenantRelatedModels) ?
\tenant('id') :
null;
});
}
// * METHODS
/**

View file

@ -3,22 +3,26 @@
namespace App\Models;
use App\Casts\HtmlColor;
use App\Lib\Helpers\FileStorageHelper;
use App\Lib\Helpers\ToolboxHelper;
use App\Helpers\ShowroomFileHelper;
use App\Traits\Models\ActivityLog;
use App\Traits\Models\HasTranslations;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Support\Str;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
use Uwil\LaravelCommon\Helpers\FileHelper;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Http\UploadedFile;
/**
* @property string $name Name of the brand.
* @property string $slug Slug of the name for the brand.
* @property string $color HTML Color string.
* @property string $picture Picture path for the brand.
* @property boolean $shelf Les enfant sont rendus sur un étagere.
* @property boolean $status Visible ? (présent dans l'export).
* @property string $tenant_id Id of the related tenant.
* @property-read \Illuminate\Support\Carbon $created_at Created date.
* @property-read \Illuminate\Support\Carbon $updated_at Updated date.
*
@ -28,17 +32,18 @@ class Brand extends Model
{
use HasFactory;
use HasTranslations;
use BelongsToTenant;
use ActivityLog;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
* @var list<string>
*/
protected $fillable = [
'name',
'color',
'picture',
'shelf',
'status',
];
@ -59,7 +64,6 @@ class Brand extends Model
*/
protected $casts = [
'color' => HtmlColor::class,
'shelf' => 'bool',
'status' => 'bool',
];
@ -75,22 +79,28 @@ class Brand extends Model
self::assertNameIsUnique($model->name);
// * TRANSFORMATIONS
$model->slug = Str::slug($model->name);
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
$model->slug = Str::slug($model->name);
/** @phpstan-ignore-next-line */
if ($model->picture instanceof UploadedFile) {
$model->picture = ShowroomFileHelper::storeFileWithUUIDName($model->picture, $model);
}
});
static::updating(function (self $model) {
// * ASSERTIONS
self::assertNameIsUnique($model->name, $model->id);
// * TRANSFORMATIONS
$model->slug = Str::slug($model->name);
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
$model->slug = Str::slug($model->name);
/** @phpstan-ignore-next-line */
if ($model->picture instanceof UploadedFile) {
$model->picture = ShowroomFileHelper::storeFileWithUUIDName($model->picture, $model);
}
});
static::updated(function (self $model) {
FileStorageHelper::removeOldFile($model, 'picture');
FileHelper::removeOldFile($model, 'picture');
});
static::deleted(function (self $model) {
FileStorageHelper::removeFile($model, 'picture');
FileHelper::removeFile($model, 'picture');
});
}
@ -99,7 +109,7 @@ class Brand extends Model
/**
* Get all of the ranges for the Brand
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
* @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\Range, $this>
*/
public function ranges(): HasMany
{

View file

@ -2,21 +2,26 @@
namespace App\Models;
use App\Lib\Helpers\FileStorageHelper;
use App\Traits\Models\ActivityLog;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
use Uwil\LaravelCommon\Helpers\FileHelper;
/**
* @property string $font_name The slugified font name.
* @property string|null $font The font path.
* @property string $tenant_id Id of the related tenant.
* @property-read \Illuminate\Support\Carbon $created_at Created date.
* @property-read \Illuminate\Support\Carbon $updated_at Updated date.
*/
class Font extends Model
{
use HasFactory;
use BelongsToTenant;
use ActivityLog;
/**
* Default available fonts
@ -26,11 +31,12 @@ class Font extends Model
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
* @var list<string>
*/
protected $fillable = [
'font_name',
'font',
'tenant_id',
];
/**
@ -46,7 +52,7 @@ class Font extends Model
// * TRANSFORMATIONS
if ($model->font) {
$model->font = FileStorageHelper::storeFile($model, $model->font, true);
$model->font = FileHelper::storeFile($model, $model->font, true, prefix: $model->tenant_id);
$font_basename = Str::of(\pathinfo($model->font, \PATHINFO_BASENAME))->explode('.')->get(0);
$model->font_name = Str::slug($font_basename);
}
@ -57,16 +63,16 @@ class Font extends Model
// * TRANSFORMATIONS
if ($model->font) {
$model->font = FileStorageHelper::storeFile($model, $model->font, true);
$model->font = FileHelper::storeFile($model, $model->font, true, prefix: $model->tenant_id);
$font_basename = Str::of(\pathinfo($model->font, \PATHINFO_BASENAME))->explode('.')->get(0);
$model->font_name = Str::slug($font_basename);
}
});
static::updated(function (self $model) {
FileStorageHelper::removeOldFile($model, 'font');
FileHelper::removeOldFile($model, 'font');
});
static::deleted(function (self $model) {
FileStorageHelper::removeFile($model, 'font');
FileHelper::removeFile($model, 'font');
});
}

View file

@ -2,14 +2,18 @@
namespace App\Models;
use App\Lib\Helpers\FileStorageHelper;
use App\Lib\Helpers\ToolboxHelper;
use App\Traits\Models\ActivityLog;
use App\Traits\Models\HasTranslations;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Support\Str;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
use Uwil\LaravelCommon\Helpers\FileHelper;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Http\UploadedFile;
use App\Helpers\ShowroomFileHelper;
/**
* @property string $name Name of the product.
@ -17,6 +21,7 @@ use Illuminate\Support\Str;
* @property string $description WYSIWYG text description using ckeditor.
* @property string $picture Picture path for the product.
* @property boolean $status Visible ? (présent dans l'export).
* @property string $tenant_id Id of the related tenant.
* @property-read \Illuminate\Support\Carbon $created_at Created date.
* @property-read \Illuminate\Support\Carbon $updated_at Updated date.
*
@ -26,11 +31,13 @@ class Product extends Model
{
use HasFactory;
use HasTranslations;
use BelongsToTenant;
use ActivityLog;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
* @var list<string>
*/
protected $fillable = [
'name',
@ -72,22 +79,28 @@ class Product extends Model
self::assertNameIsUnique($model->name);
// * TRANSFORMATIONS
$model->slug = Str::slug($model->name);
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
$model->slug = Str::slug($model->name);
/** @phpstan-ignore-next-line */
if ($model->picture instanceof UploadedFile) {
$model->picture = ShowroomFileHelper::storeFileWithUUIDName($model->picture, $model);
}
});
static::updating(function (self $model) {
// * ASSERTIONS
self::assertNameIsUnique($model->name, $model->id);
// * TRANSFORMATIONS
$model->slug = Str::slug($model->name);
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
$model->slug = Str::slug($model->name);
/** @phpstan-ignore-next-line */
if ($model->picture instanceof UploadedFile) {
$model->picture = ShowroomFileHelper::storeFileWithUUIDName($model->picture, $model);
}
});
static::updated(function (self $model) {
FileStorageHelper::removeOldFile($model, 'picture');
FileHelper::removeOldFile($model, 'picture');
});
static::deleted(function (self $model) {
FileStorageHelper::removeFile($model, 'picture');
FileHelper::removeFile($model, 'picture');
});
}

View file

@ -2,14 +2,19 @@
namespace App\Models;
use App\Lib\Helpers\FileStorageHelper;
use App\Lib\Helpers\ToolboxHelper;
use App\Enums\Width;
use App\Traits\Models\ActivityLog;
use App\Traits\Models\HasTranslations;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
use Uwil\LaravelCommon\Helpers\FileHelper;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Http\UploadedFile;
use App\Helpers\ShowroomFileHelper;
/**
* @property string $name Name of the range.
@ -19,8 +24,8 @@ use Illuminate\Support\Str;
* @property integer $width Width or the range to display
* (1 or 2 columns, 2 is full width).
* @property integer $order Order for the range to display.
* @property boolean $shelf Les enfant sont rendus sur un étagere.
* @property boolean $status Visible ? (présent dans l'export).
* @property string $tenant_id Id of the related tenant.
* @property-read \Illuminate\Support\Carbon $created_at Created date.
* @property-read \Illuminate\Support\Carbon $updated_at Updated date.
*
@ -32,11 +37,13 @@ class Range extends Model
{
use HasFactory;
use HasTranslations;
use BelongsToTenant;
use ActivityLog;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
* @var list<string>
*/
protected $fillable = [
'name',
@ -46,7 +53,6 @@ class Range extends Model
'color',
'width',
'order',
'shelf',
'status',
];
@ -66,8 +72,8 @@ class Range extends Model
* @var array<string, string>
*/
protected $casts = [
'shelf' => 'bool',
'status' => 'bool',
'width' => Width::class
];
/**
@ -82,8 +88,11 @@ class Range extends Model
self::assertNameIsUnique($model->name);
// * TRANSFORMATIONS
$model->slug = Str::slug($model->name);
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
$model->slug = Str::slug($model->name);
/** @phpstan-ignore-next-line */
if ($model->picture instanceof UploadedFile) {
$model->picture = ShowroomFileHelper::storeFileWithUUIDName($model->picture, $model);
}
self::setOrder($model);
});
static::updating(function (self $model) {
@ -91,16 +100,19 @@ class Range extends Model
self::assertNameIsUnique($model->name, $model->id);
// * TRANSFORMATIONS
$model->slug = Str::slug($model->name);
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
$model->slug = Str::slug($model->name);
/** @phpstan-ignore-next-line */
if ($model->picture instanceof UploadedFile) {
$model->picture = ShowroomFileHelper::storeFileWithUUIDName($model->picture, $model);
}
});
static::updated(function (self $model) {
FileStorageHelper::removeOldFile($model, 'picture');
FileStorageHelper::removeOldFile($model, 'font');
FileHelper::removeOldFile($model, 'picture');
FileHelper::removeOldFile($model, 'font');
});
static::deleted(function (self $model) {
FileStorageHelper::removeFile($model, 'picture');
FileStorageHelper::removeFile($model, 'font');
FileHelper::removeFile($model, 'picture');
FileHelper::removeFile($model, 'font');
});
}
@ -129,7 +141,7 @@ class Range extends Model
/**
* Get all of the products for the Brand
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
* @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\Product, $this>
*/
public function products(): HasMany
{

View file

@ -2,18 +2,24 @@
namespace App\Models;
use App\Lib\Helpers\FileStorageHelper;
use App\Traits\Models\HasTranslations;
use App\Traits\Models\ActivityLog;
use App\Traits\Models\IsCachedAsCollection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
use Uwil\LaravelCommon\Helpers\FileHelper;
use App\Helpers\ShowroomFileHelper;
/**
* @property string $sub_title Sous-titre de l'application'.
* @property string $footer_phrase Texte en bas de page.
* @property array<int, string> $locales Selected locales used in the application.
* @property array<string, string> $sub_title Sous-titre de l'application.
* @property array<string, string> $footer_phrase Texte en bas de page.
* @property string $color HTML Color string.
* @property string $logo Logo path for the app.
* @property integer $home_timeout Timeout time in seconds before returning to homepage.
* @property string $tenant_id Id of the related tenant.
* @property-read \Illuminate\Support\Carbon $created_at Created date.
* @property-read \Illuminate\Support\Carbon $updated_at Updated date.
*
@ -21,30 +27,34 @@ use Illuminate\Database\Eloquent\Model;
class Settings extends Model
{
use HasFactory;
use HasTranslations;
use IsCachedAsCollection;
use BelongsToTenant;
use ActivityLog;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
* @var list<string>
*/
protected $fillable = [
'locales',
'sub_title',
'footer_phrase',
'color',
'logo',
'home_timeout',
'tenant_id',
];
/**
* Translatable fields array
* The attributes that should be cast.
*
* @var array<int, string>
* @var array<string, string>
*/
public $translatable = [
'sub_title',
'footer_phrase',
protected $casts = [
'locales' => 'array',
'sub_title' => 'array',
'footer_phrase' => 'array',
];
/**
@ -55,21 +65,130 @@ class Settings extends Model
protected static function booted(): void
{
static::creating(function (self $model) {
// * TRANSFORMATIONS
$model->logo = FileStorageHelper::storeFile($model, $model->logo, true);
self::manageFields($model);
});
static::created(function (self $model) {
self::resetCache($model->tenant_id);
});
static::created(fn () => self::removeCached());
static::updating(function (self $model) {
// * TRANSFORMATIONS
$model->logo = FileStorageHelper::storeFile($model, $model->logo, true);
self::manageFields($model);
});
static::updated(function (self $model) {
FileStorageHelper::removeOldFile($model, 'logo');
self::removeCached();
FileHelper::removeOldFile($model, 'logo');
self::resetCache($model->tenant_id);
});
static::deleted(function (self $model) {
FileStorageHelper::removeFile($model, 'logo');
self::removeCached();
FileHelper::removeFile($model, 'logo');
self::removeFromCache($model->tenant_id);
});
}
// * METHODS
/**
* Common methods function.
*
* @param self $model
* @return void
*/
private static function manageFields(self $model)
{
// * TRANSFORMATIONS
/** @phpstan-ignore-next-line */
if ($model->logo instanceof UploadedFile) {
$model->logo = ShowroomFileHelper::storeFileWithUUIDName($model->logo, $model);
}
if (!$model->locales) {
$model->locales = [];
}
}
/**
* Get from cache a Setting model with a key like this : {tenant}_settings and for remember it 24 hours.
*
* @param string|null $tenant_id
*
* @return self|null
*/
public static function getFromCache(?string $tenant_id): self|null
{
if (!$tenant_id) {
return null;
};
return Cache::remember(
self::getCacheKey($tenant_id),
86400,
function () use ($tenant_id) {
return self::query()->where('tenant_id', $tenant_id)->first();
},
);
}
/**
* Reset all cache elements related to a given tenant id.
*
* @param string $tenant_id
*
* @return void
*/
private static function resetCache(string $tenant_id): void
{
Cache::forget(self::getCacheKey($tenant_id));
self::getFromCache($tenant_id);
}
/**
* Remove from cache a Setting model.
*
* @param string $tenant_id
*
* @return void
*/
private static function removeFromCache(string $tenant_id): void
{
Cache::forget(self::getCacheKey($tenant_id));
}
/**
* Get the cache key for a setting related to its tenant.
*
* @param string $tenant_id
*
* @return string
*/
public static function getCacheKey(string $tenant_id): string
{
return \sprintf('%s_settings', $tenant_id);
}
/**
* Create a new Setting with fake datas.
*
* @param string $tenantId The ID of the related tenant.
*
* @return void
*/
public static function createNewData(string $tenantId)
{
$settings = new Settings();
$settings->logo = FileHelper::storeFile(
new Settings(),
new \SplFileInfo(
\resource_path('../database/factories/assets/settings/default-picture-200x200.jpg')
),
prefix: $tenantId
);
$settings->locales = ['fr'];
$settings->sub_title = [
'fr' => 'FR',
];
$settings->footer_phrase = [
'fr' => 'FR',
];
$settings->color = '#000000';
$settings->home_timeout = 60;
$settings->tenant_id = $tenantId;
$settings->saveOrFail();
}
}

View file

@ -3,11 +3,11 @@
namespace App\Models;
use App\Enums\StaticPageType;
use App\Lib\Helpers\ToolboxHelper;
use App\Traits\Models\ActivityLog;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use App\Lib\Helpers\ToolboxHelper;
/**
* @property \App\Enums\StaticPageType $type Page type (StaticPageEnum).
@ -27,7 +27,7 @@ class StaticPage extends Model
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
* @var list<string>
*/
protected $fillable = [
'seo_title',

190
app/Models/Tenant.php Normal file
View file

@ -0,0 +1,190 @@
<?php
namespace App\Models;
use App\Jobs\Mails\SendTenantDeletedMail;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\Tenant as TenantContract;
use Illuminate\Support\Str;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Support\Facades\Cache;
class Tenant extends BaseTenant implements TenantContract
{
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'id',
'name',
];
/**
* Define the custom columns (that won't be stored in the data JSON column)
* by overriding the getCustomColumns() method.
*
* @see https://tenancyforlaravel.com/docs/v3/tenants
*
* @return array
*/
public static function getCustomColumns(): array
{
return [
'id',
'name',
];
}
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function booted(): void
{
static::creating(function (self $model) {
// * ASSERTIONS
self::assertNameIsUnique($model->name);
// * TRANSFORMATIONS
$model->id = Str::slug($model->name);
});
static::created(function () {
self::resetCache();
});
static::updating(function (self $model) {
// * ASSERTIONS
self::assertNameIsUnique($model->name, $model->id);
// * TRANSFORMATIONS
$model->id = Str::slug($model->name);
});
static::updated(function () {
self::resetCache();
});
static::deleted(function (self $model) {
self::resetCache();
dispatch(new SendTenantDeletedMail($model->name));
});
}
// * METHODS
/**
* Reset all cache elements related to a given tenant id.
*
* @return void
*/
private static function resetCache(): void
{
Cache::forget('all_tenants');
self::getFromCache();
}
/**
* Get from cache a Setting model with a key like this : {tenant}_settings and for remember it 24 hours.
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function getFromCache(): \Illuminate\Database\Eloquent\Collection
{
return Cache::remember(
'all_tenants',
86400,
function () {
return self::query()->get();
},
);
}
/**
* Asserts using validation that the name is unique
*
* @param string|null $name
* @param string|null $id
* @return void
* @throws \Illuminate\Validation\ValidationException If name already exists.
*/
private static function assertNameIsUnique(string|null $name, ?string $id = null)
{
$table = (new self())->getTable();
ToolboxHelper::assertFieldIsUnique($table, 'name', $name, $id);
ToolboxHelper::assertFieldIsUnique($table, 'id', Str::slug($name), $id);
}
// * RELATIONSHIPS
/**
* The tenants that belong to the user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
/**
* The settings associated to the tenant.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function settings(): HasMany
{
return $this->hasMany(Settings::class);
}
/**
* The fonts associated to the tenant.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function fonts(): HasMany
{
return $this->hasMany(Font::class);
}
/**
* The brands associated to the tenant.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function brands(): HasMany
{
return $this->hasMany(Brand::class);
}
/**
* The brands associated to the tenant.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
/**
* The brands associated to the tenant.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function ranges(): HasMany
{
return $this->hasMany(Range::class);
}
/**
* The activity logs associated to the tenant.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function activityLogs(): HasMany
{
return $this->hasMany(ActivityLog::class);
}
}

View file

@ -6,15 +6,14 @@ 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;
use Illuminate\Support\Facades\Hash;
/**
* @property string $first_name Firstname.
* @property string $last_name Lastname.
* @property string $email Email.
* @property string $password Password.
* @property \App\Enums\Users\RoleEnum $role Role.
* @property \Illuminate\Support\Carbon $published Published status.
* @property \Illuminate\Support\Carbon $published_at Publication status update date.
@ -32,26 +31,25 @@ class User extends Authenticatable
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
* @var list<string>
*/
protected $fillable = [
'first_name',
'last_name',
'email',
'password',
'role',
'published',
'published_at',
'tenant_id',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
'remember_token'
];
/**
@ -74,7 +72,6 @@ class User extends Authenticatable
protected static function booted(): void
{
static::creating(function (self $model) {
self::updatePassword($model);
// * Prevent user elevation privileges
throw_if(
auth('backend')->user() and auth('backend')->user()->role->value() > $model->role->value(),
@ -82,7 +79,6 @@ class User extends Authenticatable
);
});
static::updating(function (self $model) {
self::updatePassword($model);
if (optional(auth('backend')->user())->getKey() === $model->getKey()) {
\validator(
['published' => $model->published],
@ -98,30 +94,15 @@ class User extends Authenticatable
});
}
// * METHODS
// * RELATIONSHIPS
/**
* Update password.
* The tenants that belong to the user.
*
* @param self $model
* @return void
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
private static function updatePassword(self $model): void
public function tenants(): BelongsToMany
{
if (Hash::needsRehash($model->password)) {
$model->password = Hash::make($model->password);
}
}
// * RELATIONS
/**
* Activities logs One-to-many relationship.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function activityLogs(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(ActivityLog::class);
return $this->belongsToMany(Tenant::class);
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Policies;
use App\Enums\Users\RoleEnum;
use App\Models\Tenant;
use App\Models\User;
use App\Policies\Rules\UserStaticRules;
class TenantPolicy
{
/**
* 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 create models.
*
* @param \App\Models\User $authUser
* @return boolean
*/
public function create(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can delete the model.
*
* @param \App\Models\User $authUser
* @param \App\Models\Tenant $tenant
* @return boolean
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
*/
public function delete(User $authUser, Tenant $tenant): bool
{
// phpcs:enable
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
* Determine whether the user can permanently delete the model.
*
* @param \App\Models\User $authUser
* @param \App\Models\Tenant $tenant
* @return boolean
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
*/
public function forceDelete(User $authUser, \App\Models\Tenant $tenant): bool
{
// phpcs:enable
return false;
}
}

View file

@ -16,7 +16,7 @@ class UserPolicy
*/
public function viewAny(User $authUser): bool
{
return UserStaticRules::atLeastRole($authUser, RoleEnum::admin);
return UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
/**
@ -126,4 +126,18 @@ class UserPolicy
UserStaticRules::atLeastRole($authUser, RoleEnum::admin) and
UserStaticRules::atLeastRole($authUser, $user->role);
}
/**
* Determine whether the user can edit the thenants associated to a user.
*
* @param \App\Models\User $authUser
* @param \App\Models\User $user
*
* @return boolean
*/
public function editTenants(User $authUser, User $user): bool
{
return $authUser->getRouteKey() !== $user->getRouteKey() and
UserStaticRules::atLeastRole($authUser, RoleEnum::conceptor);
}
}

View file

@ -22,6 +22,10 @@ class AppServiceProvider extends ServiceProvider
// * HTTPS FIX
URL::forceScheme('https');
if (app()->environment('local')) {
URL::forceRootUrl(config('app.url'));
}
// * HA PROXY FIX
App::singleton('clientIp', function () {
if (config('app.env') === 'local') {

View file

@ -3,6 +3,7 @@
namespace App\Providers;
use App\Enums\Users\RoleEnum;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
@ -27,7 +28,7 @@ class AuthServiceProvider extends ServiceProvider
return $user->role === RoleEnum::conceptor;
});
Gate::define('exportZip', function (User $user) {
return $user->role === RoleEnum::conceptor;
return $user->role === RoleEnum::conceptor && Tenant::count();
});
}
}

View file

@ -5,6 +5,13 @@ namespace App\Providers;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Session;
use Slides\Saml2\Events\SignedIn;
use Slides\Saml2\Events\SignedOut;
class EventServiceProvider extends ServiceProvider
{
@ -28,4 +35,49 @@ class EventServiceProvider extends ServiceProvider
{
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();
$fromCache = Cache::get(config('cache.prefix') . "_saml_message_id_" . $messageId);
if ($fromCache) {
logger()->error(
'SAML MESSAGE ID HANDLER',
['error' => 'Message ID : ' . $messageId . " replayed !"]
);
abort(403);
} else {
Cache::put(config('cache.prefix') . "_saml_message_id_" . $messageId, $messageId, 28800);
// Cache for 8h.
}
$user = $event->getSaml2User();
$email = $user->getNameId();
$lUser = User::where('email', '=', $email)->first();
if (!$lUser) {
abort(403);
}
Auth::guard('backend')->login($lUser);
session(['sessionIndex' => $user->getSessionIndex()]);
$lUser->last_connection = now();
$lUser->saveQuietly();
});
//phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
Event::listen('Slides\Saml2\Events\SignedOut', function (SignedOut $event) {
Auth::guard('backend')->logout();
Session::save();
});
}
}

View file

@ -2,7 +2,11 @@
namespace App\Providers;
use App\Models\Brand;
use App\Models\Font;
use App\Models\Product;
use App\Models\Range;
use App\Models\Settings;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
@ -42,8 +46,25 @@ class RouteServiceProvider extends ServiceProvider
->group(base_path('routes/web-fo.php'));
});
// Bind tenant related Models.
$tenantRelatedModels = [
'brand' => Brand::class,
'font' => Font::class,
'product' => Product::class,
'range' => Range::class,
'settings' => Settings::class,
];
foreach ($tenantRelatedModels as $key => $tenantRelatedModel) {
Route::bind($key, function (string $value) use ($tenantRelatedModel) {
return $tenantRelatedModel::query()
->where('id', $value)
->where('tenant_id', \request()->tenant)
->firstOrFail();
});
}
Route::model('user', User::class);
Route::model('range', Range::class);
}
/**
@ -87,8 +108,11 @@ class RouteServiceProvider extends ServiceProvider
})->all();
// * Filter array keys
$cspReport = collect(array_intersect_key($cspReport, [
'blocked-uri' => '', 'document-uri' => '', 'original-policy' => '',
'referrer' => '', 'violated-directive' => ''
'blocked-uri' => '',
'document-uri' => '',
'original-policy' => '',
'referrer' => '',
'violated-directive' => ''
]));
\Illuminate\Support\Facades\Log::warning(sprintf(
"CSP REPORT for %s : \n %s",

View file

@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Events;
use Stancl\Tenancy\Jobs;
use Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Middleware;
class TenancyServiceProvider extends ServiceProvider
{
/** @var string By default, no namespace is used to support the callable array syntax. */
public static string $controllerNamespace = '';
/**
* Returns array of all Tenant related events.
*
* @return array
*/
public function events()
{
return [
// Tenant events.
Events\CreatingTenant::class => [],
Events\TenantCreated::class => [
JobPipeline::make([
Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class,
])->send(function (Events\TenantCreated $event) {
return $event->tenant;
})->shouldBeQueued(false),
// `false` by default, but you probably want to make this `true` for production.
],
Events\SavingTenant::class => [],
Events\TenantSaved::class => [],
Events\UpdatingTenant::class => [],
Events\TenantUpdated::class => [],
Events\DeletingTenant::class => [],
Events\TenantDeleted::class => [
JobPipeline::make([
Jobs\DeleteDatabase::class,
])->send(function (Events\TenantDeleted $event) {
return $event->tenant;
})->shouldBeQueued(false),
// `false` by default, but you probably want to make this `true` for production.
],
// Domain events.
Events\CreatingDomain::class => [],
Events\DomainCreated::class => [],
Events\SavingDomain::class => [],
Events\DomainSaved::class => [],
Events\UpdatingDomain::class => [],
Events\DomainUpdated::class => [],
Events\DeletingDomain::class => [],
Events\DomainDeleted::class => [],
// Database events.
Events\DatabaseCreated::class => [],
Events\DatabaseMigrated::class => [],
Events\DatabaseSeeded::class => [],
Events\DatabaseRolledBack::class => [],
Events\DatabaseDeleted::class => [],
// Tenancy events.
Events\InitializingTenancy::class => [],
Events\TenancyInitialized::class => [
Listeners\BootstrapTenancy::class,
],
Events\EndingTenancy::class => [],
Events\TenancyEnded::class => [
Listeners\RevertToCentralContext::class,
],
Events\BootstrappingTenancy::class => [],
Events\TenancyBootstrapped::class => [],
Events\RevertingToCentralContext::class => [],
Events\RevertedToCentralContext::class => [],
// Resource syncing.
Events\SyncedResourceSaved::class => [
Listeners\UpdateSyncedResource::class,
],
/**
* Fired only when a synced resource is changed in a different DB than the origin DB
* (to avoid infinite loops).
*/
Events\SyncedResourceChangedInForeignDatabase::class => [],
];
}
/**
* Register method.
*
* @return void
*/
public function register()
{
// Empty.
}
/**
* Boot method.
*
* @return void
*/
public function boot()
{
$this->bootEvents();
$this->mapRoutes();
$this->makeTenancyMiddlewareHighestPriority();
}
/**
* Boot events.
*
* @return void
*/
protected function bootEvents()
{
foreach ($this->events() as $event => $listeners) {
foreach ($listeners as $listener) {
if ($listener instanceof JobPipeline) {
$listener = $listener->toListener();
}
Event::listen($event, $listener);
}
}
}
/**
* Map tenants routes.
*
* @return void
*/
protected function mapRoutes()
{
$this->app->booted(function () {
if (file_exists(base_path('routes/tenant.php'))) {
Route::namespace(static::$controllerNamespace)
->group(base_path('routes/tenant.php'));
}
});
}
/**
* Make Tenancy Middleware Highest Priority
*
* @return void
*/
protected function makeTenancyMiddlewareHighestPriority()
{
$tenancyMiddleware = [
// Even higher priority than the initialization middleware.
Middleware\PreventAccessFromCentralDomains::class,
Middleware\InitializeTenancyByDomain::class,
Middleware\InitializeTenancyBySubdomain::class,
Middleware\InitializeTenancyByDomainOrSubdomain::class,
Middleware\InitializeTenancyByPath::class,
Middleware\InitializeTenancyByRequestData::class,
];
foreach (array_reverse($tenancyMiddleware) as $middleware) {
$this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware);
}
}
}

View file

@ -2,12 +2,12 @@
namespace App\Providers;
use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\Finder\SplFileInfo;
use App\Lib\Helpers\ToolboxHelper;
class TranslationServiceProvider extends ServiceProvider
{

View file

@ -3,9 +3,11 @@
namespace App\Providers;
use App\Models\Settings;
use App\Models\Tenant;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
@ -35,7 +37,14 @@ class ViewServiceProvider extends ServiceProvider
} else {
Paginator::useBootstrapFive();
}
View::share('settingsGlobal', Settings::getCached()->first());
}
View::composer('*', function () {
View::share('settingsGlobal', Settings::getFromCache(tenant('id')));
});
if (Schema::hasTable('tenants')) {
View::share('tenantGlobal', Tenant::getFromCache());
}
} //end if
}
}

76
app/Rules/Hex.php Normal file
View file

@ -0,0 +1,76 @@
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\ValidationRule;
class Hex implements ValidationRule
{
/**
* @var boolean
*/
protected $forceFull;
/**
* @var boolean
*/
protected $allowAlpha;
/**
* __construct method
*
* @param boolean $forceFull
* @param boolean $allowAlpha
*
* @return void
*/
public function __construct(bool $forceFull = false, bool $allowAlpha = false)
{
$this->forceFull = $forceFull;
$this->allowAlpha = $allowAlpha;
}
/**
* Run the validation rule.
*
* @param string $attribute
* @param mixed $value
* @param \Closure(string):\Illuminate\Translation\PotentiallyTranslatedString $fail
* @return void
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceBeforeLastUsed
* @phpcs:disable Squiz.Commenting.FunctionComment.IncorrectTypeHint
*/
public function validate(string $attribute, mixed $value, \Closure $fail): void
{
$pattern = '/^#([a-fA-F0-9]{6}';
if (!$this->forceFull) {
$pattern .= '|[a-fA-F0-9]{3}';
}
if ($this->allowAlpha) {
$pattern .= '|[a-fA-F0-9]{8}';
if (!$this->forceFull) {
$pattern .= '|[a-fA-F0-9]{4}';
}
}
$pattern .= ')$/';
if (!(bool) preg_match($pattern, $value)) {
$fail(trans($this->message()));
}
}
/**
* Get the validation error message.
*
* @return string
*/
public function message()
{
return 'The hex code is not valid';
}
}

View file

@ -109,7 +109,7 @@ trait BaseEnum
}
$baseArray[$index] = $elements[$enum->name];
}
return [$enum->name => (object)$baseArray];
return [$enum->name => (object) $baseArray];
})->all());
}
return self::getCache('enums');

View file

@ -3,6 +3,7 @@
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]),
@ -20,4 +21,16 @@ trait ActivityLog
{
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

@ -2,10 +2,10 @@
namespace App\Traits\Models;
use App\Lib\Helpers\FileStorageHelper;
use App\Lib\Helpers\ToolboxHelper;
use Spatie\Translatable\HasTranslations as BaseHasTranslations;
use Illuminate\Support\Str;
use Uwil\LaravelCommon\Helpers\FileHelper;
use App\Lib\Helpers\ToolboxHelper;
trait HasTranslations
{
@ -39,7 +39,7 @@ trait HasTranslations
$storeAsPrivate = (isset($this->privatePictures) and
\is_array($this->privatePictures) and
\in_array($key, $this->privatePictures)) ? true : false;
$value = FileStorageHelper::storeFile($this, $value, true, null, $storeAsPrivate);
$value = FileHelper::storeFile($this, $value, true, null, $storeAsPrivate);
}
// If the attribute is translatable and not already translated, set a

View file

@ -3,7 +3,7 @@
namespace App\Traits\Models;
use App\Enums\InputType;
use App\Lib\Helpers\FileStorageHelper;
use Uwil\LaravelCommon\Helpers\FileHelper;
use App\Lib\Helpers\ToolboxHelper;
trait MiniCrudModel
@ -38,7 +38,7 @@ trait MiniCrudModel
switch ($input['inputType']) {
case InputType::text->value:
$input['text'] = clean($input['text']);
$input['text'] = FileStorageHelper::storeModelFilesFromHtml($this, $input['text']);
$input['text'] = FileHelper::storeModelFilesFromHtml($this, $input['text']);
break;
} //end switch
} //end foreach
@ -67,7 +67,7 @@ trait MiniCrudModel
switch ($input['inputType']) {
case InputType::text->value:
$original = ToolboxHelper::getOriginal($this, 'data');
FileStorageHelper::removeOldModelFilesFromHtml(
FileHelper::removeOldModelFilesFromHtml(
$input['text'],
$original[$index]['text'] ?? ''
);
@ -87,7 +87,7 @@ trait MiniCrudModel
foreach ($this->data as $input) {
switch ($input['inputType']) {
case InputType::text->value:
FileStorageHelper::removeModelFilesOfHtml($input['text']);
FileHelper::removeModelFilesOfHtml($input['text']);
break;
} //end switch
} //end foreach
@ -151,7 +151,7 @@ trait MiniCrudModel
$data[$o]['value'] = \preg_replace(
'/^\/|^/',
'',
FileStorageHelper::storeFile($this, $input['value'], true)
FileHelper::storeFile($this, $input['value'], true)
);
break;
case InputType::selectEnum->value:

View file

@ -62,10 +62,10 @@ trait HasPicture
?string $requiredWith = null,
?string $requiredWithout = null,
): array {
$dimensions = (object)[
'minWidth' => $minWidth ?? $this->minWidth,
$dimensions = (object) [
'minWidth' => $minWidth ?? $this->minWidth,
'minHeight' => $minHeight ?? $this->minHeight,
'maxWidth' => $maxWidth ?? $this->maxWidth,
'maxWidth' => $maxWidth ?? $this->maxWidth,
'maxHeight' => $maxHeight ?? $this->maxHeight
];
return [
@ -104,10 +104,10 @@ trait HasPicture
int $limitMax = 5,
bool $nullable = false
): array {
$sizes = (object)[
'minWidth' => $minWidth ?? $this->minWidth,
$sizes = (object) [
'minWidth' => $minWidth ?? $this->minWidth,
'minHeight' => $minHeight ?? $this->minHeight,
'maxWidth' => $maxWidth ?? $this->maxWidth,
'maxWidth' => $maxWidth ?? $this->maxWidth,
'maxHeight' => $maxHeight ?? $this->maxHeight,
];
return [

View file

@ -1,6 +1,6 @@
{
"name": "uwil/showroom-bo",
"version": "1.1.0",
"version": "2.3.3",
"type": "project",
"description": "Showroom backoffice for Showroom add data management",
"support": {
@ -12,30 +12,33 @@
],
"license": "MIT",
"require": {
"php": "^8.2",
"diglactic/laravel-breadcrumbs": "^8.0",
"php": "^8.5",
"24slides/laravel-saml2": "^2.4",
"carsdotcom/laravel-json-schema": "^2.3",
"diglactic/laravel-breadcrumbs": "^10.0",
"guzzlehttp/guzzle": "^7.2",
"kaishiyoku/laravel-color-validation": "^2.1",
"laravel/framework": "^10.0",
"laravel-lang/locales": "^2.0",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.8",
"laravel/ui": "^4.2",
"mews/purifier": "^3.4",
"sentry/sentry-laravel": "^3.8",
"sixlive/laravel-json-schema-assertions": "^4.2",
"sentry/sentry-laravel": "^4.0",
"spatie/laravel-translatable": "^6.5",
"uwil/k8s-laravel": "^1.2"
"stancl/tenancy": "^3.9",
"uwil/admin-secure-laravel": "^5.0",
"uwil/k8s-laravel": "^2.0",
"uwil/laravel-common": "^3.2"
},
"require-dev": {
"fakerphp/faker": "^1.9.1",
"kitloong/laravel-migrations-generator": "^6.8",
"krlove/eloquent-model-generator": "dev-l10-compatibility",
"larastan/larastan": "^2.8",
"laravel-lang/common": "^4.0",
"kitloong/laravel-migrations-generator": "^7.0",
"larastan/larastan": "^3.0",
"laravel-lang/common": "^6.0",
"laravel/pint": "^1.0",
"laravel/sail": "^1.18",
"mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^7.0",
"phpunit/phpunit": "^10.0",
"nunomaduro/collision": "^8.1",
"phpunit/phpunit": "^11.0",
"slevomat/coding-standard": "^8.10",
"spatie/laravel-ignition": "^2.0",
"thedoctor0/laravel-factory-generator": "^1.2"
@ -49,10 +52,7 @@
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/laravel-shift/eloquent-model-generator.git"
},
{
"name": "lab.s-web.fr",
"type": "composer",
"url": "https://lab.s-web.fr/api/v4/group/23/-/packages/composer/packages.json"
}
@ -91,6 +91,9 @@
"php-http/discovery": true,
"dealerdirect/phpcodesniffer-composer-installer": true
},
"audit": {
"block-insecure": false
},
"gitlab-domains": [
"lab.s-web.fr"
]

10855
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -41,7 +41,7 @@ return [
|
*/
'debug' => (bool)env('APP_DEBUG', false),
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
@ -56,8 +56,6 @@ return [
'url' => env('APP_URL', 'http://localhost'),
'asset_url' => env('ASSET_URL'),
/*
|--------------------------------------------------------------------------
| Application Timezone
@ -82,11 +80,13 @@ return [
|
*/
'locale' => 'fr',
'locale' => 'fr',
'locales' => [
'fr',
'en',
'nl'
'nl',
'es',
'de',
],
/*

View file

@ -63,6 +63,11 @@ return [
'throw' => false,
],
'schemas' => [
'driver' => 'local',
// Must match the 'config.json-schema.local_base_prefix' value.
'root' => base_path('schemas'),
]
],
/*

52
config/json-schema.php Normal file
View file

@ -0,0 +1,52 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Base URL
|--------------------------------------------------------------------------
|
| This is an absolute base URL where schemas will be accessible.
|
| (URL our local schemas must be relative to)
|
*/
'base_url' => 'file://localhost',
/*
|--------------------------------------------------------------------------
| Schema base directory
|--------------------------------------------------------------------------
|
| Where will all local schema files be stored?
|
*/
'local_base_prefix' => base_path('schemas/'),
/*
|--------------------------------------------------------------------------
| Schema base directory for tests
|--------------------------------------------------------------------------
|
| Where will all local schema files be stored?
|
*/
'local_base_prefix_tests' => base_path('tests/Schemas/'),
/*
|--------------------------------------------------------------------------
| Storage disk name
|--------------------------------------------------------------------------
|
| In order to generate schamas automatically, a storage disk needs to be created
| that points to the same directory as `local_base_prefix`. This is the name
| of that storage disk.
|
*/
'storage_disk_name' => 'schemas',
];

View file

@ -96,6 +96,11 @@ return [
'name' => env('MAIL_FROM_NAME', 'Example'),
],
'to' => [
'address' => env('MAIL_TO_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_TO_NAME', 'Example'),
],
/*
|--------------------------------------------------------------------------
| Markdown Mail Settings

397
config/saml2.php Normal file
View file

@ -0,0 +1,397 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Tenant Model
|--------------------------------------------------------------------------
|
| This will allow you to override the tenant model with your own.
|
*/
'tenantModel' => \Slides\Saml2\Models\Tenant::class,
/*
|--------------------------------------------------------------------------
| Use built-in routes
|--------------------------------------------------------------------------
|
| If "useRoutes" set to true, the package defines five new routes:
|
| Method | URI | Name
| -------|---------------------------------|------------------
| POST | {routesPrefix}/{uuid}/acs | saml.acs
| GET | {routesPrefix}/{uuid}/login | saml.login
| GET | {routesPrefix}/{uuid}/logout | saml.logout
| GET | {routesPrefix}/{uuid}/metadata | saml.metadata
| GET | {routesPrefix}/{uuid}/sls | saml.sls
|
*/
'useRoutes' => true,
/*
|--------------------------------------------------------------------------
| Built-in routes prefix
|--------------------------------------------------------------------------
|
| Here you may define the prefix for built-in routes.
|
*/
'routesPrefix' => '/saml2',
/*
|--------------------------------------------------------------------------
| Middle groups to use for the SAML routes
|--------------------------------------------------------------------------
|
| Note, Laravel 5.2 requires a group which includes StartSession
|
*/
'routesMiddleware' => ['saml'],
/*
|--------------------------------------------------------------------------
| Signature validation
|--------------------------------------------------------------------------
|
| Set to true if you want to use parameters from $_SERVER to validate the signature.
|
*/
'retrieveParametersFromServer' => false,
/*
|--------------------------------------------------------------------------
| Login redirection URL.
|--------------------------------------------------------------------------
|
| The redirection URL after successful login.
|
*/
'loginRoute' => '/bo',
/*
|--------------------------------------------------------------------------
| Logout redirection URL.
|--------------------------------------------------------------------------
|
| The redirection URL after successful logout.
|
*/
'logoutRoute' => '/bo',
/*
|--------------------------------------------------------------------------
| Login error redirection URL.
|--------------------------------------------------------------------------
|
| The redirection URL after login failing.
|
*/
'errorRoute' => '/bo/login-error',
/*
|--------------------------------------------------------------------------
| Strict mode.
|--------------------------------------------------------------------------
|
| If 'strict' is True, then the PHP Toolkit will reject unsigned
| or unencrypted messages if it expects them signed or encrypted
| Also will reject the messages if not strictly follow the SAML
| standard: Destination, NameId, Conditions... are validated too.
|
*/
'strict' => true,
/*
|--------------------------------------------------------------------------
| Debug mode.
|--------------------------------------------------------------------------
|
| When enabled, errors must be printed.
|
*/
'debug' => env('SAML2_DEBUG', env('APP_DEBUG', false)),
/*
|--------------------------------------------------------------------------
| Whether to use `X-Forwarded-*` headers to determine port/domain/protocol.
|--------------------------------------------------------------------------
|
| If 'proxyVars' is True, then the Saml lib will trust proxy headers
| e.g X-Forwarded-Proto / HTTP_X_FORWARDED_PROTO. This is useful if
| your application is running behind a load balancer which terminates SSL.
|
*/
'proxyVars' => true,
/*
|--------------------------------------------------------------------------
| Service Provider configuration.
|--------------------------------------------------------------------------
|
| General setting of the service provider.
|
*/
'sp' => [
/*
|--------------------------------------------------------------------------
| NameID format.
|--------------------------------------------------------------------------
|
| Specifies constraints on the name identifier to be used to
| represent the requested subject.
|
*/
'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
/*
|--------------------------------------------------------------------------
| SP Certificates.
|--------------------------------------------------------------------------
|
| Usually x509cert and privateKey of the SP are provided by files placed at
| the certs folder. But we can also provide them with the following parameters.
|
*/
'x509cert' => env('SAML2_SP_CERT_x509', ''),
'privateKey' => env('SAML2_SP_CERT_PRIVATEKEY', ''),
/*
|--------------------------------------------------------------------------
| Identifier (URI) of the SP entity.
|--------------------------------------------------------------------------
|
| Leave blank to use the 'saml.metadata' route.
|
*/
'entityId' => env('SAML2_SP_ENTITYID', ''),
/*
|--------------------------------------------------------------------------
| The Assertion Consumer Service (ACS) URL.
|--------------------------------------------------------------------------
|
| URL Location where the <Response> from the IdP will be returned, using HTTP-POST binding.
| Leave blank to use the 'saml.acs' route.
|
*/
'assertionConsumerService' => [
'url' => '',
],
/*
|--------------------------------------------------------------------------
| The Single Logout Service URL.
|--------------------------------------------------------------------------
|
| Specifies info about where and how the <Logout Response> message MUST be
| returned to the requester, in this case our SP.
|
| URL Location where the <Response> from the IdP will be returned, using HTTP-Redirect binding.
| Leave blank to use the 'saml.sls' route.
|
*/
'singleLogoutService' => [
'url' => ''
],
],
/*
|--------------------------------------------------------------------------
| OneLogin security settings.
|--------------------------------------------------------------------------
|
|
|
*/
'security' => [
'signatureAlgorithm' => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
'digestAlgorithm' => 'http://www.w3.org/2001/04/xmlenc#sha256',
/*
|--------------------------------------------------------------------------
| NameId encryption
|--------------------------------------------------------------------------
|
| Indicates that the nameID of the <samlp:logoutRequest> sent by this SP
| will be encrypted.
|
*/
'nameIdEncrypted' => true,
/*
|--------------------------------------------------------------------------
| AuthnRequest signage
|--------------------------------------------------------------------------
|
| Indicates whether the <samlp:AuthnRequest> messages sent by
| this SP will be signed. The Metadata of the SP will offer this info
|
*/
'authnRequestsSigned' => true,
/*
|--------------------------------------------------------------------------
| Logout request signage
|--------------------------------------------------------------------------
|
| Indicates whether the <samlp:logoutRequest> messages sent by this SP
| will be signed.
|
*/
'logoutRequestSigned' => true,
/*
|--------------------------------------------------------------------------
| Logout response signage
|--------------------------------------------------------------------------
|
| Indicates whether the <samlp:logoutResponse> messages sent by this SP
| will be signed.
|
*/
'logoutResponseSigned' => true,
/*
|--------------------------------------------------------------------------
| Whether need to sign metadata.
|--------------------------------------------------------------------------
|
| The possible values:
| - false
| - true (use certs)
| - array:
| ```
| [
| 'keyFileName' => 'metadata.key',
| 'certFileName' => 'metadata.crt'
| ]
| ```
|
*/
'signMetadata' => true,
/*
|--------------------------------------------------------------------------
| Requirement to sign messages.
|--------------------------------------------------------------------------
|
| Indicates a requirement for the <samlp:Response>, <samlp:LogoutRequest> and
| <samlp:LogoutResponse> elements received by this SP to be signed.
|
*/
'wantMessagesSigned' => true,
/*
|--------------------------------------------------------------------------
| Requirement to sign assertion elements.
|--------------------------------------------------------------------------
|
| Indicates a requirement for the <saml:Assertion> elements received by
| this SP to be signed.
|
*/
'wantAssertionsSigned' => true,
/*
|--------------------------------------------------------------------------
| Requirement to encrypt NameID.
|--------------------------------------------------------------------------
|
| Indicates a requirement for the NameID received by this SP to be encrypted.
|
*/
'wantNameIdEncrypted' => false,
/*
|--------------------------------------------------------------------------
| Authentication context.
|--------------------------------------------------------------------------
|
| Set to false and no AuthContext will be sent in the AuthNRequest,
|
| Set true or don't present this parameter and you will get an
| AuthContext 'exact' 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
|
| Set an array with the possible auth context values:
| ['urn:oasis:names:tc:SAML:2.0:ac:classes:Password', 'urn:oasis:names:tc:SAML:2.0:ac:classes:X509']
|
*/
'requestedAuthnContext' => true,
],
/*
|--------------------------------------------------------------------------
| Contact information.
|--------------------------------------------------------------------------
|
| It is recommended to supply a technical and support contacts.
|
*/
'contactPerson' => [
'technical' => [
'givenName' => env('SAML2_CONTACT_TECHNICAL_NAME', env('APP_NAME', 'Technical')),
'emailAddress' => env('SAML2_CONTACT_TECHNICAL_EMAIL', 'no@reply.com')
],
'support' => [
'givenName' => env('SAML2_CONTACT_SUPPORT_NAME', env('APP_NAME', 'Support')),
'emailAddress' => env('SAML2_CONTACT_SUPPORT_EMAIL', 'no@reply.com')
],
],
/*
|--------------------------------------------------------------------------
| Organization information.
|--------------------------------------------------------------------------
|
| The info in en_US lang is recommended, add more if required.
|
*/
'organization' => [
'en-US' => [
'name' => env('SAML2_ORGANIZATION_NAME', env('APP_NAME', 'Name')),
'displayname' => env('SAML2_ORGANIZATION_NAME', env('APP_NAME', 'Name')),
'url' => env('SAML2_ORGANIZATION_URL', env('APP_URL', 'http://url'))
],
],
/**
* Default tenant UUID
*/
'default_tenant_uuid' => env('SAML2_DEFAULT_TENANT_UUID', null)
];

View file

@ -55,7 +55,7 @@ return [
'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces-sample-rate.
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float)env('SENTRY_TRACES_SAMPLE_RATE'),
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
'before_send' => [\App\Lib\Helpers\ExceptionsHelper::class, 'sentryEventHandler'],
'before_send' => [\Uwil\LaravelCommon\Helpers\ExceptionsHelper::class, 'sentryEventHandler'],
];

191
config/tenancy.php Normal file
View file

@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
use Stancl\Tenancy\Database\Models\Domain;
return [
'tenant_model' => \App\Models\Tenant::class,
'id_generator' => Stancl\Tenancy\UUIDGenerator::class,
'domain_model' => Domain::class,
/**
* The list of domains hosting your central app.
*
* Only relevant if you're using the domain or subdomain identification middleware.
*/
'central_domains' => [],
/**
* Tenancy bootstrappers are executed when tenancy is initialized.
* Their responsibility is making Laravel features tenant-aware.
*
* To configure their behavior, see the config keys below.
*/
'bootstrappers' => [
Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
],
/**
* Database tenancy config. Used by DatabaseTenancyBootstrapper.
*/
'database' => [
'central_connection' => env('DB_CONNECTION', 'mysql'),
/**
* Connection used as a "template" for the dynamically created tenant database connection.
* Note: don't name your template connection tenant. That name is reserved by package.
*/
'template_tenant_connection' => null,
/**
* Tenant database names are created like this:
* prefix + tenant_id + suffix.
*/
'prefix' => 'tenant',
'suffix' => '',
/**
* TenantDatabaseManagers are classes that handle the creation & deletion of tenant databases.
*/
'managers' => [
'sqlite' => Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager::class,
'mysql' => Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager::class,
'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager::class,
],
],
/**
* Cache tenancy config. Used by CacheTenancyBootstrapper.
*
* This works for all Cache facade calls, cache() helper
* calls and direct calls to injected cache stores.
*
* Each key in cache will have a tag applied on it. This tag is used to
* scope the cache both when writing to it and when reading from it.
*
* You can clear cache selectively by specifying the tag.
*/
'cache' => [
// This tag_base, followed by the tenant_id, will form a tag that will be applied on each cache call.
'tag_base' => 'tenant',
],
/**
* Filesystem tenancy config. Used by FilesystemTenancyBootstrapper.
* https://tenancyforlaravel.com/docs/v3/tenancy-bootstrappers/#filesystem-tenancy-boostrapper.
*/
'filesystem' => [
/**
* Each disk listed in the 'disks' array will be suffixed by the suffix_base, followed by the tenant_id.
*/
'suffix_base' => 'tenant',
'disks' => [
'local',
'public',
// 's3',
],
/**
* Use this for local disks.
*
* See https://tenancyforlaravel.com/docs/v3/tenancy-bootstrappers/#filesystem-tenancy-boostrapper
*/
'root_override' => [
// Disks whose roots should be overridden after storage_path() is suffixed.
'local' => '%storage_path%/app/',
'public' => '%storage_path%/app/public/',
],
/**
* Should storage_path() be suffixed.
*
* Note: Disabling this will likely break local disk tenancy.
* Only disable this if you're using an external file storage service like S3.
*
* For the vast majority of applications, this feature should be enabled. But in some
* edge cases, it can cause issues (like using Passport with Vapor - see #196), so
* you may want to disable this if you are experiencing these edge case issues.
*/
'suffix_storage_path' => true,
/**
* By default, asset() calls are made multi-tenant too. You can use global_asset() and mix()
* for global, non-tenant-specific assets. However, you might have some issues when using
* packages that use asset() calls inside the tenant app. To avoid such issues, you can
* disable asset() helper tenancy and explicitly use tenant_asset() calls in places
* where you want to use tenant-specific assets (product images, avatars, etc).
*/
'asset_helper_tenancy' => true,
],
/**
* Redis tenancy config. Used by RedisTenancyBootstrapper.
*
* Note: You need phpredis to use Redis tenancy.
*
* Note: You don't need to use this if you're using Redis only for cache.
* Redis tenancy is only relevant if you're making direct Redis calls,
* either using the Redis facade or by injecting it as a dependency.
*/
'redis' => [
// Each key in Redis will be prepended by this prefix_base, followed by the tenant id.
'prefix_base' => 'tenant',
// Redis connections whose keys are prefixed, to separate one tenant's keys from another.
'prefixed_connections' => [
// 'default',
],
],
/**
* Features are classes that provide additional functionality
* not needed for tenancy to be bootstrapped. They are run
* regardless of whether tenancy has been initialized.
*
* See the documentation page for each class to
* understand which ones you want to enable.
*/
'features' => [
//phpcs:disable
// Stancl\Tenancy\Features\UserImpersonation::class,
// Stancl\Tenancy\Features\TelescopeTags::class,
// Stancl\Tenancy\Features\UniversalRoutes::class,
// Stancl\Tenancy\Features\TenantConfig::class, // https://tenancyforlaravel.com/docs/v3/features/tenant-config
// Stancl\Tenancy\Features\CrossDomainRedirect::class, // https://tenancyforlaravel.com/docs/v3/features/cross-domain-redirect
// Stancl\Tenancy\Features\ViteBundler::class,
//phpcs:enable
],
/**
* Should tenancy routes be registered.
*
* Tenancy routes include tenant asset routes. By default, this route is
* enabled. But it may be useful to disable them if you use external
* storage (e.g. S3 / Dropbox) or have a custom asset controller.
*/
'routes' => true,
/**
* Parameters used by the tenants:migrate command.
*/
'migration_parameters' => [
// This needs to be true to run migrations in production.
'--force' => true,
'--path' => [database_path('migrations/tenant')],
'--realpath' => true,
],
/**
* Parameters used by the tenants:seed command.
*/
'seeder_parameters' => [
// Root seeder class.
'--class' => 'DatabaseSeeder',
// This needs to be true to seed tenant databases in production.
// phpcs:ignore
// '--force' => true,
],
];

View file

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@ -17,10 +18,10 @@ class BrandFactory extends Factory
public function definition(): array
{
return [
'name' => 'brand ' . fake()->unique()->safeColorName(),
'color' => fake()->unique()->hexColor(),
'shelf' => fake()->boolean(75),
'status' => fake()->boolean(75)
'name' => 'brand ' . fake()->unique()->safeColorName(),
'color' => fake()->unique()->hexColor(),
'status' => fake()->boolean(75),
'tenant_id' => Tenant::query()->first('id'),
];
}
}

View file

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@ -16,6 +17,8 @@ class FontFactory extends Factory
*/
public function definition(): array
{
return [];
return [
'tenant_id' => Tenant::query()->first('id'),
];
}
}

View file

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@ -19,7 +20,8 @@ class ProductFactory extends Factory
return [
'name' => 'product ' . fake()->unique()->words(3, true),
'description' => '<strong>TEXT</strong>' . fake()->realText(),
'status' => fake()->boolean(75)
'status' => fake()->boolean(75),
'tenant_id' => Tenant::query()->first('id'),
];
}
}

View file

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@ -24,12 +25,12 @@ class RangeFactory extends Factory
public function definition(): array
{
return [
'name' => 'gamme ' . fake()->unique()->streetName(),
'color' => fake()->unique()->hexColor(),
'width' => fake()->numberBetween(1, 2),
'order' => self::$order++,
'shelf' => fake()->boolean(75),
'status' => fake()->boolean(75)
'name' => 'gamme ' . fake()->unique()->streetName(),
'color' => fake()->unique()->hexColor(),
'width' => fake()->numberBetween(1, 2),
'order' => self::$order++,
'status' => fake()->boolean(75),
'tenant_id' => Tenant::query()->first('id'),
];
}
}

View file

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@ -21,6 +22,7 @@ class SettingsFactory extends Factory
'footer_phrase' => fake()->unique()->realText(38),
'color' => fake()->unique()->hexColor(),
'home_timeout' => \random_int(20, 100),
'tenant_id' => Tenant::query()->first('id'),
];
}
}

View file

@ -20,8 +20,6 @@ class UserFactory extends Factory
'first_name' => fake()->firstname(),
'last_name' => fake()->lastname(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi',
'remember_token' => Str::random(10),
'role' => \collect(RoleEnum::toArray())->random()->value,
'published' => $published,
@ -37,7 +35,7 @@ class UserFactory extends Factory
public function unverified(): static
{
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return $this->state(fn (array $attributes) => [
return $this->state(fn(array $attributes) => [
'email_verified_at' => null,
]);
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateTenantsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::create('tenants', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name')->comment('Human-readable name of the tenant.');
$table->json('data')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down(): void
{
Schema::dropIfExists('tenants');
}
}

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('email_verified_at');
$table->dropColumn('password');
$table->date('last_connection')->nullable()->after('role');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('email_verified_at')->nullable()->after('email');
$table->string('password')->after('email_verified_at');
$table->dropColumn('last_connection');
});
}
};

View file

@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
$tables = [
['name' => 'activity_logs', 'after' => 'data', 'nullable' => true],
['name' => 'brands', 'after' => 'status', 'nullable' => false],
['name' => 'fonts', 'after' => 'font', 'nullable' => false],
['name' => 'products', 'after' => 'range_id', 'nullable' => false],
['name' => 'ranges', 'after' => 'font_id', 'nullable' => false],
['name' => 'settings', 'after' => 'home_timeout', 'nullable' => false],
];
foreach ($tables as $entry) {
$tableName = $entry['name'];
$after = $entry['after'];
$nullable = $entry['nullable'];
Schema::table($tableName, function (Blueprint $table) use ($after, $nullable) {
$table->string('tenant_id')
->nullable($nullable)
->index()
->after($after)
->comment('Tenant reference.');
});
Schema::table($tableName, function (Blueprint $table) use ($tableName) {
$table->foreign('tenant_id', $tableName . '_tenant_id_foreign')
->references('id')
->on('tenants')
->onUpdate('cascade')
->onDelete('cascade');
});
} //end foreach
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down(): void
{
$tables = [
'activity_logs',
'brands',
'fonts',
'products',
'ranges',
'settings',
];
foreach ($tables as $entry) {
Schema::table($entry, function (Blueprint $table) {
$table->dropColumn('tenant_id');
});
}
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['tenant_id', 'email']);
$table->unique('email');
$table->dropColumn('tenant_id');
});
}
};

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('tenant_user', function (Blueprint $table) {
$table->unsignedBigInteger('user_id');
$table->string('tenant_id', 255);
$table->primary(['user_id', 'tenant_id']);
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('tenant_user');
}
};

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('brands', function (Blueprint $table) {
$table->dropColumn('shelf');
});
Schema::table('ranges', function (Blueprint $table) {
$table->dropColumn('shelf');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('brands', function (Blueprint $table) {
$table->boolean('shelf')->comment('Les enfant sont rendus sur un étagere.');
});
Schema::table('ranges', function (Blueprint $table) {
$table->boolean('shelf')->comment('Les enfant sont rendus sur un étagere.');
});
}
};

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->text('locales')->comment('Selected locales used in the application.')->after('id');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->dropColumn('locales');
});
}
};

View file

@ -2,12 +2,12 @@
namespace Database\Seeders\Development;
use App\Lib\Helpers\FileStorageHelper;
use App\Models\Brand;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Uwil\LaravelCommon\Helpers\FileHelper;
class BrandSeeder extends Seeder
{
@ -24,11 +24,12 @@ class BrandSeeder extends Seeder
File::deleteDirectory(\storage_path('app/public/modelfiles/' . (new Brand())->getTable()));
Brand::factory(fake()->randomDigitNot([0, 1, 2, 3]))->make()->each(function (Brand $brand) {
$brand->picture = FileStorageHelper::storeFile(
$brand->picture = FileHelper::storeFile(
new Brand(),
new \SplFileInfo(
\resource_path('../database/factories/assets/brands/default-picture-600x350.jpg')
)
),
prefix: $brand->tenant_id
);
$brand->save();
});

View file

@ -2,11 +2,11 @@
namespace Database\Seeders\Development;
use App\Lib\Helpers\FileStorageHelper;
use App\Models\Font;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Uwil\LaravelCommon\Helpers\FileHelper;
class FontSeeder extends Seeder
{
@ -21,15 +21,18 @@ class FontSeeder extends Seeder
collect(['Kanit-Regular.woff2', 'RubikBurned-Regular.woff2'])->each(function (string $font_filename) {
$font_basename = Str::of($font_filename)->explode('.')->get(0);
Font::factory(1)->makeOne([
$font = Font::factory(1)->makeOne();
$font->fill([
'font_name' => Str::slug($font_basename),
'font' => FileStorageHelper::storeFile(
'font' => FileHelper::storeFile(
new Font(),
new \SplFileInfo(
\resource_path("../database/factories/assets/fonts/{$font_filename}")
)
),
prefix: $font->tenant_id
),
])->saveOrFail();
]);
$font->saveOrFail();
});
}
}

View file

@ -2,13 +2,13 @@
namespace Database\Seeders\Development;
use App\Lib\Helpers\FileStorageHelper;
use App\Models\Product;
use App\Models\Range;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Uwil\LaravelCommon\Helpers\FileHelper;
class ProductSeeder extends Seeder
{
@ -30,11 +30,12 @@ class ProductSeeder extends Seeder
foreach ($rangeList as $rangeModel) {
Product::factory($hasDoneOne === 0 ? 1 : \random_int(6, 10))->make()
->each(function (Product $product) use ($rangeModel) {
$product->picture = FileStorageHelper::storeFile(
$product->picture = FileHelper::storeFile(
new Product(),
new \SplFileInfo(
\resource_path('../database/factories/assets/products/default-picture-800x800.jpg')
)
),
prefix: $product->tenant_id
);
$product->range()->associate($rangeModel);
$product->save();

View file

@ -2,7 +2,6 @@
namespace Database\Seeders\Development;
use App\Lib\Helpers\FileStorageHelper;
use App\Models\Brand;
use App\Models\Font;
use App\Models\Range;
@ -10,6 +9,7 @@ use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Uwil\LaravelCommon\Helpers\FileHelper;
class RangeSeeder extends Seeder
{
@ -28,21 +28,23 @@ class RangeSeeder extends Seeder
$fonts = Font::all();
Brand::all()->each(
fn (Brand $brand) =>
fn(Brand $brand) =>
Range::factory(\random_int(6, 16))->make()->each(function (Range $range) use ($brand, $fonts) {
if ($range->width === 1) {
$range->picture = FileStorageHelper::storeFile(
$range->picture = FileHelper::storeFile(
new Range(),
new \SplFileInfo(
\resource_path('../database/factories/assets/ranges/default-picture-600x500.jpg')
)
),
prefix: $range->tenant_id
);
} else {
$range->picture = FileStorageHelper::storeFile(
$range->picture = FileHelper::storeFile(
new Range(),
new \SplFileInfo(
\resource_path('../database/factories/assets/ranges/default-picture-1300x500.jpg')
)
),
prefix: $range->tenant_id
);
}

View file

@ -2,9 +2,9 @@
namespace Database\Seeders\Development\Realistic;
use App\Lib\Helpers\FileStorageHelper;
use App\Models\Brand;
use Illuminate\Database\Seeder;
use Uwil\LaravelCommon\Helpers\FileHelper;
class BrandSeeder extends Seeder
{
@ -17,16 +17,16 @@ class BrandSeeder extends Seeder
{
$brand = Brand::factory()->makeOne();
$brand->fill([
'name' => 'Los Pollos Hermanos',
'color' => '#FFFFFF',
'shelf' => true,
'name' => 'Los Pollos Hermanos',
'color' => '#FFFFFF',
'status' => true,
]);
$brand->picture = FileStorageHelper::storeFile(
$brand->picture = FileHelper::storeFile(
new Brand(),
new \SplFileInfo(
\resource_path('../database/factories/assets/brands/los_pollos_hermanos.png')
)
),
prefix: $brand->tenant_id
);
$brand->saveOrFail();
}

View file

@ -2,11 +2,11 @@
namespace Database\Seeders\Development\Realistic;
use App\Lib\Helpers\FileStorageHelper;
use App\Models\Font;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Uwil\LaravelCommon\Helpers\FileHelper;
class FontSeeder extends Seeder
{
@ -20,15 +20,18 @@ class FontSeeder extends Seeder
collect(['Montserrat-Regular.woff2'])->each(function (string $font_filename) {
File::delete(\storage_path('app/public/modelfiles/' . (new Font())->getTable() . "/{$font_filename}"));
$font_basename = Str::of($font_filename)->explode('.')->get(0);
Font::factory(1)->makeOne([
$font = Font::factory(1)->makeOne();
$font->fill([
'font_name' => Str::slug($font_basename),
'font' => FileStorageHelper::storeFile(
'font' => FileHelper::storeFile(
new Font(),
new \SplFileInfo(
\resource_path("../database/factories/assets/fonts/{$font_filename}")
)
),
prefix: $font->tenant_id
),
])->saveOrFail();
]);
$font->saveOrFail();
});
}
}

View file

@ -2,11 +2,11 @@
namespace Database\Seeders\Development\Realistic;
use App\Lib\Helpers\FileStorageHelper;
use App\Models\Brand;
use App\Models\Product;
use App\Models\Range;
use Illuminate\Database\Seeder;
use Uwil\LaravelCommon\Helpers\FileHelper;
class ProductSeeder extends Seeder
{
@ -18,35 +18,46 @@ class ProductSeeder extends Seeder
public function run(): void
{
$productsNameList = collect([
'Basic', 'Westsite', 'New Mexico', 'Albuquerque', 'South Valley',
'Taos', 'Rio Grande', 'Supreme', 'Sante Fe', 'Vegetarian'
'Basic',
'Westsite',
'New Mexico',
'Albuquerque',
'South Valley',
'Taos',
'Rio Grande',
'Supreme',
'Sante Fe',
'Vegetarian'
]);
$brand = Brand::query()->whereRaw("JSON_EXTRACT(name, '$.fr') = 'Los Pollos Hermanos'")->firstOrFail();
$brand->ranges->each(
// @phpstan-ignore-next-line
fn (Range $range) => Product::factory($productsNameList->count())->make()
fn(Range $range) => Product::factory($productsNameList->count())->make()
->each(function (Product $product, int $index) use ($range, $productsNameList) {
$product->name = $productsNameList->pull($index);
$product->setRawAttributes([
'name' => \json_encode([
'fr' => 'FR ' . $product->name,
'nl' => 'NL ' . $product->name,
'en' => 'EN ' . $product->name,
]),
'description' => \json_encode([
'fr' => 'FR ' . $product->description,
'nl' => 'NL ' . $product->description,
'en' => 'EN ' . $product->description,
])
$product->setTranslations('name', [
'fr' => 'FR ' . $product->name,
'nl' => 'NL ' . $product->name,
'en' => 'EN ' . $product->name,
'es' => 'ES ' . $product->name,
'de' => 'DE ' . $product->name,
]);
$product->picture = FileStorageHelper::storeFile(
$product->setTranslations('description', [
'fr' => 'FR ' . $product->description,
'nl' => 'NL ' . $product->description,
'en' => 'EN ' . $product->description,
'es' => 'ES ' . $product->description,
'de' => 'DE ' . $product->description,
]);
$product->picture = FileHelper::storeFile(
new Product(),
new \SplFileInfo(
\resource_path('../database/factories/assets/products/default-picture-800x800.jpg')
)
),
prefix: $product->tenant_id
);
$product->status = true;
$product->status = true;
$product->range()->associate($range);
$product->save();
})

View file

@ -2,12 +2,12 @@
namespace Database\Seeders\Development\Realistic;
use App\Lib\Helpers\FileStorageHelper;
use App\Models\Brand;
use App\Models\Font;
use App\Models\Range;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
use Uwil\LaravelCommon\Helpers\FileHelper;
class RangeSeeder extends Seeder
{
@ -22,24 +22,24 @@ class RangeSeeder extends Seeder
$brand = Brand::query()->whereRaw("JSON_EXTRACT(name, '$.fr') = 'Los Pollos Hermanos'")->firstOrFail();
$font = Font::query()->where('font_name', Str::slug('Montserrat-Regular'))->firstOrFail();
$range = Range::factory()->makeOne();
$range->setRawAttributes([
'name' => \json_encode([
'fr' => 'Burritos',
'nl' => 'Burrito\'s',
'en' => 'Burritos',
])
$range->setTranslations('name', [
'fr' => 'Burritos',
'nl' => 'Burrito\'s',
'en' => 'Burritos',
'es' => 'Burritos',
'de' => 'Burritos',
]);
$range->fill([
'picture' => FileStorageHelper::storeFile(
'picture' => FileHelper::storeFile(
new Range(),
new \SplFileInfo(
\resource_path("../database/factories/assets/ranges/burittos.jpg")
)
),
prefix: $range->tenant_id
),
'color' => '#311e91',
'width' => 1,
'shelf' => true,
'status' => true
'color' => '#311e91',
'width' => 1,
'status' => true
]);
$range->brand()->associate($brand);
$range->font()->associate($font);

View file

@ -2,13 +2,13 @@
namespace Database\Seeders\Mandatory;
use App\Lib\Helpers\FileStorageHelper;
use App\Models\Settings;
use Illuminate\Database\Seeder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Uwil\LaravelCommon\Helpers\FileHelper;
class SettingsSeeder extends Seeder
{
@ -25,11 +25,12 @@ class SettingsSeeder extends Seeder
File::deleteDirectory(\storage_path('app/public/modelfiles/' . (new Settings())->getTable()));
Settings::factory(1)->make()->each(function (Settings $settings) {
$settings->logo = FileStorageHelper::storeFile(
$settings->logo = FileHelper::storeFile(
new Settings(),
new \SplFileInfo(
\resource_path('../database/factories/assets/settings/default-picture-200x200.jpg')
)
),
prefix: $settings->tenant_id
);
$attributes = $settings->getAttributes();
$settings->setRawAttributes([
@ -37,11 +38,15 @@ class SettingsSeeder extends Seeder
'fr' => 'FR ' . $settings->sub_title,
'nl' => 'NL ' . $settings->sub_title,
'en' => 'EN ' . $settings->sub_title,
'es' => 'ES ' . $settings->sub_title,
'de' => 'DE ' . $settings->sub_title,
]),
'footer_phrase' => \json_encode([
'fr' => 'FR ' . $settings->footer_phrase,
'nl' => 'NL ' . $settings->footer_phrase,
'en' => 'EN ' . $settings->footer_phrase,
'es' => 'ES ' . $settings->footer_phrase,
'de' => 'DE ' . $settings->footer_phrase,
])
]);
$settings->fill(Arr::except($attributes, ['sub_title', 'footer_phrase']));

View file

@ -0,0 +1,21 @@
<?php
namespace Database\Seeders\Mandatory;
use App\Models\Tenant;
use Illuminate\Database\Seeder;
class TenantSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run(): void
{
if (!Tenant::count()) {
Tenant::create(['name' => 'Tenant 1']);
}
}
}

View file

@ -4,6 +4,7 @@ namespace Database\Seeders;
use Database\Seeders\Mandatory\FontSeeder;
use Database\Seeders\Mandatory\SettingsSeeder;
use Database\Seeders\Mandatory\TenantSeeder;
use Illuminate\Database\Seeder;
class MandatorySeeder extends Seeder
@ -16,6 +17,7 @@ class MandatorySeeder extends Seeder
public function run(): void
{
$this->call([
TenantSeeder::class,
SettingsSeeder::class,
FontSeeder::class
]);

30
k8s-queues.sh Normal file
View file

@ -0,0 +1,30 @@
#!/usr/bin/env bash
# From https://stackoverflow.com/a/76488746/17320077
work=true
function stopQueues() {
echo "Stopping queues..."
work=false
php artisan queue:restart
sleep 45s
exit 0
}
# handle graceful shutdown
trap "" SIGPIPE
trap stopQueues EXIT
echo "Starting PHP queue workers..."
while $work; do
php artisan queue:work database --queue=default,database --sleep=3 --tries=3 --max-time=3600 --no-interaction
sleep 1
done
# Si besoin de plusieurs threads puis terminer par un done sans &
#(while $work; do
# php artisan queue:work --queue="$(grep -oP 'SQS_QUEUE=\K.*' .env)" --no-interaction
# sleep 1
#done &)

View file

@ -1,4 +1,3 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
@ -10,7 +9,8 @@ data:
APP_DEBUG: "${APP_APP_DEBUG}"
APP_URL: "https://${APP_DOMAIN}"
LOG_CHANNEL: "k8s"
DB_HOST: "mariadb-service.common-services"
DB_HOST: "${APP_GLOBAL_OVH_MYSQL_HOST}"
DB_PORT: "${APP_GLOBAL_OVH_MYSQL_PORT}"
DB_DATABASE: "${APP_DB_DATABASE}"
DB_USERNAME: "${APP_DB_USERNAME}"
DB_PASSWORD: "${APP_DB_PASSWORD}"
@ -21,24 +21,17 @@ data:
MAIL_ENCRYPTION: "${APP_MAIL_ENCRYPTION}"
MAIL_FROM_ADDRESS: "${APP_MAIL_FROM_ADDRESS}"
MAIL_FROM_NAME: "${APP_APP_NAME}"
CRM_ENDPOINT: "${APP_CRM_ENDPOINT}"
CRM_TOKEN: "${APP_CRM_TOKEN}"
CRM_PASSWORD: "${APP_CRM_PASSWORD}"
MESBR_REF_SITE: "${APP_MESBR_REF_SITE}"
MESBR_REF_CAMP: "${APP_MESBR_REF_CAMP}"
MESBR_ENDPOINT: "${APP_MESBR_ENDPOINT}"
LDC_WEB_API_KEY: "${APP_LDC_WEB_API_KEY}"
GOOGLE_RECAPTCHA_PRIVATE: "${APP_GOOGLE_RECAPTCHA_PRIVATE}"
GOOGLE_RECAPTCHA_PUBLIC: "${APP_GOOGLE_RECAPTCHA_PUBLIC}"
GOOGLE_RECAPTCHA_VERSION: "${APP_GOOGLE_RECAPTCHA_VERSION}"
MAIL_TO_ADDRESS: "${APP_MAIL_TO_ADDRESS}"
MAIL_TO_NAME: "${APP_MAIL_TO_NAME}"
SENTRY_LARAVEL_DSN: "${APP_SENTRY_LARAVEL_DSN}"
MATOMO_SITE_ID: "${APP_MATOMO_SITE_ID}"
MAIL_CONTACT_RECLAIM: "${APP_MAIL_CONTACT_RECLAIM}"
MAIL_CONTACT_INFO: "${APP_MAIL_CONTACT_INFO}"
MAIL_CONTACT_ASSOCIATIVE: "${APP_MAIL_CONTACT_ASSOCIATIVE}"
MAIL_CONTACT_PARTNERSHIP: "${APP_MAIL_CONTACT_PARTNERSHIP}"
MAIL_CONTACT_COMMERCIAL: "${APP_MAIL_CONTACT_COMMERCIAL}"
MAIL_CONTACT_VARIOUS: "${APP_MAIL_CONTACT_VARIOUS}"
LDC_WEB_API_KEY: "${APP_LDC_WEB_API_KEY}"
SAML2_SP_CERT_x509: "${APP_SAML2_SP_CERT_x509}"
SAML2_SP_CERT_PRIVATEKEY: "${APP_SAML2_SP_CERT_PRIVATEKEY}"
SAML2_CONTACT_TECHNICAL_EMAIL: "${APP_SAML2_CONTACT_TECHNICAL_EMAIL}"
SAML2_CONTACT_SUPPORT_EMAIL: "${APP_SAML2_CONTACT_SUPPORT_EMAIL}"
SAML2_DEFAULT_TENANT_UUID: "${APP_SAML2_DEFAULT_TENANT_UUID}"
CACHE_DRIVER: "${APP_CACHE_DRIVER}"
UWIL_ADMIN_SECURE_CIDR_LIST: "${APP_GLOBAL_UWIL_ADMIN_SECURE_CIDR_LIST}"
---
apiVersion: v1
@ -52,7 +45,6 @@ spec:
resources:
requests:
storage: $APP_PVC_SIZE
---
apiVersion: apps/v1
kind: Deployment
@ -71,6 +63,41 @@ spec:
containers:
- name: ${CI_PROJECT_PATH_SLUG}-container
image: $IMAGE_TAG
envFrom:
- configMapRef:
name: ${CI_PROJECT_PATH_SLUG}-config-map
resources:
limits:
cpu: "${APP_CI_CPU_LIMIT}"
memory: "${APP_CI_MEMORY_LIMIT}"
requests:
cpu: "${APP_CI_CPU_REQUEST}"
memory: "${APP_CI_MEMORY_REQUEST}"
volumeMounts:
- name: storage
mountPath: /app/srcs/storage
- name: ${CI_PROJECT_PATH_SLUG}-queue-worker
image: $IMAGE_TAG
lifecycle:
preStop:
exec:
command:
[
"/bin/sh",
"-c",
"kill -s TERM 1; while test $(ps ax | grep -v grep | grep bash | wc -l) -gt 0; do sleep 2s; done",
]
resources:
limits:
cpu: "${APP_CI_CPU_LIMIT}"
memory: "${APP_CI_MEMORY_LIMIT}"
requests:
cpu: "${APP_CI_CPU_REQUEST}"
memory: "${APP_CI_MEMORY_REQUEST}"
command:
- "bash"
args:
- "k8s-queues.sh"
envFrom:
- configMapRef:
name: ${CI_PROJECT_PATH_SLUG}-config-map
@ -80,15 +107,14 @@ spec:
imagePullSecrets:
- name: docker-registry-credentials
securityContext:
runAsUser: 33
runAsGroup: 33
fsGroup: 33
runAsUser: 82
runAsGroup: 82
fsGroup: 82
fsGroupChangePolicy: "OnRootMismatch"
volumes:
- name: storage
persistentVolumeClaim:
claimName: ${CI_PROJECT_PATH_SLUG}-pvc-storage
---
apiVersion: v1
kind: Service
@ -99,10 +125,9 @@ spec:
app: ${CI_PROJECT_PATH_SLUG}-app
ports:
- port: 80
targetPort: 443
targetPort: 80
---
apiVersion: traefik.containo.us/v1alpha1
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: ${CI_PROJECT_PATH_SLUG}-ingress-route
@ -117,21 +142,7 @@ spec:
- name: ${CI_PROJECT_PATH_SLUG}-service
port: 80
tls:
secretName: $APP_DOMAIN
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: $APP_DOMAIN
spec:
dnsNames:
- $APP_DOMAIN
secretName: $APP_DOMAIN
issuerRef:
name: $APP_CERT_ISSUER
kind: ClusterIssuer
secretName: ${CRT_TLS_NAME}
---
apiVersion: batch/v1
kind: CronJob
@ -139,6 +150,9 @@ metadata:
name: ${CI_PROJECT_PATH_SLUG}-cronjob
spec:
schedule: "* * * * *"
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
@ -176,8 +190,8 @@ spec:
persistentVolumeClaim:
claimName: ${CI_PROJECT_PATH_SLUG}-pvc-storage
securityContext:
runAsUser: 33
runAsGroup: 33
fsGroup: 33
runAsUser: 82
runAsGroup: 82
fsGroup: 82
fsGroupChangePolicy: "OnRootMismatch"
restartPolicy: OnFailure

View file

@ -29,8 +29,7 @@
<rule ref="Generic.Files.LineLength"/>
<rule ref="Generic.Formatting.DisallowMultipleStatements"/>
<rule ref="Generic.Formatting.MultipleStatementAlignment"/>
<rule ref="Generic.Formatting.NoSpaceAfterCast"/>
<rule ref="Generic.Functions.CallTimePassByReference"/>
<rule ref="Generic.Formatting.SpaceAfterCast"/>
<rule ref="Generic.Functions.FunctionCallArgumentSpacing"/>
<rule ref="Generic.Metrics.CyclomaticComplexity"/>
<rule ref="Generic.Metrics.NestingLevel"/>
@ -43,10 +42,8 @@
<rule ref="Generic.PHP.NoSilencedErrors"/>
<rule ref="Generic.Strings.UnnecessaryStringConcat"/>
<rule ref="Generic.WhiteSpace.DisallowTabIndent"/>
<rule ref="Generic.WhiteSpace.LanguageConstructSpacing"/>
<rule ref="Generic.WhiteSpace.ScopeIndent"/>
<rule ref="MySource.PHP.EvalObjectFactory"/>
<rule ref="MySource.PHP.GetRequestData"/>
<rule ref="MySource.PHP.AjaxNullComparison"/>
<rule ref="PEAR.Classes.ClassDeclaration"/>
<rule ref="PEAR.Commenting.InlineComment"/>
<rule ref="PSR1.Classes.ClassDeclaration"/>
@ -79,7 +76,6 @@
<rule ref="Squiz.Scope.MemberVarScope"/>
<rule ref="Squiz.Scope.MethodScope"/>
<rule ref="Squiz.Scope.StaticThisUsage"/>
<rule ref="Squiz.WhiteSpace.LanguageConstructSpacing"/>
<rule ref="Squiz.WhiteSpace.LogicalOperatorSpacing"/>
<rule ref="Squiz.WhiteSpace.MemberVarSpacing">
<properties>

View file

@ -8,3 +8,6 @@ parameters:
# Level 9 is the highest level
level: 5
ignoreErrors:
- '#Trait App\\Traits\\Models\\MiniCrudModel is used zero times and is not analysed#'

11
production-resources.yaml Normal file
View file

@ -0,0 +1,11 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: $APP_DOMAIN
spec:
dnsNames:
- $APP_DOMAIN
secretName: $APP_DOMAIN
issuerRef:
name: $APP_CERT_ISSUER
kind: ClusterIssuer

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