commit d36edf88cf1bfa950cba478b0031a07f61b4ea7b Author: Simon Vieille Date: Sun Mar 6 17:51:10 2022 +0100 init diff --git a/.env b/.env new file mode 100644 index 0000000..dcac2e3 --- /dev/null +++ b/.env @@ -0,0 +1,32 @@ +# In all environments, the following files are loaded if they exist, +# the latter taking precedence over the former: +# +# * .env contains default values for the environment variables needed by the app +# * .env.local uncommitted file with local overrides +# * .env.$APP_ENV committed environment-specific defaults +# * .env.$APP_ENV.local uncommitted environment-specific overrides +# +# Real environment variables win over .env files. +# +# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. +# +# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). +# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration + +###> symfony/framework-bundle ### +APP_ENV=dev +APP_SECRET= +###< symfony/framework-bundle ### + +###> symfony/mailer ### +MAILER_DSN=smtp://localhost +###< symfony/mailer ### + +###> doctrine/doctrine-bundle ### +# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url +# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml +# +DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" +# DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7" +# DATABASE_URL="postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=13&charset=utf8" +###< doctrine/doctrine-bundle ### diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..9e7162f --- /dev/null +++ b/.env.test @@ -0,0 +1,6 @@ +# define your env variables for the test env here +KERNEL_CLASS='App\Kernel' +APP_SECRET='$ecretf0rt3st' +SYMFONY_DEPRECATIONS_HELPER=999999 +PANTHER_APP_ENV=panther +PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..2affb30 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,19 @@ +{ + "env": { + "browser": true, + "commonjs": true, + "es2021": true + }, + "extends": [ + "plugin:vue/essential", + "standard" + ], + "parserOptions": { + "ecmaVersion": 12 + }, + "plugins": [ + "vue" + ], + "rules": { + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2d6405 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ + +###> symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/public/js/ +/src/Command/TestCommand.php +/var/ +/vendor/ +###< symfony/framework-bundle ### + +###> symfony/phpunit-bridge ### +.phpunit +.phpunit.result.cache +/phpunit.xml +###< symfony/phpunit-bridge ### + +###> symfony/webpack-encore-bundle ### +/node_modules/ +/public/build/ +npm-debug.log +yarn-error.log +###< symfony/webpack-encore-bundle ### + +/public/uploads/* +!/public/uploads/.gitkeep +/public/media/ +/migrations/* +!/migrations/.gitkeep diff --git a/.php-version b/.php-version new file mode 100644 index 0000000..cc40bca --- /dev/null +++ b/.php-version @@ -0,0 +1 @@ +8.0 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b76e38e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,97 @@ +## [Unreleased] + +### Added +### Fixed +### Changed + +## [1.7.2] - 2022-03-03 +### Added +* add templates to render sections and items in the admin menu +### Fixed +* fix the analytic table when a path is a long + +## [1.7.1] - 2022-03-01 +### Added +* add translations +### Fixed +* fix missing directories + +## [1.7.0] - 2022-03-01 +### Fixed +* fix the analytic referers table when a referer has a long domain +### Changed +* upgrade dependencies +* move assets to the core directory + +## [1.6.0] - 2022-02-28 +### Added +* add block in field templates to allow override +* merge route params in crud admin redirects +* improve murph:user:create command + +### Fixed +* fix form namespace prefix in the crud controller maker +* fix date field when the value is empty +* fix crud batch column width +* fix sidebar icon width +* fix cache clear task + +### Changed +* remove password generation from the user factory + +## [1.5.0] - 2022-02-25 +### Added +* add desktop views and mobile views + +### Changed +* upgrade dependencies +* replace jaybizzle/crawler-detect with matomo/device-detector + +## [1.4.1] - 2022-02-23 +### Added +* handle app urls in twig routing filters + +### Fixed +* fix views in analytics modal +* replace empty path with "/" in analytics +### Changed +* update default templates + +## [1.4.0] - 2022-02-21 +### Added +* add basic analytics + +## [1.3.0] - 2022-02-19 +### Added +* add support of regexp with substitution in redirect +* url tags can be used as redirect location +* add builders to replace file information tags and url tags + +### Fixed +* fix filemanager sorting +* fix batch action setter + +## [1.2.0] - 2022-02-14 +### Added +* add sort in file manager +* add redirect manager + +### Changed +* replace node-sass with sass + +## [1.1.0] - 2022-02-29 +### Added +* add directory upload in file manager + +### Fixed +* fix admin node routing + +### Changed +* symfony/swiftmailer-bundle is replaced by symfony/mailer + +## [1.0.1] - 2022-02-25 +### Fixed +* fix Makefile environment vars (renaming) +* fix composer minimum stability + +## [1.0.0] - 2022-01-23 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a6d548d --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +COMPOSER_BIN ?= composer +PHP_BIN ?= php8.0 +SSH_BIN ?= ssh +YARN_BIN ?= yarn +NPM_BIN ?= npm + +all: build + +asset-watch: + $(YARN_BIN) + $(NPM_BIN) run watch + +asset: js-routing + $(YARN_BIN) + $(NPM_BIN) run build + +js-routing: doctrine-migration + $(PHP_BIN) bin/console fos:js-routing:dump --format=json --target=public/js/fos_js_routes.json + +clean: + rm -fr var/cache/dev/* + rm -fr var/cache/prod/* + +doctrine-migration: + PHP=$(PHP_BIN) ./bin/doctrine-migrate + +build: clean js-routing asset diff --git a/README.md b/README.md new file mode 100644 index 0000000..c57e005 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Murph + +Muprh is an **open-source CMF** built on top of Symfony that helps you to **build your own CMS with several domains and languages**. It comes with: + +* A fully implemented and customizable **tree manager** ๐ŸŒณ +* A **CRUD generator** โœ๏ธ +* A global **settings manager** and a navigation settings manager โš™๏ธ +* A **tasks manager** ๐Ÿงน +* A basic **web analytics** ๐Ÿ“Š +* **2FA authentication** ๐Ÿ”’ + +**Symfony developers will love build on Murph ๐Ÿงช** + +**End users will be fond of the interface and the powerful tools ๐Ÿ’œ** + +๐Ÿ“— [Read the documentation](https://doc.murph-project.org/) diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..0d80069 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,53 @@ +## [Unreleased] + +## Upgrade to v1.7.0 +### Commands + +``` +yarn add sortablejs@^1.14.0 + +``` + +### Files + +* `assets/css/_admin_extend.scss` is removed +* `assets/css/_admin_vars.scss` is removed +* `assets/css/_admin_vars.scss` is changed +* `assets/js/admin` is removed +* `assets/js/admin.js` is changed + + +## Upgrade to v1.5.0 +### Commands + +``` +composer remove jaybizzle/crawler-detect +composer require matomo/device-detector +make doctrine-migration +``` + +## Upgrade to v1.4.0 +### Commands + +``` +yarn remove node-sass +yarn add sass --dev --save +yarn add chart.js --save +composer require jaybizzle/crawler-detect +make doctrine-migration +make asset +``` + +### Configuration + +``` +// config/services.yaml +services: + App\Core\EventListener\RedirectListener: + tags: + - { name: kernel.event_listener, event: kernel.exception } + + App\Core\EventListener\AnalyticListener: + tags: + - { name: kernel.event_listener, event: kernel.request } +``` diff --git a/assets/css/admin.scss b/assets/css/admin.scss new file mode 100644 index 0000000..96a0afb --- /dev/null +++ b/assets/css/admin.scss @@ -0,0 +1,6 @@ +/* Custom variables */ + +@import "../../core/Resources/assets/css/admin.scss"; + +/* Custom CSS */ + diff --git a/assets/css/app.scss b/assets/css/app.scss new file mode 100644 index 0000000..08c1c99 --- /dev/null +++ b/assets/css/app.scss @@ -0,0 +1,2 @@ +/* CSS of you app */ + diff --git a/assets/images/core/logo.svg b/assets/images/core/logo.svg new file mode 100644 index 0000000..0fcfb57 --- /dev/null +++ b/assets/images/core/logo.svg @@ -0,0 +1,92 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..da5f170 --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1 @@ +import '../../core/Resources/assets/js/admin.js' diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..0fc863e --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1 @@ +import '../css/app.scss' diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..8fe9d49 --- /dev/null +++ b/bin/console @@ -0,0 +1,43 @@ +#!/usr/bin/env php +getParameterOption(['--env', '-e'], null, true)) { + putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env); +} + +if ($input->hasParameterOption('--no-debug', true)) { + putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0'); +} + +(new Dotenv())->bootEnv(dirname(__DIR__).'/.env'); + +if ($_SERVER['APP_DEBUG']) { + umask(0000); + + if (class_exists(Debug::class)) { + Debug::enable(); + } +} + +$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); +$application = new Application($kernel); +$application->run($input); diff --git a/bin/doctrine-migrate b/bin/doctrine-migrate new file mode 100755 index 0000000..42a19a0 --- /dev/null +++ b/bin/doctrine-migrate @@ -0,0 +1,7 @@ +#!/bin/sh + +CLASS_NAME="$(echo "yes" | "$PHP" ./bin/console doctrine:migration:diff -e dev | grep -o "Version[0-9]*" | tail -n 1)" + +if [ -n "$CLASS_NAME" ]; then + echo "yes" | "$PHP" ./bin/console doctrine:migration:exec --up "DoctrineMigrations\\$CLASS_NAME" -e dev +fi diff --git a/bin/phpunit b/bin/phpunit new file mode 100755 index 0000000..4d1ed05 --- /dev/null +++ b/bin/phpunit @@ -0,0 +1,13 @@ +#!/usr/bin/env php +=8.0.0", + "ext-ctype": "*", + "ext-iconv": "*", + "bjeavons/zxcvbn-php": "^1.3", + "cocur/slugify": "^4.1", + "composer/package-versions-deprecated": "1.11.99.1", + "doctrine/annotations": "^1.0", + "doctrine/doctrine-bundle": "^2.5", + "doctrine/doctrine-migrations-bundle": "^3.2", + "doctrine/orm": "^2.11", + "friendsofsymfony/jsrouting-bundle": "^2.8", + "jaybizzle/crawler-detect": "^1.2", + "knplabs/doctrine-behaviors": "^2.6", + "knplabs/knp-paginator-bundle": "^5.8", + "liip/imagine-bundle": "^2.7", + "matomo/device-detector": "^5.0", + "phpdocumentor/reflection-docblock": "^5.3", + "scheb/2fa-google-authenticator": "^5.13", + "scheb/2fa-qr-code": "^5.13", + "sensio/framework-extra-bundle": "^6.2", + "sensiolabs/ansi-to-html": "^1.2", + "spe/filesize-extension-bundle": "~2.0.0", + "stof/doctrine-extensions-bundle": "^1.7", + "symfony/apache-pack": "^1.0", + "symfony/asset": "5.4.*", + "symfony/console": "5.4.*", + "symfony/dotenv": "5.4.*", + "symfony/event-dispatcher": "5.4.*", + "symfony/expression-language": "5.4.*", + "symfony/finder": "5.4.*", + "symfony/flex": "^1.3.1", + "symfony/form": "5.4.*", + "symfony/framework-bundle": "5.4.*", + "symfony/http-client": "5.4.*", + "symfony/intl": "5.4.*", + "symfony/mailer": "5.4.*", + "symfony/mime": "5.4.*", + "symfony/monolog-bundle": "^3.1", + "symfony/notifier": "5.4.*", + "symfony/process": "5.4.*", + "symfony/property-access": "5.4.*", + "symfony/property-info": "5.4.*", + "symfony/proxy-manager-bridge": "5.4.*", + "symfony/security-bundle": "5.4.*", + "symfony/serializer": "5.4.*", + "symfony/string": "5.4.*", + "symfony/translation": "5.4.*", + "symfony/twig-bundle": "^5.2", + "symfony/validator": "5.4.*", + "symfony/web-link": "5.4.*", + "symfony/webpack-encore-bundle": "^1.11", + "symfony/yaml": "5.4.*", + "twig/extra-bundle": "^2.12|^3.3", + "twig/twig": "^2.12|^3.3" + }, + "require-dev": { + "symfony/browser-kit": "^5.4", + "symfony/css-selector": "^5.4", + "symfony/debug-bundle": "^5.4", + "symfony/maker-bundle": "^1.0", + "symfony/phpunit-bridge": "^5.4", + "symfony/stopwatch": "^5.4", + "symfony/var-dumper": "^5.4", + "symfony/web-profiler-bundle": "^5.4" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "symfony/flex": true + } + }, + "autoload": { + "psr-4": { + "App\\": "src/", + "App\\Core\\": "core/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "replace": { + "symfony/polyfill-ctype": "*", + "symfony/polyfill-iconv": "*", + "symfony/polyfill-php72": "*" + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" + }, + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ] + }, + "conflict": { + "symfony/symfony": "*" + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "5.4.*" + } + } +} diff --git a/config/bundles.php b/config/bundles.php new file mode 100644 index 0000000..15f9428 --- /dev/null +++ b/config/bundles.php @@ -0,0 +1,25 @@ + ['all' => true], + Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true], + Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], + Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], + Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], + Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], + Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], + Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], + Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], + Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], + Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], + Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], + Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true], + Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true], + Knp\DoctrineBehaviors\DoctrineBehaviorsBundle::class => ['all' => true], + FOS\JsRoutingBundle\FOSJsRoutingBundle::class => ['all' => true], + SPE\FilesizeExtensionBundle\SPEFilesizeExtensionBundle::class => ['all' => true], + Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true], + App\Core\Bundle\CoreBundle::class => ['all' => true], + App\Bundle\AppBundle::class => ['all' => true], +]; diff --git a/config/packages/ansi_to_html.yaml b/config/packages/ansi_to_html.yaml new file mode 100644 index 0000000..78e983c --- /dev/null +++ b/config/packages/ansi_to_html.yaml @@ -0,0 +1,6 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + SensioLabs\AnsiConverter\Bridge\Twig\AnsiExtension: null diff --git a/config/packages/app.yaml b/config/packages/app.yaml new file mode 100644 index 0000000..07a8def --- /dev/null +++ b/config/packages/app.yaml @@ -0,0 +1,42 @@ +core: + site: + name: "Murph" + logo: "build/images/core/logo.svg" + # controllers: + # - {name: 'Foo', action: 'App\Controller\ExampleController::foo'} + pages: + App\Entity\Page\SimplePage: + name: 'Simple page' + templates: + - {name: "Default", file: "page/simple/default.html.twig"} + file_manager: + # mimes: + # - image/png + # - image/jpg + # - image/jpeg + # - image/gif + # - image/svg+xml + # - video/mp4 + # - audio/mpeg3 + # - audio/x-mpeg-3 + # - multipart/x-zip + # - multipart/x-gzip + # - application/pdf + # - application/ogg + # - application/zip + # - application/rar + # - application/x-rar-compressed + # - application/x-zip-compressed + # - application/tar + # - application/x-tar + # - application/x-bzip + # - application/x-bzip2 + # - application/x-gzip + # - application/octet-stream + # - application/msword + # - text/plain + # - text/css + # path: "%kernel.project_dir%/public/uploads" + # path_uri: "/uploads" + # path_locked: + # - "%kernel.project_dir%/public/uploads" diff --git a/config/packages/assets.yaml b/config/packages/assets.yaml new file mode 100644 index 0000000..051d36d --- /dev/null +++ b/config/packages/assets.yaml @@ -0,0 +1,3 @@ +framework: + assets: + json_manifest_path: '%kernel.project_dir%/public/build/manifest.json' diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml new file mode 100644 index 0000000..6899b72 --- /dev/null +++ b/config/packages/cache.yaml @@ -0,0 +1,19 @@ +framework: + cache: + # Unique name of your app: used to compute stable namespaces for cache keys. + #prefix_seed: your_vendor_name/app_name + + # The "app" cache stores to the filesystem by default. + # The data in this cache should persist between deploys. + # Other options include: + + # Redis + #app: cache.adapter.redis + #default_redis_provider: redis://localhost + + # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) + #app: cache.adapter.apcu + + # Namespaced pools use the above "app" backend by default + #pools: + #my.dedicated.cache: null diff --git a/config/packages/dev/debug.yaml b/config/packages/dev/debug.yaml new file mode 100644 index 0000000..26d4e53 --- /dev/null +++ b/config/packages/dev/debug.yaml @@ -0,0 +1,4 @@ +debug: + # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. + # See the "server:dump" command to start a new server. + dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" diff --git a/config/packages/dev/monolog.yaml b/config/packages/dev/monolog.yaml new file mode 100644 index 0000000..b1998da --- /dev/null +++ b/config/packages/dev/monolog.yaml @@ -0,0 +1,19 @@ +monolog: + handlers: + main: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + channels: ["!event"] + # uncomment to get logging in your browser + # you may have to allow bigger header sizes in your Web server configuration + #firephp: + # type: firephp + # level: info + #chromephp: + # type: chromephp + # level: info + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine", "!console"] diff --git a/config/packages/dev/web_profiler.yaml b/config/packages/dev/web_profiler.yaml new file mode 100644 index 0000000..e92166a --- /dev/null +++ b/config/packages/dev/web_profiler.yaml @@ -0,0 +1,6 @@ +web_profiler: + toolbar: true + intercept_redirects: false + +framework: + profiler: { only_exceptions: false } diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml new file mode 100644 index 0000000..103af1c --- /dev/null +++ b/config/packages/doctrine.yaml @@ -0,0 +1,30 @@ +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' + + # IMPORTANT: You MUST configure your server version, + # either here or in the DATABASE_URL env var (see .env file) + #server_version: '13' + orm: + auto_generate_proxy_classes: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + auto_mapping: true + mappings: + App\Core\Entity: + is_bundle: false + type: annotation + dir: '%kernel.project_dir%/core/Entity' + prefix: 'App\Core\Entity' + alias: App\Core\Entity + App\Entity: + is_bundle: false + type: annotation + dir: '%kernel.project_dir%/src/Entity' + prefix: 'App\Entity' + alias: App\Entity + gedmo_tree: + type: annotation + prefix: Gedmo\Tree\Entity + dir: "%kernel.project_dir%/vendor/gedmo/doctrine-extensions/src/Tree/Entity" + alias: GedmoTree # (optional) it will default to the name set for the mapping + is_bundle: false diff --git a/config/packages/doctrine_migrations.yaml b/config/packages/doctrine_migrations.yaml new file mode 100644 index 0000000..61e6612 --- /dev/null +++ b/config/packages/doctrine_migrations.yaml @@ -0,0 +1,5 @@ +doctrine_migrations: + migrations_paths: + # namespace is arbitrary but should be different from App\Migrations + # as migrations classes should NOT be autoloaded + 'DoctrineMigrations': '%kernel.project_dir%/migrations' diff --git a/config/packages/fos_js_routing.yaml b/config/packages/fos_js_routing.yaml new file mode 100644 index 0000000..0268498 --- /dev/null +++ b/config/packages/fos_js_routing.yaml @@ -0,0 +1,3 @@ +fos_js_routing: + routes_to_expose: + - liip_imagine_filter diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml new file mode 100644 index 0000000..cad7f78 --- /dev/null +++ b/config/packages/framework.yaml @@ -0,0 +1,17 @@ +# see https://symfony.com/doc/current/reference/configuration/framework.html +framework: + secret: '%env(APP_SECRET)%' + #csrf_protection: true + #http_method_override: true + + # Enables session support. Note that the session will ONLY be started if you read or write from it. + # Remove or comment this section to explicitly disable session support. + session: + handler_id: null + cookie_secure: auto + cookie_samesite: lax + + #esi: true + #fragments: true + php_errors: + log: true diff --git a/config/packages/knp.yaml b/config/packages/knp.yaml new file mode 100644 index 0000000..894d10d --- /dev/null +++ b/config/packages/knp.yaml @@ -0,0 +1,11 @@ +knp_paginator: + page_range: 4 + default_options: + page_name: page # page query parameter name + sort_field_name: sort # sort field query parameter name + sort_direction_name: direction # sort direction query parameter name + distinct: true # ensure distinct results, useful when ORM queries are using GROUP BY statements + template: + pagination: '@Core/pager/sliding.html.twig' # sliding pagination controls template + sortable: '@KnpPaginator/Pagination/sortable_link.html.twig' # sort link template + filtration: '@KnpPaginator/Pagination/filtration.html.twig' # filters template diff --git a/config/packages/liip_imagine.yaml b/config/packages/liip_imagine.yaml new file mode 100644 index 0000000..f201244 --- /dev/null +++ b/config/packages/liip_imagine.yaml @@ -0,0 +1,9 @@ +# See dos how to configure the bundle: https://symfony.com/doc/current/bundles/LiipImagineBundle/basic-usage.html +liip_imagine: + # valid drivers options include "gd" or "gmagick" or "imagick" + driver: "imagick" + filter_sets: + file_manager_thumbnail_filter: + filters: + downscale: + max: [120, 120] diff --git a/config/packages/mailer.yaml b/config/packages/mailer.yaml new file mode 100644 index 0000000..d6863f8 --- /dev/null +++ b/config/packages/mailer.yaml @@ -0,0 +1,5 @@ +framework: + mailer: + dsn: '%env(MAILER_DSN)%' + headers: + From: 'Example ' diff --git a/config/packages/notifier.yaml b/config/packages/notifier.yaml new file mode 100644 index 0000000..3984a48 --- /dev/null +++ b/config/packages/notifier.yaml @@ -0,0 +1,16 @@ +framework: + notifier: + #chatter_transports: + # slack: '%env(SLACK_DSN)%' + # telegram: '%env(TELEGRAM_DSN)%' + #texter_transports: + # twilio: '%env(TWILIO_DSN)%' + # nexmo: '%env(NEXMO_DSN)%' + channel_policy: + # use chat/slack, chat/telegram, sms/twilio or sms/nexmo + urgent: ['email'] + high: ['email'] + medium: ['email'] + low: ['email'] + admin_recipients: + - { email: admin@example.com } diff --git a/config/packages/prod/deprecations.yaml b/config/packages/prod/deprecations.yaml new file mode 100644 index 0000000..920a061 --- /dev/null +++ b/config/packages/prod/deprecations.yaml @@ -0,0 +1,8 @@ +# As of Symfony 5.1, deprecations are logged in the dedicated "deprecation" channel when it exists +#monolog: +# channels: [deprecation] +# handlers: +# deprecation: +# type: stream +# channels: [deprecation] +# path: "%kernel.logs_dir%/%kernel.environment%.deprecations.log" diff --git a/config/packages/prod/doctrine.yaml b/config/packages/prod/doctrine.yaml new file mode 100644 index 0000000..084f59a --- /dev/null +++ b/config/packages/prod/doctrine.yaml @@ -0,0 +1,20 @@ +doctrine: + orm: + auto_generate_proxy_classes: false + metadata_cache_driver: + type: pool + pool: doctrine.system_cache_pool + query_cache_driver: + type: pool + pool: doctrine.system_cache_pool + result_cache_driver: + type: pool + pool: doctrine.result_cache_pool + +framework: + cache: + pools: + doctrine.result_cache_pool: + adapter: cache.app + doctrine.system_cache_pool: + adapter: cache.system diff --git a/config/packages/prod/monolog.yaml b/config/packages/prod/monolog.yaml new file mode 100644 index 0000000..bfe69c0 --- /dev/null +++ b/config/packages/prod/monolog.yaml @@ -0,0 +1,16 @@ +monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + buffer_size: 50 # How many messages should be saved? Prevent memory leaks + nested: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine"] diff --git a/config/packages/prod/routing.yaml b/config/packages/prod/routing.yaml new file mode 100644 index 0000000..b3e6a0a --- /dev/null +++ b/config/packages/prod/routing.yaml @@ -0,0 +1,3 @@ +framework: + router: + strict_requirements: null diff --git a/config/packages/prod/webpack_encore.yaml b/config/packages/prod/webpack_encore.yaml new file mode 100644 index 0000000..d0b3ba8 --- /dev/null +++ b/config/packages/prod/webpack_encore.yaml @@ -0,0 +1,4 @@ +#webpack_encore: + # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes) + # Available in version 1.2 + #cache: true diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml new file mode 100644 index 0000000..b45c1ce --- /dev/null +++ b/config/packages/routing.yaml @@ -0,0 +1,7 @@ +framework: + router: + utf8: true + + # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. + # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands + #default_uri: http://localhost diff --git a/config/packages/scheb_2fa.yaml b/config/packages/scheb_2fa.yaml new file mode 100644 index 0000000..119eac5 --- /dev/null +++ b/config/packages/scheb_2fa.yaml @@ -0,0 +1,15 @@ +# See the configuration reference at https://github.com/scheb/2fa/blob/master/doc/configuration.md +scheb_two_factor: + google: + enabled: true + issuer: "Muprh" + server_name: + digits: 6 + window: 1 + template: "@Core/auth/2fa.html.twig" + security_tokens: + - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken + # If you're using guard-based authentication, you have to use this one: + - Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken + # If you're using authenticator-based security (introduced in Symfony 5.1), you have to use this one: + # - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken diff --git a/config/packages/security.yaml b/config/packages/security.yaml new file mode 100644 index 0000000..9665acd --- /dev/null +++ b/config/packages/security.yaml @@ -0,0 +1,54 @@ +security: + encoders: + App\Entity\User: + algorithm: auto + + # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers + providers: + # used to reload user from session & other features (e.g. switch_user) + app_user_provider: + entity: + class: App\Entity\User + property: email + + role_hierarchy: + ROLE_WRITER: ROLE_USER + ROLE_ADMIN: ROLE_WRITER + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + main: + anonymous: ~ + two_factor: + auth_form_path: 2fa_login # The route name you have used in the routes.yaml + check_path: 2fa_login_check # The route name you have used in the routes.yaml + guard: + authenticators: + - App\Core\Authenticator\LoginFormAuthenticator + form_login: + login_path: auth_login + check_path: auth_login + csrf_token_generator: security.csrf.token_manager + logout: + path: auth_logout + target: / + remember_me: + secret: '%kernel.secret%' + lifetime: 604800 + path: / + + # Easy way to control access for large sections of your site + # Note: Only the *first* access control that matches will be used + access_control: + - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/resetting, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS } + - { path: ^/admin/user, roles: ROLE_ADMIN } + - { path: ^/admin/task, roles: ROLE_ADMIN } + - { path: ^/admin/setting, roles: ROLE_ADMIN } + - { path: ^/admin/site, roles: ROLE_WRITER } + - { path: ^/admin/file_manager, roles: ROLE_WRITER } + - { path: ^/admin, roles: ROLE_USER } + - { path: ^/_internal, roles: IS_AUTHENTICATED_ANONYMOUSLY } diff --git a/config/packages/sensio_framework_extra.yaml b/config/packages/sensio_framework_extra.yaml new file mode 100644 index 0000000..1821ccc --- /dev/null +++ b/config/packages/sensio_framework_extra.yaml @@ -0,0 +1,3 @@ +sensio_framework_extra: + router: + annotations: false diff --git a/config/packages/stof_doctrine_extensions.yaml b/config/packages/stof_doctrine_extensions.yaml new file mode 100644 index 0000000..c83f3b1 --- /dev/null +++ b/config/packages/stof_doctrine_extensions.yaml @@ -0,0 +1,4 @@ +# Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html +# See the official DoctrineExtensions documentation for more details: https://github.com/Atlantic18/DoctrineExtensions/tree/master/doc/ +stof_doctrine_extensions: + default_locale: en_US diff --git a/config/packages/test/framework.yaml b/config/packages/test/framework.yaml new file mode 100644 index 0000000..d051c84 --- /dev/null +++ b/config/packages/test/framework.yaml @@ -0,0 +1,4 @@ +framework: + test: true + session: + storage_id: session.storage.mock_file diff --git a/config/packages/test/monolog.yaml b/config/packages/test/monolog.yaml new file mode 100644 index 0000000..fc40641 --- /dev/null +++ b/config/packages/test/monolog.yaml @@ -0,0 +1,12 @@ +monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + channels: ["!event"] + nested: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug diff --git a/config/packages/test/twig.yaml b/config/packages/test/twig.yaml new file mode 100644 index 0000000..8c6e0b4 --- /dev/null +++ b/config/packages/test/twig.yaml @@ -0,0 +1,2 @@ +twig: + strict_variables: true diff --git a/config/packages/test/validator.yaml b/config/packages/test/validator.yaml new file mode 100644 index 0000000..1e5ab78 --- /dev/null +++ b/config/packages/test/validator.yaml @@ -0,0 +1,3 @@ +framework: + validation: + not_compromised_password: false diff --git a/config/packages/test/web_profiler.yaml b/config/packages/test/web_profiler.yaml new file mode 100644 index 0000000..03752de --- /dev/null +++ b/config/packages/test/web_profiler.yaml @@ -0,0 +1,6 @@ +web_profiler: + toolbar: false + intercept_redirects: false + +framework: + profiler: { collect: false } diff --git a/config/packages/test/webpack_encore.yaml b/config/packages/test/webpack_encore.yaml new file mode 100644 index 0000000..02a7651 --- /dev/null +++ b/config/packages/test/webpack_encore.yaml @@ -0,0 +1,2 @@ +#webpack_encore: +# strict_mode: false diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml new file mode 100644 index 0000000..01b30ac --- /dev/null +++ b/config/packages/translation.yaml @@ -0,0 +1,8 @@ +framework: + default_locale: fr + translator: + default_path: '%kernel.project_dir%/translations' + paths: + - '%kernel.project_dir%/core/Resources/translations' + fallbacks: + - en diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml new file mode 100644 index 0000000..da46d6e --- /dev/null +++ b/config/packages/twig.yaml @@ -0,0 +1,6 @@ +twig: + default_path: '%kernel.project_dir%/templates' + form_themes: ['@Core/form/bootstrap_4_form_theme.html.twig'] + paths: + '%kernel.project_dir%/templates/core/': Core + '%kernel.project_dir%/core/Resources/views/': Core diff --git a/config/packages/validator.yaml b/config/packages/validator.yaml new file mode 100644 index 0000000..350786a --- /dev/null +++ b/config/packages/validator.yaml @@ -0,0 +1,8 @@ +framework: + validation: + email_validation_mode: html5 + + # Enables validator auto-mapping support. + # For instance, basic validation constraints will be inferred from Doctrine's metadata. + #auto_mapping: + # App\Entity\: [] diff --git a/config/packages/webpack_encore.yaml b/config/packages/webpack_encore.yaml new file mode 100644 index 0000000..90f1a1d --- /dev/null +++ b/config/packages/webpack_encore.yaml @@ -0,0 +1,30 @@ +webpack_encore: + # The path where Encore is building the assets - i.e. Encore.setOutputPath() + output_path: '%kernel.project_dir%/public/build' + # If multiple builds are defined (as shown below), you can disable the default build: + # output_path: false + + # Set attributes that will be rendered on all script and link tags + script_attributes: + defer: true + # link_attributes: + + # If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials') + # crossorigin: 'anonymous' + + # Preload all rendered script and link tags automatically via the HTTP/2 Link header + # preload: true + + # Throw an exception if the entrypoints.json file is missing or an entry is missing from the data + # strict_mode: false + + # If you have multiple builds: + # builds: + # pass "frontend" as the 3rg arg to the Twig functions + # {{ encore_entry_script_tags('entry1', null, 'frontend') }} + + # frontend: '%kernel.project_dir%/public/frontend/build' + + # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes) + # Put in config/packages/prod/webpack_encore.yaml + # cache: true diff --git a/config/preload.php b/config/preload.php new file mode 100644 index 0000000..5ebcdb2 --- /dev/null +++ b/config/preload.php @@ -0,0 +1,5 @@ + + */ +class DateRangeAnalytic +{ + protected ViewRepositoryQuery $viewQuery; + protected RefererRepositoryQuery $refererQuery; + protected ?Node $node; + protected ?\DateTime $from; + protected ?\DateTime $to; + protected bool $reload = true; + protected array $cache = []; + + public function __construct(ViewRepositoryQuery $viewQuery, RefererRepositoryQuery $refererQuery) + { + $this->viewQuery = $viewQuery; + $this->refererQuery = $refererQuery; + } + + public function getViews(): array + { + $entities = $this->getEntities('view'); + $this->reload = false; + + if ($entities) { + $first = $entities[0]; + $last = $entities[count($entities) - 1]; + + $diff = $first->getDate()->diff($last->getDate()); + + if ($diff->days >= 90) { + $format = 'Y-m'; + } else { + $format = 'Y-m-d'; + } + } + + $datas = []; + + foreach ($entities as $entity) { + $index = $entity->getDate()->format($format); + + if (!isset($datas[$index])) { + $datas[$index] = 0; + } + + $datas[$index] += $entity->getViews(); + } + + return $datas; + } + + public function getPathViews(): array + { + $entities = $this->getEntities('view'); + $this->reload = false; + + $datas = []; + + foreach ($entities as $entity) { + $index = $entity->getPath(); + + if (!isset($datas[$index])) { + $datas[$index] = [ + 'views' => 0, + 'desktopViews' => 0, + 'mobileViews' => 0, + ]; + } + + $datas[$index]['views'] += $entity->getViews(); + $datas[$index]['desktopViews'] += $entity->getDesktopViews(); + $datas[$index]['mobileViews'] += $entity->getMobileViews(); + } + + uasort($datas, function($a, $b) { + if ($a['views'] > $b['views']) { + return -1; + } + + if ($a['views'] < $b['views']) { + return 1; + } + + return 0; + }); + + return $datas; + } + + public function getReferers(): array + { + $entities = $this->getEntities('referer'); + $this->reload = false; + + $datas = []; + + foreach ($entities as $entity) { + $index = parse_url($entity->getUri(), PHP_URL_HOST); + + if (!isset($datas[$index])) { + $datas[$index] = [ + 'views' => 0, + 'uris' => [], + ]; + } + + $datas[$index]['views'] += $entity->getViews(); + + $path = parse_url($entity->getUri(), PHP_URL_PATH); + + if (empty($path)) { + $path = '/'; + } + + if (!isset($datas[$index]['uris'][$path])) { + $datas[$index]['uris'][$path] = 0; + } + + $datas[$index]['uris'][$path] += $entity->getViews(); + } + + uasort($datas, function($a, $b) { + if ($a['views'] > $b['views']) { + return -1; + } + + if ($a['views'] < $b['views']) { + return 1; + } + + return 0; + }); + + return $datas; + } + + public function setDateRange(?\DateTime $from, ?\DateTime $to): self + { + $this->from = $from; + $this->to = $to; + $this->reload = true; + + return $this; + } + + public function setNode(?Node $node): self + { + $this->node = $node; + $this->reload = true; + + return $this; + } + + protected function getEntities(string $type): array + { + if ('view' === $type) { + $query = $this->viewQuery->create(); + } elseif ('referer' === $type) { + $query = $this->refererQuery->create(); + } else { + throw new \InvalidArgumentException('Invalid type'); + } + + if (!$this->reload && isset($this->cache[$type])) { + return $this->cache[$type]; + } + + if (null !== $this->from) { + $query + ->andWhere('.date >= :from') + ->setParameter(':from', $this->from) + ; + } + + if (null !== $this->to) { + $query + ->andWhere('.date <= :to') + ->setParameter(':to', $this->to) + ; + } + + if (null !== $this->node) { + $query + ->andWhere('.node = :node') + ->setParameter(':node', $this->node->getId()) + ; + } + + $this->cache[$type] = $query->orderBy('.date')->find(); + + return $this->cache[$type]; + } +} diff --git a/core/Annotation/UrlGenerator.php b/core/Annotation/UrlGenerator.php new file mode 100644 index 0000000..c3ebae2 --- /dev/null +++ b/core/Annotation/UrlGenerator.php @@ -0,0 +1,20 @@ + + * @Annotation + */ +class UrlGenerator +{ + public string $service; + + public string $method; + + public array $options = []; +} diff --git a/core/Authenticator/LoginFormAuthenticator.php b/core/Authenticator/LoginFormAuthenticator.php new file mode 100644 index 0000000..193c61f --- /dev/null +++ b/core/Authenticator/LoginFormAuthenticator.php @@ -0,0 +1,96 @@ +entityManager = $entityManager; + $this->urlGenerator = $urlGenerator; + $this->csrfTokenManager = $csrfTokenManager; + $this->passwordEncoder = $passwordEncoder; + } + + public function supports(Request $request) + { + return 'auth_login' === $request->attributes->get('_route') && $request->isMethod('POST'); + } + + public function getCredentials(Request $request) + { + $credentials = [ + 'email' => $request->request->get('_username'), + 'password' => $request->request->get('_password'), + 'csrf_token' => $request->request->get('_csrf_token'), + ]; + + $request->getSession()->set(Security::LAST_USERNAME, $credentials['email']); + + return $credentials; + } + + public function getUser($credentials, UserProviderInterface $userProvider) + { + $token = new CsrfToken('authenticate', $credentials['csrf_token']); + + if (!$this->csrfTokenManager->isTokenValid($token)) { + throw new InvalidCsrfTokenException(); + } + + $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['email']]); + + if (!$user) { + // fail authentication with a custom error + throw new CustomUserMessageAuthenticationException('Email could not be found.'); + } + + return $user; + } + + public function checkCredentials($credentials, UserInterface $user) + { + return $this->passwordEncoder->isPasswordValid($user, $credentials['password']); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) + { + if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) { + return new RedirectResponse($targetPath); + } + + return new RedirectResponse($this->urlGenerator->generate('admin_dashboard_index')); + } + + protected function getLoginUrl() + { + return $this->urlGenerator->generate('auth_login'); + } +} diff --git a/core/Bundle/CoreBundle.php b/core/Bundle/CoreBundle.php new file mode 100644 index 0000000..034a544 --- /dev/null +++ b/core/Bundle/CoreBundle.php @@ -0,0 +1,24 @@ + + */ +class SymfonyCacheManager +{ + protected KernelInterface $kernel; + protected HttpClientInterface $httpClient; + protected UrlGeneratorInterface $urlGenerator; + + public function __construct(KernelInterface $kernel, HttpClientInterface $httpClient, UrlGeneratorInterface $urlGenerator) + { + $this->kernel = $kernel; + $this->httpClient = $httpClient; + $this->urlGenerator = $urlGenerator; + } + + public function cleanRouting() + { + $finder = new Finder(); + $finder + ->in($this->kernel->getCacheDir()) + ->depth('== 0') + ->name('url_*.php*') + ; + + $pingUrl = $this->urlGenerator->generate('_ping', [], UrlGeneratorInterface::ABSOLUTE_URL); + + foreach ($finder as $file) { + unlink((string) $file->getPathname()); + } + + try { + // Hack: used to regenerate cache of url generator + $this->httpClient->request('POST', $pingUrl); + } catch (ClientException $e) { + } catch (TransportException $e) { + } + } + + public function cleanAll(OutputInterface $output = null) + { + $application = new Application($this->kernel); + $application->setAutoExit(false); + + if (null === $output) { + $output = new BufferedOutput(); + } + + $input = new ArrayInput([ + 'command' => 'cache:warmup', + '-e' => $this->kernel->getEnvironment(), + ]); + + $application->run($input, $output); + } +} diff --git a/core/Command/UserCreateCommand.php b/core/Command/UserCreateCommand.php new file mode 100644 index 0000000..598bfaf --- /dev/null +++ b/core/Command/UserCreateCommand.php @@ -0,0 +1,108 @@ +userFactory = $userFactory; + $this->entityManager = $entityManager; + $this->tokenGenerator = $tokenGenerator; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription(self::$defaultDescription) + ->addArgument('email', InputArgument::OPTIONAL, 'E-mail') + ->addOption('is-admin', null, InputOption::VALUE_NONE, 'Add the admin role') + ->addOption('is-writer', null, InputOption::VALUE_NONE, 'Add the write role') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $helper = $this->getHelper('question'); + + $emailQuestion = new Question('E-mail: '); + $emailQuestion->setValidator(function ($value) { + if (empty($value)) { + throw new \RuntimeException('The email must not be empty.'); + } + + return $value; + }); + + $passwordQuestion = new Question('Password (leave empty to generate a random password): '); + $passwordQuestion->setHidden(true); + + $isAdminDefault = $input->getOption('is-admin'); + $isWriterDefault = $input->getOption('is-writer'); + + $isAdminQuestionLabel = sprintf('Administrator [%s] ', $isAdminDefault ? 'Y/n' : 'y/N'); + $isWriterQuestionLabel = sprintf('Writer [%s] ', $isWriterDefault ? 'Y/n' : 'y/N'); + + $isAdminQuestion = new ConfirmationQuestion($isAdminQuestionLabel, $isAdminDefault); + $isWriterQuestion = new ConfirmationQuestion($isWriterQuestionLabel, $isWriterDefault); + + $io->section('Authentication'); + + $email = $input->getArgument('email'); + + if (empty($email)) { + $email = $helper->ask($input, $output, $emailQuestion); + } + + $password = $helper->ask($input, $output, $passwordQuestion); + + $showPassword = empty($password); + + if ($showPassword) { + $password = mb_substr($this->tokenGenerator->generateToken(), 0, 18); + $io->info(sprintf('Password: %s', $password)); + } else { + $io->newLine(); + } + + $io->section('Roles'); + + $isAdmin = $helper->ask($input, $output, $isAdminQuestion); + $isWriter = $helper->ask($input, $output, $isWriterQuestion); + + $user = $this->userFactory->create($email, $password); + $user->setIsAdmin($isAdmin); + $user->setIsWriter($isWriter); + + $this->entityManager->create($user); + + $io->newLine(); + $io->success('User created!'); + + return Command::SUCCESS; + } +} diff --git a/core/Controller/.gitignore b/core/Controller/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/core/Controller/Account/AccountAdminController.php b/core/Controller/Account/AccountAdminController.php new file mode 100644 index 0000000..5ea4b6e --- /dev/null +++ b/core/Controller/Account/AccountAdminController.php @@ -0,0 +1,150 @@ +getUser(); + + return $this->render('@Core/account/admin/edit.html.twig', [ + 'account' => $account, + ]); + } + + /** + * @Route("/2fa", name="admin_account_2fa") + */ + public function twoFactorAuthentication( + Request $request, + GoogleAuthenticatorInterface $totpAuthenticatorService, + EntityManager $entityManager + ): Response { + if ($request->isMethod('GET')) { + return $this->redirectToRoute('admin_account'); + } + + $account = $this->getUser(); + $csrfToken = $request->request->get('_csrf_token'); + $enable = (bool) $request->request->get('enable'); + $code = $request->request->get('code', ''); + $secret = $request->request->get('secret', ''); + $qrCodeContent = null; + + if ($this->isCsrfTokenValid('2fa', $csrfToken)) { + if ($enable && !$account->isTotpAuthenticationEnabled()) { + if (empty($secret)) { + $secret = $totpAuthenticatorService->generateSecret(); + + $account->setTotpSecret($secret); + + $qrCodeContent = $totpAuthenticatorService->getQRContent($account); + } else { + $account->setTotpSecret($secret); + + $qrCodeContent = $totpAuthenticatorService->getQRContent($account); + + if (!$totpAuthenticatorService->checkCode($account, $code)) { + $this->addFlash('error', 'The code is not valid.'); + } else { + $this->addFlash('success', 'Double authentication enabled.'); + + $entityManager->update($account); + + return $this->redirectToRoute('admin_account'); + } + } + } + + if (!$enable && $account->isTotpAuthenticationEnabled()) { + $account->setTotpSecret(null); + + $entityManager->update($account); + + $this->addFlash('success', 'Double authentication disabled.'); + + return $this->redirectToRoute('admin_account'); + } + } + + return $this->render('@Core/account/admin/edit.html.twig', [ + 'account' => $account, + 'twoFaKey' => $secret, + 'twoFaQrCodeContent' => $qrCodeContent, + ]); + } + + /** + * @Route("/password", name="admin_account_password", methods={"POST"}) + */ + public function password( + Request $request, + UserRepository $repository, + TokenGeneratorInterface $tokenGenerator, + UserPasswordEncoderInterface $encoder, + EntityManager $entityManager + ): Response { + $account = $this->getUser(); + $csrfToken = $request->request->get('_csrf_token'); + + if ($this->isCsrfTokenValid('password', $csrfToken)) { + $password = $request->request->get('password'); + + if (!$encoder->isPasswordValid($account, $password)) { + $this->addFlash('error', 'The form is not valid.'); + + return $this->redirectToRoute('admin_account'); + } + + $password1 = $request->request->get('password1'); + $password2 = $request->request->get('password2'); + + $zxcvbn = new Zxcvbn(); + $strength = $zxcvbn->passwordStrength($password1, []); + + if (4 === $strength['score'] && $password1 === $password2) { + $account + ->setPassword($encoder->encodePassword($account, $password1)) + ->setConfirmationToken($tokenGenerator->generateToken()) + ; + + $entityManager->update($account); + + $this->addFlash('success', 'Password updated.'); + + return $this->redirectToRoute('admin_account'); + } + } + + $this->addFlash('error', 'The form is not valid.'); + + return $this->redirectToRoute('admin_account'); + } + + /** + * {@inheritdoc} + */ + protected function getSection(): string + { + return 'account'; + } +} diff --git a/core/Controller/Admin/AdminController.php b/core/Controller/Admin/AdminController.php new file mode 100644 index 0000000..02d726e --- /dev/null +++ b/core/Controller/Admin/AdminController.php @@ -0,0 +1,40 @@ +coreParameters = $parameters->get('core'); + } + + /** + * @Route("/_ping", name="_ping") + */ + public function ping() + { + return $this->json(true); + } + + /** + * {@inheritdoc} + */ + protected function render(string $view, array $parameters = [], Response $response = null): Response + { + $parameters['section'] = $this->getSection(); + $parameters['site_name'] = $this->coreParameters['site']['name']; + $parameters['site_logo'] = $this->coreParameters['site']['logo']; + + return parent::render($view, $parameters, $response); + } + + abstract protected function getSection(): string; +} diff --git a/core/Controller/Admin/Crud/CrudController.php b/core/Controller/Admin/Crud/CrudController.php new file mode 100644 index 0000000..57f6688 --- /dev/null +++ b/core/Controller/Admin/Crud/CrudController.php @@ -0,0 +1,342 @@ + + */ +abstract class CrudController extends AdminController +{ + protected array $filters = []; + protected array $sort = [ + 'label' => null, + 'direction' => null, + ]; + + abstract protected function getConfiguration(): CrudConfiguration; + + protected function doIndex(int $page = 1, RepositoryQuery $query, Request $request, Session $session): Response + { + $configuration = $this->getConfiguration(); + + $this->applySort('index', $query, $request); + $this->updateFilters($request, $session); + + $pager = $query + ->usefilters($this->filters) + ->paginate($page, $configuration->getmaxperpage('index')) + ; + + return $this->render($this->getConfiguration()->getView('index'), [ + 'configuration' => $configuration, + 'pager' => $pager, + 'sort' => $this->sort, + 'filters' => [ + 'show' => null !== $configuration->getForm('filter'), + 'isEmpty' => empty($this->filters), + ], + ]); + } + + protected function doNew(EntityInterface $entity, EntityManager $entityManager, Request $request, callable $beforeCreate = null): Response + { + $configuration = $this->getConfiguration(); + + $this->prepareEntity($entity); + + $form = $this->createForm($configuration->getForm('new'), $entity, $configuration->getFormOptions('new')); + + if ($request->isMethod('POST')) { + $form->handleRequest($request); + + if ($form->isValid()) { + if (null !== $beforeCreate) { + call_user_func_array($beforeCreate, [$entity, $form, $request]); + } + + $entityManager->create($entity); + $this->addFlash('success', 'The data has been saved.'); + + return $this->redirectToRoute($configuration->getPageRoute('edit'), array_merge( + ['entity' => $entity->getId()], + $configuration->getPageRouteParams('edit') + )); + } + $this->addFlash('warning', 'The form is not valid.'); + } + + return $this->render($configuration->getView('new'), [ + 'form' => $form->createView(), + 'configuration' => $configuration, + 'entity' => $entity, + ]); + } + + protected function doShow(EntityInterface $entity): Response + { + $configuration = $this->getConfiguration(); + + return $this->render($configuration->getView('show'), [ + 'entity' => $entity, + 'configuration' => $configuration, + ]); + } + + protected function doEdit(EntityInterface $entity, EntityManager $entityManager, Request $request, callable $beforeUpdate = null): Response + { + $configuration = $this->getConfiguration(); + + $this->prepareEntity($entity); + + $form = $this->createForm($configuration->getForm('edit'), $entity, $configuration->getFormOptions('edit')); + + if ($request->isMethod('POST')) { + $form->handleRequest($request); + + if ($form->isValid()) { + if (null !== $beforeUpdate) { + call_user_func_array($beforeUpdate, [$entity, $form, $request]); + } + + $entityManager->update($entity); + $this->addFlash('success', 'The data has been saved.'); + + return $this->redirectToRoute($configuration->getPageRoute('edit'), array_merge( + ['entity' => $entity->getId()], + $configuration->getPageRouteParams('edit') + )); + } + $this->addFlash('warning', 'The form is not valid.'); + } + + return $this->render($configuration->getView('edit'), [ + 'form' => $form->createView(), + 'configuration' => $configuration, + 'entity' => $entity, + ]); + } + + protected function doSort(int $page = 1, RepositoryQuery $query, EntityManager $entityManager, Request $request, Session $session): Response + { + $configuration = $this->getConfiguration(); + $context = $request->query->get('context', 'index'); + + if (!$configuration->getIsSortableCollection($context)) { + throw $this->createNotFoundException(); + } + + $this->applySort($context, $query, $request); + $this->updateFilters($request, $session); + + $pager = $query + ->useFilters($this->filters) + ->paginate($page, $configuration->getMaxPerPage($context)) + ; + + if ($this->isCsrfTokenValid('sort', $request->query->get('_token'))) { + $items = $request->request->get('items', []); + $setter = 'set'.$configuration->getSortableCollectionProperty(); + $orderStart = ($page - 1) * $configuration->getMaxPerPage($context); + + foreach ($pager as $key => $entity) { + if (isset($items[$key + 1])) { + $entity->{$setter}($items[$key + 1] + $orderStart); + + $entityManager->update($entity); + } + } + + $this->addFlash('success', 'The data has been saved.'); + } else { + $this->addFlash('warning', 'The form is not valid.'); + } + + return $this->json([]); + } + + protected function doBatch(int $page = 1, RepositoryQuery $query, EntityManager $entityManager, Request $request, Session $session): Response + { + $configuration = $this->getConfiguration(); + $datas = $request->request->get('batch', []); + + $context = $datas['context'] ?? 'index'; + $target = $datas['target'] ?? null; + $action = $datas['action'] ?? null; + $token = $datas['_token'] ?? null; + $items = $datas['items'] ?? []; + $batchAction = $configuration->getBatchAction($context, $action); + + if (empty($context) || empty($action) || empty($target)) { + return $this->json([]); + } + + if (!$this->isCsrfTokenValid('batch', $token) || empty($batchAction)) { + $this->addFlash('warning', 'The form is not valid.'); + + return $this->json([]); + } + + $callback = $batchAction['callback']; + + $this->applySort($context, $query, $request); + $this->updateFilters($request, $session); + + $query->useFilters($this->filters); + + if ('selection' === $target) { + $isSelection = true; + $pager = $query->paginate($page, $configuration->getMaxPerPage($context)); + } else { + $isSelection = false; + $pager = $query->find(); + } + + foreach ($pager as $key => $entity) { + if (($isSelection && isset($items[$key + 1])) || !$isSelection) { + $callback($entity, $entityManager); + } + } + + $this->addFlash('success', 'Batch action done.'); + + return $this->json([]); + } + + protected function doDelete(EntityInterface $entity, EntityManager $entityManager, Request $request, callable $beforeDelete = null): Response + { + $configuration = $this->getConfiguration(); + + if ($this->isCsrfTokenValid('delete'.$entity->getId(), $request->request->get('_token'))) { + if (null !== $beforeDelete) { + call_user_func($beforeDelete, $entity); + } + + $entityManager->delete($entity); + + $this->addFlash('success', 'The data has been removed.'); + } + + return $this->redirectToRoute($configuration->getPageRoute('index')); + } + + protected function doFilter(Session $session): Response + { + $configuration = $this->getConfiguration(); + $type = $configuration->getForm('filter'); + + if (null === $type) { + throw $this->createNotFoundException(); + } + + $form = $this->createForm($type); + $form->submit($session->get($form->getName(), [])); + + return $this->render($configuration->getView('filter'), [ + 'form' => $form->createView(), + 'configuration' => $configuration, + ]); + } + + protected function updateFilters(Request $request, Session $session) + { + $configuration = $this->getConfiguration(); + $type = $configuration->getForm('filter'); + + if (null === $type) { + return; + } + + $form = $this->createForm($type); + + if ($request->query->has($form->getName())) { + $filters = $request->query->get($form->getName()); + + if ('0' === $filters) { + $filters = []; + } + } elseif ($session->has($form->getName())) { + $filters = $session->get($form->getName()); + } else { + $filters = []; + } + + $form->submit($filters); + + if (empty($filters)) { + $this->filters = $filters; + $session->set($form->getName(), $filters); + } elseif ($form->isValid()) { + $this->filters = $form->getData(); + $session->set($form->getName(), $filters); + } + } + + protected function prepareEntity(EntityInterface $entity) + { + $configuration = $this->getConfiguration(); + + if ($configuration->isI18n()) { + foreach ($configuration->getLocales() as $locale) { + $entity->addTranslation($entity->translate($locale, false)); + } + } + } + + protected function applySort(string $context, RepositoryQuery $query, Request $request) + { + $configuration = $this->getConfiguration(); + + if ($configuration->getIsSortableCollection($context)) { + $query->orderBy(sprintf('.%s', $configuration->getSortableCollectionProperty())); + + return; + } + + $defaultSort = $configuration->getDefaultSort($context); + + $name = $request->query->get('_sort', $defaultSort['label'] ?? null); + $direction = strtolower($request->query->get('_sort_direction', $defaultSort['direction'] ?? 'asc')); + + if (!in_array($direction, ['asc', 'desc'])) { + $direction = 'asc'; + } + + foreach ($configuration->getFields($context) as $label => $field) { + $sortOption = $field['options']['sort'] ?? null; + + if (null === $sortOption) { + continue; + } + + if ($sortOption[0] !== $name) { + continue; + } + + $sorter = $sortOption[1]; + + if (is_string($sorter)) { + $query->orderBy($sorter, $direction); + } else { + call_user_func_array($sorter, [$query, $direction]); + } + + $this->sort = [ + 'label' => $label, + 'direction' => $direction, + ]; + + return; + } + } +} diff --git a/core/Controller/Analytic/AnalyticController.php b/core/Controller/Analytic/AnalyticController.php new file mode 100644 index 0000000..6d2fd88 --- /dev/null +++ b/core/Controller/Analytic/AnalyticController.php @@ -0,0 +1,38 @@ +createNotFoundException(); + } + + $analytic + ->setDateRange(new \DateTime('now - '.$range), new \DateTime()) + ->setNode($node) + ; + + return $this->render('@Core/analytic/stats.html.twig', [ + 'range' => $range, + 'views' => $analytic->getViews(), + 'pathViews' => $analytic->getPathViews(), + 'referers' => $analytic->getReferers(), + 'node' => $node, + ]); + } +} diff --git a/core/Controller/Auth/AuthController.php b/core/Controller/Auth/AuthController.php new file mode 100644 index 0000000..054ce3a --- /dev/null +++ b/core/Controller/Auth/AuthController.php @@ -0,0 +1,155 @@ +coreParameters = $parameters->get('core'); + } + + /** + * @Route("/login", name="auth_login") + */ + public function login(AuthenticationUtils $authenticationUtils): Response + { + if ($this->getUser()) { + return $this->redirectToRoute('admin_dashboard_index'); + } + + $error = $authenticationUtils->getLastAuthenticationError(); + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('@Core/auth/login.html.twig', [ + 'last_username' => $lastUsername, + 'error' => $error, + 'site_name' => $this->coreParameters['site']['name'], + 'site_logo' => $this->coreParameters['site']['logo'], + ]); + } + + /** + * @Route("/resetting/request", name="auth_resetting_request") + */ + public function requestResetting(Request $request, UserRepository $repository, EventDispatcherInterface $eventDispatcher): Response + { + if ($this->getUser()) { + return $this->redirectToRoute('admin_dashboard_index'); + } + + if ($request->isMethod('POST')) { + $csrfToken = $request->request->get('_csrf_token'); + + if (!$this->isCsrfTokenValid('resetting_request', $csrfToken)) { + throw $this->createAccessDeniedException(); + } + + $username = trim((string) $request->request->get('username')); + + if (!$username) { + throw $this->createAccessDeniedException(); + } + + $account = $repository->findOneByEmail($username); + + if ($account) { + $requestedAt = $account->getPasswordRequestedAt(); + + if (null === $requestedAt || $requestedAt->getTimestamp() < (time() - 3600 / 2)) { + $eventDispatcher->dispatch(new PasswordRequestEvent($account), PasswordRequestEvent::EVENT); + } + } + } + + return $this->render('@Core/auth/resetting_request.html.twig', [ + 'email_sent' => $request->isMethod('POST'), + 'site_name' => $this->coreParameters['site']['name'], + 'site_logo' => $this->coreParameters['site']['logo'], + ]); + } + + /** + * @Route("/resetting/update/{token}", name="auth_resetting_update") + */ + public function requestUpdate( + string $token, + Request $request, + UserRepository $repository, + TokenGeneratorInterface $tokenGenerator, + UserPasswordEncoderInterface $encoder, + EntityManager $entityManager + ): Response { + if ($this->getUser()) { + return $this->redirectToRoute('admin_dashboard_index'); + } + + $account = $repository->findOneByConfirmationToken($token); + $passwordUpdated = false; + $expired = true; + + if ($account) { + $requestedAt = $account->getPasswordRequestedAt(); + $expired = (null === $requestedAt || ($requestedAt->getTimestamp() < (time() - 3600 * 2))); + } + + if ($request->isMethod('POST') && !$expired) { + $csrfToken = $request->request->get('_csrf_token'); + + if ($this->isCsrfTokenValid('resetting_update', $csrfToken)) { + $password = $request->request->get('password'); + $password2 = $request->request->get('password2'); + + $zxcvbn = new Zxcvbn(); + $strength = $zxcvbn->passwordStrength($password, []); + + if (4 === $strength['score'] && $password === $password2) { + $account + ->setPassword($encoder->encodePassword( + $account, + $password + )) + ->setConfirmationToken($tokenGenerator->generateToken()) + ->setPasswordRequestedAt(new \DateTime('now')) + ; + + $entityManager->update($account); + + $passwordUpdated = true; + } + } + } + + return $this->render('@Core/auth/resetting_update.html.twig', [ + 'password_updated' => $passwordUpdated, + 'token' => $token, + 'expired' => $expired, + 'site_name' => $this->coreParameters['site']['name'], + 'site_logo' => $this->coreParameters['site']['logo'], + ]); + } + + /** + * @Route("/logout", name="auth_logout") + */ + public function logout() + { + throw new \Exception('This method can be blank - it will be intercepted by the logout key on your firewall'); + } +} diff --git a/core/Controller/Dashboard/DashboardAdminController.php b/core/Controller/Dashboard/DashboardAdminController.php new file mode 100644 index 0000000..eb33ba8 --- /dev/null +++ b/core/Controller/Dashboard/DashboardAdminController.php @@ -0,0 +1,27 @@ +render('@Core/dashboard/index.html.twig', [ + ]); + } + + protected function getSection(): string + { + return 'dashboard'; + } +} diff --git a/core/Controller/FileManager/FileManagerAdminController.php b/core/Controller/FileManager/FileManagerAdminController.php new file mode 100644 index 0000000..ff768b2 --- /dev/null +++ b/core/Controller/FileManager/FileManagerAdminController.php @@ -0,0 +1,438 @@ +render('@Core/file_manager/index.html.twig'); + } + + /** + * @Route("/api/directory", name="admin_file_manager_api_directory", options={"expose"=true}) + */ + public function directory(FsFileManager $manager, Request $request): Response + { + $options = [ + 'sort' => $request->query->get('_sort', 'name'), + 'sort_direction' => $request->query->get('_sort_direction', 'asc'), + ]; + + $files = $manager->list($request->query->get('directory', '/'), $options); + + return $this->json($files); + } + + /** + * @Route("/info/{tab}/{context}/{ajax}", name="admin_file_manager_info", options={"expose"=true}) + */ + public function info( + FsFileManager $manager, + Request $request, + EntityManager $entityManager, + TranslatorInterface $translator, + string $context = 'crud', + string $tab = 'information', + bool $ajax = false + ): Response { + $splInfo = $manager->getSplInfo($request->query->get('file')); + + if (!$splInfo) { + throw $this->createNotFoundException(); + } + + $fileInfo = $manager->getFileInformation($request->query->get('file')); + $path = $manager->getPathUri().'/'.$splInfo->getRelativePathname(); + + $form = $this->createForm(FileInformationType::class, $fileInfo); + + if ($request->isMethod('POST')) { + $form->handleRequest($request); + + if ($form->isValid()) { + $entityManager->update($fileInfo); + + if (!$request->isXmlHttpRequest()) { + $this->addFlash('success', 'The data has been saved.'); + } else { + return $this->json([ + '_error' => 0, + '_message' => $translator->trans('The data has been saved.'), + '_level' => 'success', + '_dispatch' => 'file_manager.info.update.success', + ]); + } + } else { + if (!$request->isXmlHttpRequest()) { + $this->addFlash('warning', 'The form is not valid.'); + } else { + return $this->json([ + '_error' => 1, + '_message' => $translator->trans('The form is not valid.'), + '_level' => 'warning', + '_dispatch' => 'file_manager.info.update.error', + ]); + } + } + + return $this->redirectToRoute('admin_file_manager_index', [ + 'data-modal' => $this->generateUrl('admin_file_manager_info', [ + 'file' => $request->query->get('file'), + 'tab' => 'attributes', + ]), + 'path' => $splInfo->getRelativePath(), + ]); + } + + return $this->render('@Core/file_manager/info.html.twig', [ + 'splInfo' => $splInfo, + 'path' => $path, + 'isLocked' => $manager->isLocked($splInfo->getRelativePathname()), + 'tab' => $tab, + 'form' => $form->createView(), + 'context' => $context, + 'ajax' => $ajax, + ]); + } + + /** + * @Route("/directory/new/{ajax}", name="admin_file_manager_directory_new", options={"expose"=true}, methods={"GET", "POST"}) + */ + public function directoryNew(FsFileManager $manager, Request $request, TranslatorInterface $translator, bool $ajax = false): Response + { + $splInfo = $manager->getSplInfo($request->query->get('file')); + + if (!$splInfo) { + throw $this->createNotFoundException(); + } + + if (!$splInfo->isDir()) { + throw $this->createNotFoundException(); + } + + if ($manager->isLocked($request->query->get('file'))) { + return $this->render('@Core/file_manager/directory_new.html.twig', [ + 'locked' => true, + ]); + } + + $form = $this->createForm(DirectoryCreateType::class); + + if ($request->isMethod('POST')) { + $form->handleRequest($request); + + if ($form->isValid()) { + $status = $manager->createDirectory($form->get('name')->getData(), $request->query->get('file')); + + if (true === $status) { + if (!$request->isXmlHttpRequest()) { + $this->addFlash('success', 'Directory created.'); + } else { + return $this->json([ + '_error' => 0, + '_message' => $translator->trans('Directory created.'), + '_level' => 'success', + '_dispatch' => 'file_manager.directory.new.success', + ]); + } + } else { + if (!$request->isXmlHttpRequest()) { + $this->addFlash('warning', 'Directory not created.'); + } else { + return $this->json([ + '_error' => 1, + '_message' => $translator->trans('Directory not created.'), + '_level' => 'warning', + '_dispatch' => 'file_manager.directory.new.error', + ]); + } + } + } else { + $this->addFlash('warning', 'Unauthorized char(s).'); + } + + return $this->redirectToRoute('admin_file_manager_index', [ + 'path' => $splInfo->getRelativePathname(), + ]); + } + + return $this->render('@Core/file_manager/directory_new.html.twig', [ + 'form' => $form->createView(), + 'file' => $request->query->get('file'), + 'ajax' => $ajax, + 'locked' => false, + ]); + } + + /** + * @Route("/directory/rename/{ajax}", name="admin_file_manager_directory_rename", methods={"GET", "POST"}) + */ + public function directoryRename(FsFileManager $manager, Request $request, TranslatorInterface $translator, bool $ajax = false): Response + { + $splInfo = $manager->getSplInfo($request->query->get('file')); + + if (!$splInfo) { + throw $this->createNotFoundException(); + } + + if (!$splInfo->isDir()) { + throw $this->createNotFoundException(); + } + + if ($manager->isLocked($request->query->get('file'))) { + return $this->render('@Core/file_manager/directory_rename.html.twig', [ + 'locked' => true, + ]); + } + + $form = $this->createForm(DirectoryRenameType::class, [ + 'name' => $splInfo->getFilename(), + ]); + + if ($request->isMethod('POST')) { + $form->handleRequest($request); + + if ($form->isValid()) { + $status = $manager->renameDirectory($form->get('name')->getData(), $request->query->get('file')); + + if (true === $status) { + if (!$request->isXmlHttpRequest()) { + $this->addFlash('success', 'Directory renamed.'); + } else { + return $this->json([ + '_error' => 0, + '_message' => $translator->trans('Directory renamed.'), + '_level' => 'success', + '_dispatch' => 'file_manager.directory.rename.success', + ]); + } + } else { + if (!$request->isXmlHttpRequest()) { + $this->addFlash('warning', 'Directory not renamed.'); + } else { + return $this->json([ + '_error' => 1, + '_message' => $translator->trans('Directory not renamed.'), + '_level' => 'warning', + '_dispatch' => 'file_manager.directory.rename.error', + ]); + } + } + } else { + $this->addFlash('warning', 'Unauthorized char(s).'); + } + + return $this->redirectToRoute('admin_file_manager_index', [ + 'path' => $splInfo->getRelativePath(), + ]); + } + + return $this->render('@Core/file_manager/directory_rename.html.twig', [ + 'form' => $form->createView(), + 'file' => $request->query->get('file'), + 'locked' => false, + 'ajax' => $ajax, + ]); + } + + /** + * @Route("/file/rename/{ajax}", name="admin_file_manager_file_rename", methods={"GET", "POST"}) + */ + public function fileRename(FsFileManager $manager, Request $request, TranslatorInterface $translator, bool $ajax = false): Response + { + $splInfo = $manager->getSplInfo($request->query->get('file')); + + if (!$splInfo) { + throw $this->createNotFoundException(); + } + + if ($splInfo->isDir()) { + throw $this->createNotFoundException(); + } + + if ($manager->isLocked($request->query->get('file'))) { + return $this->render('@Core/file_manager/file_rename.html.twig', [ + 'locked' => true, + ]); + } + + $form = $this->createForm(FileRenameType::class, [ + 'name' => preg_replace(sprintf('/\.%s/', $splInfo->getExtension()), '', $splInfo->getFilename()), + ]); + + if ($request->isMethod('POST')) { + $form->handleRequest($request); + + if ($form->isValid()) { + $status = $manager->renameFile($form->get('name')->getData(), $request->query->get('file')); + + if (true === $status) { + if (!$request->isXmlHttpRequest()) { + $this->addFlash('success', 'File renamed.'); + } else { + return $this->json([ + '_error' => 0, + '_message' => $translator->trans('File renamed.'), + '_level' => 'success', + '_dispatch' => 'file_manager.file.rename.success', + ]); + } + } else { + if (!$request->isXmlHttpRequest()) { + $this->addFlash('warning', 'File not renamed.'); + } else { + return $this->json([ + '_error' => 1, + '_message' => $translator->trans('File not renamed.'), + '_level' => 'warning', + '_dispatch' => 'file_manager.file.rename.error', + ]); + } + } + } else { + $this->addFlash('warning', 'Unauthorized char(s).'); + } + + return $this->redirectToRoute('admin_file_manager_index', [ + 'path' => $splInfo->getRelativePath(), + ]); + } + + return $this->render('@Core/file_manager/file_rename.html.twig', [ + 'form' => $form->createView(), + 'file' => $request->query->get('file'), + 'exention' => $splInfo->getExtension(), + 'locked' => false, + 'ajax' => $ajax, + ]); + } + + /** + * @Route("/upload/{ajax}", name="admin_file_manager_upload", options={"expose"=true}, methods={"GET", "POST"}) + */ + public function upload(FsFileManager $manager, Request $request, TranslatorInterface $translator, bool $ajax = false): Response + { + $splInfo = $manager->getSplInfo($request->query->get('file')); + + if (!$splInfo) { + throw $this->createNotFoundException(); + } + + if (!$splInfo->isDir()) { + throw $this->createAccessDeniedException(); + } + + if ($manager->isLocked($request->query->get('file'))) { + return $this->render('@Core/file_manager/upload.html.twig', [ + 'locked' => true, + ]); + } + $form = $this->createForm(FileUploadType::class, null, [ + 'mimes' => $manager->getMimes(), + ]); + + if ($request->isMethod('POST')) { + $form->handleRequest($request); + + if ($form->isValid()) { + if ($form->get('files')->getData()) { + $manager->upload( + $form->get('files')->getData(), + $request->query->get('file') + ); + } + + if ($form->get('directory')->getData()) { + $manager->upload( + $form->get('directory')->getData(), + $request->query->get('file'), + $_FILES['file_upload']['full_path']['directory'] ?? [] + ); + } + + if (!$request->isXmlHttpRequest()) { + $this->addFlash('success', 'Files uploaded.'); + } else { + return $this->json([ + '_error' => 0, + '_message' => $translator->trans('Files uploaded.'), + '_level' => 'success', + '_dispatch' => 'file_manager.file.new.success', + ]); + } + } else { + if (!$request->isXmlHttpRequest()) { + $this->addFlash('warning', 'Unauthorized file type(s).'); + } else { + return $this->json([ + '_error' => 1, + '_message' => $translator->trans('Unauthorized file type(s).'), + '_level' => 'warning', + '_dispatch' => 'file_manager.file.new.error', + ]); + } + } + + return $this->redirectToRoute('admin_file_manager_index', [ + 'path' => $splInfo->getRelativePathname(), + ]); + } + + return $this->render('@Core/file_manager/upload.html.twig', [ + 'form' => $form->createView(), + 'file' => $request->query->get('file'), + 'locked' => false, + 'ajax' => $ajax, + ]); + } + + /** + * @Route("/delete", name="admin_file_manager_delete", methods={"DELETE"}) + */ + public function delete(FsFileManager $manager, Request $request): Response + { + $path = $request->request->get('file'); + $splInfo = $manager->getSplInfo($request->request->get('file')); + + if (!$splInfo) { + throw $this->createNotFoundException(); + } + + if ($this->isCsrfTokenValid('delete', $request->request->get('_token'))) { + if ($manager->delete($path)) { + $this->addFlash('success', 'The data has been removed.'); + } else { + $this->addFlash('warning', 'The data has not been removed.'); + } + } + + return $this->redirectToRoute('admin_file_manager_index', [ + 'path' => $splInfo->getRelativePath(), + ]); + } + + protected function getSection(): string + { + return 'file_manager'; + } +} diff --git a/core/Controller/Redirect/RedirectAdminController.php b/core/Controller/Redirect/RedirectAdminController.php new file mode 100644 index 0000000..2916a3c --- /dev/null +++ b/core/Controller/Redirect/RedirectAdminController.php @@ -0,0 +1,154 @@ +doIndex($page, $query, $request, $session); + } + + /** + * @Route("/admin/redirect/new", name="admin_redirect_new", methods={"GET", "POST"}) + */ + public function new(Factory $factory, EntityManager $entityManager, Request $request): Response + { + return $this->doNew($factory->create(), $entityManager, $request); + } + + /** + * @Route("/admin/redirect/show/{entity}", name="admin_redirect_show", methods={"GET"}) + */ + public function show(Entity $entity): Response + { + return $this->doShow($entity); + } + + /** + * @Route("/admin/redirect/filter", name="admin_redirect_filter", methods={"GET"}) + */ + public function filter(Session $session): Response + { + return $this->doFilter($session); + } + + /** + * @Route("/admin/redirect/edit/{entity}", name="admin_redirect_edit", methods={"GET", "POST"}) + */ + public function edit(Entity $entity, EntityManager $entityManager, Request $request): Response + { + return $this->doEdit($entity, $entityManager, $request); + } + + /** + * @Route("/admin/redirect/sort/{page}", name="admin_redirect_sort", methods={"POST"}, requirements={"page":"\d+"}) + */ + public function sort(RepositoryQuery $query, EntityManager $entityManager, Request $request, Session $session, int $page = 1): Response + { + return $this->doSort($page, $query, $entityManager, $request, $session); + } + + /** + * @Route("/admin/redirect/batch/{page}", name="admin_redirect_batch", methods={"POST"}, requirements={"page":"\d+"}) + */ + public function batch(RepositoryQuery $query, EntityManager $entityManager, Request $request, Session $session, int $page = 1): Response + { + return $this->doBatch($page, $query, $entityManager, $request, $session); + } + + /** + * @Route("/admin/redirect/delete/{entity}", name="admin_redirect_delete", methods={"DELETE"}) + */ + public function delete(Entity $entity, EntityManager $entityManager, Request $request): Response + { + return $this->doDelete($entity, $entityManager, $request); + } + + protected function getConfiguration(): CrudConfiguration + { + return CrudConfiguration::create() + ->setPageTitle('index', 'Redirects') + ->setPageTitle('edit', '{label}') + ->setPageTitle('new', 'New redirect') + + ->setPageRoute('index', 'admin_redirect_index') + ->setPageRoute('new', 'admin_redirect_new') + ->setPageRoute('edit', 'admin_redirect_edit') + ->setPageRoute('sort', 'admin_redirect_sort') + ->setPageRoute('batch', 'admin_redirect_batch') + ->setPageRoute('delete', 'admin_redirect_delete') + ->setPageRoute('filter', 'admin_redirect_filter') + + ->setForm('edit', Type::class, []) + ->setForm('new', Type::class) + ->setForm('filter', FilterType::class) + + ->setView('form', '@Core/redirect/redirect_admin/_form.html.twig') + ->setMaxPerPage('index', 100) + ->setIsSortableCollection('index', true) + + ->setAction('index', 'show', false) + ->setAction('edit', 'show', false) + + ->setField('index', 'Label', Field\TextField::class, [ + 'property' => 'label', + 'attr' => ['class' => 'col-4'], + ]) + ->setField('index', 'Rule', Field\TextField::class, [ + 'view' => '@Core/redirect/redirect_admin/field/rule.html.twig', + 'attr' => ['class' => 'col-6'], + ]) + ->setField('index', 'Enabled', Field\ButtonField::class, [ + 'property_builder' => function(EntityInterface $entity) { + return $entity->getIsEnabled() ? 'Yes' : 'No'; + }, + 'attr' => ['class' => 'col-1'], + 'button_attr_builder' => function(EntityInterface $entity) { + return ['class' => 'btn btn-sm btn-'.($entity->getIsEnabled() ? 'success' : 'primary')]; + }, + ]) + ->setField('index', 'Type', Field\ButtonField::class, [ + 'property' => 'redirectCode', + 'attr' => ['class' => 'col-1'], + 'button_attr' => ['class' => 'btn btn-sm btn-light border-secondary font-weight-bold'], + ]) + ->setBatchAction('index', 'enable', 'Enable', function (EntityInterface $entity, EntityManager $manager) { + $entity->setIsEnabled(true); + + $manager->update($entity); + }) + ->setBatchAction('index', 'disable', 'Disable', function (EntityInterface $entity, EntityManager $manager) { + $entity->setIsEnabled(false); + + $manager->update($entity); + }) + ->setBatchAction('index', 'delete', 'Delete', function (EntityInterface $entity, EntityManager $manager) { + $manager->delete($entity); + }) + ; + } + + protected function getSection(): string + { + return 'site_navigation'; + } +} diff --git a/core/Controller/Setting/NavigationSettingAdminController.php b/core/Controller/Setting/NavigationSettingAdminController.php new file mode 100644 index 0000000..24ecca0 --- /dev/null +++ b/core/Controller/Setting/NavigationSettingAdminController.php @@ -0,0 +1,81 @@ +createFormBuilder($entity); + $event = new NavigationSettingEvent([ + 'builder' => $builder, + 'entity' => $entity, + 'options' => [], + ]); + + $eventDispatcher->dispatch($event, NavigationSettingEvent::FORM_INIT_EVENT); + + $form = $builder->getForm(); + + if ($request->isMethod('POST')) { + $form->handleRequest($request); + + if ($form->isValid()) { + $entityManager->update($entity); + $this->addFlash('success', 'The data has been saved.'); + + return $this->redirectToRoute('admin_site_navigation_show', [ + 'entity' => $entity->getNavigation()->getId(), + ]); + } + + $this->addFlash('warning', 'The form is not valid.'); + } + + return $this->render('@Core/setting/navigation_setting_admin/edit.html.twig', [ + 'form' => $form->createView(), + 'entity' => $entity, + 'options' => $event->getData()['options'], + ]); + } + + /** + * @Route("/delete/{entity}", name="admin_navigation_setting_delete", methods={"DELETE"}) + */ + public function delete(Entity $entity, EntityManager $entityManager, Request $request): Response + { + if ($this->isCsrfTokenValid('delete'.$entity->getId(), $request->request->get('_token'))) { + $entityManager->delete($entity); + + $this->addFlash('success', 'The data has been removed.'); + } + + return $this->redirectToRoute('admin_site_navigation_show', [ + 'entity' => $entity->getNavigation()->getId(), + ]); + } + + public function getSection(): string + { + return ''; + } +} diff --git a/core/Controller/Setting/SettingAdminController.php b/core/Controller/Setting/SettingAdminController.php new file mode 100644 index 0000000..afcb82e --- /dev/null +++ b/core/Controller/Setting/SettingAdminController.php @@ -0,0 +1,99 @@ +dispatch(new SettingEvent(), SettingEvent::INIT_EVENT); + + $pager = $query + ->orderBy('.section, .label') + ->paginate($page) + ; + + return $this->render('@Core/setting/setting_admin/index.html.twig', [ + 'pager' => $pager, + ]); + } + + /** + * @Route("/edit/{entity}", name="admin_setting_edit") + */ + public function edit( + Entity $entity, + EntityManager $entityManager, + EventDispatcherInterface $eventDispatcher, + Request $request + ): Response { + $builder = $this->createFormBuilder($entity); + $event = new SettingEvent([ + 'builder' => $builder, + 'entity' => $entity, + 'options' => [], + ]); + + $eventDispatcher->dispatch($event, SettingEvent::FORM_INIT_EVENT); + + $form = $builder->getForm(); + + if ($request->isMethod('POST')) { + $form->handleRequest($request); + + if ($form->isValid()) { + $entityManager->update($entity); + $this->addFlash('success', 'The data has been saved.'); + + return $this->redirectToRoute('admin_setting_index'); + } + + $this->addFlash('warning', 'The form is not valid.'); + } + + return $this->render('@Core/setting/setting_admin/edit.html.twig', [ + 'form' => $form->createView(), + 'entity' => $entity, + 'options' => $event->getData()['options'], + ]); + } + + /** + * @Route("/delete/{entity}", name="admin_setting_delete", methods={"DELETE"}) + */ + public function delete(Entity $entity, EntityManager $entityManager, Request $request): Response + { + if ($this->isCsrfTokenValid('delete'.$entity->getId(), $request->request->get('_token'))) { + $entityManager->delete($entity); + + $this->addFlash('success', 'The data has been removed.'); + } + + return $this->redirectToRoute('admin_setting_index'); + } + + public function getSection(): string + { + return 'setting'; + } +} diff --git a/core/Controller/Site/MenuAdminController.php b/core/Controller/Site/MenuAdminController.php new file mode 100644 index 0000000..a542434 --- /dev/null +++ b/core/Controller/Site/MenuAdminController.php @@ -0,0 +1,82 @@ +create($navigation); + $form = $this->createForm(EntityType::class, $entity); + $form->handleRequest($request); + + if ($form->isValid()) { + $entityManager->create($entity); + + $this->addFlash('success', 'The data has been saved.'); + } else { + $this->addFlash('warning', 'The form is not valid.'); + } + + return $this->redirectToRoute('admin_site_tree_navigation', [ + 'navigation' => $navigation->getId(), + ]); + } + + /** + * @Route("/edit/{entity}", name="admin_site_menu_edit", methods={"POST"}) + */ + public function edit(Entity $entity, EntityManager $entityManager, Request $request): Response + { + $form = $this->createForm(EntityType::class, $entity); + $form->handleRequest($request); + + if ($form->isValid()) { + $entityManager->update($entity); + $this->addFlash('success', 'The data has been saved.'); + } else { + $this->addFlash('warning', 'The form is not valid.'); + } + + return $this->redirectToRoute('admin_site_tree_navigation', [ + 'navigation' => $entity->getNavigation()->getId(), + ]); + } + + /** + * @Route("/delete/{entity}", name="admin_site_menu_delete", methods={"DELETE"}) + */ + public function delete(Entity $entity, EntityManager $entityManager, Request $request): Response + { + if ($this->isCsrfTokenValid('delete'.$entity->getId(), $request->request->get('_token'))) { + $entityManager->delete($entity); + + $this->addFlash('success', 'The data has been removed.'); + } + + return $this->redirectToRoute('admin_site_tree_navigation', [ + 'navigation' => $entity->getNavigation()->getId(), + ]); + } + + public function getSection(): string + { + return ''; + } +} diff --git a/core/Controller/Site/NavigationAdminController.php b/core/Controller/Site/NavigationAdminController.php new file mode 100644 index 0000000..b1676d8 --- /dev/null +++ b/core/Controller/Site/NavigationAdminController.php @@ -0,0 +1,142 @@ +doIndex($page, $query, $request, $session); + } + + /** + * @Route("/admin/site/navigation/new", name="admin_site_navigation_new", methods={"GET", "POST"}) + */ + public function new(Factory $factory, EntityManager $entityManager, Request $request): Response + { + return $this->doNew($factory->create(), $entityManager, $request); + } + + /** + * @Route("/admin/site/navigation/show/{entity}", name="admin_site_navigation_show", methods={"GET"}) + */ + public function show( + Entity $entity, + EventDispatcherInterface $eventDispatcher, + NavigationSettingRepositoryQuery $settingQuery + ): Response { + $eventDispatcher->dispatch(new NavigationSettingEvent([ + 'navigation' => $entity, + ]), NavigationSettingEvent::INIT_EVENT); + + $settings = $settingQuery + ->where('.navigation = :navigation') + ->orderBy('.section, .label') + ->setParameter(':navigation', $entity->getId()) + ->paginate(1, 1000) + ; + + $this->getConfiguration()->addViewData('show', 'settings', $settings); + + return $this->doShow($entity); + } + + /** + * @Route("/admin/site/navigation/filter", name="admin_site_navigation_filter", methods={"GET"}) + */ + public function filter(Session $session): Response + { + return $this->doFilter($session); + } + + /** + * @Route("/admin/site/navigation/edit/{entity}", name="admin_site_navigation_edit", methods={"GET", "POST"}) + */ + public function edit(Entity $entity, EntityManager $entityManager, Request $request): Response + { + return $this->doEdit($entity, $entityManager, $request); + } + + /** + * @Route("/admin/site/navigation/sort/{page}", name="admin_site_navigation_sort", methods={"POST"}, requirements={"page":"\d+"}) + */ + public function sort(RepositoryQuery $query, EntityManager $entityManager, Request $request, Session $session, int $page = 1, ): Response + { + return $this->doSort($page, $query, $entityManager, $request, $session); + } + + /** + * @Route("/admin/site/navigation/delete/{entity}", name="admin_site_navigation_delete", methods={"DELETE"}) + */ + public function delete(Entity $entity, EntityManager $entityManager, Request $request): Response + { + return $this->doDelete($entity, $entityManager, $request); + } + + protected function getConfiguration(): CrudConfiguration + { + return CrudConfiguration::create() + ->setPageTitle('index', 'Navigations') + ->setPageTitle('edit', '{label}') + ->setPageTitle('new', 'New navigation') + ->setPageTitle('show', '{label}') + + ->setPageRoute('index', 'admin_site_navigation_index') + ->setPageRoute('new', 'admin_site_navigation_new') + ->setPageRoute('edit', 'admin_site_navigation_edit') + ->setPageRoute('show', 'admin_site_navigation_show') + ->setPageRoute('sort', 'admin_site_navigation_sort') + ->setPageRoute('delete', 'admin_site_navigation_delete') + ->setPageRoute('filter', 'admin_site_navigation_filter') + ->setPageRoute('redirects', 'admin_redirect_index') + + ->setForm('edit', Type::class, []) + ->setForm('new', Type::class) + + ->setView('index', '@Core/site/navigation_admin/index.html.twig') + ->setView('show', '@Core/site/navigation_admin/show.html.twig') + ->setView('show_entity', '@Core/site/navigation_admin/_show.html.twig') + ->setView('form', '@Core/site/navigation_admin/_form.html.twig') + + ->setIsSortableCollection('index', true) + + ->setField('index', 'Label', Field\TextField::class, [ + 'property' => 'label', + 'attr' => ['class' => 'miw-200'], + ]) + ->setField('index', 'Domain', Field\ButtonField::class, [ + 'property' => 'domain', + 'button_attr' => ['class' => 'btn btn-light'], + 'attr' => ['class' => 'miw-200'], + ]) + ->setField('index', 'Locale', Field\ButtonField::class, [ + 'property' => 'locale', + 'button_attr' => ['class' => 'btn btn-light'], + ]) + ; + } + + protected function getSection(): string + { + return 'site_navigation'; + } +} diff --git a/core/Controller/Site/NodeAdminController.php b/core/Controller/Site/NodeAdminController.php new file mode 100644 index 0000000..327bd34 --- /dev/null +++ b/core/Controller/Site/NodeAdminController.php @@ -0,0 +1,305 @@ +create($node->getMenu()); + $form = $this->createForm(EntityType::class, $entity, [ + 'pages' => $pageLocator->getPages(), + 'controllers' => $controllerLocator->getControllers(), + 'navigation' => $node->getMenu()->getNavigation(), + ]); + + if ($request->isMethod('POST')) { + $form->handleRequest($request); + + if ($form->isValid()) { + $position = $form->get('position')->getData(); + + $parent = 'above' === $position ? $node : $node->getParent(); + $entity->setParent($parent); + + if ('above' === $position) { + $nodeRepository->persistAsLastChild($entity, $node); + } else { + if ('after' === $position) { + $nodeRepository->persistAsNextSiblingOf($entity, $node); + } elseif ('before' === $position) { + $nodeRepository->persistAsPrevSiblingOf($entity, $node); + } + } + + $this->handlePageAssociation( + $form->get('pageAction')->getData(), + $form->get('pageEntity')->getData(), + $form->get('pageType')->getData(), + $entity, + $pageFactory, + $pageLocator + ); + + $entityManager->create($entity); + + $this->addFlash('success', 'The data has been saved.'); + + return $this->redirect($this->generateUrl('admin_site_tree_navigation', [ + 'navigation' => $node->getMenu()->getNavigation()->getId(), + 'data-modal' => $this->generateUrl('admin_site_node_edit', ['entity' => $entity->getId()]), + ]).sprintf('#node-%d', $entity->getId())); + } + $this->addFlash('warning', 'The form is not valid.'); + + return $this->redirect($this->generateUrl('admin_site_tree_navigation', [ + 'navigation' => $entity->getMenu()->getNavigation()->getId(), + ]).sprintf('#node-%d', $entity->getId())); + } + + return $this->render('@Core/site/node_admin/new.html.twig', [ + 'form' => $form->createView(), + 'node' => $node, + 'entity' => $entity, + 'tab' => 'content', + ]); + } + + /** + * @Route("/edit/{entity}/{tab}", name="admin_site_node_edit") + */ + public function edit( + Entity $entity, + EntityManager $entityManager, + PageFactory $pageFactory, + PageLocator $pageLocator, + ControllerLocator $controllerLocator, + Request $request, + string $tab = 'content' + ): Response { + $form = $this->createForm(EntityType::class, $entity, [ + 'pages' => $pageLocator->getPages(), + 'controllers' => $controllerLocator->getControllers(), + 'navigation' => $entity->getMenu()->getNavigation(), + ]); + + if ($request->isMethod('POST')) { + $form->handleRequest($request); + + if ($form->isValid()) { + $this->handlePageAssociation( + $form->get('pageAction')->getData(), + $form->get('pageEntity')->getData(), + $form->get('pageType')->getData(), + $entity, + $pageFactory, + $pageLocator + ); + + $entityManager->update($entity); + + $this->addFlash('success', 'The data has been saved.'); + } else { + $this->addFlash('warning', 'The form is not valid.'); + } + + return $this->redirect($this->generateUrl('admin_site_tree_navigation', [ + 'navigation' => $entity->getMenu()->getNavigation()->getId(), + 'data-modal' => $this->generateUrl('admin_site_node_edit', ['entity' => $entity->getId()]), + ]).sprintf('#node-%d', $entity->getId())); + } + + $page = $entity->getPage(); + + if ($page !== null) { + $pageConfiguration = $pageLocator->getPages()[get_class($page)] ?? null; + } else { + $pageConfiguration = null; + } + + return $this->render('@Core/site/node_admin/edit.html.twig', [ + 'form' => $form->createView(), + 'entity' => $entity, + 'page' => $page, + 'pageConfiguration' => $pageConfiguration, + 'tab' => $tab, + ]); + } + + /** + * @Route("/urls/{entity}", name="admin_site_node_urls") + */ + public function urls(Entity $entity, SitemapBuilder $builder): Response + { + return $this->render('@Core/site/node_admin/urls.html.twig', [ + 'entity' => $entity, + 'urls' => $builder->getNodeUrls($entity), + ]); + } + + /** + * @Route("/move/{entity}", name="admin_site_node_move") + */ + public function move( + Entity $entity, + EntityManager $entityManager, + NodeRepository $nodeRepository, + Request $request + ): Response { + $form = $this->createForm(NodeMoveType::class, null, [ + 'menu' => $entity->getMenu(), + ]); + + if ($request->isMethod('POST')) { + $form->handleRequest($request); + + if ($form->get('node')->getData()->getId() === $entity->getId()) { + $form->get('node')->addError(new FormError('ร‰lement de rรฉfรฉrence invalide.')); + } + + if ($form->isValid()) { + $position = $form->get('position')->getData(); + $node = $form->get('node')->getData(); + + $parent = 'above' === $position ? $node : $node->getParent(); + $entity->setParent($parent); + + if ('above' === $position) { + $nodeRepository->persistAsLastChild($entity, $node); + $entityManager->flush(); + } else { + if ('after' === $position) { + $nodeRepository->persistAsNextSiblingOf($entity, $node); + } elseif ('before' === $position) { + $nodeRepository->persistAsPrevSiblingOf($entity, $node); + } + + $entityManager->flush(); + } + + $this->addFlash('success', 'The data has been saved.'); + } else { + $this->addFlash('warning', 'The form is not valid.'); + } + + return $this->redirect($this->generateUrl('admin_site_tree_navigation', [ + 'navigation' => $entity->getMenu()->getNavigation()->getId(), + ]).sprintf('#node-%d', $entity->getId())); + } + + return $this->render('@Core/site/node_admin/move.html.twig', [ + 'form' => $form->createView(), + 'entity' => $entity, + ]); + } + + /** + * @Route("/toggle/visibility/{entity}", name="admin_site_node_toggle_visibility", methods={"POST"}) + */ + public function toggleVisibility(Entity $entity, EntityManager $entityManager, Request $request): Response + { + if ($this->isCsrfTokenValid('toggle_visibility'.$entity->getId(), $request->request->get('_token'))) { + $entity->setIsVisible(!$entity->getIsVisible()); + + $entityManager->update($entity); + + $this->addFlash('success', 'The data has been saved.'); + } + + return $this->redirect($this->generateUrl('admin_site_tree_navigation', [ + 'navigation' => $entity->getMenu()->getNavigation()->getId(), + ]).sprintf('#node-%d', $entity->getId())); + } + + /** + * @Route("/delete/{entity}", name="admin_site_node_delete", methods={"DELETE"}) + */ + public function delete( + Entity $entity, + NodeRepository $nodeRepository, + EventDispatcherInterface $eventDispatcher, + Request $request + ): Response { + if ($this->isCsrfTokenValid('delete'.$entity->getId(), $request->request->get('_token'))) { + $eventDispatcher->dispatch(new EntityManagerEvent($entity), EntityManagerEvent::PRE_DELETE_EVENT); + $nodeRepository->removeFromTree($entity); + $nodeRepository->reorder($entity->getMenu()->getRootNode()); + $eventDispatcher->dispatch(new EntityManagerEvent($entity), EntityManagerEvent::DELETE_EVENT); + + $this->addFlash('success', 'Donnรฉe supprimรฉe.'); + } + + return $this->redirectToRoute('admin_site_tree_navigation', [ + 'navigation' => $entity->getMenu()->getNavigation()->getId(), + ]); + } + + protected function handlePageAssociation( + string $pageAction, + ?Page $pageEntity, + string $pageType, + Entity $entity, + PageFactory $pageFactory, + PageLocator $pageLocator + ) { + if ('new' === $pageAction) { + $pageConfiguration = $pageLocator->getPage($pageType); + $page = $pageFactory->create($pageType, $entity->getLabel()); + $page->setTemplate($pageConfiguration->getTemplates()[0]['file']); + + $entity + ->setPage($page) + ->setAliasNode(null) + ; + } elseif ('existing' === $pageAction) { + if ($pageEntity) { + $entity->setPage($pageEntity); + } else { + $this->addFlash('info', 'Aucun changement de page effectuรฉ.'); + } + $entity->setAliasNode(null); + } elseif ('alias' === $pageAction) { + $entity->setPage(null); + } elseif ('none' === $pageAction) { + $entity + ->setPage(null) + ->setAliasNode(null) + ; + } + } +} diff --git a/core/Controller/Site/PageAdminController.php b/core/Controller/Site/PageAdminController.php new file mode 100644 index 0000000..cb0a794 --- /dev/null +++ b/core/Controller/Site/PageAdminController.php @@ -0,0 +1,136 @@ +doIndex($page, $query, $request, $session); + } + + /** + * @Route("/admin/site/page/show/{entity}", name="admin_site_page_show", methods={"GET"}) + */ + public function show(Entity $entity): Response + { + return $this->doShow($entity); + } + + /** + * @Route("/admin/site/page/filter", name="admin_site_page_filter", methods={"GET"}) + */ + public function filter(Session $session): Response + { + return $this->doFilter($session); + } + + /** + * @Route("/admin/site/page/edit/{entity}", name="admin_site_page_edit", methods={"GET", "POST"}) + */ + public function edit( + int $entity, + EntityManager $entityManager, + RepositoryQuery $repositoryQuery, + PageLocator $pageLocator, + EventDispatcherInterface $eventDispatcher, + Request $request + ): Response { + $entity = $repositoryQuery->filterById($entity)->findOne(); + + $event = new PageEditEvent($entity); + $eventDispatcher->dispatch($event, PageEditEvent::FORM_INIT_EVENT); + + $this->getConfiguration()->setFormOptions('edit', [ + 'page_configuration' => $pageLocator->getPage(get_class($entity)), + 'page_builder_options' => $event->getPageBuilderOptions(), + ]); + + return $this->doEdit($entity, $entityManager, $request); + } + + /** + * @Route("/admin/site/page/delete/{entity}", name="admin_site_page_delete", methods={"DELETE"}) + */ + public function delete(Entity $entity, EntityManager $entityManager, Request $request): Response + { + return $this->doDelete($entity, $entityManager, $request); + } + + /** + * @Route("/admin/site/page/batch/{page}", name="admin_site_page_batch", methods={"POST"}, requirements={"page":"\d+"}) + */ + public function batch(RepositoryQuery $query, EntityManager $entityManager, Request $request, Session $session, int $page = 1): Response + { + return $this->doBatch($page, $query, $entityManager, $request, $session); + } + + protected function getConfiguration(): CrudConfiguration + { + return CrudConfiguration::create() + ->setPageTitle('index', 'Pages') + ->setPageTitle('edit', '{name}') + ->setPageTitle('show', '{name}') + + ->setPageRoute('index', 'admin_site_page_index') + ->setPageRoute('edit', 'admin_site_page_edit') + ->setPageRoute('delete', 'admin_site_page_delete') + ->setPageRoute('filter', 'admin_site_page_filter') + ->setPageRoute('batch', 'admin_site_page_batch') + + ->setForm('edit', Type::class, []) + ->setForm('filter', FilterType::class) + ->setView('form', '@Core/site/page_admin/_form.html.twig') + + ->setAction('index', 'new', false) + ->setAction('index', 'show', false) + ->setAction('edit', 'show', false) + + ->setField('index', 'Name', Field\TextField::class, [ + 'property' => 'name', + 'sort' => ['name', '.name'], + 'attr' => ['class' => 'col-4'], + ]) + ->setField('index', 'Elements', Field\TextField::class, [ + 'view' => '@Core/site/page_admin/fields/nodes.html.twig', + 'sort' => ['navigation', function (RepositoryQuery $query, $direction) { + $query + ->leftJoin('.nodes', 'node') + ->leftJoin('node.menu', 'menu') + ->leftJoin('menu.navigation', 'navigation') + ->orderBy('navigation.label', $direction) + ; + }], + 'attr' => ['class' => 'col-6'], + ]) + ->setBatchAction('index', 'delete', 'Delete', function(EntityInterface $entity, EntityManager $manager) { + $manager->delete($entity); + }) + ; + } + + protected function getSection(): string + { + return 'site_page'; + } +} diff --git a/core/Controller/Site/PageController.php b/core/Controller/Site/PageController.php new file mode 100644 index 0000000..3a081b7 --- /dev/null +++ b/core/Controller/Site/PageController.php @@ -0,0 +1,57 @@ +siteRequest = $siteRequest; + $this->siteStore = $siteStore; + } + + public function show(): Response + { + if (!$this->siteRequest->getPage()) { + throw $this->createNotFoundException(); + } + + return $this->defaultRender($this->siteRequest->getPage()->getTemplate()); + } + + protected function defaultRender(string $view, array $parameters = [], Response $response = null): Response + { + $parameters = array_merge($this->getDefaultRenderParameters(), $parameters); + + if (null === $response) { + $contentType = $this->siteRequest->getNode()->getContentType(); + + $response = new Response(null, 200, [ + 'Content-Type' => $contentType ?? 'text/html', + ]); + } + + return parent::render($view, $parameters, $response); + } + + protected function getDefaultRenderParameters(): array + { + return [ + '_node' => $this->siteRequest->getNode(), + '_page' => $this->siteRequest->getPage(), + '_menu' => $this->siteRequest->getMenu(), + '_navigation' => $this->siteRequest->getNavigation(), + '_domain' => $this->siteRequest->getDomain(), + '_locale' => $this->siteRequest->getNavigation()->getLocale(), + '_store' => $this->siteStore, + ]; + } +} diff --git a/core/Controller/Site/SitemapController.php b/core/Controller/Site/SitemapController.php new file mode 100644 index 0000000..ea1fe29 --- /dev/null +++ b/core/Controller/Site/SitemapController.php @@ -0,0 +1,40 @@ +whereDomain($request->getHost()) + ->find() + ; + + $items = []; + + foreach ($navigations as $navigation) { + $items = array_merge( + $items, + $builder->build($navigation) + ); + } + + $response = new Response(); + $response->headers->set('Content-Type', 'text/xml'); + + return $this->render('@Core/site/sitemap/sitemap.xml.twig', [ + 'items' => $items, + ], $response); + } +} diff --git a/core/Controller/Site/TreeAdminController.php b/core/Controller/Site/TreeAdminController.php new file mode 100644 index 0000000..934d900 --- /dev/null +++ b/core/Controller/Site/TreeAdminController.php @@ -0,0 +1,87 @@ +has('site_tree_last_navigation')) { + $navigation = $navigationQuery->create() + ->filterById((int) $session->get('site_tree_last_navigation')) + ->findOne() + ; + } + + if (null === $navigation) { + $navigation = $navigationQuery->create() + ->orderBy('.sortOrder') + ->findOne() + ; + } + + if (null === $navigation) { + $this->addFlash('warning', 'You must add a navigation.'); + + return $this->redirectToRoute('admin_site_navigation_new'); + } + + return $this->redirectToRoute('admin_site_tree_navigation', [ + 'navigation' => $navigation->getId(), + ]); + } + + /** + * @Route("/navigation/{navigation}", name="admin_site_tree_navigation") + */ + public function navigation( + Navigation $navigation, + NavigationRepositoryQuery $navigationQuery, + MenuFactory $menuFactory, + Session $session + ): Response { + $navigations = $navigationQuery->create() + ->orderBy('.sortOrder') + ->find() + ; + + $session->set('site_tree_last_navigation', $navigation->getId()); + + $forms = [ + 'menu' => $this->createForm(MenuType::class, $menuFactory->create())->createView(), + 'menus' => [], + ]; + + foreach ($navigation->getMenus() as $menu) { + $forms['menus'][$menu->getId()] = $this->createForm(MenuType::class, $menu)->createView(); + } + + return $this->render('@Core/site/tree_admin/navigation.html.twig', [ + 'navigation' => $navigation, + 'navigations' => $navigations, + 'forms' => $forms, + ]); + } + + public function getSection(): string + { + return 'site_tree'; + } +} diff --git a/core/Controller/Task/TaskAdminController.php b/core/Controller/Task/TaskAdminController.php new file mode 100644 index 0000000..1b18573 --- /dev/null +++ b/core/Controller/Task/TaskAdminController.php @@ -0,0 +1,62 @@ +dispatch($event, TaskInitEvent::INIT_EVENT); + + return $this->render('@Core/task/task_admin/index.html.twig', [ + 'pager' => $event->getTasks(), + ]); + } + + /** + * @Route("/run/{task}", name="admin_task_run", methods={"GET"}) + */ + public function run( + string $task, + Request $request, + EventDispatcherInterface $eventDispatcher + ): Response { + if (!$this->isCsrfTokenValid('task_run', $request->query->get('_token'))) { + throw $this->createAccessDeniedException(); + } + + $output = new BufferedOutput(); + $event = new TaskRunRequestedEvent($task, $request->query, $output); + $eventDispatcher->dispatch($event, TaskRunRequestedEvent::RUN_REQUEST_EVENT); + + $converter = new AnsiToHtmlConverter(new SolarizedTheme()); + $content = $converter->convert($output->fetch()); + + return $this->render('@Core/task/task_admin/run.html.twig', [ + 'output' => $content, + ]); + } + + public function getSection(): string + { + return 'task'; + } +} diff --git a/core/Controller/User/UserAdminController.php b/core/Controller/User/UserAdminController.php new file mode 100644 index 0000000..7fdeea9 --- /dev/null +++ b/core/Controller/User/UserAdminController.php @@ -0,0 +1,132 @@ +doIndex($page, $query, $request, $session); + } + + /** + * @Route("/admin/user/new", name="admin_user_new", methods={"GET", "POST"}) + */ + public function new(Factory $factory, EntityManager $entityManager, Request $request): Response + { + $entity = $factory->create($this->getUser()); + + return $this->doNew($factory->create(), $entityManager, $request); + } + + /** + * @Route("/admin/user/show/{entity}", name="admin_user_show", methods={"GET"}) + */ + public function show(Entity $entity): Response + { + return $this->doShow($entity); + } + + /** + * @Route("/admin/user/filter", name="admin_user_filter", methods={"GET"}) + */ + public function filter(Session $session): Response + { + return $this->doFilter($session); + } + + /** + * @Route("/admin/user/edit/{entity}", name="admin_user_edit", methods={"GET", "POST"}) + */ + public function edit(Entity $entity, EntityManager $entityManager, Request $request): Response + { + return $this->doEdit($entity, $entityManager, $request); + } + + /** + * @Route("/admin/user/delete/{entity}", name="admin_user_delete", methods={"DELETE"}) + */ + public function delete(Entity $entity, EntityManager $entityManager, Request $request): Response + { + return $this->doDelete($entity, $entityManager, $request); + } + + /** + * @Route("/admin/user/resetting_request/{entity}", name="admin_user_resetting_request", methods={"POST"}) + */ + public function requestResetting(Entity $entity, EventDispatcherInterface $eventDispatcher, Request $request): Response + { + if ($this->isCsrfTokenValid('resetting_request'.$entity->getId(), $request->request->get('_token'))) { + $eventDispatcher->dispatch(new PasswordRequestEvent($entity), PasswordRequestEvent::EVENT); + + $this->addFlash('success', 'E-mail sent.'); + } + + return $this->redirectToRoute('admin_user_edit', [ + 'entity' => $entity->getId(), + ]); + } + + protected function getConfiguration(): CrudConfiguration + { + return CrudConfiguration::create() + ->setPageTitle('index', 'Users') + ->setPageTitle('edit', '{username}') + ->setPageTitle('new', 'New user') + ->setPageTitle('show', '{username}') + + ->setPageRoute('index', 'admin_user_index') + ->setPageRoute('new', 'admin_user_new') + ->setPageRoute('edit', 'admin_user_edit') + ->setPageRoute('show', 'admin_user_show') + ->setPageRoute('delete', 'admin_user_delete') + ->setPageRoute('filter', 'admin_user_filter') + + ->setForm('edit', Type::class, []) + ->setForm('new', Type::class) + + ->setView('form', '@Core/user/user_admin/_form.html.twig') + ->setView('index', '@Core/user/user_admin/index.html.twig') + ->setView('new', '@Core/user/user_admin/new.html.twig') + ->setView('show', '@Core/user/user_admin/show.html.twig') + ->setView('show_entity', '@Core/user/user_admin/_show.html.twig') + ->setView('edit', '@Core/user/user_admin/edit.html.twig') + + ->setDefaultSort('index', 'username') + + ->setField('index', 'E-mail', Field\TextField::class, [ + 'property' => 'email', + 'sort' => ['email', '.email'], + 'attr' => ['class' => 'miw-200'], + ]) + ->setField('index', 'Display name', Field\TextField::class, [ + 'property' => 'displayName', + 'sort' => ['displayName', '.displayName'], + 'attr' => ['class' => 'miw-200'], + ]) + ; + } + + protected function getSection(): string + { + return 'user'; + } +} diff --git a/core/Crud/CrudConfiguration.php b/core/Crud/CrudConfiguration.php new file mode 100644 index 0000000..bfe9121 --- /dev/null +++ b/core/Crud/CrudConfiguration.php @@ -0,0 +1,330 @@ + + */ +class CrudConfiguration +{ + protected array $pageTitles = []; + protected array $pageRoutes = []; + protected array $pageRouteParams = []; + protected array $actions = []; + protected array $batchActions = []; + protected array $actionTitles = []; + protected array $forms = []; + protected array $formOptions = []; + protected array $views = []; + protected array $viewDatas = []; + protected array $fields = []; + protected array $maxPerPage = []; + protected array $locales = []; + protected array $defaultSort = []; + protected array $isSortableCollection = []; + protected string $sortableCollectionProperty = 'sortOrder'; + protected ?string $defaultLocale = null; + protected bool $showActions = true; + + protected static $self; + + public static function create() + { + if (null === self::$self) { + self::$self = new self(); + } + + return self::$self; + } + + /* -- */ + + public function setPageTitle(string $page, string $title): self + { + $this->pageTitles[$page] = $title; + + return $this; + } + + public function getPageTitle(string $page, ?string $default = null): ?string + { + return $this->pageTitles[$page] ?? $default; + } + + /* -- */ + + public function setPageRoute(string $page, string $route): self + { + $this->pageRoutes[$page] = $route; + + return $this; + } + + public function getPageRoute(string $page): ?string + { + return $this->pageRoutes[$page]; + } + + public function setPageRouteParams(string $page, array $params): self + { + $this->pageRouteParams[$page] = $params; + + return $this; + } + + public function getPageRouteParams(string $page): array + { + return $this->pageRouteParams[$page] ?? []; + } + + /* -- */ + + public function setForm(string $context, string $form, array $options = []): self + { + $this->forms[$context] = $form; + + return $this; + } + + public function getForm(string $context): ?string + { + return $this->forms[$context] ?? null; + } + + public function setFormOptions(string $context, array $options = []): self + { + $this->formOptions[$context] = $options; + + return $this; + } + + public function getFormOptions(string $context): array + { + return $this->formOptions[$context] ?? []; + } + + /* -- */ + + public function setAction(string $page, string $action, bool $enabled): self + { + if (!isset($this->actions[$page])) { + $this->actions[$page] = []; + } + + $this->actions[$page][$action] = $enabled; + + return $this; + } + + public function getAction(string $page, string $action, bool $default = true) + { + return $this->actions[$page][$action] ?? $default; + } + + public function setBatchAction(string $page, string $action, string $label, callable $callback): self + { + if (!isset($this->batchActions[$page])) { + $this->batchActions[$page] = []; + } + + $this->batchActions[$page][$action] = [ + 'label' => $label, + 'callback' => $callback, + ]; + + return $this; + } + + public function getBatchActions(string $page) + { + return $this->batchActions[$page] ?? []; + } + + public function getBatchAction(string $page, string $action) + { + return $this->batchActions[$page][$action] ?? null; + } + + public function hasBatchAction(string $page) + { + return !empty($this->batchActions[$page]); + } + + /* -- */ + + public function setActionTitle(string $page, string $action, string $title): self + { + if (!isset($this->actionTitles[$page])) { + $this->actionTitles[$page] = []; + } + + $this->actions[$page][$action] = $title; + + return $this; + } + + public function getActionTitle(string $page, string $action, ?string $default = null): ?string + { + return $this->actionTitles[$page][$action] ?? $default; + } + + /* -- */ + + public function setView(string $context, string $view): self + { + $this->views[$context] = $view; + + return $this; + } + + public function getView(string $context, ?string $default = null) + { + if (null === $default) { + $default = sprintf('@Core/admin/crud/%s.html.twig', $context); + } + + return $this->views[$context] ?? $default; + } + + public function addViewData(string $context, string $name, $value): self + { + if (!isset($this->viewDatas[$context])) { + $this->viewDatas[$context] = []; + } + + $this->viewDatas[$context][$name] = $value; + + return $this; + } + + public function setViewDatas(string $context, array $datas): self + { + foreach ($datas as $name => $value) { + $this->addViewData($name, $value); + } + + return $this; + } + + public function getViewDatas(string $context): array + { + return $this->viewDatas[$context] ?? []; + } + + /* -- */ + + public function setField(string $context, string $label, string $field, array $options): self + { + if (!isset($this->fields[$context])) { + $this->fields[$context] = []; + } + + $this->fields[$context][$label] = [ + 'field' => $field, + 'options' => $options, + ]; + + return $this; + } + + public function getFields(string $context): array + { + return $this->fields[$context] ?? []; + } + + /* -- */ + + public function setMaxPerPage(string $page, int $max) + { + $this->maxPerPage[$page] = $max; + + return $this; + } + + public function getMaxPerPage(string $page, int $default = 20) + { + return $this->maxPerPage[$page] ?? $default; + } + + /* -- */ + + public function setI18n(array $locales, string $defaultLocale): self + { + $this->locales = $locales; + $this->defaultLocale = $defaultLocale; + + return $this; + } + + public function getLocales(): array + { + return $this->locales; + } + + public function getDefaultLocale(): ?string + { + return $this->defaultLocale; + } + + public function isI18n(): bool + { + return !empty($this->locales); + } + + /* -- */ + + public function setDefaultSort(string $context, string $label, string $direction = 'asc'): self + { + $this->defaultSort[$context] = [ + 'label' => $label, + 'direction' => $direction, + ]; + + return $this; + } + + public function getDefaultSort(string $context) + { + return $this->defaultSort[$context] ?? null; + } + + public function setIsSortableCollection(string $page, bool $isSortableCollection): self + { + $this->isSortableCollection[$page] = $isSortableCollection; + + return $this; + } + + public function getIsSortableCollection(string $page): bool + { + return $this->isSortableCollection[$page] ?? false; + } + + public function setSortableCollectionProperty(string $sortableCollectionProperty): self + { + $this->sortableCollectionProperty = $sortableCollectionProperty; + + return $this; + } + + public function getSortableCollectionProperty(): string + { + return $this->sortableCollectionProperty; + } + + public function setShowActions(bool $showActions): self + { + $this->showActions = $showActions; + + return $this; + } + + public function getShowActions(): bool + { + return $this->showActions; + } +} diff --git a/core/Crud/Exception/CrudConfigurationException.php b/core/Crud/Exception/CrudConfigurationException.php new file mode 100644 index 0000000..17990c7 --- /dev/null +++ b/core/Crud/Exception/CrudConfigurationException.php @@ -0,0 +1,12 @@ + + */ +class CrudConfigurationException extends \Exception +{ +} diff --git a/core/Crud/Field/ButtonField.php b/core/Crud/Field/ButtonField.php new file mode 100644 index 0000000..fcd4961 --- /dev/null +++ b/core/Crud/Field/ButtonField.php @@ -0,0 +1,41 @@ + + */ +class ButtonField extends Field +{ + public function buildView(Environment $twig, $entity, array $options, ?string $locale = null) + { + if (isset($options['button_attr_builder']) && is_callable($options['button_attr_builder'])) { + $options['button_attr'] = (array) call_user_func($options['button_attr_builder'], $entity, $options); + } + + return parent::buildView($twig, $entity, $options, $locale); + } + + public function configureOptions(OptionsResolver $resolver): OptionsResolver + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'view' => '@Core/admin/crud/field/button.html.twig', + 'button_attr' => [], + 'button_attr_builder' => null, + 'button_tag' => 'button', + ]); + + $resolver->setAllowedTypes('button_attr', ['array']); + $resolver->setAllowedTypes('button_tag', ['string']); + $resolver->setAllowedTypes('button_attr_builder', ['null', 'callable']); + + return $resolver; + } +} diff --git a/core/Crud/Field/DateField.php b/core/Crud/Field/DateField.php new file mode 100644 index 0000000..f97da96 --- /dev/null +++ b/core/Crud/Field/DateField.php @@ -0,0 +1,25 @@ + + */ +class DateField extends Field +{ + public function configureOptions(OptionsResolver $resolver): OptionsResolver + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'view' => '@Core/admin/crud/field/date.html.twig', + 'format' => 'Y-m-d', + ]); + + return $resolver; + } +} diff --git a/core/Crud/Field/DatetimeField.php b/core/Crud/Field/DatetimeField.php new file mode 100644 index 0000000..99aa739 --- /dev/null +++ b/core/Crud/Field/DatetimeField.php @@ -0,0 +1,25 @@ + + */ +class DatetimeField extends Field +{ + public function configureOptions(OptionsResolver $resolver): OptionsResolver + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'view' => '@Core/admin/crud/field/date.html.twig', + 'format' => 'Y-m-d H:i:s', + ]); + + return $resolver; + } +} diff --git a/core/Crud/Field/Field.php b/core/Crud/Field/Field.php new file mode 100644 index 0000000..42256e8 --- /dev/null +++ b/core/Crud/Field/Field.php @@ -0,0 +1,92 @@ + + */ +abstract class Field +{ + public function buildView(Environment $twig, $entity, array $options, ?string $locale = null) + { + return $twig->render($this->getView($options), [ + 'entity' => $entity, + 'value' => $this->getValue($entity, $options, $locale), + 'options' => $options, + ]); + } + + public function configureOptions(OptionsResolver $resolver): OptionsResolver + { + $resolver->setDefaults([ + 'property' => null, + 'property_builder' => null, + 'view' => null, + 'raw' => false, + 'sort' => null, + 'href' => null, + 'href_attr' => [], + 'attr' => [], + ]); + + $resolver->setRequired('view'); + $resolver->setAllowedTypes('property', ['null', 'string']); + $resolver->setAllowedTypes('view', 'string'); + $resolver->setAllowedTypes('attr', 'array'); + $resolver->setAllowedTypes('href', ['null', 'string', 'callable']); + $resolver->setAllowedTypes('href_attr', 'array', 'callable'); + $resolver->setAllowedTypes('raw', 'boolean'); + $resolver->setAllowedTypes('property_builder', ['null', 'callable']); + $resolver->setAllowedValues('sort', function($value) { + if ($value === null) { + return true; + } + + if (!is_array($value)) { + return false; + } + + $isValidParam1 = !empty($value[0]) && is_string($value[0]); + $isValidParam2 = !empty($value[1]) && (is_string($value[1]) || is_callable($value[1])); + + return $isValidParam1 && $isValidParam2; + }); + + return $resolver; + } + + protected function getValue($entity, array $options, ?string $locale = null) + { + if (null !== $options['property']) { + $propertyAccessor = PropertyAccess::createPropertyAccessorBuilder()->getPropertyAccessor(); + + try { + $value = $propertyAccessor->getValue($entity, $options['property']); + } catch (NoSuchPropertyException $e) { + if (null !== $locale) { + $value = $propertyAccessor->getValue($entity->translate($locale), $options['property']); + } else { + throw $e; + } + } + } elseif (null !== $options['property_builder']) { + $value = call_user_func($options['property_builder'], $entity, $options); + } else { + $value = null; + } + + return $value; + } + + protected function getView(array $options) + { + return $options['view']; + } +} diff --git a/core/Crud/Field/ImageField.php b/core/Crud/Field/ImageField.php new file mode 100644 index 0000000..bbecab8 --- /dev/null +++ b/core/Crud/Field/ImageField.php @@ -0,0 +1,27 @@ + + */ +class ImageField extends Field +{ + public function configureOptions(OptionsResolver $resolver): OptionsResolver + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'view' => '@Core/admin/crud/field/image.html.twig', + 'image_attr' => [], + ]); + + $resolver->setAllowedTypes('image_attr', ['array']); + + return $resolver; + } +} diff --git a/core/Crud/Field/TextField.php b/core/Crud/Field/TextField.php new file mode 100644 index 0000000..50366b2 --- /dev/null +++ b/core/Crud/Field/TextField.php @@ -0,0 +1,24 @@ + + */ +class TextField extends Field +{ + public function configureOptions(OptionsResolver $resolver): OptionsResolver + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'view' => '@Core/admin/crud/field/text.html.twig', + ]); + + return $resolver; + } +} diff --git a/core/DependencyInjection/Configuration.php b/core/DependencyInjection/Configuration.php new file mode 100644 index 0000000..8334f69 --- /dev/null +++ b/core/DependencyInjection/Configuration.php @@ -0,0 +1,122 @@ +getRootNode() + ->children() + ->arrayNode('site') + ->children() + ->scalarNode('name') + ->defaultValue('Murph') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->scalarNode('logo') + ->defaultValue('build/images/core/logo.svg') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->arrayNode('controllers') + ->prototype('array') + ->children() + ->scalarNode('name') + ->cannotBeEmpty() + ->end() + ->scalarNode('action') + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('pages') + ->prototype('array') + ->children() + ->scalarNode('name') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->arrayNode('templates') + ->prototype('array') + ->children() + ->scalarNode('name') + ->cannotBeEmpty() + ->end() + ->scalarNode('file') + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('file_manager') + ->children() + ->arrayNode('mimes') + ->scalarPrototype() + ->end() + ->defaultValue($defaultMimetypes) + ->end() + ->scalarNode('path') + ->defaultValue('%kernel.project_dir%/public/uploads') + ->cannotBeEmpty() + ->end() + ->scalarNode('path_uri') + ->defaultValue('/uploads') + ->cannotBeEmpty() + ->end() + ->arrayNode('path_locked') + ->scalarPrototype() + ->end() + ->defaultValue($defaultLocked) + ->end() + ->end() + ->end() + ->end(); + + return $treeBuilder; + } +} diff --git a/core/DependencyInjection/CoreExtension.php b/core/DependencyInjection/CoreExtension.php new file mode 100644 index 0000000..4708e21 --- /dev/null +++ b/core/DependencyInjection/CoreExtension.php @@ -0,0 +1,28 @@ +getConfiguration($configs, $container); + $config = $this->processConfiguration($configuration, $configs); + + $container->setParameter('core', $config); + } + + /** + * {@inheritdoc} + */ + public function getConfiguration(array $configs, ContainerBuilder $container) + { + return new Configuration(); + } +} diff --git a/core/Doctrine/Timestampable.php b/core/Doctrine/Timestampable.php new file mode 100644 index 0000000..41ff4b9 --- /dev/null +++ b/core/Doctrine/Timestampable.php @@ -0,0 +1,59 @@ +createdAt = new \DateTime(); + $this->updatedAt = new \DateTime(); + } + + /** + * @ORM\PreUpdate + */ + public function onPreUpdate(): void + { + $this->updatedAt = new \DateTime(); + } + + public function setCreatedAt(?\DateTime $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getCreatedAt(): ?\DateTime + { + return $this->createdAt; + } + + public function setUpdatedAt(?\DateTime $updatedAt): self + { + $this->updatedAt = $updatedAt; + + return $this; + } + + public function getUpdatedAt(): ?\DateTime + { + return $this->updatedAt; + } +} diff --git a/core/Entity/.gitignore b/core/Entity/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/core/Entity/Analytic/Referer.php b/core/Entity/Analytic/Referer.php new file mode 100644 index 0000000..0eeb73c --- /dev/null +++ b/core/Entity/Analytic/Referer.php @@ -0,0 +1,103 @@ +id; + } + + public function getNode(): ?Node + { + return $this->node; + } + + public function setNode(?Node $node): self + { + $this->node = $node; + + return $this; + } + + public function getUri(): ?string + { + return $this->uri; + } + + public function setUri(string $uri): self + { + $this->uri = $uri; + + return $this; + } + + public function getViews(): ?int + { + return $this->views; + } + + public function setViews(int $views): self + { + $this->views = $views; + + return $this; + } + + public function addView(): self + { + ++$this->views; + + return $this; + } + + public function getDate(): ?\DateTimeInterface + { + return $this->date; + } + + public function setDate(\DateTimeInterface $date): self + { + $this->date = $date; + + return $this; + } +} diff --git a/core/Entity/Analytic/View.php b/core/Entity/Analytic/View.php new file mode 100644 index 0000000..25dd681 --- /dev/null +++ b/core/Entity/Analytic/View.php @@ -0,0 +1,151 @@ +id; + } + + public function getNode(): ?Node + { + return $this->node; + } + + public function setNode(?Node $node): self + { + $this->node = $node; + + return $this; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function setPath(string $path): self + { + $this->path = $path; + + return $this; + } + + public function getViews(): ?int + { + return $this->views; + } + + public function setViews(int $views): self + { + $this->views = $views; + + return $this; + } + + public function addView(): self + { + ++$this->views; + + return $this; + } + + public function getDesktopViews(): ?int + { + return $this->desktopViews; + } + + public function setDesktopViews(int $desktopViews): self + { + $this->desktopViews = $desktopViews; + + return $this; + } + + public function addDesktopView(): self + { + ++$this->desktopViews; + + return $this; + } + + public function getMobileViews(): ?int + { + return $this->mobileViews; + } + + public function setMobileViews(int $mobileViews): self + { + $this->mobileViews = $mobileViews; + + return $this; + } + + public function addMobileView(): self + { + ++$this->mobileViews; + + return $this; + } + + public function getDate(): ?\DateTimeInterface + { + return $this->date; + } + + public function setDate(\DateTimeInterface $date): self + { + $this->date = $date; + + return $this; + } +} diff --git a/core/Entity/EntityInterface.php b/core/Entity/EntityInterface.php new file mode 100644 index 0000000..0f8ecd3 --- /dev/null +++ b/core/Entity/EntityInterface.php @@ -0,0 +1,7 @@ +id; + } + + public function setId(string $id): self + { + $this->id = $id; + + return $this; + } + + public function getAttributes() + { + return (array) json_decode($this->attributes, true); + } + + public function setAttributes($attributes): self + { + $this->attributes = json_encode($attributes); + + return $this; + } +} diff --git a/core/Entity/NavigationSetting.php b/core/Entity/NavigationSetting.php new file mode 100644 index 0000000..21d37d7 --- /dev/null +++ b/core/Entity/NavigationSetting.php @@ -0,0 +1,111 @@ +id; + } + + public function getSection(): ?string + { + return $this->section; + } + + public function setSection(string $section): self + { + $this->section = $section; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): self + { + $this->code = $code; + + return $this; + } + + public function getValue() + { + return json_decode($this->value, true); + } + + public function setValue($value): self + { + $this->value = json_encode($value); + + return $this; + } + + public function getNavigation(): ?Navigation + { + return $this->navigation; + } + + public function setNavigation(?Navigation $navigation): self + { + $this->navigation = $navigation; + + return $this; + } +} diff --git a/core/Entity/Redirect.php b/core/Entity/Redirect.php new file mode 100644 index 0000000..0b1bcb4 --- /dev/null +++ b/core/Entity/Redirect.php @@ -0,0 +1,211 @@ +id; + } + + public function getScheme(): ?string + { + return $this->scheme; + } + + public function setScheme(string $scheme): self + { + $this->scheme = $scheme; + + return $this; + } + + public function getDomain(): ?string + { + return $this->domain; + } + + public function setDomain(string $domain): self + { + $this->domain = $domain; + + return $this; + } + + public function getDomainType(): ?string + { + return $this->domainType; + } + + public function setDomainType(string $domainType): self + { + $this->domainType = $domainType; + + return $this; + } + + public function getRule(): ?string + { + return $this->rule; + } + + public function setRule(string $rule): self + { + $this->rule = $rule; + + return $this; + } + + public function getRuleType(): ?string + { + return $this->ruleType; + } + + public function setRuleType(string $ruleType): self + { + $this->ruleType = $ruleType; + + return $this; + } + + public function getLocation(): ?string + { + return $this->location; + } + + public function setLocation(string $location): self + { + $this->location = $location; + + return $this; + } + + public function getRedirectCode(): ?int + { + return $this->redirectCode; + } + + public function setRedirectCode(int $redirectCode): self + { + $this->redirectCode = $redirectCode; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + public function getSortOrder(): ?int + { + return $this->sortOrder; + } + + public function setSortOrder(?int $sortOrder): self + { + $this->sortOrder = $sortOrder; + + return $this; + } + + public function getIsEnabled(): ?bool + { + return $this->isEnabled; + } + + public function setIsEnabled(bool $isEnabled): self + { + $this->isEnabled = $isEnabled; + + return $this; + } + + public function getReuseQueryString(): ?bool + { + return $this->reuseQueryString; + } + + public function setReuseQueryString(bool $reuseQueryString): self + { + $this->reuseQueryString = $reuseQueryString; + + return $this; + } +} diff --git a/core/Entity/Setting.php b/core/Entity/Setting.php new file mode 100644 index 0000000..2e3a9a0 --- /dev/null +++ b/core/Entity/Setting.php @@ -0,0 +1,92 @@ +id; + } + + public function getSection(): ?string + { + return $this->section; + } + + public function setSection(string $section): self + { + $this->section = $section; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): self + { + $this->code = $code; + + return $this; + } + + public function getValue() + { + return json_decode($this->value, true); + } + + public function setValue($value): self + { + $this->value = json_encode($value); + + return $this; + } +} diff --git a/core/Entity/Site/Menu.php b/core/Entity/Site/Menu.php new file mode 100644 index 0000000..7728c36 --- /dev/null +++ b/core/Entity/Site/Menu.php @@ -0,0 +1,146 @@ +nodes = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): self + { + $this->code = $code; + + return $this; + } + + public function getNavigation(): ?Navigation + { + return $this->navigation; + } + + public function setNavigation(?Navigation $navigation): self + { + $this->navigation = $navigation; + + return $this; + } + + /** + * @return Collection|Node[] + */ + public function getNodes(): Collection + { + return $this->nodes; + } + + public function addNode(Node $node): self + { + if (!$this->nodes->contains($node)) { + $this->nodes[] = $node; + $node->setMenu($this); + } + + return $this; + } + + public function removeNode(Node $node): self + { + if ($this->nodes->removeElement($node)) { + // set the owning side to null (unless already changed) + if ($node->getMenu() === $this) { + $node->setMenu(null); + } + } + + return $this; + } + + public function getRootNode(): ?Node + { + return $this->rootNode; + } + + public function setRootNode(?Node $rootNode): self + { + $this->rootNode = $rootNode; + + return $this; + } + + public function getRouteName(): string + { + return $this->getNavigation()->getRouteName().'_'.($this->getCode() ? $this->getCode() : $this->getId()); + } +} diff --git a/core/Entity/Site/Navigation.php b/core/Entity/Site/Navigation.php new file mode 100644 index 0000000..084f357 --- /dev/null +++ b/core/Entity/Site/Navigation.php @@ -0,0 +1,243 @@ +menus = new ArrayCollection(); + $this->navigationSettings = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): self + { + $this->code = $code; + + return $this; + } + + public function getDomain(): ?string + { + return $this->domain; + } + + public function setDomain(string $domain): self + { + $this->domain = $domain; + + return $this; + } + + public function getForceDomain(): ?bool + { + return $this->forceDomain; + } + + public function setForceDomain(bool $forceDomain): self + { + $this->forceDomain = $forceDomain; + + return $this; + } + + public function getAdditionalDomains(): array + { + return (array) json_decode($this->additionalDomains, true); + } + + public function setAdditionalDomains(array $additionalDomains): self + { + $this->additionalDomains = json_encode($additionalDomains); + + return $this; + } + + /** + * @return Collection|Menu[] + */ + public function getMenus(): Collection + { + return $this->menus; + } + + public function addMenu(Menu $menu): self + { + if (!$this->menus->contains($menu)) { + $this->menus[] = $menu; + $menu->setNavigation($this); + } + + return $this; + } + + public function removeMenu(Menu $menu): self + { + if ($this->menus->removeElement($menu)) { + // set the owning side to null (unless already changed) + if ($menu->getNavigation() === $this) { + $menu->setNavigation(null); + } + } + + return $this; + } + + public function getMenu(string $code): ?Menu + { + foreach ($this->menus as $menu) { + if ($menu->getCode() === $code) { + return $menu; + } + } + + return $menu; + } + + public function getRouteName(): string + { + return $this->getCode() ? $this->getCode() : 'navigation_'.$this->getId(); + } + + public function getLocale(): ?string + { + return $this->locale; + } + + public function setLocale(string $locale): self + { + $this->locale = $locale; + + return $this; + } + + public function getSortOrder(): ?int + { + return $this->sortOrder; + } + + public function setSortOrder(?int $sortOrder): self + { + $this->sortOrder = $sortOrder; + + return $this; + } + + /** + * @return Collection|NavigationSetting[] + */ + public function getNavigationSettings(): Collection + { + return $this->navigationSettings; + } + + public function addNavigationSetting(NavigationSetting $navigationSetting): self + { + if (!$this->navigationSettings->contains($navigationSetting)) { + $this->navigationSettings[] = $navigationSetting; + $navigationSetting->setNavigation($this); + } + + return $this; + } + + public function removeNavigationSetting(NavigationSetting $navigationSetting): self + { + if ($this->navigationSettings->removeElement($navigationSetting)) { + // set the owning side to null (unless already changed) + if ($navigationSetting->getNavigation() === $this) { + $navigationSetting->setNavigation(null); + } + } + + return $this; + } +} diff --git a/core/Entity/Site/Node.php b/core/Entity/Site/Node.php new file mode 100644 index 0000000..4196966 --- /dev/null +++ b/core/Entity/Site/Node.php @@ -0,0 +1,643 @@ +children = new ArrayCollection(); + $this->aliasNodes = new ArrayCollection(); + $this->analyticViews = new ArrayCollection(); + $this->analyticReferers = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getMenu(): ?Menu + { + return $this->menu; + } + + public function setMenu(?Menu $menu): self + { + $this->menu = $menu; + + return $this; + } + + public function getTreeLeft(): ?int + { + return $this->treeLeft; + } + + public function setTreeLeft(int $treeLeft): self + { + $this->treeLeft = $treeLeft; + + return $this; + } + + public function getTreeLevel(): ?int + { + return $this->treeLevel; + } + + public function setTreeLevel(int $treeLevel): self + { + $this->treeLevel = $treeLevel; + + return $this; + } + + public function getTreeRight(): ?int + { + return $this->treeRight; + } + + public function setTreeRight(int $treeRight): self + { + $this->treeRight = $treeRight; + + return $this; + } + + public function getTreeRoot(): ?self + { + return $this->treeRoot; + } + + public function setTreeRoot(?self $treeRoot): self + { + $this->treeRoot = $treeRoot; + + return $this; + } + + public function getParent(): ?self + { + return $this->parent; + } + + public function setParent(?self $parent): self + { + $this->parent = $parent; + + return $this; + } + + /** + * @return Collection|Node[] + */ + public function getChildren(array $criteria = []): Collection + { + if (null === $this->children) { + $this->children = new ArrayCollection(); + } + + if (!empty($criteria)) { + $children = new ArrayCollection(); + + foreach ($this->children as $child) { + $add = true; + + if (isset($criteria['visible']) && $child->getIsVisible() !== $criteria['visible']) { + $add = false; + } + + if ($add) { + $children->add($child); + } + } + + return $children; + } + + return $this->children; + } + + public function addChild(Node $child): self + { + if (!$this->children->contains($child)) { + $this->children[] = $child; + $child->setParent($this); + } + + return $this; + } + + public function removeChild(Node $child): self + { + if ($this->children->removeElement($child)) { + // set the owning side to null (unless already changed) + if ($child->getParent() === $this) { + $child->setParent(null); + } + } + + return $this; + } + + public function getAllChildren(array $criteria = []): ArrayCollection + { + $children = []; + + $getChildren = function (Node $node) use (&$children, &$getChildren) { + foreach ($node->getChildren() as $nodeChildren) { + $children[] = $nodeChildren; + + $getChildren($nodeChildren); + } + }; + + $getChildren($this); + + usort($children, function ($a, $b) { + return $a->getTreeLeft() < $b->getTreeLeft() ? -1 : 1; + }); + + if (!empty($criteria)) { + foreach ($children as $key => $child) { + if (isset($criteria['visible']) && $child->getIsVisible() !== $criteria['visible']) { + unset($children[$key]); + } + } + } + + return new ArrayCollection($children); + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(?string $label): self + { + $this->label = $label; + + return $this; + } + + public function getUrl(): ?string + { + return $this->url; + } + + public function setUrl(?string $url): self + { + $this->url = $url; + + return $this; + } + + public function hasExternalUrl(): bool + { + $string = u($this->getUrl()); + + return $string->startsWith('http://') || $string->startsWith('https://'); + } + + public function hasAppUrl(): bool + { + $string = u($this->getUrl()); + + foreach (['tel:', 'fax:', 'mailto:'] as $prefix) { + if ($string->startsWith($prefix)) { + return true; + } + } + + return false; + } + + public function getIsVisible(): ?bool + { + return $this->isVisible; + } + + public function setIsVisible(bool $isVisible): self + { + $this->isVisible = $isVisible; + + return $this; + } + + public function getDisableUrl(): ?bool + { + return $this->disableUrl; + } + + public function setDisableUrl(bool $disableUrl): self + { + $this->disableUrl = $disableUrl; + + return $this; + } + + public function getTreeLabel() + { + $prefix = str_repeat('-', ($this->getTreeLevel() - 1) * 5); + + return trim($prefix.' '.$this->getLabel()); + } + + public function getPage(): ?Page + { + if ($this->getAliasNode()) { + return $this->getAliasNode()->getPage(); + } + + return $this->page; + } + + public function setPage(?Page $page): self + { + $this->page = $page; + + return $this; + } + + public function getRouteName(): string + { + if ($this->getAliasNode()) { + return $this->getAliasNode()->getRouteName(); + } + + return $this->getMenu()->getRouteName().'_'.($this->getCode() ? $this->getCode() : $this->getId()); + } + + public function getCode(): ?string + { + if ($this->getAliasNode()) { + return $this->getAliasNode()->getCode(); + } + + return $this->code; + } + + public function setCode(?string $code): self + { + $this->code = $code; + + return $this; + } + + public function getParameters(): ?array + { + if ($this->getAliasNode()) { + return $this->getAliasNode()->getParameters(); + } + + if (!is_array($this->parameters)) { + $this->parameters = []; + } + + return $this->parameters; + } + + public function setParameters(array $parameters): self + { + $this->parameters = $parameters; + + return $this; + } + + public function getAttributes(): ?array + { + if (!is_array($this->attributes)) { + $this->attributes = []; + } + + return $this->attributes; + } + + public function setAttributes(array $attributes): self + { + $this->attributes = $attributes; + + return $this; + } + + public function getController(): ?string + { + if ($this->getAliasNode()) { + return $this->getAliasNode()->getController(); + } + + return $this->controller; + } + + public function setController(?string $controller): self + { + $this->controller = $controller; + + return $this; + } + + public function getSitemapParameters(): ?array + { + if ($this->getAliasNode()) { + return $this->getAliasNode()->getSitemapParameters(); + } + + if (!is_array($this->sitemapParameters)) { + $this->sitemapParameters = [ + 'isVisible' => false, + 'priority' => 0, + 'changeFrequency' => 'daily', + ]; + } + + return $this->sitemapParameters; + } + + public function setSitemapParameters(?array $sitemapParameters): self + { + $this->sitemapParameters = $sitemapParameters; + + return $this; + } + + public function getAliasNode(): ?self + { + return $this->aliasNode; + } + + public function setAliasNode(?self $aliasNode): self + { + $this->aliasNode = $aliasNode; + + return $this; + } + + /** + * @return Collection|self[] + */ + public function getAliasNodes(): Collection + { + return $this->aliasNodes; + } + + public function addAliasNode(self $aliasNode): self + { + if (!$this->aliasNodes->contains($aliasNode)) { + $this->aliasNodes[] = $aliasNode; + $aliasNode->setAliasNode($this); + } + + return $this; + } + + public function removeAliasNode(self $aliasNode): self + { + if ($this->aliasNodes->removeElement($aliasNode)) { + // set the owning side to null (unless already changed) + if ($aliasNode->getAliasNode() === $this) { + $aliasNode->setAliasNode(null); + } + } + + return $this; + } + + public function getContentType(): ?string + { + return $this->contentType; + } + + public function setContentType(?string $contentType): self + { + $this->contentType = $contentType; + + return $this; + } + + public function getEnableAnalytics(): ?bool + { + return $this->enableAnalytics; + } + + public function setEnableAnalytics(bool $enableAnalytics): self + { + $this->enableAnalytics = $enableAnalytics; + + return $this; + } + + /** + * @return Collection|View[] + */ + public function getAnalyticViews(): Collection + { + return $this->analyticViews; + } + + public function addAnalyticView(View $view): self + { + if (!$this->analyticViews->contains($view)) { + $this->analyticViews[] = $view; + $view->setNode($this); + } + + return $this; + } + + public function removeAnalyticView(View $view): self + { + if ($this->analyticViews->removeElement($view)) { + // set the owning side to null (unless already changed) + if ($view->getNode() === $this) { + $view->setNode(null); + } + } + + return $this; + } + + /** + * @return Collection|Referer[] + */ + public function getAnalyticReferers(): Collection + { + return $this->analyticReferers; + } + + public function addAnalyticReferer(Referer $referer): self + { + if (!$this->analyticReferers->contains($referer)) { + $this->analyticReferers[] = $referer; + $referer->setNode($this); + } + + return $this; + } + + public function removeAnalyticReferer(Referer $referer): self + { + if ($this->analyticReferers->removeElement($referer)) { + // set the owning side to null (unless already changed) + if ($referer->getNode() === $this) { + $referer->setNode(null); + } + } + + return $this; + } +} diff --git a/core/Entity/Site/Page/Block.php b/core/Entity/Site/Page/Block.php new file mode 100644 index 0000000..978bd01 --- /dev/null +++ b/core/Entity/Site/Page/Block.php @@ -0,0 +1,82 @@ +id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getValue() + { + return $this->value; + } + + public function setValue($value): self + { + $this->value = $value; + + return $this; + } + + public function getPage(): ?Page + { + return $this->page; + } + + public function setPage(?Page $page): self + { + $this->page = $page; + + return $this; + } +} diff --git a/core/Entity/Site/Page/ChoiceBlock.php b/core/Entity/Site/Page/ChoiceBlock.php new file mode 100644 index 0000000..69f5b38 --- /dev/null +++ b/core/Entity/Site/Page/ChoiceBlock.php @@ -0,0 +1,21 @@ +getValue() instanceof File && null === $value) { + return $this; + } + + return parent::setValue($value); + } +} diff --git a/core/Entity/Site/Page/Page.php b/core/Entity/Site/Page/Page.php new file mode 100644 index 0000000..7d74000 --- /dev/null +++ b/core/Entity/Site/Page/Page.php @@ -0,0 +1,276 @@ +blocks = new ArrayCollection(); + $this->nodes = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getTemplate(): ?string + { + return $this->template; + } + + public function setTemplate(?string $template): self + { + $this->template = $template; + + return $this; + } + + /** + * @return Collection|Block[] + */ + public function getBlocks(): Collection + { + return $this->blocks; + } + + public function addBlock(Block $block): self + { + if (!$this->blocks->contains($block)) { + $this->blocks[] = $block; + $block->setPage($this); + } + + return $this; + } + + public function removeBlock(Block $block): self + { + if ($this->blocks->removeElement($block)) { + // set the owning side to null (unless already changed) + if ($block->getPage() === $this) { + $block->setPage(null); + } + } + + return $this; + } + + public function buildForm(FormBuilderInterface $builder, array $options) + { + } + + public function getBlock($name, string $className = null) + { + foreach ($this->getBlocks() as $block) { + if ($block->getName() === $name) { + return $block; + } + } + + if ($className) { + $block = new $className(); + } else { + $block = new Block(); + } + + $block->setName($name); + $block->setPage($this); + + return $block; + } + + public function setBlock(Block $block): self + { + foreach ($this->blocks->toArray() as $key => $value) { + if ($value->getName() === $block->getName()) { + $this->blocks->remove($key); + $this->blocks->add($block); + + return $this; + } + } + + $this->blocks->add($block); + + return $this; + } + + public function getMetaTitle(): ?string + { + return $this->metaTitle; + } + + public function setMetaTitle(?string $metaTitle): self + { + $this->metaTitle = $metaTitle; + + return $this; + } + + public function getMetaDescription(): ?string + { + return $this->metaDescription; + } + + public function setMetaDescription(?string $metaDescription): self + { + $this->metaDescription = $metaDescription; + + return $this; + } + + public function getOgTitle(): ?string + { + return $this->ogTitle; + } + + public function setOgTitle(?string $ogTitle): self + { + $this->ogTitle = $ogTitle; + + return $this; + } + + public function getOgDescription(): ?string + { + return $this->ogDescription; + } + + public function setOgDescription(?string $ogDescription): self + { + $this->ogDescription = $ogDescription; + + return $this; + } + + public function getOgImage() + { + return FileAttribute::handleFile($this->ogImage); + } + + public function setOgImage($ogImage): self + { + if (null !== $this->ogImage && null === $ogImage) { + return $this; + } + + $this->ogImage = $ogImage; + + return $this; + } + + /** + * @return Collection|Node[] + */ + public function getNodes(): Collection + { + return $this->nodes; + } + + public function addNode(Node $node): self + { + if (!$this->nodes->contains($node)) { + $this->nodes[] = $node; + $node->setPage($this); + } + + return $this; + } + + public function removeNode(Node $node): self + { + if ($this->nodes->removeElement($node)) { + // set the owning side to null (unless already changed) + if ($node->getPage() === $this) { + $node->setPage(null); + } + } + + return $this; + } +} diff --git a/core/Entity/Site/Page/TextBlock.php b/core/Entity/Site/Page/TextBlock.php new file mode 100644 index 0000000..54ec679 --- /dev/null +++ b/core/Entity/Site/Page/TextBlock.php @@ -0,0 +1,12 @@ + + */ +class PasswordRequestEvent extends Event +{ + const EVENT = 'account_event.password_request'; + + protected User $user; + + public function __construct(User $user) + { + $this->user = $user; + } + + public function getUser(): USer + { + return $this->user; + } +} diff --git a/core/Event/EntityManager/EntityManagerEvent.php b/core/Event/EntityManager/EntityManagerEvent.php new file mode 100644 index 0000000..7c5c666 --- /dev/null +++ b/core/Event/EntityManager/EntityManagerEvent.php @@ -0,0 +1,33 @@ + + */ +class EntityManagerEvent extends Event +{ + const CREATE_EVENT = 'entity_manager_event.create'; + const UPDATE_EVENT = 'entity_manager_event.update'; + const DELETE_EVENT = 'entity_manager_event.delete'; + const PRE_CREATE_EVENT = 'entity_manager_event.pre_create'; + const PRE_UPDATE_EVENT = 'entity_manager_event.pre_update'; + const PRE_DELETE_EVENT = 'entity_manager_event.pre_delete'; + + protected EntityInterface $entity; + + public function __construct(EntityInterface $entity) + { + $this->entity = $entity; + } + + public function getEntity(): EntityInterface + { + return $this->entity; + } +} diff --git a/core/Event/Page/PageEditEvent.php b/core/Event/Page/PageEditEvent.php new file mode 100644 index 0000000..afa7d3d --- /dev/null +++ b/core/Event/Page/PageEditEvent.php @@ -0,0 +1,44 @@ + + */ +class PageEditEvent extends Event +{ + const FORM_INIT_EVENT = 'page_edit_event.form_init'; + + protected Page $page; + protected array $pageBuilderOptions = []; + + public function __construct(Page $page) + { + $this->page = $page; + } + + public function getPage() + { + return $this->page; + } + + public function getPageBuilderOptions(): array + { + return $this->pageBuilderOptions; + } + + public function addPageBuilderOptions(array $options): self + { + $this->pageBuilderOptions = array_merge( + $this->pageBuilderOptions, + $options + ); + + return $this; + } +} diff --git a/core/Event/Setting/NavigationSettingEvent.php b/core/Event/Setting/NavigationSettingEvent.php new file mode 100644 index 0000000..60133c7 --- /dev/null +++ b/core/Event/Setting/NavigationSettingEvent.php @@ -0,0 +1,35 @@ + + */ +class NavigationSettingEvent extends Event +{ + const INIT_EVENT = 'navigation_setting_event.init'; + const FORM_INIT_EVENT = 'navigation_setting_event.form_init'; + + protected $data; + + public function __construct($data = null) + { + $this->data = $data; + } + + public function getData() + { + return $this->data; + } + + public function setOption(string $key, $value): self + { + $this->data['options'][$key] = $value; + + return $this; + } +} diff --git a/core/Event/Setting/SettingEvent.php b/core/Event/Setting/SettingEvent.php new file mode 100644 index 0000000..f5a8805 --- /dev/null +++ b/core/Event/Setting/SettingEvent.php @@ -0,0 +1,35 @@ + + */ +class SettingEvent extends Event +{ + const INIT_EVENT = 'setting_event.init'; + const FORM_INIT_EVENT = 'setting_event.form_init'; + + protected $data; + + public function __construct($data = null) + { + $this->data = $data; + } + + public function getData() + { + return $this->data; + } + + public function setOption(string $key, $value): self + { + $this->data['options'][$key] = $value; + + return $this; + } +} diff --git a/core/Event/Task/TaskInitEvent.php b/core/Event/Task/TaskInitEvent.php new file mode 100644 index 0000000..d436195 --- /dev/null +++ b/core/Event/Task/TaskInitEvent.php @@ -0,0 +1,37 @@ + + */ +class TaskInitEvent extends Event +{ + const INIT_EVENT = 'task_event.init'; + + protected array $tasks = []; + + public function getTasks(): array + { + usort($this->tasks, function ($t1, $t2) { + return $t1['section'] != $t2['section']; + }); + + return $this->tasks; + } + + public function addTask(string $task, string $section, string $label): self + { + $this->tasks[$task] = [ + 'label' => $label, + 'section' => $section, + 'task' => $task, + ]; + + return $this; + } +} diff --git a/core/Event/Task/TaskRunRequestedEvent.php b/core/Event/Task/TaskRunRequestedEvent.php new file mode 100644 index 0000000..d25ed89 --- /dev/null +++ b/core/Event/Task/TaskRunRequestedEvent.php @@ -0,0 +1,44 @@ + + */ +class TaskRunRequestedEvent extends Event +{ + const RUN_REQUEST_EVENT = 'task_event.run_request'; + + protected string $task; + protected InputBag $parameters; + protected BufferedOutput $output; + + public function __construct(string $task, InputBag $parameters, BufferedOutput $output) + { + $this->task = $task; + $this->parameters = $parameters; + $this->output = $output; + } + + public function getTask(): string + { + return $this->task; + } + + public function getParameters(): ParameterBagInterface + { + return $this->parameters; + } + + public function getOutput(): BufferedOutput + { + return $this->output; + } +} diff --git a/core/EventListener/AnalyticListener.php b/core/EventListener/AnalyticListener.php new file mode 100644 index 0000000..5fe1069 --- /dev/null +++ b/core/EventListener/AnalyticListener.php @@ -0,0 +1,153 @@ + + */ +class AnalyticListener +{ + protected NodeRepository $nodeRepository; + protected ViewRepositoryQuery $viewRepositoryQuery; + protected ViewFactory $viewFactory; + protected RefererRepositoryQuery $refererRepositoryQuery; + protected RefererFactory $refererFactory; + protected EntityManager $manager; + protected DeviceDetector $deviceDetector; + protected Request $request; + protected Node $node; + + public function __construct( + NodeRepository $nodeRepository, + ViewRepositoryQuery $viewRepositoryQuery, + ViewFactory $viewFactory, + RefererRepositoryQuery $refererRepositoryQuery, + RefererFactory $refererFactory, + EntityManager $manager + ) { + $this->nodeRepository = $nodeRepository; + $this->viewRepositoryQuery = $viewRepositoryQuery; + $this->viewFactory = $viewFactory; + $this->refererRepositoryQuery = $refererRepositoryQuery; + $this->refererFactory = $refererFactory; + $this->manager = $manager; + $this->createDeviceDetector(); + } + + public function onKernelRequest(RequestEvent $event) + { + $request = $event->getRequest(); + + if (!$request->attributes->has('_node')) { + return; + } + + $this->deviceDetector->setUserAgent($request->headers->get('user-agent')); + $this->deviceDetector->parse(); + + if ($this->deviceDetector->isBot()) { + return; + } + + $node = $this->nodeRepository->findOneBy([ + 'id' => $request->attributes->get('_node'), + 'enableAnalytics' => true, + ]); + + if (!$node) { + return; + } + + $this->node = $node; + $this->request = $request; + + $this->createView(); + $this->createReferer(); + } + + protected function createDeviceDetector() + { + $cache = new ApcuAdapter(); + + $this->deviceDetector = new DeviceDetector(); + $this->deviceDetector->setCache(new PSR6Bridge($cache)); + } + + protected function createView() + { + $entity = $this->viewRepositoryQuery->create() + ->filterByRequest($this->request) + ->andWhere('.date=CURRENT_DATE()') + ->findOne() + ; + + if (!$entity) { + $entity = $this->viewFactory->create($this->node, $this->request->getPathInfo()); + } + + $entity->addView(); + + if ($this->deviceDetector->isDesktop()) { + $entity->addDesktopView(); + } elseif ($this->deviceDetector->isMobile()) { + $entity->addMobileView(); + } + + $this->save($entity); + } + + protected function createReferer() + { + if (!$this->request->headers->has('referer')) { + return; + } + + $referer = $this->request->headers->get('referer'); + + if (!filter_var($referer, FILTER_VALIDATE_URL)) { + return; + } + + if (!in_array(parse_url($referer, PHP_URL_SCHEME), ['http', 'https'])) { + return; + } + + $entity = $this->refererRepositoryQuery->create() + ->filterByRequest($this->request) + ->andWhere('.date=CURRENT_DATE()') + ->findOne() + ; + + if (!$entity) { + $entity = $this->refererFactory->create($this->node, $referer); + } + + $entity->addView(); + $this->save($entity); + } + + protected function save(EntityInterface $entity) + { + if ($entity->getId()) { + $this->manager->update($entity); + } else { + $this->manager->create($entity); + } + } +} diff --git a/core/EventListener/RedirectListener.php b/core/EventListener/RedirectListener.php new file mode 100644 index 0000000..edb7218 --- /dev/null +++ b/core/EventListener/RedirectListener.php @@ -0,0 +1,50 @@ + + */ +class RedirectListener +{ + protected RedirectMatcher $matcher; + protected RedirectBuilder $builder; + protected RedirectRepositoryQuery $repository; + + public function __construct(RedirectMatcher $matcher, RedirectBuilder $builder, RedirectRepositoryQuery $repository) + { + $this->matcher = $matcher; + $this->builder = $builder; + $this->repository = $repository; + } + + public function onKernelException(ExceptionEvent $event) + { + $request = $event->getRequest(); + + if (!$event->getThrowable() instanceof NotFoundHttpException) { + return; + } + + $redirects = $this->repository + ->orderBy('.sortOrder') + ->where('.isEnabled=1') + ->find() + ; + + foreach ($redirects as $redirect) { + if ($this->matcher->match($redirect, $event->getRequest()->getUri())) { + $event->setResponse($this->builder->buildResponse($redirect, $event->getRequest())); + } + } + } +} diff --git a/core/EventSuscriber/Account/PasswordRequestEventSubscriber.php b/core/EventSuscriber/Account/PasswordRequestEventSubscriber.php new file mode 100644 index 0000000..909cb22 --- /dev/null +++ b/core/EventSuscriber/Account/PasswordRequestEventSubscriber.php @@ -0,0 +1,69 @@ + + */ +class PasswordRequestEventSubscriber implements EventSubscriberInterface +{ + protected MailNotifier $notifier; + protected UrlGeneratorInterface $urlGenerator; + protected EntityManager $entityManager; + protected TokenGeneratorInterface $tokenGenerator; + protected TranslatorInterface $translator; + + public function __construct( + MailNotifier $notifier, + UrlGeneratorInterface $urlGenerator, + EntityManager $entityManager, + TokenGeneratorInterface $tokenGenerator, + TranslatorInterface $translator + ) { + $this->notifier = $notifier; + $this->urlGenerator = $urlGenerator; + $this->entityManager = $entityManager; + $this->tokenGenerator = $tokenGenerator; + $this->translator = $translator; + } + + public static function getSubscribedEvents() + { + return [ + PasswordRequestEvent::EVENT => 'onRequest', + ]; + } + + public function onRequest(PasswordRequestEvent $event) + { + $user = $event->getUser(); + $user->setConfirmationToken($this->tokenGenerator->generateToken()); + $user->setPasswordRequestedAt(new \DateTime('now')); + + $this->entityManager->update($user); + + $this->notifier + ->setSubject($this->translator->trans('Mot de passe perdu')) + ->addRecipient($user->getEmail()) + ->notify('@Core/mail/account/resetting_request.html.twig', [ + 'reseting_update_link' => $this->urlGenerator->generate( + 'auth_resetting_update', + [ + 'token' => $user->getConfirmationToken(), + ], + UrlGeneratorInterface::ABSOLUTE_URL + ), + ]) + ; + } +} diff --git a/core/EventSuscriber/EntityManagerEventSubscriber.php b/core/EventSuscriber/EntityManagerEventSubscriber.php new file mode 100644 index 0000000..9ba7a91 --- /dev/null +++ b/core/EventSuscriber/EntityManagerEventSubscriber.php @@ -0,0 +1,52 @@ + + */ +abstract class EntityManagerEventSubscriber implements EventSubscriberInterface +{ + protected static int $priority = 0; + + public static function getSubscribedEvents() + { + return [ + EntityManagerEvent::CREATE_EVENT => ['onCreate', self::$priority], + EntityManagerEvent::UPDATE_EVENT => ['onUpdate', self::$priority], + EntityManagerEvent::DELETE_EVENT => ['onDelete', self::$priority], + EntityManagerEvent::PRE_CREATE_EVENT => ['onPreCreate', self::$priority], + EntityManagerEvent::PRE_UPDATE_EVENT => ['onPreUpdate', self::$priority], + EntityManagerEvent::PRE_DELETE_EVENT => ['onPreDelete', self::$priority], + ]; + } + + public function onCreate(EntityManagerEvent $event) + { + } + + public function onUpdate(EntityManagerEvent $event) + { + } + + public function onDelete(EntityManagerEvent $event) + { + } + + public function onPreCreate(EntityManagerEvent $event) + { + } + + public function onPreUpdate(EntityManagerEvent $event) + { + } + + public function onPreDelete(EntityManagerEvent $event) + { + } +} diff --git a/core/EventSuscriber/NavigationSettingEventSubscriber.php b/core/EventSuscriber/NavigationSettingEventSubscriber.php new file mode 100644 index 0000000..d9ce5a6 --- /dev/null +++ b/core/EventSuscriber/NavigationSettingEventSubscriber.php @@ -0,0 +1,32 @@ + + */ +abstract class NavigationSettingEventSubscriber implements EventSubscriberInterface +{ + protected static int $priority = 0; + + public static function getSubscribedEvents() + { + return [ + NavigationSettingEvent::INIT_EVENT => ['onInit', self::$priority], + NavigationSettingEvent::FORM_INIT_EVENT => ['onFormInit', self::$priority], + ]; + } + + public function onInit(NavigationSettingEvent $event) + { + } + + public function onFormInit(NavigationSettingEvent $event) + { + } +} diff --git a/core/EventSuscriber/SettingEventSubscriber.php b/core/EventSuscriber/SettingEventSubscriber.php new file mode 100644 index 0000000..d8db765 --- /dev/null +++ b/core/EventSuscriber/SettingEventSubscriber.php @@ -0,0 +1,32 @@ + + */ +abstract class SettingEventSubscriber implements EventSubscriberInterface +{ + protected static int $priority = 0; + + public static function getSubscribedEvents() + { + return [ + SettingEvent::INIT_EVENT => ['onInit', self::$priority], + SettingEvent::FORM_INIT_EVENT => ['onFormInit', self::$priority], + ]; + } + + public function onInit(SettingEvent $event) + { + } + + public function onFormInit(SettingEvent $event) + { + } +} diff --git a/core/EventSuscriber/Site/ForcedDomainEventSubscriber.php b/core/EventSuscriber/Site/ForcedDomainEventSubscriber.php new file mode 100644 index 0000000..d1a11e9 --- /dev/null +++ b/core/EventSuscriber/Site/ForcedDomainEventSubscriber.php @@ -0,0 +1,53 @@ +siteRequest = $siteRequest; + } + + public function onKernelResponse(ResponseEvent $event) + { + $navigation = $this->siteRequest->getNavigation(); + + if (!$navigation) { + return; + } + + if (!$navigation->getForceDomain()) { + return; + } + + if ($navigation->getDomain() === $this->siteRequest->getDomain()) { + return; + } + + $uri = u($this->siteRequest->getUri()) + ->replace( + '://'.$this->siteRequest->getDomain(), + '://'.$navigation->getDomain() + ); + + $event->getResponse()->headers->set('Location', $uri); + $event->getResponse()->setStatusCode(Response::HTTP_MOVED_PERMANENTLY); + } + + public static function getSubscribedEvents() + { + return [ + KernelEvents::RESPONSE => [['onKernelResponse', 20]], + ]; + } +} diff --git a/core/EventSuscriber/Site/MenuEventSubscriber.php b/core/EventSuscriber/Site/MenuEventSubscriber.php new file mode 100644 index 0000000..4f2dbea --- /dev/null +++ b/core/EventSuscriber/Site/MenuEventSubscriber.php @@ -0,0 +1,107 @@ + + */ +class MenuEventSubscriber extends EntityManagerEventSubscriber +{ + protected NodeFactory $nodeFactory; + protected NodeRepository $nodeRepository; + protected EntityManager $entityManager; + protected CodeSlugify $slugify; + protected SymfonyCacheManager $cacheManager; + protected TranslatorInterface $translation; + + public function __construct( + NodeFactory $nodeFactory, + NodeRepository $nodeRepository, + EntityManager $entityManager, + CodeSlugify $slugify, + SymfonyCacheManager $cacheManager, + TranslatorInterface $translator + ) { + $this->nodeFactory = $nodeFactory; + $this->nodeRepository = $nodeRepository; + $this->entityManager = $entityManager; + $this->slugify = $slugify; + $this->cacheManager = $cacheManager; + $this->translator = $translator; + } + + public function support(EntityInterface $entity) + { + return $entity instanceof Menu; + } + + public function onPreUpdate(EntityManagerEvent $event) + { + if (!$this->support($event->getEntity())) { + return; + } + + $menu = $event->getEntity(); + $menu->setCode($this->slugify->slugify($menu->getCode())); + } + + public function onCreate(EntityManagerEvent $event) + { + if (!$this->support($event->getEntity())) { + return; + } + + $menu = $event->getEntity(); + + if (count($menu->getNodes()) > 2) { + return; + } + + $rootNode = $menu->getRootNode(); + + if (!$rootNode) { + $rootNode = $this->nodeFactory->create($menu); + } + + $childNode = $this->nodeFactory->create($menu, '/'); + $childNode + ->setParent($rootNode) + ->setLabel($this->translator->trans('First element')) + ; + + $menu->setRootNode($rootNode); + + $this->entityManager->getEntityManager()->persist($rootNode); + $this->entityManager->getEntityManager()->persist($childNode); + + $this->entityManager->getEntityManager()->persist($menu); + $this->entityManager->flush(); + + $this->nodeRepository->persistAsFirstChild($childNode, $rootNode); + + $this->cacheManager->cleanRouting(); + } + + public function onUpdate(EntityManagerEvent $event) + { + return $this->onCreate($event); + } + + public function onPreCreate(EntityManagerEvent $event) + { + return $this->onPreUpdate($event); + } +} diff --git a/core/EventSuscriber/Site/NavigationEventSubscriber.php b/core/EventSuscriber/Site/NavigationEventSubscriber.php new file mode 100644 index 0000000..f36c958 --- /dev/null +++ b/core/EventSuscriber/Site/NavigationEventSubscriber.php @@ -0,0 +1,46 @@ + + */ +class NavigationEventSubscriber extends EntityManagerEventSubscriber +{ + public function __construct( + EntityManager $entityManager, + CodeSlugify $slugify + ) { + $this->entityManager = $entityManager; + $this->slugify = $slugify; + } + + public function support(EntityInterface $entity) + { + return $entity instanceof Navigation; + } + + public function onPreUpdate(EntityManagerEvent $event) + { + if (!$this->support($event->getEntity())) { + return; + } + + $menu = $event->getEntity(); + $menu->setCode($this->slugify->slugify($menu->getCode())); + } + + public function onPreCreate(EntityManagerEvent $event) + { + return $this->onPreUpdate($event); + } +} diff --git a/core/EventSuscriber/Site/NodeEventSubscriber.php b/core/EventSuscriber/Site/NodeEventSubscriber.php new file mode 100644 index 0000000..9b57909 --- /dev/null +++ b/core/EventSuscriber/Site/NodeEventSubscriber.php @@ -0,0 +1,183 @@ + + */ +class NodeEventSubscriber extends EntityManagerEventSubscriber +{ + protected NodeFactory $nodeFactory; + protected EntityManager $entityManager; + protected KernelInterface $kernel; + protected Slugify $slugify; + protected CodeSlugify $codeSlugify; + protected RouteParameterSlugify $routeParameterSlugify; + + public function __construct( + NodeFactory $nodeFactory, + NodeRepository $nodeRepository, + EntityManager $entityManager, + Slugify $slugify, + CodeSlugify $codeSlugify, + RouteParameterSlugify $routeParameterSlugify + ) { + $this->nodeFactory = $nodeFactory; + $this->nodeRepository = $nodeRepository; + $this->entityManager = $entityManager; + $this->slugify = $slugify; + $this->codeSlugify = $codeSlugify; + $this->routeParameterSlugify = $routeParameterSlugify; + } + + public function support(EntityInterface $entity) + { + return $entity instanceof Node; + } + + public function onPreCreate(EntityManagerEvent $event) + { + return $this->onPreUpdate($event); + } + + public function onPreUpdate(EntityManagerEvent $event) + { + if (!$this->support($event->getEntity())) { + return; + } + + $node = $event->getEntity(); + + $node->setCode($this->codeSlugify->slugify($node->getCode())); + + if ($node->getDisableUrl()) { + $node->setUrl(null); + } else { + if ($node->getUrl()) { + $generatedUrl = $node->getUrl(); + } else { + $path = []; + $parent = $node->getParent(); + + if ($parent && $parent->getUrl()) { + $pPath = trim($parent->getUrl(), '/'); + + if ($pPath) { + $path[] = $pPath; + } + } + + $path[] = $this->slugify->slugify($node->getLabel()); + + $generatedUrl = '/'.implode('/', $path); + } + + if ('/' !== $generatedUrl) { + $generatedUrl = rtrim($generatedUrl, '/'); + } + + $parameters = $node->getParameters(); + $routeParameters = []; + + foreach ($parameters as $key => $parameter) { + $parameter['name'] = $this->routeParameterSlugify->slugify($parameter['name']); + $routeParameter = sprintf('{%s}', $parameter['name']); + $regex = '/'.preg_quote($routeParameter).'/'; + $routeParameters[] = $parameter['name']; + + if (!preg_match($regex, $generatedUrl)) { + $generatedUrl .= '/'.$routeParameter; + } + + $parameters[$key] = $parameter; + } + + preg_match_all('/\{(.*)\}/isU', $generatedUrl, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + if (!in_array($match[1], $routeParameters)) { + $parameters[] = [ + 'name' => $this->routeParameterSlugify->slugify($match[1]), + 'defaultValue' => null, + 'requirement' => null, + ]; + } + } + + $node->setParameters($parameters); + + $urlExists = $this->nodeRepository->urlExists($generatedUrl, $node); + + if ($urlExists) { + $number = 1; + + while ($this->nodeRepository->urlExists($generatedUrl.'-'.$number, $node)) { + ++$number; + } + + $generatedUrl = $generatedUrl.'-'.$number; + } + + if ( + !u($generatedUrl)->startsWith('https://') + && !u($generatedUrl)->startsWith('http://') + && !u($generatedUrl)->startsWith('tel:') + && !u($generatedUrl)->startsWith('mailto:') + && !u($generatedUrl)->startsWith('fax:') + ) { + $generatedUrl = '/'.$generatedUrl; + $generatedUrl = preg_replace('#/{2,}#', '/', $generatedUrl); + } + + $node->setUrl($generatedUrl); + } + + $attributes = $node->getAttributes(); + $realAttributes = []; + + foreach ($attributes as $key => $attribute) { + $realAttributes[$this->routeParameterSlugify->slugify($attribute['label'])] = $attribute; + } + + $node->setAttributes($realAttributes); + } + + public function onDelete(EntityManagerEvent $event) + { + if (!$this->support($event->getEntity())) { + return; + } + + $menu = $event->getEntity()->getMenu(); + $rootNode = $menu->getRootNode(); + + if (0 !== count($rootNode->getChildren())) { + return; + } + + $childNode = $this->nodeFactory->create($menu); + $childNode + ->setParent($rootNode) + ->setLabel('Premier รฉlรฉment') + ; + + $this->entityManager->update($rootNode, false); + $this->entityManager->create($childNode, false); + $this->nodeRepository->persistAsFirstChild($childNode, $rootNode); + } +} diff --git a/core/EventSuscriber/Site/Page/BlockEventSubscriber.php b/core/EventSuscriber/Site/Page/BlockEventSubscriber.php new file mode 100644 index 0000000..0cc299f --- /dev/null +++ b/core/EventSuscriber/Site/Page/BlockEventSubscriber.php @@ -0,0 +1,59 @@ + + */ +class BlockEventSubscriber extends EntityManagerEventSubscriber +{ + protected FileUploadHandler $fileUpload; + + public function __construct(FileUploadHandler $fileUpload) + { + $this->fileUpload = $fileUpload; + } + + public function support(EntityInterface $entity) + { + return $entity instanceof Page; + } + + public function onPreUpdate(EntityManagerEvent $event) + { + if (!$this->support($event->getEntity())) { + return; + } + + foreach ($event->getEntity()->getBlocks() as $block) { + if ($block instanceof FileBlock) { + if ($block->getValue() instanceof UploadedFile) { + $directory = 'uploads/page/block'; + + $this->fileUpload->handleForm( + $block->getValue(), + $directory, + function ($filename) use ($block, $directory) { + $block->setValue($directory.'/'.$filename); + } + ); + } + } + } + } + + public function onPreCreate(EntityManagerEvent $event) + { + return $this->onPreUpdate($event); + } +} diff --git a/core/EventSuscriber/Site/Page/PageEventSubscriber.php b/core/EventSuscriber/Site/Page/PageEventSubscriber.php new file mode 100644 index 0000000..ef9e8db --- /dev/null +++ b/core/EventSuscriber/Site/Page/PageEventSubscriber.php @@ -0,0 +1,56 @@ + + */ +class PageEventSubscriber extends EntityManagerEventSubscriber +{ + protected FileUploadHandler $fileUpload; + + public function __construct(FileUploadHandler $fileUpload) + { + $this->fileUpload = $fileUpload; + } + + public function support(EntityInterface $entity) + { + return $entity instanceof Page; + } + + public function onPreUpdate(EntityManagerEvent $event) + { + if (!$this->support($event->getEntity())) { + return; + } + + $page = $event->getEntity(); + + if ($page->getOgImage() instanceof UploadedFile) { + $directory = 'uploads/page/ogImage'; + + $this->fileUpload->handleForm( + $page->getOgImage(), + $directory, + function ($filename) use ($page, $directory) { + $page->setOgImage($directory.'/'.$filename); + } + ); + } + } + + public function onPreCreate(EntityManagerEvent $event) + { + return $this->onPreUpdate($event); + } +} diff --git a/core/EventSuscriber/Site/SiteEventSubscriber.php b/core/EventSuscriber/Site/SiteEventSubscriber.php new file mode 100644 index 0000000..7655cc8 --- /dev/null +++ b/core/EventSuscriber/Site/SiteEventSubscriber.php @@ -0,0 +1,53 @@ + + */ +class SiteEventSubscriber extends EntityManagerEventSubscriber +{ + protected KernelInterface $kernel; + protected SymfonyCacheManager $cacheManager; + + public function __construct(KernelInterface $kernel, SymfonyCacheManager $cacheManager) + { + $this->kernel = $kernel; + $this->cacheManager = $cacheManager; + } + + public function support(EntityInterface $entity) + { + return $entity instanceof Node || $entity instanceof Menu || $entity instanceof Navigation; + } + + public function onUpdate(EntityManagerEvent $event) + { + if (!$this->support($event->getEntity())) { + return; + } + + $this->cacheManager->cleanRouting(); + } + + public function onCreate(EntityManagerEvent $event) + { + return $this->onUpdate($event); + } + + public function onDelete(EntityManagerEvent $event) + { + return $this->onUpdate($event); + } +} diff --git a/core/EventSuscriber/Task/CacheCleanTaskEventSubscriber.php b/core/EventSuscriber/Task/CacheCleanTaskEventSubscriber.php new file mode 100644 index 0000000..a0b5a01 --- /dev/null +++ b/core/EventSuscriber/Task/CacheCleanTaskEventSubscriber.php @@ -0,0 +1,36 @@ + + */ +class CacheCleanTaskEventSubscriber extends TaskEventSubscriber +{ + protected SymfonyCacheManager $cacheManager; + + public function __construct(SymfonyCacheManager $cacheManager) + { + $this->cacheManager = $cacheManager; + } + + public function onInit(TaskInitEvent $event) + { + $event->addTask('cache:clear', 'โ™ป๏ธ Cache', 'Clean all cache'); + } + + public function onRunRequest(TaskRunRequestedEvent $event) + { + if ('cache:clear' !== $event->getTask()) { + return; + } + + $this->cacheManager->cleanAll($event->getOutput()); + } +} diff --git a/core/EventSuscriber/Task/TaskEventSubscriber.php b/core/EventSuscriber/Task/TaskEventSubscriber.php new file mode 100644 index 0000000..a6ab5d8 --- /dev/null +++ b/core/EventSuscriber/Task/TaskEventSubscriber.php @@ -0,0 +1,33 @@ + + */ +abstract class TaskEventSubscriber implements EventSubscriberInterface +{ + protected static int $priority = 0; + + public static function getSubscribedEvents() + { + return [ + TaskInitEvent::INIT_EVENT => ['onInit', self::$priority], + TaskRunRequestedEvent::RUN_REQUEST_EVENT => ['onRunRequest', self::$priority], + ]; + } + + public function onInit(TaskInitEvent $event) + { + } + + public function onRunRequest(TaskRunRequestedEvent $event) + { + } +} diff --git a/core/Factory/Analytic/RefererFactory.php b/core/Factory/Analytic/RefererFactory.php new file mode 100644 index 0000000..8884338 --- /dev/null +++ b/core/Factory/Analytic/RefererFactory.php @@ -0,0 +1,22 @@ +setNode($node) + ->setUri($uri) + ->setDate(new \DateTime()) + ; + + return $entity; + } +} diff --git a/core/Factory/Analytic/ViewFactory.php b/core/Factory/Analytic/ViewFactory.php new file mode 100644 index 0000000..840c7ad --- /dev/null +++ b/core/Factory/Analytic/ViewFactory.php @@ -0,0 +1,22 @@ +setNode($node) + ->setPath($path) + ->setDate(new \DateTime()) + ; + + return $entity; + } +} diff --git a/core/Factory/FactoryInterface.php b/core/Factory/FactoryInterface.php new file mode 100644 index 0000000..e31ca9e --- /dev/null +++ b/core/Factory/FactoryInterface.php @@ -0,0 +1,12 @@ + + */ +interface FactoryInterface +{ +} diff --git a/core/Factory/FileInformationFactory.php b/core/Factory/FileInformationFactory.php new file mode 100644 index 0000000..d8b081a --- /dev/null +++ b/core/Factory/FileInformationFactory.php @@ -0,0 +1,16 @@ +setId($id); + + return $entity; + } +} diff --git a/core/Factory/NavigationSettingFactory.php b/core/Factory/NavigationSettingFactory.php new file mode 100644 index 0000000..a0d84f4 --- /dev/null +++ b/core/Factory/NavigationSettingFactory.php @@ -0,0 +1,26 @@ + + */ +class NavigationSettingFactory implements FactoryInterface +{ + public function create(Navigation $navigation, string $code): NavigationSetting + { + $entity = new NavigationSetting(); + + $entity + ->setNavigation($navigation) + ->setCode($code) + ; + + return $entity; + } +} diff --git a/core/Factory/RedirectFactory.php b/core/Factory/RedirectFactory.php new file mode 100644 index 0000000..d7a138a --- /dev/null +++ b/core/Factory/RedirectFactory.php @@ -0,0 +1,14 @@ + + */ +class SettingFactory implements FactoryInterface +{ + public function create(string $code): Setting + { + $entity = new Setting(); + + $entity->setCode($code); + + return $entity; + } +} diff --git a/core/Factory/Site/MenuFactory.php b/core/Factory/Site/MenuFactory.php new file mode 100644 index 0000000..48e2c44 --- /dev/null +++ b/core/Factory/Site/MenuFactory.php @@ -0,0 +1,26 @@ + + */ +class MenuFactory implements FactoryInterface +{ + public function create(?Navigation $navigation = null): Menu + { + $entity = new Menu(); + + if (null !== $navigation) { + $entity->setNavigation($navigation); + } + + return $entity; + } +} diff --git a/core/Factory/Site/NavigationFactory.php b/core/Factory/Site/NavigationFactory.php new file mode 100644 index 0000000..a4196db --- /dev/null +++ b/core/Factory/Site/NavigationFactory.php @@ -0,0 +1,19 @@ + + */ +class NavigationFactory implements FactoryInterface +{ + public function create(): Navigation + { + return new Navigation(); + } +} diff --git a/core/Factory/Site/NodeFactory.php b/core/Factory/Site/NodeFactory.php new file mode 100644 index 0000000..587d38a --- /dev/null +++ b/core/Factory/Site/NodeFactory.php @@ -0,0 +1,30 @@ + + */ +class NodeFactory implements FactoryInterface +{ + public function create(?Menu $menu = null, string $url = null): Node + { + $entity = new Node(); + + if (null !== $menu) { + $entity->setMenu($menu); + } + + if (null !== $url) { + $entity->setUrl($url); + } + + return $entity; + } +} diff --git a/core/Factory/Site/Page/PageFactory.php b/core/Factory/Site/Page/PageFactory.php new file mode 100644 index 0000000..c408d31 --- /dev/null +++ b/core/Factory/Site/Page/PageFactory.php @@ -0,0 +1,22 @@ + + */ +class PageFactory implements FactoryInterface +{ + public function create(string $className, string $name): Page + { + $entity = new $className(); + $entity->setName($name); + + return $entity; + } +} diff --git a/core/Factory/UserFactory.php b/core/Factory/UserFactory.php new file mode 100644 index 0000000..0218062 --- /dev/null +++ b/core/Factory/UserFactory.php @@ -0,0 +1,38 @@ + + */ +class UserFactory implements FactoryInterface +{ + protected TokenGeneratorInterface $tokenGenerator; + protected UserPasswordEncoderInterface $encoder; + + public function __construct(UserPasswordEncoderInterface $encoder) + { + $this->encoder = $encoder; + } + + public function create(?string $email = null, ?string $password = null): User + { + $entity = new User(); + + if (null !== $email) { + $entity->setEmail($email); + } + + if (null !== $email) { + $entity->setPassword($this->encoder->encodePassword($entity, $password)); + } + + return $entity; + } +} diff --git a/core/File/FileAttribute.php b/core/File/FileAttribute.php new file mode 100644 index 0000000..b643187 --- /dev/null +++ b/core/File/FileAttribute.php @@ -0,0 +1,30 @@ + + */ +class FileAttribute +{ + public static function handleFile($attribute, string $class = null) + { + if (null === $class) { + $class = File::class; + } + + if (is_string($attribute)) { + if (file_exists($attribute)) { + return new $class($attribute); + } + + return null; + } + + return $attribute; + } +} diff --git a/core/FileManager/FsFileManager.php b/core/FileManager/FsFileManager.php new file mode 100644 index 0000000..e4a1751 --- /dev/null +++ b/core/FileManager/FsFileManager.php @@ -0,0 +1,321 @@ + + */ +class FsFileManager +{ + protected array $mimes; + protected string $path; + protected string $pathUri; + protected array $pathLocked; + protected FileUploadHandler $uploadHandler; + protected FileInformationFactory $fileInformationFactory; + protected FileInformationRepositoryQuery $fileInformationRepositoryQuery; + + public function __construct( + ParameterBagInterface $params, + FileUploadHandler $uploadHandler, + FileInformationFactory $fileInformationFactory, + FileInformationRepositoryQuery $fileInformationRepositoryQuery + ) { + $config = $params->get('core')['file_manager']; + + $this->uploadHandler = $uploadHandler; + $this->fileInformationFactory = $fileInformationFactory; + $this->fileInformationRepositoryQuery = $fileInformationRepositoryQuery; + + $this->mimes = $config['mimes']; + $this->path = $config['path']; + $this->pathUri = $this->normalizePath($config['path_uri']); + + foreach ($config['path_locked'] as $k => $v) { + $config['path_locked'][$k] = sprintf('/%s/', $this->normalizePath($v)); + } + + $this->pathLocked = $config['path_locked']; + } + + public function list(string $directory, array $options = []): array + { + $directory = $this->normalizePath($directory); + + $breadcrumb = ['/']; + + if ($directory) { + $breadcrumb = array_merge( + $breadcrumb, + explode('/', $directory) + ); + } + + $data = [ + 'breadcrumb' => $breadcrumb, + 'parent' => dirname($directory), + 'directories' => [], + 'files' => [], + ]; + + $finder = new Finder(); + $finder->directories()->depth('== 0')->in($this->path.'/'.$directory); + + $this->applySort($finder, $options['sort'] ?? 'name', $options['sort_direction'] ?? 'asc'); + + foreach ($finder as $file) { + $data['directories'][] = [ + 'basename' => $file->getBasename(), + 'path' => $directory.'/'.$file->getBasename(), + 'webPath' => $this->pathUri.'/'.$directory.'/'.$file->getBasename(), + 'locked' => $this->isLocked($directory.'/'.$file->getBasename()), + 'mime' => null, + ]; + } + + $finder = new Finder(); + $finder->files()->depth('== 0')->in($this->path.'/'.$directory); + + $this->applySort($finder, $options['sort'] ?? 'name', $options['sort_direction'] ?? 'asc'); + + foreach ($finder as $file) { + $data['files'][] = [ + 'basename' => $file->getBasename(), + 'path' => $directory, + 'webPath' => $this->pathUri.'/'.$directory.'/'.$file->getBasename(), + 'locked' => $this->isLocked($directory.'/'.$file->getBasename()), + 'mime' => mime_content_type($file->getRealPath()), + ]; + } + + return $data; + } + + public function getSplInfo(string $path): ?SplFileInfo + { + $path = $this->normalizePath($path); + + if ('' === $path) { + return new SplFileInfo($this->path, '', ''); + } + + $finder = new Finder(); + $finder->in($this->path) + ->depth('== '.substr_count($path, '/')) + ->name(basename($path)) + ; + + $dirname = dirname($path); + + if ('.' === $dirname) { + $dirname = ''; + } + + foreach ($finder as $file) { + if ($file->getRelativePath() === $dirname) { + return $file; + } + } + + return null; + } + + public function getFileInformation(string $path): ?FileInformation + { + $file = $this->getSplInfo($path); + + if (!$file) { + return null; + } + + if ($file->isDir()) { + return null; + } + + $hash = hash_file('sha384', $file->getPathName()); + + $info = $this->fileInformationRepositoryQuery + ->where('.id = :hash') + ->setParameter(':hash', $hash) + ->findOne() + ; + + if (!$info) { + $info = $this->fileInformationFactory->create($hash); + } + + return $info; + } + + public function createDirectory(string $name, string $path): bool + { + $file = $this->getSplInfo($path); + + if (!$file || $this->isLocked($path)) { + return false; + } + + $filesystem = new Filesystem(); + $path = $file->getPathname().'/'.$this->normalizePath($name); + + if ($filesystem->exists($path)) { + return false; + } + + $filesystem->mkdir($path, 0755); + + return true; + } + + public function renameDirectory(string $name, string $path): bool + { + $file = $this->getSplInfo($path); + + if (!$file || $this->isLocked($path)) { + return false; + } + + $filesystem = new Filesystem(); + $newPath = $file->getPath().'/'.$this->normalizePath($name); + + if ($filesystem->exists($newPath)) { + return false; + } + + $filesystem->rename($file->getPathName(), $newPath); + + return true; + } + + public function renameFile(string $name, string $path, bool $keepExtension = true): bool + { + $file = $this->getSplInfo($path); + + if (!$file || $this->isLocked($path)) { + return false; + } + + $filesystem = new Filesystem(); + $newPath = $file->getPath().'/'.$this->normalizePath($name); + + if ($keepExtension && $file->getExtension()) { + $newPath .= sprintf('.%s', $file->getExtension()); + } + + if ($filesystem->exists($newPath)) { + return false; + } + + $filesystem->rename($file->getPathName(), $newPath); + + return true; + } + + public function upload($files, string $path, array $fullPaths = []) + { + if ($files instanceof UploadedFile) { + $files = [$files]; + } + + foreach ($files as $key => $file) { + $directory = $this->path.'/'.$path; + + if (isset($fullPaths[$key])) { + $directory .= '/'.trim(dirname($fullPaths[$key]), '/'); + } + + $this->uploadHandler->handleForm($file, $directory, null, true); + } + } + + public function delete(string $path): bool + { + $file = $this->getSplInfo($path); + + if ($this->isLocked($file)) { + return false; + } + + if ($file) { + $filesystem = new Filesystem(); + $filesystem->remove($file); + + return true; + } + + return false; + } + + public function isLocked($path): bool + { + $file = $this->getSplInfo($path); + + if (!$file) { + return false; + } + + foreach ($this->pathLocked as $lock) { + if (u($file->getPathName().'/')->startsWith($lock)) { + return true; + } + } + + return false; + } + + public function getPath(): string + { + return $this->path; + } + + public function getPathUri(): string + { + return $this->pathUri; + } + + public function getMimes(): array + { + return $this->mimes; + } + + public function getPathLocked(): array + { + return $this->pathLocked; + } + + protected function applySort(Finder $finder, string $sort, string $direction) + { + if ('name' === $sort) { + $finder->sortByName(); + } elseif ('modification_date' === $sort) { + $finder->sortByModifiedTime(); + } + + if ('desc' === $direction) { + $finder->reverseSorting(); + } + } + + protected function normalizePath(string $path): string + { + return (string) u($path) + ->replace('..', '.') + ->replaceMatches('#/{2,}#', '/') + ->replaceMatches('#^.$#', '') + ->trim('/') + ->trim() + ; + } +} diff --git a/core/Form/FileManager/DirectoryCreateType.php b/core/Form/FileManager/DirectoryCreateType.php new file mode 100644 index 0000000..fdc7dba --- /dev/null +++ b/core/Form/FileManager/DirectoryCreateType.php @@ -0,0 +1,40 @@ +add( + 'name', + TextType::class, + [ + 'label' => 'Name', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new Regex([ + 'pattern' => '#['.preg_quote('\\/?%*:|"<>').'\t\n\r]+#', + 'match' => false, + ]), + new NotBlank(), + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + ]); + } +} diff --git a/core/Form/FileManager/DirectoryRenameType.php b/core/Form/FileManager/DirectoryRenameType.php new file mode 100644 index 0000000..a4eac6d --- /dev/null +++ b/core/Form/FileManager/DirectoryRenameType.php @@ -0,0 +1,40 @@ +add( + 'name', + TextType::class, + [ + 'label' => 'Name', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new Regex([ + 'pattern' => '#['.preg_quote('\\/?%*:|"<>').'\t\n\r]+#', + 'match' => false, + ]), + new NotBlank(), + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + ]); + } +} diff --git a/core/Form/FileManager/FileInformationAttributeType.php b/core/Form/FileManager/FileInformationAttributeType.php new file mode 100644 index 0000000..8bca9b0 --- /dev/null +++ b/core/Form/FileManager/FileInformationAttributeType.php @@ -0,0 +1,49 @@ +add( + 'label', + TextType::class, + [ + 'label' => 'Label', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'value', + TextType::class, + [ + 'label' => 'Value', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => null, + ]); + } +} diff --git a/core/Form/FileManager/FileInformationType.php b/core/Form/FileManager/FileInformationType.php new file mode 100644 index 0000000..839ea0d --- /dev/null +++ b/core/Form/FileManager/FileInformationType.php @@ -0,0 +1,34 @@ +add( + 'attributes', + CollectionType::class, + [ + 'entry_type' => FileInformationAttributeType::class, + 'by_reference' => false, + 'allow_add' => true, + 'allow_delete' => true, + 'prototype' => true, + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => FileInformation::class, + ]); + } +} diff --git a/core/Form/FileManager/FilePickerType.php b/core/Form/FileManager/FilePickerType.php new file mode 100644 index 0000000..666c789 --- /dev/null +++ b/core/Form/FileManager/FilePickerType.php @@ -0,0 +1,37 @@ +vars['pattern'] = null; + unset($view->vars['attr']['pattern']); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return TextType::class; + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix() + { + return 'file_picker'; + } +} diff --git a/core/Form/FileManager/FileRenameType.php b/core/Form/FileManager/FileRenameType.php new file mode 100644 index 0000000..b2a2ea5 --- /dev/null +++ b/core/Form/FileManager/FileRenameType.php @@ -0,0 +1,40 @@ +add( + 'name', + TextType::class, + [ + 'label' => 'Name', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new Regex([ + 'pattern' => '#['.preg_quote('\\/?%*:|"<>').'\t\n\r]+#', + 'match' => false, + ]), + new NotBlank(), + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + ]); + } +} diff --git a/core/Form/FileManager/FileUploadType.php b/core/Form/FileManager/FileUploadType.php new file mode 100644 index 0000000..b24bbcc --- /dev/null +++ b/core/Form/FileManager/FileUploadType.php @@ -0,0 +1,64 @@ +add( + 'files', + FileType::class, + [ + 'label' => 'Files', + 'required' => false, + 'multiple' => true, + 'attr' => [ + ], + 'constraints' => [ + new All([ + new File([ + 'mimeTypes' => $options['mimes'], + ]), + ]), + ], + ] + ); + + $builder->add( + 'directory', + FileType::class, + [ + 'label' => 'Directory', + 'required' => false, + 'multiple' => true, + 'attr' => [ + 'webkitdirectory' => '', + 'mozdirectory' => '', + 'directory' => '', + ], + 'constraints' => [ + new All([ + new File([ + 'mimeTypes' => $options['mimes'], + ]), + ]), + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'mimes' => [], + ]); + } +} diff --git a/core/Form/FileUploadHandler.php b/core/Form/FileUploadHandler.php new file mode 100644 index 0000000..68b9b90 --- /dev/null +++ b/core/Form/FileUploadHandler.php @@ -0,0 +1,35 @@ + + */ +class FileUploadHandler +{ + public function handleForm(?UploadedFile $uploadedFile, string $path, ?callable $afterUploadCallback = null, bool $keepOriginalFilename = false): void + { + if (null === $uploadedFile) { + return; + } + + $originalFilename = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME); + + if ($keepOriginalFilename) { + $filename = $originalFilename.'.'.$uploadedFile->guessExtension(); + } else { + $safeFilename = transliterator_transliterate('Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()', $originalFilename); + $filename = date('Ymd-his').$safeFilename.'.'.$uploadedFile->guessExtension(); + } + + $uploadedFile->move($path, $filename); + + if ($afterUploadCallback) { + $afterUploadCallback($filename); + } + } +} diff --git a/core/Form/Filter/RedirectFilterType.php b/core/Form/Filter/RedirectFilterType.php new file mode 100644 index 0000000..2cf4cd4 --- /dev/null +++ b/core/Form/Filter/RedirectFilterType.php @@ -0,0 +1,146 @@ +add( + 'label', + TextType::class, + [ + 'label' => 'Label', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'scheme', + ChoiceType::class, + [ + 'label' => 'Scheme', + 'required' => false, + 'choices' => [ + 'http(s)://' => 'all', + 'http://' => 'http', + 'https://' => 'https', + ], + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'domain', + TextType::class, + [ + 'label' => 'Domain', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'domainType', + ChoiceType::class, + [ + 'label' => 'Type', + 'required' => false, + 'choices' => [ + 'Domain' => 'domain', + 'Regular expression' => 'regexp', + ], + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'rule', + TextType::class, + [ + 'label' => '', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'ruleType', + ChoiceType::class, + [ + 'label' => 'Type', + 'required' => false, + 'choices' => [ + 'Path' => 'path', + 'Regular expression' => 'regexp', + ], + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'location', + TextType::class, + [ + 'label' => 'Location', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'redirectCode', + ChoiceType::class, + [ + 'label' => 'Code', + 'required' => false, + 'choices' => [ + '301 - Moved Permanently' => 301, + '307 - Temporary Redirect' => 307, + ], + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => null, + 'csrf_protection' => false, + ]); + } +} diff --git a/core/Form/RedirectType.php b/core/Form/RedirectType.php new file mode 100644 index 0000000..3acce35 --- /dev/null +++ b/core/Form/RedirectType.php @@ -0,0 +1,182 @@ +add( + 'label', + TextType::class, + [ + 'label' => 'Label', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'scheme', + ChoiceType::class, + [ + 'label' => 'Scheme', + 'required' => true, + 'choices' => [ + 'http(s)://' => 'all', + 'http://' => 'http', + 'https://' => 'https', + ], + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'domain', + TextType::class, + [ + 'label' => 'Domain', + 'required' => true, + 'attr' => [ + ], + 'help' => 'Regular expression: do not add the delimiter', + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'domainType', + ChoiceType::class, + [ + 'label' => 'Type', + 'required' => true, + 'choices' => [ + 'Domain' => 'domain', + 'Regular expression' => 'regexp', + ], + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'rule', + TextType::class, + [ + 'label' => 'Rule', + 'required' => true, + 'attr' => [ + ], + 'help' => 'Regular expression: do not add the delimiter', + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'ruleType', + ChoiceType::class, + [ + 'label' => 'Type', + 'required' => true, + 'choices' => [ + 'Path' => 'path', + 'Regular expression' => 'regexp', + ], + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'location', + TextType::class, + [ + 'label' => 'Location', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'redirectCode', + ChoiceType::class, + [ + 'label' => 'Code', + 'required' => true, + 'choices' => [ + '301 - Moved Permanently' => 301, + '307 - Temporary Redirect' => 307, + ], + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'isEnabled', + CheckboxType::class, + [ + 'label' => 'Enabled', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'reuseQueryString', + CheckboxType::class, + [ + 'label' => 'Reuse the query string', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Redirect::class, + ]); + } +} diff --git a/core/Form/Site/MenuType.php b/core/Form/Site/MenuType.php new file mode 100644 index 0000000..3efefdd --- /dev/null +++ b/core/Form/Site/MenuType.php @@ -0,0 +1,51 @@ +add( + 'label', + TextType::class, + [ + 'label' => 'Label', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'code', + TextType::class, + [ + 'label' => 'Code', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Menu::class, + ]); + } +} diff --git a/core/Form/Site/NavigationAdditionalDomainType.php b/core/Form/Site/NavigationAdditionalDomainType.php new file mode 100644 index 0000000..cc4ecf4 --- /dev/null +++ b/core/Form/Site/NavigationAdditionalDomainType.php @@ -0,0 +1,55 @@ +add( + 'domain', + TextType::class, + [ + 'label' => 'Domain', + 'required' => true, + 'help' => 'Regular expression: do not add the delimiter', + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'type', + ChoiceType::class, + [ + 'label' => 'Type', + 'required' => true, + 'choices' => [ + 'Domain' => 'domain', + 'Regular expression' => 'regexp', + ], + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + ]); + } +} diff --git a/core/Form/Site/NavigationType.php b/core/Form/Site/NavigationType.php new file mode 100644 index 0000000..9ccf546 --- /dev/null +++ b/core/Form/Site/NavigationType.php @@ -0,0 +1,109 @@ +add( + 'label', + TextType::class, + [ + 'label' => 'Label', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'code', + TextType::class, + [ + 'label' => 'Code', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'domain', + TextType::class, + [ + 'label' => 'Domain', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'forceDomain', + CheckboxType::class, + [ + 'label' => 'Force this domain', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'additionalDomains', + CollectionType::class, + [ + 'entry_type' => NavigationAdditionalDomainType::class, + 'label' => 'Additional domains', + 'by_reference' => false, + 'allow_add' => true, + 'allow_delete' => true, + 'prototype' => true, + ] + ); + + $builder->add( + 'locale', + TextType::class, + [ + 'label' => 'Locale', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + new Length(['min' => 2, 'max' => 10]), + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Navigation::class, + ]); + } +} diff --git a/core/Form/Site/NodeAttributeType.php b/core/Form/Site/NodeAttributeType.php new file mode 100644 index 0000000..4005dd9 --- /dev/null +++ b/core/Form/Site/NodeAttributeType.php @@ -0,0 +1,49 @@ +add( + 'label', + TextType::class, + [ + 'label' => 'Label', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'value', + TextType::class, + [ + 'label' => 'Value', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => null, + ]); + } +} diff --git a/core/Form/Site/NodeMoveType.php b/core/Form/Site/NodeMoveType.php new file mode 100644 index 0000000..f632151 --- /dev/null +++ b/core/Form/Site/NodeMoveType.php @@ -0,0 +1,62 @@ +add( + 'position', + ChoiceType::class, + [ + 'label' => 'Position', + 'required' => true, + 'choices' => [ + 'After' => 'after', + 'Before' => 'before', + 'Above' => 'above', + ], + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'node', + EntityType::class, + [ + 'label' => 'Element', + 'class' => Node::class, + 'choices' => call_user_func(function () use ($options) { + return $options['menu']->getRootNode()->getAllChildren(); + }), + 'choice_label' => 'treeLabel', + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => null, + 'menu' => null, + ]); + } +} diff --git a/core/Form/Site/NodeParameterType.php b/core/Form/Site/NodeParameterType.php new file mode 100644 index 0000000..b00ecdd --- /dev/null +++ b/core/Form/Site/NodeParameterType.php @@ -0,0 +1,63 @@ +add( + 'name', + TextType::class, + [ + 'label' => 'Name', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'defaultValue', + TextType::class, + [ + 'label' => 'Default value', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'requirement', + TextType::class, + [ + 'label' => 'Requirement', + 'help' => 'Regular expression', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => null, + ]); + } +} diff --git a/core/Form/Site/NodeSitemapParametersType.php b/core/Form/Site/NodeSitemapParametersType.php new file mode 100644 index 0000000..36a65cf --- /dev/null +++ b/core/Form/Site/NodeSitemapParametersType.php @@ -0,0 +1,82 @@ +add( + 'isVisible', + CheckboxType::class, + [ + 'label' => 'Visible', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'priority', + ChoiceType::class, + [ + 'label' => 'Priority', + 'required' => true, + 'attr' => [ + ], + 'choices' => call_user_func(function () { + $choices = []; + + for ($u = 0; $u <= 10; ++$u) { + $choices[$u] = $u / 10; + } + + return $choices; + }), + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'changeFrequency', + ChoiceType::class, + [ + 'label' => 'Frequency of change', + 'required' => true, + 'attr' => [ + ], + 'choices' => [ + 'Toujours' => 'always', + 'Toutes les heures' => 'hourly', + 'Quotidienne' => 'daily', + 'Hebdomadaire' => 'weekly', + 'Mensuelle' => 'monthly', + 'Annuelle' => 'yearly', + 'Jamais' => 'never', + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => null, + ]); + } +} diff --git a/core/Form/Site/NodeType.php b/core/Form/Site/NodeType.php new file mode 100644 index 0000000..284d0f9 --- /dev/null +++ b/core/Form/Site/NodeType.php @@ -0,0 +1,284 @@ +add( + 'label', + TextType::class, + [ + 'label' => 'Label', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'url', + TextType::class, + [ + 'label' => 'URL', + 'required' => false, + 'help' => 'Leave blank for automatic generation', + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'disableUrl', + CheckboxType::class, + [ + 'label' => 'Disable URL', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'enableAnalytics', + CheckboxType::class, + [ + 'label' => 'Enable analytics', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'code', + TextType::class, + [ + 'label' => 'Code', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'contentType', + TextType::class, + [ + 'label' => 'Content type', + 'required' => false, + 'help' => 'Leave blank equals "text/html"', + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'controller', + ChoiceType::class, + [ + 'label' => 'Controller', + 'required' => false, + 'help' => 'Leave blank to use the default one', + 'choices' => call_user_func(function () use ($options) { + $choices = []; + + foreach ($options['controllers'] as $controller) { + $choices[$controller->getName()] = $controller->getAction(); + } + + return $choices; + }), + ] + ); + + $actions = [ + 'New page' => 'new', + 'Use an existing page' => 'existing', + 'Alias element' => 'alias', + 'No page' => 'none', + ]; + + if ($builder->getData()->getId()) { + $actions['Keep the current configuration'] = 'keep'; + } + + $builder->add( + 'pageAction', + ChoiceType::class, + [ + 'label' => false, + 'required' => true, + 'expanded' => true, + 'mapped' => false, + 'choices' => $actions, + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'pageType', + ChoiceType::class, + [ + 'label' => false, + 'required' => true, + 'mapped' => false, + 'choices' => call_user_func(function () use ($options) { + $choices = []; + + foreach ($options['pages'] as $page) { + $choices[$page->getName()] = $page->getClassName(); + } + + return $choices; + }), + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'pageEntity', + EntityType::class, + [ + 'label' => false, + 'required' => true, + 'mapped' => false, + 'class' => Page::class, + 'choice_label' => 'name', + 'query_builder' => function (EntityRepository $repo) { + return $repo->createQueryBuilder('p') + ->orderBy('p.name', 'ASC') + ; + }, + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'aliasNode', + EntityType::class, + [ + 'label' => false, + 'required' => false, + 'class' => Node::class, + 'choice_label' => 'label', + 'choices' => call_user_func(function () use ($options, $builder) { + $nodes = []; + + foreach ($options['navigation']->getMenus() as $menu) { + $nodes = array_merge( + $nodes, + $menu->getRootNode()->getAllChildren()->toArray() + ); + } + + foreach ($nodes as $k => $value) { + if ($value->getId() === $builder->getData()->getId()) { + unset($nodes[$k]); + } + } + + return $nodes; + }), + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'parameters', + CollectionType::class, + [ + 'entry_type' => NodeParameterType::class, + 'by_reference' => false, + 'allow_add' => true, + 'allow_delete' => true, + 'prototype' => true, + ] + ); + + $builder->add( + 'attributes', + CollectionType::class, + [ + 'entry_type' => NodeAttributeType::class, + 'by_reference' => false, + 'allow_add' => true, + 'allow_delete' => true, + 'prototype' => true, + ] + ); + + $builder->add( + 'sitemapParameters', + NodeSitemapParametersType::class, + [ + 'label' => false, + ] + ); + + if (null === $builder->getData()->getId()) { + $builder->add( + 'position', + ChoiceType::class, + [ + 'label' => 'Position', + 'required' => true, + 'mapped' => false, + 'choices' => [ + 'After' => 'after', + 'Before' => 'before', + 'Above' => 'above', + ], + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + } + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Node::class, + 'pages' => [], + 'controllers' => [], + 'navigation' => null, + ]); + } +} diff --git a/core/Form/Site/Page/ChoiceBlockType.php b/core/Form/Site/Page/ChoiceBlockType.php new file mode 100644 index 0000000..536b7b9 --- /dev/null +++ b/core/Form/Site/Page/ChoiceBlockType.php @@ -0,0 +1,32 @@ +add( + 'value', + ChoiceType::class, + array_merge([ + 'required' => false, + 'label' => false, + ], $options['options']), + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => ChoiceBlock::class, + 'options' => [], + ]); + } +} diff --git a/core/Form/Site/Page/CollectionBlockType.php b/core/Form/Site/Page/CollectionBlockType.php new file mode 100644 index 0000000..d05fe48 --- /dev/null +++ b/core/Form/Site/Page/CollectionBlockType.php @@ -0,0 +1,50 @@ +add( + 'value', + $options['collection_type'], + array_merge([ + 'required' => false, + 'label' => false, + ], $options['options']), + ); + } + + /** + * {@inheritdoc} + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars = array_replace($view->vars, [ + 'collection_name' => $options['collection_name'], + 'label_add' => $options['label_add'], + 'label_delete' => $options['label_delete'], + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Block::class, + 'collection_type' => CollectionType::class, + 'collection_name' => '', + 'label_add' => 'Add', + 'label_delete' => 'Delete', + 'options' => [], + ]); + } +} diff --git a/core/Form/Site/Page/FileBlockType.php b/core/Form/Site/Page/FileBlockType.php new file mode 100644 index 0000000..7f9de07 --- /dev/null +++ b/core/Form/Site/Page/FileBlockType.php @@ -0,0 +1,32 @@ +add( + 'value', + FileType::class, + array_merge([ + 'required' => false, + 'label' => false, + ], $options['options']), + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => FileBlock::class, + 'block_prefix' => 'file_block', + 'options' => [], + ]); + } +} diff --git a/core/Form/Site/Page/FilePickerBlockType.php b/core/Form/Site/Page/FilePickerBlockType.php new file mode 100644 index 0000000..85a7ed9 --- /dev/null +++ b/core/Form/Site/Page/FilePickerBlockType.php @@ -0,0 +1,33 @@ +add( + 'value', + FilePickerType::class, + array_merge([ + 'required' => false, + 'label' => false, + ], $options['options']), + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Block::class, + 'block_prefix' => 'file_picker_page_block', + 'options' => [], + ]); + } +} diff --git a/core/Form/Site/Page/Filter/PageFilterType.php b/core/Form/Site/Page/Filter/PageFilterType.php new file mode 100644 index 0000000..0aee47f --- /dev/null +++ b/core/Form/Site/Page/Filter/PageFilterType.php @@ -0,0 +1,73 @@ +add( + 'name', + TextType::class, + [ + 'label' => 'Name', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'navigation', + EntityType::class, + [ + 'label' => 'Navigation', + 'class' => Navigation::class, + 'choice_label' => 'label', + 'choice_value' => 'id', + 'required' => false, + 'attr' => [ + ], + 'query_builder' => function (EntityRepository $repo) { + return $repo->createQueryBuilder('n') + ->orderBy('n.label, n.domain', 'ASC') + ; + }, + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'isAssociated', + ChoiceType::class, + [ + 'label' => 'Associated', + 'choices' => [ + 'Anyway' => -1, + 'No' => 0, + 'Yes' => 1, + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => null, + 'csrf_protection' => false, + ]); + } +} diff --git a/core/Form/Site/Page/ImageBlockType.php b/core/Form/Site/Page/ImageBlockType.php new file mode 100644 index 0000000..df651b6 --- /dev/null +++ b/core/Form/Site/Page/ImageBlockType.php @@ -0,0 +1,25 @@ +add( + 'value', + FileType::class, + array_merge([ + 'required' => false, + 'label' => false, + 'constraints' => [ + new Image(), + ], + ], $options['options']), + ); + } +} diff --git a/core/Form/Site/Page/PageType.php b/core/Form/Site/Page/PageType.php new file mode 100644 index 0000000..352c740 --- /dev/null +++ b/core/Form/Site/Page/PageType.php @@ -0,0 +1,133 @@ +add( + 'name', + TextType::class, + [ + 'label' => 'Name', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->add( + 'metaTitle', + TextType::class, + [ + 'label' => 'Title', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'metaDescription', + TextType::class, + [ + 'label' => 'Description', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'ogTitle', + TextType::class, + [ + 'label' => 'Title', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'ogDescription', + TextType::class, + [ + 'label' => 'Description', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'ogImage', + FileType::class, + [ + 'label' => 'Image', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + new Image(), + ], + ] + ); + + $builder->add( + 'template', + ChoiceType::class, + [ + 'label' => 'Render', + 'required' => true, + 'choices' => call_user_func(function () use ($options) { + $choices = []; + + foreach ($options['page_configuration']->getTemplates() as $template) { + $choices[$template['name']] = $template['file']; + } + + return $choices; + }), + 'attr' => [ + ], + 'constraints' => [ + new NotBlank(), + ], + ] + ); + + $builder->getData()->buildForm($builder, $options['page_builder_options']); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Page::class, + 'page_configuration' => null, + 'page_builder_options' => [], + ]); + } +} diff --git a/core/Form/Site/Page/TextBlockType.php b/core/Form/Site/Page/TextBlockType.php new file mode 100644 index 0000000..a642499 --- /dev/null +++ b/core/Form/Site/Page/TextBlockType.php @@ -0,0 +1,32 @@ +add( + 'value', + TextType::class, + array_merge([ + 'required' => false, + 'label' => false, + ], $options['options']), + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Block::class, + 'options' => [], + ]); + } +} diff --git a/core/Form/Site/Page/TextareaBlockType.php b/core/Form/Site/Page/TextareaBlockType.php new file mode 100644 index 0000000..87ea68a --- /dev/null +++ b/core/Form/Site/Page/TextareaBlockType.php @@ -0,0 +1,21 @@ +add( + 'value', + TextareaType::class, + array_merge([ + 'required' => false, + 'label' => false, + ], $options['options']), + ); + } +} diff --git a/core/Form/Type/CollectionType.php b/core/Form/Type/CollectionType.php new file mode 100644 index 0000000..166d5d5 --- /dev/null +++ b/core/Form/Type/CollectionType.php @@ -0,0 +1,41 @@ +vars = array_replace($view->vars, [ + 'collection_name' => $options['collection_name'], + 'label_add' => $options['label_add'], + 'label_delete' => $options['label_delete'], + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'collection_name' => '', + 'label_add' => 'Add', + 'label_delete' => 'Delete', + ]); + } + + public function getBlockPrefix() + { + return 'murph_collection'; + } +} diff --git a/core/Form/UserType.php b/core/Form/UserType.php new file mode 100644 index 0000000..6df1c04 --- /dev/null +++ b/core/Form/UserType.php @@ -0,0 +1,78 @@ +add( + 'email', + EmailType::class, + [ + 'label' => 'E-mail', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + new Email(), + ], + ] + ); + + $builder->add( + 'displayName', + TextType::class, + [ + 'label' => 'Display name', + 'required' => true, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'isAdmin', + CheckboxType::class, + [ + 'label' => 'Administrator', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + + $builder->add( + 'isWriter', + CheckboxType::class, + [ + 'label' => 'Writer', + 'required' => false, + 'attr' => [ + ], + 'constraints' => [ + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } +} diff --git a/core/Maker/MakeCrudController.php b/core/Maker/MakeCrudController.php new file mode 100644 index 0000000..d8e3f71 --- /dev/null +++ b/core/Maker/MakeCrudController.php @@ -0,0 +1,119 @@ +addArgument( + 'controller-class', + InputArgument::OPTIONAL, + 'Choose a name for your CRUD controller class (e.g. MyEntityAdminController)' + ) + ->addArgument( + 'entity-class', + InputArgument::OPTIONAL, + 'Define the entity (e.g. MyEntity)' + ) + ->addArgument( + 'repository-query-class', + InputArgument::OPTIONAL, + 'Define the repository query (e.g. MyEntityRepositoryQuery)' + ) + ->addArgument( + 'factory-class', + InputArgument::OPTIONAL, + 'Define the factory (e.g. MyEntityFactory)' + ) + ->addArgument( + 'form-class', + InputArgument::OPTIONAL, + 'Define the form (e.g. MyEntityType)' + ) + ->setHelp('') + ; + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator) + { + $controllerClassNameDetails = $generator->createClassNameDetails( + $input->getArgument('controller-class'), + 'Controller\\', + 'Controller' + ); + + $entityDetails = $generator->createClassNameDetails( + $input->getArgument('entity-class'), + 'Entity\\', + '' + ); + + $repoDetails = $generator->createClassNameDetails( + $input->getArgument('repository-query-class'), + 'Repository\\', + '' + ); + + $formDetails = $generator->createClassNameDetails( + $input->getArgument('form-class'), + 'Form\\', + '' + ); + + $factoryDetails = $generator->createClassNameDetails( + $input->getArgument('factory-class'), + 'Factory\\', + '' + ); + + $options = [ + 'entity' => $entityDetails->getFullName(), + 'route' => (string) u($entityDetails->getShortName())->snake(), + 'repository_query' => $repoDetails->getFullName(), + 'form' => $formDetails->getFullName(), + 'factory' => $factoryDetails->getFullName(), + ]; + + $controllerPath = $generator->generateController( + $controllerClassNameDetails->getFullName(), + __DIR__.'/../Resources/maker/controller/CrudController.tpl.php', + $options + ); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + $io->text('Next: Open your new controller class and configure it!'); + } + + public function configureDependencies(DependencyBuilder $dependencies) + { + $dependencies->addClassDependency( + Annotation::class, + 'doctrine/annotations' + ); + } +} diff --git a/core/Maker/MakeFactory.php b/core/Maker/MakeFactory.php new file mode 100644 index 0000000..4ed7369 --- /dev/null +++ b/core/Maker/MakeFactory.php @@ -0,0 +1,82 @@ +addArgument( + 'factory-class', + InputArgument::OPTIONAL, + 'Choose a name for your factory (e.g. MyEntityFactory)' + ) + ->addArgument( + 'entity-class', + InputArgument::OPTIONAL, + 'Define the entity (e.g. MyEntity)' + ) + ->setHelp('') + ; + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator) + { + $entityDetails = $generator->createClassNameDetails( + $input->getArgument('entity-class'), + 'Entity\\', + '' + ); + + $factoryDetails = $generator->createClassNameDetails( + $input->getArgument('factory-class'), + 'Factory\\', + '' + ); + + $options = [ + 'entity' => $entityDetails->getFullName(), + ]; + + $factoryPath = $generator->generateController( + $factoryDetails->getFullName(), + __DIR__.'/../Resources/maker/factory/Factory.tpl.php', + $options + ); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + $io->text('Next: Open your new factory class and configure it!'); + } + + public function configureDependencies(DependencyBuilder $dependencies) + { + $dependencies->addClassDependency( + Annotation::class, + 'doctrine/annotations' + ); + } +} diff --git a/core/Maker/MakeRepositoryQuery.php b/core/Maker/MakeRepositoryQuery.php new file mode 100644 index 0000000..21b27d5 --- /dev/null +++ b/core/Maker/MakeRepositoryQuery.php @@ -0,0 +1,85 @@ +addArgument( + 'repository-class', + InputArgument::OPTIONAL, + 'Define the repository (e.g. MyEntityRepository)' + ) + ->setHelp('') + ; + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator) + { + $repositoryClass = $input->getArgument('repository-class'); + + $repositoryDetails = $generator->createClassNameDetails( + $repositoryClass, + 'Repository\\', + '' + ); + + $queryDetails = $generator->createClassNameDetails( + $repositoryClass.'Query', + 'Repository\\', + '' + ); + + $id = u($queryDetails->getShortName()) + ->truncate(1) + ->lower() + ; + + $options = [ + 'repository' => $repositoryDetails->getFullName(), + 'id' => $id, + ]; + + $factoryPath = $generator->generateController( + $queryDetails->getFullName(), + __DIR__.'/../Resources/maker/repository/RepositoryQuery.tpl.php', + $options + ); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + $io->text('Next: Open your new repository query class and configure it!'); + } + + public function configureDependencies(DependencyBuilder $dependencies) + { + $dependencies->addClassDependency( + Annotation::class, + 'doctrine/annotations' + ); + } +} diff --git a/core/Manager/EntityManager.php b/core/Manager/EntityManager.php new file mode 100644 index 0000000..c4eb80e --- /dev/null +++ b/core/Manager/EntityManager.php @@ -0,0 +1,98 @@ + + */ +class EntityManager +{ + protected EventDispatcherInterface $eventDispatcher; + + protected DoctrineEntityManager $entityManager; + + public function __construct(EventDispatcherInterface $eventDispatcher, EntityManagerInterface $entityManager) + { + $this->eventDispatcher = $eventDispatcher; + $this->entityManager = $entityManager; + } + + public function create(EntityInterface $entity, bool $dispatchEvent = true): self + { + if ($dispatchEvent) { + $this->eventDispatcher->dispatch(new EntityManagerEvent($entity), EntityManagerEvent::PRE_CREATE_EVENT); + } + + $this->persist($entity); + + if ($dispatchEvent) { + $this->eventDispatcher->dispatch(new EntityManagerEvent($entity), EntityManagerEvent::CREATE_EVENT); + } + + return $this; + } + + public function update(EntityInterface $entity, bool $dispatchEvent = true): self + { + if ($dispatchEvent) { + $this->eventDispatcher->dispatch(new EntityManagerEvent($entity), EntityManagerEvent::PRE_UPDATE_EVENT); + } + + $this->persist($entity); + + if ($dispatchEvent) { + $this->eventDispatcher->dispatch(new EntityManagerEvent($entity), EntityManagerEvent::UPDATE_EVENT); + } + + return $this; + } + + public function delete(EntityInterface $entity, bool $dispatchEvent = true): self + { + if ($dispatchEvent) { + $this->eventDispatcher->dispatch(new EntityManagerEvent($entity), EntityManagerEvent::PRE_DELETE_EVENT); + } + + $this->entityManager->remove($entity); + $this->flush(); + + if ($dispatchEvent) { + $this->eventDispatcher->dispatch(new EntityManagerEvent($entity), EntityManagerEvent::DELETE_EVENT); + } + + return $this; + } + + public function flush(): self + { + $this->entityManager->flush(); + + return $this; + } + + public function clear(): self + { + $this->entityManager->clear(); + + return $this; + } + + public function getEntityManager(): EntityManagerInterface + { + return $this->entityManager; + } + + protected function persist(EntityInterface $entity) + { + $this->entityManager->persist($entity); + $this->flush(); + } +} diff --git a/core/Manager/TranslatableEntityManager.php b/core/Manager/TranslatableEntityManager.php new file mode 100644 index 0000000..2cb87a0 --- /dev/null +++ b/core/Manager/TranslatableEntityManager.php @@ -0,0 +1,20 @@ + + */ +class TranslatableEntityManager extends EntityManager +{ + protected function persist(EntityInterface $entity) + { + $this->entityManager->persist($entity); + $entity->mergeNewTranslations(); + $this->flush(); + } +} diff --git a/core/Notification/MailNotifier.php b/core/Notification/MailNotifier.php new file mode 100644 index 0000000..c4fc96a --- /dev/null +++ b/core/Notification/MailNotifier.php @@ -0,0 +1,236 @@ + + */ +class MailNotifier +{ + protected MailerInterface $mailer; + protected array $attachments = []; + protected array $recipients = []; + protected array $bccRecipients = []; + protected ?string $subject = null; + protected ?string $from = null; + protected ?string $replyTo = null; + + public function __construct(TwigEnvironment $twig, MailerInterface $mailer) + { + $this->mailer = $mailer; + $this->twig = $twig; + } + + public function setMailer(Swift_Mailer $mailer): self + { + $this->mailer = $mailer; + + return $this; + } + + public function getMailer(): Swift_Mailer + { + return $this->mailer; + } + + public function setRecipients(array $recipients): self + { + $this->recipients = $recipients; + + return $this; + } + + public function getRecipients(): array + { + return $this->recipients; + } + + public function setBccRecipients(array $bccRecipients): self + { + $this->bccRecipients = $bccRecipients; + + return $this; + } + + public function getBccRecipients(): array + { + return $this->bccRecipients; + } + + public function setSubject(?string $subject): self + { + $this->subject = $subject; + + return $this; + } + + public function getSubject(): string + { + return $this->subject; + } + + public function setFrom($from): self + { + $this->from = $from; + + return $this; + } + + public function getFrom(): ?string + { + return $this->from; + } + + public function setReplyTo($replyTo): self + { + $this->replyTo = $replyTo; + + return $this; + } + + public function getReplyTo(): ?string + { + return $this->replyTo; + } + + public function setAttachments(array $attachments): self + { + $this->attachments = $attachments; + + return $this; + } + + public function getAttachments(): array + { + return $this->attachments; + } + + public function addRecipient(string $email, bool $isBcc = false): self + { + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException(sprintf('Invalid email "%s".', $email)); + } + + if ($isBcc) { + if (!in_array($email, $this->bccRecipients)) { + $this->bccRecipients[] = $email; + } + } else { + if (!in_array($email, $this->recipients)) { + $this->recipients[] = $email; + } + } + + return $this; + } + + public function addRecipients(array $emails, bool $isBcc = false): self + { + foreach ($emails as $email) { + $this->addRecipient($email, $isBcc); + } + + return $this; + } + + public function addRecipientByAccount(Account $account, bool $isBcc = false): self + { + return $this->addRecipient($account->getEmail(), $isBcc); + } + + public function addRecipientsByAccounts($accounts, bool $isBcc = false) + { + if (!is_array($accounts)) { + throw new InvalidArgumentException('The "accounts" parameter must be an array or an instance of ObjectCollection'); + } + + foreach ($accounts as $account) { + $this->addRecipientByAccount($account, $isBcc); + } + + return $this; + } + + public function addAttachment(string $attachment): self + { + if (!in_array($attachment, $this->attachments)) { + $this->attachments[] = $attachment; + } + + return $this; + } + + public function addAttachments(array $attachments): self + { + foreach ($attachments as $attachment) { + $this->addAttachment($attachment); + } + + return $this; + } + + public function init(): self + { + $this + ->setSubject(null) + ->setRecipients([]) + ->setBccRecipients([]) + ->setAttachments([]) + ; + + return $this; + } + + public function notify(string $template, array $data = [], string $type = 'text/html'): self + { + $message = $this->createMessage(); + $message->context($data); + + if (in_array($type, ['text/plain', 'text'])) { + $message->textTemplate($template); + } else { + $message->htmlTemplate($template); + } + + $this->mailer->send($message); + + return $this; + } + + protected function createMessage(): TemplatedEmail + { + $message = new TemplatedEmail(); + + if ($this->getSubject()) { + $message->subject($this->getSubject()); + } + + if ($this->getFrom()) { + $message->from($this->getFrom()); + } + + if ($this->getReplyTo()) { + $message->replyTo($this->getReplyTo()); + } + + if (count($this->getRecipients()) > 0) { + $message->to(...$this->getRecipients()); + } + + if (count($this->getBccRecipients()) > 0) { + $message->bcc(...$this->getBccRecipients()); + } + + foreach ($this->getAttachments() as $attachment) { + $message->attachFromPath($attachment); + } + + return $message; + } +} diff --git a/core/Repository/.gitignore b/core/Repository/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/core/Repository/Analytic/RefererRepository.php b/core/Repository/Analytic/RefererRepository.php new file mode 100644 index 0000000..2bd6b73 --- /dev/null +++ b/core/Repository/Analytic/RefererRepository.php @@ -0,0 +1,21 @@ +andWhere('.node = :node') + ->andWhere('.uri = :uri') + ->setParameters([ + ':node' => $request->attributes->get('_node'), + ':uri' => $request->headers->get('referer'), + ]) + ; + } +} diff --git a/core/Repository/Analytic/ViewRepository.php b/core/Repository/Analytic/ViewRepository.php new file mode 100644 index 0000000..4ddf0a3 --- /dev/null +++ b/core/Repository/Analytic/ViewRepository.php @@ -0,0 +1,21 @@ +andWhere('.node = :node') + ->andWhere('.path = :path') + ->setParameters([ + ':node' => $request->attributes->get('_node'), + ':path' => $request->getPathInfo(), + ]) + ; + } +} diff --git a/core/Repository/FileInformationRepository.php b/core/Repository/FileInformationRepository.php new file mode 100644 index 0000000..faad05a --- /dev/null +++ b/core/Repository/FileInformationRepository.php @@ -0,0 +1,21 @@ + + */ +class NavigationSettingRepositoryQuery extends RepositoryQuery +{ + public function __construct(NavigationSettingRepository $repository, PaginatorInterface $paginator) + { + parent::__construct($repository, 'ns', $paginator); + } +} diff --git a/core/Repository/RedirectRepository.php b/core/Repository/RedirectRepository.php new file mode 100644 index 0000000..c6d6070 --- /dev/null +++ b/core/Repository/RedirectRepository.php @@ -0,0 +1,21 @@ + + */ +abstract class RepositoryQuery +{ + protected ServiceEntityRepository $repository; + protected QueryBuilder $query; + protected PaginatorInterface $paginator; + protected string $id; + protected array $forcedFilterHandlers; + + public function __construct(ServiceEntityRepository $repository, string $id, PaginatorInterface $paginator = null) + { + $this->repository = $repository; + $this->query = $repository->createQueryBuilder($id); + $this->paginator = $paginator; + $this->id = $id; + $this->forcedFilterHandlers = []; + } + + public function __call(string $name, $params): self + { + foreach ($params as $key => $value) { + $this->populateDqlId($params[$key]); + } + + call_user_func_array([$this->query, $name], $params); + + return $this; + } + + public function create() + { + $class = get_called_class(); + + return new $class($this->repository, $this->paginator); + } + + public function call(callable $fn): self + { + $fn($this->query, $this); + + return $this; + } + + public function findOne() + { + return $this->query->getQuery() + ->setMaxResults(1) + ->getOneOrNullResult() + ; + } + + public function find() + { + return $this->query->getQuery()->getResult(); + } + + public function paginate(int $page = 1, int $limit = 20) + { + return $this->paginator->paginate($this->query->getQuery(), $page, $limit); + } + + public function getRepository(): ServiceEntityRepository + { + return $this->repository; + } + + public function useFilters(array $filters) + { + foreach ($filters as $name => $value) { + if (null === $value) { + continue; + } + + if (in_array($name, $this->forcedFilterHandlers)) { + $this->filterHandler($name, $value); + } elseif (is_int($value) || is_bool($value)) { + $this->andWhere('.'.$name.' = :'.$name); + $this->setParameter(':'.$name, $value); + } elseif (is_string($value)) { + $this->andWhere('.'.$name.' LIKE :'.$name); + $this->setParameter(':'.$name, '%'.$value.'%'); + } else { + $this->filterHandler($name, $value); + } + } + + return $this; + } + + protected function populateDqlId(&$data) + { + if (is_string($data)) { + $words = explode(' ', $data); + + foreach ($words as $k => $v) { + if (isset($v[0]) && '.' === $v[0]) { + $words[$k] = $this->id.$v; + } + } + + $data = implode(' ', $words); + } elseif (is_array($data)) { + foreach ($data as $k => $v) { + $this->populateDqlId($data[$k]); + } + } + + return $data; + } + + protected function filterHandler(string $name, $value) + { + } +} diff --git a/core/Repository/SettingRepository.php b/core/Repository/SettingRepository.php new file mode 100644 index 0000000..6976ae2 --- /dev/null +++ b/core/Repository/SettingRepository.php @@ -0,0 +1,21 @@ + + */ +class SettingRepositoryQuery extends RepositoryQuery +{ + public function __construct(SettingRepository $repository, PaginatorInterface $paginator) + { + parent::__construct($repository, 's', $paginator); + } +} diff --git a/core/Repository/Site/MenuRepository.php b/core/Repository/Site/MenuRepository.php new file mode 100644 index 0000000..6e7b591 --- /dev/null +++ b/core/Repository/Site/MenuRepository.php @@ -0,0 +1,15 @@ + + */ +class MenuRepositoryQuery extends RepositoryQuery +{ + public function __construct(MenuRepository $repository, PaginatorInterface $paginator) + { + parent::__construct($repository, 'm', $paginator); + } +} diff --git a/core/Repository/Site/NavigationRepository.php b/core/Repository/Site/NavigationRepository.php new file mode 100644 index 0000000..987b35e --- /dev/null +++ b/core/Repository/Site/NavigationRepository.php @@ -0,0 +1,15 @@ + + */ +class NavigationRepositoryQuery extends RepositoryQuery +{ + public function __construct(NavigationRepository $repository, PaginatorInterface $paginator) + { + parent::__construct($repository, 'n', $paginator); + } + + public function filterById($id) + { + $this + ->where('.id = :id') + ->setParameter(':id', $id) + ; + + return $this; + } + + public function whereDomain($domain) + { + return $this + ->andWhere('.domain = :domain') + ->setParameter(':domain', $domain) + ; + } +} diff --git a/core/Repository/Site/NodeRepository.php b/core/Repository/Site/NodeRepository.php new file mode 100644 index 0000000..13a5c73 --- /dev/null +++ b/core/Repository/Site/NodeRepository.php @@ -0,0 +1,40 @@ +getClassMetadata(Node::class)); + } + + public function urlExists($url, Node $node): bool + { + $query = $this->createQueryBuilder('n') + ->join('n.menu', 'm') + ->where('n.url = :url') + ->andWhere('n.disableUrl = 0') + ->andWhere('n.aliasNode is null') + ->andWhere('m.navigation = :navigation') + ->setParameter(':url', $url) + ->setParameter(':navigation', $node->getMenu()->getNavigation()) + ; + + if ($node->getId()) { + $query + ->andWhere('n.id != :id') + ->setParameter(':id', $node->getId()) + ; + } + + return $query->getQuery() + ->setMaxResults(1) + ->getOneOrNullResult() !== null + ; + } +} diff --git a/core/Repository/Site/Page/BlockRepository.php b/core/Repository/Site/Page/BlockRepository.php new file mode 100644 index 0000000..1ccca24 --- /dev/null +++ b/core/Repository/Site/Page/BlockRepository.php @@ -0,0 +1,15 @@ + + */ +class BlockRepositoryQuery extends RepositoryQuery +{ + public function __construct(BlockRepository $repository, PaginatorInterface $paginator) + { + parent::__construct($repository, 'b', $paginator); + } +} diff --git a/core/Repository/Site/Page/PageRepository.php b/core/Repository/Site/Page/PageRepository.php new file mode 100644 index 0000000..9338c67 --- /dev/null +++ b/core/Repository/Site/Page/PageRepository.php @@ -0,0 +1,15 @@ + + */ +class PageRepositoryQuery extends RepositoryQuery +{ + public function __construct(PageRepository $repository, PaginatorInterface $paginator) + { + parent::__construct($repository, 'p', $paginator); + + $this->forcedFilterHandlers[] = 'isAssociated'; + } + + public function filterByNavigation(Navigation $navigation): self + { + return $this + ->leftJoin('.nodes', 'node') + ->leftJoin('node.menu', 'menu') + ->leftJoin('menu.navigation', 'navigation') + ->where('navigation.id = :navigationId') + ->setParameter(':navigationId', $navigation->getId()) + ; + } + + public function filterById($id): self + { + $this + ->where('.id = :id') + ->setParameter(':id', $id) + ; + + return $this; + } + + protected function withAssociation(bool $isAssociated): self + { + $entities = $this->create()->find(); + $ids = []; + + foreach ($entities as $entity) { + if ($isAssociated && !$entity->getNodes()->isEmpty()) { + $ids[] = $entity->getId(); + } elseif (!$isAssociated && $entity->getNodes()->isEmpty()) { + $ids[] = $entity->getId(); + } + } + + $this + ->andWhere('.id IN (:ids)') + ->setParameter(':ids', $ids) + ; + + return $this; + } + + protected function filterHandler(string $name, $value) + { + if ('navigation' === $name) { + return $this->filterByNavigation($value); + } + + if ('isAssociated' === $name && $value > -1) { + $this->withAssociation((bool) $value); + } + + return parent::filterHandler($name, $value); + } +} diff --git a/core/Resources/assets/css/admin.scss b/core/Resources/assets/css/admin.scss new file mode 100644 index 0000000..1fb41d1 --- /dev/null +++ b/core/Resources/assets/css/admin.scss @@ -0,0 +1,548 @@ +$theme-colors: ( + "primary": #1ab5dc, + "primary-light": lighten(#3183aa, 40%), + "dark-blue": #1e2430, +) !default; + +$grid-gutter-width: 0px !default; +$pagination-color: #343a40 !default; +$pagination-bg: #ffffff !default; +$pagination-active-color: #ffffff !default; +$pagination-active-bg: #343a40 !default; + +@import "~choices.js/src/styles/choices.scss"; +@import "~bootstrap/scss/bootstrap.scss"; +@import "~@fortawesome/fontawesome-free/css/all.css"; +@import "~flag-icon-css/sass/flag-icon.scss"; + +@for $i from 1 through 100 { + .miw-#{$i*5} { + min-width: $i * 5px; + } +} + +.flag-icon-en { + background-image: url(~flag-icon-css/flags/4x3/gb.svg); +} + +body { + overflow-x: hidden; +} + +#logo { + width: 30px; +} + +.choices__list--dropdown { + display: none; +} + +.choices__list--dropdown.is-active { + display: block; +} + +.dropdown-toggle-hide-after { + &::after { + display: none; + } +} + +.login { + &-container { + margin-top: 5%; + margin-bottom: 5%; + } + + &-form { + padding: 5%; + } + + &-image { + width: 100%; + max-width: 80%; + } +} + +.sidebar { + position: fixed; + top: 0; + bottom: 0; + left: 0; + z-index: 100; + padding: 71px 0 0; + box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); +} + +.sidebar-sticky { + position: relative; + top: 0; + height: calc(100vh - 71px); + padding-top: .5rem; + overflow-x: hidden; + overflow-y: hidden; + + &:hover { + overflow-y: auto; + } +} + +@supports ((position: -webkit-sticky) or (position: sticky)) { + .sidebar-sticky { + position: -webkit-sticky; + position: sticky; + } +} + +.actions-container { + padding-right: 25px; +} + +.table .thead-light { + a, th { + color: map-get($theme-colors, 'dark-blue'); + } +} + +tr.table-primary-light { + background-color: #ecf5fa; +} + +.td-nowrap { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wrap { + word-wrap: anywhere; +} + +.analytic .analytic-referer::before { + content: 'โ–ธ'; + padding-right: 10px; +} + +.analytic[open] .analytic-referer::before { + content: 'โ–พ'; +} + +.analytic-referer { + max-width: calc(100% - 60px); +} + +.table tr { + td { + transition: border 500ms ease-out; + border-bottom: 1px solid #dee2e6; + } + + &:hover { + td { + border-bottom: 1px solid #a8aaac; + } + } +} + +.bg-dark-blue { + background: map-get($theme-colors, 'dark-blue'); + color: #fff; + + .nav-item-label { + color: #fff; + } +} + +.nav-pills { + .nav-item { + margin-right: 3px; + } + + .nav-link:not(.active) { + color: #333; + background: #eee; + } +} + + +.sidebar { + width: 260px; + display: inline-block; + + .nav-link { + font-weight: 500; + color: #333; + border-left: 4px solid map-get($theme-colors, 'dark-blue'); + padding-top: 14px; + padding-bottom: 14px; + + .fa { + font-size: 1.2rem; + margin-right: 5px; + color: #fff; + width: 35px; + display: inline-block; + } + + .nav-item-label { + display: inline-block; + } + + &.active { + font-weight: bold; + border-left: 4px solid map-get($theme-colors, 'primary'); + background: map-get($theme-colors, 'dark-blue'); + } + } + + &-heading { + font-size: .75rem; + text-transform: uppercase; + display: flex; + } +} + +*[data-selectable-selector] { + -moz-user-select: none; + -webkit-user-select: none; + user-select: none; +} + +*[data-selectable-selector] { + &:hover { + cursor: pointer; + } +} + +*[data-sortable-item] { + &:hover { + cursor: pointer; + } + + &.sortable-chosen { + background: map-get($theme-colors, 'primary-light'); + } +} + +.footer { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + z-index: 1000; + height: 35px; + background: #f8f9fa; +} + +.body { + padding-top: 60px; + width: calc(100% - 260px); + margin-left: 260px; + display: inline-block; + + .nav { + padding-left: 10px; + } +} + +@media screen and (max-width: 1080px) { + .sidebar-sticky { + overflow-y: auto !important; + } +} + +@media screen and (max-width: 770px) { + .body { + margin-left: 50px; + width: calc(100vw - 50px); + } + + .sidebar-sticky { + width: 50px; + max-width: 100% !important; + } + + .sidebar { + width: 50px; + max-width: 100% !important; + + .nav { + padding-left: 0; + } + + .nav-link { + padding-left: 10px; + + .nav-item-label { + display: none; + } + } + + .sidebar-heading { + display: none; + } + } +} + +th { + &.sorted { + &::before { + content: '\f0dc'; + font-family: 'FontAwesome'; + color: #aaa; + margin-right: 3px; + } + } +} + +.table-responsive { + max-width: 100%; + overflow-y: hidden; +} + +.toast-container { + display: flex; + position: relative; + z-index: 4000; + + .toast-wrapper { + position: fixed; + top: 20px; + right: 20px; + z-index: 1060; + width: 300px; + } +} + +.bg-tiles { + background-color: #c1c1c1; + background-image: linear-gradient(45deg, #646464 25%, transparent 25%, transparent 75%, #646464 75%), linear-gradient(45deg, #646464 25%, transparent 25%, transparent 75%, #646464 75%); + background-size: 20px 20px; + background-position: 0 0, 10px 10px; +} + +.tab-form { + padding: 15px; +} + +.icon-margin { + margin-right: 4px; +} + +.file-icon { + font-size: 2em; +} + +.d-ib { + display: inline-block; +} + +.list-checkbox { + vertical-align: middle; + margin-right: 10px; + margin-top: -2px; +} + +.password-strenth { + padding: 0 0 5px 0; + margin-top: -4px; + + .col-sm { + height: 8px; + border: 2px solid #fff; + } + + &-info { + font-size: 13px; + height: 22px; + } +} + +.notification-bell:not([disabled]) { + [data-counter]:after { + display: block; + color: #fff; + background: red; + width: 9px; + height: 9px; + position: absolute; + content: ' '; + top: 4px; + right: 10px; + border-radius: 4px; + } +} + +.form-error-icon { + margin-right: 4px; +} + +.custom-file-label::after { + content: "Parcourir"; +} + +#lease_template_html { + height: calc(100vh - 270px); +} + +.panel { + &-toggler { + &:hover { + cursor: pointer; + } + } + + &-content { + display: block; + + &:not(.active) { + display: none; + } + } +} + +*[data-collection-delete-container] { + cursor: pointer; +} + +*[data-collection-add] { + cursor: pointer; +} + +.login-image { + width: 50%; +} + +.tree { + position: relative; + background: white; + color: #212529; + + span { + font-style: italic; + letter-spacing: .4px; + color: #a8a8a8; + } + + .fa-folder-open, .fa-folder { + color: #007bff; + } + + .fa-html5 { + color: #f21f10; + } + + ul { + padding-left: 5px; + list-style: none; + margin: 0; + padding-bottom: 0; + + li { + position: relative; + padding-top: 5px; + padding-bottom: 5px; + padding-left: 15px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + + &:before { + position: absolute; + top: 15px; + left: 0; + width: 10px; + height: 1px; + margin: auto; + content: ''; + background-color: #666; + } + + &:after { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 1px; + height: 100%; + content: ''; + background-color: #666; + } + + &:last-child:after { + height: 15px; + } + } + + a { + cursor: pointer; + + &:hover { + text-decoration: none; + } + } + } +} + +fieldset.form-group { + margin-bottom: 0; +} + +.crud-header { + &-title { + font-size: 2em; + } + + &-actions { + text-align: right; + margin-bottom: 10px; + } + + @media screen and (min-width: 770px) { + &-title { + float: left; + font-size: 2em; + } + + &-actions { + float: right; + } + + } + + &::after { + display: block; + content: ""; + clear: both; + } +} + +.table .crud-batch-column { + width: 1%; +} + +form { + .loader { + display: none; + } + + &.is-loading .loader { + display: inline-block; + } +} + +.modal { + z-index: 3000; +} + +.modal-dialog-large { + max-width: 80%; + margin-left: auto; + margin-right: auto; +} + +.output { + &-console { + background: #073642; + line-height: normal; + } +} diff --git a/core/Resources/assets/js/admin.js b/core/Resources/assets/js/admin.js new file mode 100644 index 0000000..8a6501b --- /dev/null +++ b/core/Resources/assets/js/admin.js @@ -0,0 +1,26 @@ +import '../../../../assets/css/admin.scss'; + +require('../../../../node_modules/bootstrap/dist/js/bootstrap.min.js') +require('./modules/table-fixed.js')() +require('./modules/form-confirm.js')() +require('./modules/form-file.js')() +require('./modules/form-error.js')() +require('./modules/form-ajax.js')() +require('./modules/dbclick.js')() +require('./modules/toast.js')() +require('./modules/modal.js')() +require('./modules/push-state.js')() +require('./modules/password.js')() +require('./modules/tooltip.js')() +require('./modules/editor.js')() +require('./modules/panel.js')() +require('./modules/choices.js')() +require('./modules/checkbox-checker.js')() +require('./modules/rest-choices.js')() +require('./modules/form-collection.js')() +require('./modules/datepicker.js')() +require('./modules/sortable.js')() +require('./modules/batch.js')() +require('./modules/file-manager.js')() +require('./modules/file-picker.js')() +require('./modules/analytics.js')() diff --git a/core/Resources/assets/js/components/file-manager/FileIcon.vue b/core/Resources/assets/js/components/file-manager/FileIcon.vue new file mode 100644 index 0000000..4c08cca --- /dev/null +++ b/core/Resources/assets/js/components/file-manager/FileIcon.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/core/Resources/assets/js/components/file-manager/FileManager.vue b/core/Resources/assets/js/components/file-manager/FileManager.vue new file mode 100644 index 0000000..eab7f4a --- /dev/null +++ b/core/Resources/assets/js/components/file-manager/FileManager.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/core/Resources/assets/js/components/file-manager/Files.vue b/core/Resources/assets/js/components/file-manager/Files.vue new file mode 100644 index 0000000..6e6af3d --- /dev/null +++ b/core/Resources/assets/js/components/file-manager/Files.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/core/Resources/assets/js/modules/analytics.js b/core/Resources/assets/js/modules/analytics.js new file mode 100644 index 0000000..dcb6d1a --- /dev/null +++ b/core/Resources/assets/js/modules/analytics.js @@ -0,0 +1,53 @@ +const $ = require('jquery') +const Chart = require('chart.js/auto').default + +const drawChart = () => { + const ctx = document.getElementById('analytic-chart') + const options = { + type: 'bar', + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true + } + } + }, + data: { + labels: JSON.parse(ctx.getAttribute('data-labels')), + datasets: [{ + label: ctx.getAttribute('data-label'), + data: JSON.parse(ctx.getAttribute('data-values')), + backgroundColor: 'rgba(54, 162, 235, 0.2)', + borderColor: 'rgb(54, 162, 235)', + borderWidth: 1 + }] + } + } + + const chart = new Chart(ctx, options) + + const resize = () => { + const width = ctx.parentNode.parentNode.offsetWidth + const height = 250 + + chart.resize(width, height) + } + + resize() + + window.addEventListener('resize', resize) +} + +module.exports = () => { + const body = $('body') + + body.on('shown.bs.modal', '.modal', (e) => { + window.setTimeout(() => { + if (document.getElementById('analytic-chart')) { + drawChart() + } + }, 500) + }) +} diff --git a/core/Resources/assets/js/modules/batch.js b/core/Resources/assets/js/modules/batch.js new file mode 100644 index 0000000..2d5c1f2 --- /dev/null +++ b/core/Resources/assets/js/modules/batch.js @@ -0,0 +1,23 @@ +const $ = require('jquery') + +module.exports = () => { + $('th.crud-batch-column input').change((e) => { + $('td.crud-batch-column input').prop('checked', $(e.target).is(':checked')) + }) + + const form = $('#form-batch') + + form.submit((e) => { + e.preventDefault() + + const route = form.attr('action') + const datas = form.serialize() + + form.addClass('is-loading') + + $.post(route, datas) + .always(() => { + document.location.reload() + }) + }) +} diff --git a/core/Resources/assets/js/modules/checkbox-checker.js b/core/Resources/assets/js/modules/checkbox-checker.js new file mode 100644 index 0000000..c6804a5 --- /dev/null +++ b/core/Resources/assets/js/modules/checkbox-checker.js @@ -0,0 +1,31 @@ +const $ = require('jquery') + +module.exports = function () { + $('*[data-checkbox-ckecker]').click(function () { + const wrapperName = $(this).attr('data-checkbox-ckecker') + + if (!wrapperName) { + return + } + + const checkboxes = $('*[data-checkbox-wrapper="' + wrapperName + '"] *[data-checkbox] input[type="checkbox"]') + + $(checkboxes).each(function (i, v) { + $(v).prop('checked', true) + }) + }) + + $('*[data-checkbox-unckecker]').click(function () { + const wrapperName = $(this).attr('data-checkbox-unckecker') + + if (!wrapperName) { + return + } + + const checkboxes = $('*[data-checkbox-wrapper="' + wrapperName + '"] *[data-checkbox] input[type="checkbox"]') + + $(checkboxes).each(function (i, v) { + $(v).prop('checked', false) + }) + }) +} diff --git a/core/Resources/assets/js/modules/choices.js b/core/Resources/assets/js/modules/choices.js new file mode 100644 index 0000000..d2da709 --- /dev/null +++ b/core/Resources/assets/js/modules/choices.js @@ -0,0 +1,8 @@ +const Choices = require('choices.js') +const $ = require('jquery') + +module.exports = function () { + $('*[data-jschoice]').each(function (key, item) { + return new Choices(item) + }) +} diff --git a/core/Resources/assets/js/modules/datepicker.js b/core/Resources/assets/js/modules/datepicker.js new file mode 100644 index 0000000..673ae32 --- /dev/null +++ b/core/Resources/assets/js/modules/datepicker.js @@ -0,0 +1,30 @@ +const Datepicker = require('vanillajs-datepicker') + +const isDateSupported = () => { + const input = document.createElement('input') + const value = 'a' + + input.setAttribute('type', 'date') + input.setAttribute('value', value) + + return input.value !== value +} + +const createDatePicker = (input) => { + return new Datepicker.Datepicker(input, { + format: 'yyyy-mm-dd' + }) +} + +module.exports = () => { + if (isDateSupported()) { + return + } + + const inputs = document.querySelectorAll('input[type="date"]') + const size = inputs.length + + for (let i = 0, c = size; i < c; i++) { + createDatePicker(inputs[i]) + } +} diff --git a/core/Resources/assets/js/modules/dbclick.js b/core/Resources/assets/js/modules/dbclick.js new file mode 100644 index 0000000..57713cf --- /dev/null +++ b/core/Resources/assets/js/modules/dbclick.js @@ -0,0 +1,7 @@ +const $ = require('jquery') + +module.exports = function () { + $('*[data-dblclick]').dblclick(function (e) { + document.location.href = $(this).attr('data-dblclick') + }) +} diff --git a/core/Resources/assets/js/modules/document-selector.js b/core/Resources/assets/js/modules/document-selector.js new file mode 100644 index 0000000..604f77f --- /dev/null +++ b/core/Resources/assets/js/modules/document-selector.js @@ -0,0 +1,42 @@ +const $ = require('jquery') + +const DocumentSelector = () => { + const forms = $('.document-selector-form') + + const handler = function () { + forms.each((fi, f) => { + const form = $(f) + const ids = form.find('.document-selector-ids') + const btn = form.find('.document-selector-button') + + ids.html('') + let hasSelection = false + + $('*[data-documents] *[data-selectable-row] input[data-selectable-checkbox]').each((i, c) => { + const checkbox = $(c) + + if (checkbox.is(':checked')) { + ids.append(checkbox[0].outerHTML) + hasSelection = true + } + }) + + if (hasSelection && btn.length) { + btn.removeAttr('disabled') + ids.find('input').prop('checked', true) + } else { + btn.attr('disabled', 'disabled') + } + }) + } + + $('*[data-documents] *[data-selectable-row]').click(function () { + window.setTimeout(handler, 100) + }) + + $('*[data-documents] *[data-selectable-row]').on('clicked', function () { + window.setTimeout(handler, 100) + }) +} + +module.exports = DocumentSelector diff --git a/core/Resources/assets/js/modules/editor.js b/core/Resources/assets/js/modules/editor.js new file mode 100644 index 0000000..7bfba77 --- /dev/null +++ b/core/Resources/assets/js/modules/editor.js @@ -0,0 +1,626 @@ +const $ = require('jquery') +const Vue = require('vue').default +const FileManager = require('../components/file-manager/FileManager').default + +const createModal = function () { + let container = $('#fm-modal') + const body = $('body') + + if (!container.length) { + container = $('