From bd4137e00da701d61f954961c7ee09c8737bbad1 Mon Sep 17 00:00:00 2001 From: bach Date: Wed, 12 May 2021 10:33:19 +0200 Subject: [PATCH] updated grav to 1.7.14 --- .github/FUNDING.yml | 8 + .phan/config.php | 44 + .phan/internal_stubs/Redis.phan_php | 5153 +++++++++++++++++ .phan/internal_stubs/memcache.phan_php | 460 ++ .phan/internal_stubs/memcached.phan_php | 1308 +++++ CHANGELOG.md | 877 +++ CODE_OF_CONDUCT.md | 129 +- LICENSE.txt | 2 +- README.md | 22 +- SECURITY.md | 21 + assets/.gitkeep | 1 + bin/composer.phar | Bin 1909682 -> 2205196 bytes bin/gpm | 39 +- bin/grav | 38 +- bin/plugin | 119 +- composer.json | 104 +- composer.lock | 4442 ++++++++------ index.php | 12 +- now.json | 2 +- system/aliases.php | 5 - system/assets/debugger.css | 70 - system/assets/debugger/clockwork.css | 2 + system/assets/debugger/clockwork.js | 3 + system/assets/debugger/phpdebugbar.css | 70 + system/assets/grav.png | Bin 4166 -> 1612 bytes system/assets/jquery/jquery-3.x.min.js | 4 +- system/blueprints/config/scheduler.yaml | 3 +- system/blueprints/config/security.yaml | 12 + system/blueprints/config/system.yaml | 3183 +++++----- system/blueprints/flex/accounts.yaml | 8 + system/blueprints/flex/configure/compat.yaml | 17 + system/blueprints/flex/pages.yaml | 212 + system/blueprints/flex/shared/configure.yaml | 70 + system/blueprints/flex/user-accounts.yaml | 142 + system/blueprints/flex/user-groups.yaml | 123 + system/blueprints/pages/default.yaml | 2 +- system/blueprints/pages/modular.yaml | 6 +- .../blueprints/pages/partials/security.yaml | 71 + system/blueprints/pages/root.yaml | 16 + system/blueprints/user/account.yaml | 13 + system/blueprints/user/account_new.yaml | 2 +- system/blueprints/user/accounts.yaml | 39 - system/blueprints/user/group.yaml | 76 +- system/blueprints/user/group_new.yaml | 7 + system/config/media.yaml | 10 +- system/config/permissions.yaml | 53 + system/config/security.yaml | 1 + system/config/streams.yaml | 16 - system/config/system.yaml | 57 +- system/defines.php | 92 +- system/install.php | 6 +- system/languages/ar.yaml | 23 +- system/languages/bg.yaml | 4 +- system/languages/ca.yaml | 13 +- system/languages/cs.yaml | 7 +- system/languages/da.yaml | 32 +- system/languages/de.yaml | 7 +- system/languages/el.yaml | 85 +- system/languages/en.yaml | 7 +- system/languages/es.yaml | 20 +- system/languages/et.yaml | 25 +- system/languages/eu.yaml | 4 +- system/languages/fa.yaml | 4 +- system/languages/fi.yaml | 4 +- system/languages/fr.yaml | 43 +- system/languages/gl.yaml | 144 + system/languages/he.yaml | 4 +- system/languages/hr.yaml | 4 +- system/languages/hu.yaml | 2 +- system/languages/id.yaml | 6 +- system/languages/is.yaml | 4 +- system/languages/it.yaml | 50 +- system/languages/ja.yaml | 19 +- system/languages/ko.yaml | 4 +- system/languages/lt.yaml | 4 +- system/languages/nl.yaml | 4 +- system/languages/no.yaml | 9 +- system/languages/pl.yaml | 42 +- system/languages/pt.yaml | 126 +- system/languages/ro.yaml | 4 +- system/languages/ru.yaml | 21 +- system/languages/sk.yaml | 4 +- system/languages/sl.yaml | 4 +- system/languages/sr.yaml | 144 + system/languages/sv.yaml | 32 +- system/languages/th.yaml | 2 +- system/languages/tr.yaml | 39 +- system/languages/uk.yaml | 4 +- system/languages/vi.yaml | 4 +- system/languages/zh-cn.yaml | 144 + system/languages/zh.yaml | 6 +- system/pages/notfound.md | 1 + system/router.php | 3 +- system/src/Grav/Common/Assets.php | 150 +- system/src/Grav/Common/Assets/BaseAsset.php | 88 +- system/src/Grav/Common/Assets/Css.php | 18 +- system/src/Grav/Common/Assets/InlineCss.php | 14 +- system/src/Grav/Common/Assets/InlineJs.php | 14 +- system/src/Grav/Common/Assets/Js.php | 18 +- system/src/Grav/Common/Assets/Pipeline.php | 81 +- .../Common/Assets/Traits/AssetUtilsTrait.php | 48 +- .../Assets/Traits/LegacyAssetsTrait.php | 30 +- .../Assets/Traits/TestingAssetsTrait.php | 44 +- system/src/Grav/Common/Backup/Backups.php | 130 +- system/src/Grav/Common/Browser.php | 10 +- system/src/Grav/Common/Cache.php | 132 +- system/src/Grav/Common/Composer.php | 12 +- .../src/Grav/Common/Config/CompiledBase.php | 82 +- .../Grav/Common/Config/CompiledBlueprints.php | 16 +- .../src/Grav/Common/Config/CompiledConfig.php | 31 +- .../Grav/Common/Config/CompiledLanguages.php | 18 +- system/src/Grav/Common/Config/Config.php | 34 +- .../Grav/Common/Config/ConfigFileFinder.php | 26 +- system/src/Grav/Common/Config/Languages.php | 48 +- system/src/Grav/Common/Config/Setup.php | 219 +- system/src/Grav/Common/Data/Blueprint.php | 227 +- .../src/Grav/Common/Data/BlueprintSchema.php | 192 +- system/src/Grav/Common/Data/Blueprints.php | 19 +- system/src/Grav/Common/Data/Data.php | 77 +- system/src/Grav/Common/Data/DataInterface.php | 20 +- system/src/Grav/Common/Data/Validation.php | 470 +- .../Grav/Common/Data/ValidationException.php | 20 +- system/src/Grav/Common/Debugger.php | 732 ++- system/src/Grav/Common/Errors/BareHandler.php | 15 +- system/src/Grav/Common/Errors/Errors.php | 34 +- .../Grav/Common/Errors/SimplePageHandler.php | 38 +- .../src/Grav/Common/Errors/SystemFacade.php | 10 +- system/src/Grav/Common/File/CompiledFile.php | 32 +- .../src/Grav/Common/File/CompiledJsonFile.php | 8 +- .../Grav/Common/File/CompiledMarkdownFile.php | 6 +- .../src/Grav/Common/File/CompiledYamlFile.php | 6 +- .../src/Grav/Common/Filesystem/Archiver.php | 60 +- system/src/Grav/Common/Filesystem/Folder.php | 125 +- .../RecursiveDirectoryFilterIterator.php | 25 +- .../RecursiveFolderFilterIterator.php | 16 +- .../Grav/Common/Filesystem/ZipArchiver.php | 53 +- .../src/Grav/Common/Flex/FlexCollection.php | 28 + system/src/Grav/Common/Flex/FlexIndex.php | 29 + system/src/Grav/Common/Flex/FlexObject.php | 73 + .../Flex/Traits/FlexCollectionTrait.php | 51 + .../Common/Flex/Traits/FlexCommonTrait.php | 54 + .../Grav/Common/Flex/Traits/FlexGravTrait.php | 74 + .../Common/Flex/Traits/FlexIndexTrait.php | 20 + .../Common/Flex/Traits/FlexObjectTrait.php | 62 + .../Flex/Types/Generic/GenericCollection.php | 24 + .../Flex/Types/Generic/GenericIndex.php | 24 + .../Flex/Types/Generic/GenericObject.php | 22 + .../Flex/Types/Pages/PageCollection.php | 811 +++ .../Common/Flex/Types/Pages/PageIndex.php | 1156 ++++ .../Common/Flex/Types/Pages/PageObject.php | 696 +++ .../Flex/Types/Pages/Storage/PageStorage.php | 700 +++ .../Types/Pages/Traits/PageContentTrait.php | 75 + .../Types/Pages/Traits/PageLegacyTrait.php | 233 + .../Types/Pages/Traits/PageRoutableTrait.php | 122 + .../Types/Pages/Traits/PageTranslateTrait.php | 108 + .../Types/UserGroups/UserGroupCollection.php | 56 + .../Flex/Types/UserGroups/UserGroupIndex.php | 24 + .../Flex/Types/UserGroups/UserGroupObject.php | 113 + .../Types/Users/Storage/UserFileStorage.php | 47 + .../Types/Users/Storage/UserFolderStorage.php | 37 + .../Users/Traits/UserObjectLegacyTrait.php | 93 + .../Flex/Types/Users/UserCollection.php | 135 + .../Common/Flex/Types/Users/UserIndex.php | 204 + .../Common/Flex/Types/Users/UserObject.php | 905 +++ system/src/Grav/Common/Form/FormFlash.php | 9 +- .../Grav/Common/GPM/AbstractCollection.php | 14 +- .../GPM/Common/AbstractPackageCollection.php | 15 +- .../Common/GPM/Common/CachedCollection.php | 14 +- system/src/Grav/Common/GPM/Common/Package.php | 35 +- system/src/Grav/Common/GPM/GPM.php | 539 +- system/src/Grav/Common/GPM/Installer.php | 118 +- system/src/Grav/Common/GPM/Licenses.php | 35 +- .../GPM/Local/AbstractPackageCollection.php | 11 +- system/src/Grav/Common/GPM/Local/Package.php | 18 +- system/src/Grav/Common/GPM/Local/Packages.php | 6 +- system/src/Grav/Common/GPM/Local/Plugins.php | 10 +- system/src/Grav/Common/GPM/Local/Themes.php | 15 +- .../GPM/Remote/AbstractPackageCollection.php | 38 +- .../src/Grav/Common/GPM/Remote/GravCore.php | 26 +- system/src/Grav/Common/GPM/Remote/Package.php | 42 +- .../src/Grav/Common/GPM/Remote/Packages.php | 11 +- system/src/Grav/Common/GPM/Remote/Plugins.php | 14 +- system/src/Grav/Common/GPM/Remote/Themes.php | 14 +- system/src/Grav/Common/GPM/Response.php | 443 +- system/src/Grav/Common/GPM/Upgrader.php | 28 +- system/src/Grav/Common/Getters.php | 42 +- system/src/Grav/Common/Grav.php | 432 +- system/src/Grav/Common/GravTrait.php | 5 +- system/src/Grav/Common/Helpers/Base32.php | 34 +- system/src/Grav/Common/Helpers/Excerpts.php | 63 +- system/src/Grav/Common/Helpers/Exif.php | 27 +- system/src/Grav/Common/Helpers/LogViewer.php | 40 +- system/src/Grav/Common/Helpers/Truncator.php | 63 +- system/src/Grav/Common/Helpers/YamlLinter.php | 62 +- system/src/Grav/Common/Inflector.php | 123 +- system/src/Grav/Common/Iterator.php | 38 +- system/src/Grav/Common/Language/Language.php | 381 +- .../Grav/Common/Language/LanguageCodes.php | 48 +- system/src/Grav/Common/Markdown/Parsedown.php | 12 +- .../Grav/Common/Markdown/ParsedownExtra.php | 14 +- .../Common/Markdown/ParsedownGravTrait.php | 49 +- .../Media/Interfaces/AudioMediaInterface.php | 25 + .../Interfaces/ImageManipulateInterface.php | 120 + .../Media/Interfaces/ImageMediaInterface.php | 17 + .../Interfaces/MediaCollectionInterface.php | 94 +- .../Media/Interfaces/MediaFileInterface.php | 53 + .../Media/Interfaces/MediaInterface.php | 2 +- .../Media/Interfaces/MediaLinkInterface.php | 17 + .../Media/Interfaces/MediaObjectInterface.php | 223 +- .../Media/Interfaces/MediaPlayerInterface.php | 56 + .../Media/Interfaces/MediaUploadInterface.php | 73 + .../Media/Interfaces/VideoMediaInterface.php | 32 + .../Common/Media/Traits/AudioMediaTrait.php | 53 + .../Common/Media/Traits/ImageLoadingTrait.php | 37 + .../Common/Media/Traits/ImageMediaTrait.php | 420 ++ .../Common/Media/Traits/MediaFileTrait.php | 139 + .../Common/Media/Traits/MediaObjectTrait.php | 609 ++ .../Common/Media/Traits/MediaPlayerTrait.php | 113 + .../Grav/Common/Media/Traits/MediaTrait.php | 64 +- .../Common/Media/Traits/MediaUploadTrait.php | 668 +++ .../Common/Media/Traits/StaticResizeTrait.php | 40 + .../Media/Traits/ThumbnailMediaTrait.php | 149 + .../Common/Media/Traits/VideoMediaTrait.php | 68 + system/src/Grav/Common/Page/Collection.php | 208 +- system/src/Grav/Common/Page/Header.php | 27 +- .../Interfaces/PageCollectionInterface.php | 283 + .../Page/Interfaces/PageContentInterface.php | 124 +- .../Page/Interfaces/PageFormInterface.php | 33 + .../Common/Page/Interfaces/PageInterface.php | 10 +- .../Page/Interfaces/PageLegacyInterface.php | 107 +- .../Page/Interfaces/PageRoutableInterface.php | 40 +- .../Interfaces/PageTranslateInterface.php | 9 +- .../Page/Interfaces/PagesSourceInterface.php | 56 + .../Grav/Common/Page/Markdown/Excerpts.php | 103 +- system/src/Grav/Common/Page/Media.php | 66 +- .../Grav/Common/Page/Medium/AbstractMedia.php | 131 +- .../Grav/Common/Page/Medium/AudioMedium.php | 134 +- .../Grav/Common/Page/Medium/GlobalMedia.php | 43 +- .../src/Grav/Common/Page/Medium/ImageFile.php | 90 +- .../Grav/Common/Page/Medium/ImageMedium.php | 539 +- system/src/Grav/Common/Page/Medium/Link.php | 59 +- system/src/Grav/Common/Page/Medium/Medium.php | 623 +- .../Grav/Common/Page/Medium/MediumFactory.php | 58 +- .../Common/Page/Medium/ParsedownHtmlTrait.php | 18 +- .../Page/Medium/RenderableInterface.php | 25 +- .../Common/Page/Medium/StaticImageMedium.php | 17 +- .../Common/Page/Medium/StaticResizeTrait.php | 26 +- .../Page/Medium/ThumbnailImageMedium.php | 127 +- .../Grav/Common/Page/Medium/VideoMedium.php | 148 +- system/src/Grav/Common/Page/Page.php | 1112 ++-- system/src/Grav/Common/Page/Pages.php | 1343 +++-- .../Grav/Common/Page/Traits/PageFormTrait.php | 126 + system/src/Grav/Common/Page/Types.php | 73 +- system/src/Grav/Common/Plugin.php | 167 +- system/src/Grav/Common/Plugins.php | 155 +- .../Common/Processors/AssetsProcessor.php | 15 +- .../Common/Processors/BackupsProcessor.php | 15 +- .../Processors/ConfigurationProcessor.php | 30 - .../Processors/DebuggerAssetsProcessor.php | 17 +- .../Common/Processors/DebuggerProcessor.php | 29 - .../Common/Processors/ErrorsProcessor.php | 29 - .../Processors/Events/RequestHandlerEvent.php | 6 +- .../Common/Processors/InitializeProcessor.php | 452 +- .../Common/Processors/LoggerProcessor.php | 50 - .../Grav/Common/Processors/PagesProcessor.php | 20 +- .../Common/Processors/PluginsProcessor.php | 23 +- .../Grav/Common/Processors/ProcessorBase.php | 30 +- .../Common/Processors/ProcessorInterface.php | 6 +- .../Common/Processors/RenderProcessor.php | 15 +- .../Common/Processors/RequestProcessor.php | 15 +- .../Common/Processors/SchedulerProcessor.php | 15 +- .../Grav/Common/Processors/TasksProcessor.php | 16 +- .../Common/Processors/ThemesProcessor.php | 15 +- .../Grav/Common/Processors/TwigProcessor.php | 15 +- system/src/Grav/Common/Scheduler/Cron.php | 118 +- .../Grav/Common/Scheduler/IntervalTrait.php | 29 +- system/src/Grav/Common/Scheduler/Job.php | 84 +- .../src/Grav/Common/Scheduler/Scheduler.php | 136 +- system/src/Grav/Common/Security.php | 149 +- .../Service/AccountsServiceProvider.php | 198 +- .../Common/Service/AssetsServiceProvider.php | 10 +- .../Common/Service/BackupsServiceProvider.php | 10 +- .../Common/Service/ConfigServiceProvider.php | 30 +- .../Common/Service/ErrorServiceProvider.php | 10 +- .../Service/FilesystemServiceProvider.php | 10 +- .../Common/Service/FlexServiceProvider.php | 117 + .../Service/InflectorServiceProvider.php | 10 +- .../Common/Service/LoggerServiceProvider.php | 10 +- .../Common/Service/OutputServiceProvider.php | 10 +- .../Common/Service/PagesServiceProvider.php | 106 +- .../Common/Service/RequestServiceProvider.php | 71 +- .../Service/SchedulerServiceProvider.php | 10 +- .../Common/Service/SessionServiceProvider.php | 38 +- .../Common/Service/StreamsServiceProvider.php | 16 +- .../Common/Service/TaskServiceProvider.php | 10 +- system/src/Grav/Common/Session.php | 38 +- system/src/Grav/Common/Taxonomy.php | 60 +- system/src/Grav/Common/Theme.php | 59 +- system/src/Grav/Common/Themes.php | 112 +- .../Twig/Extension/FilesystemExtension.php | 351 ++ .../Common/Twig/Extension/GravExtension.php | 1597 +++++ .../Grav/Common/Twig/Node/TwigNodeCache.php | 58 + .../Common/Twig/Node/TwigNodeMarkdown.php | 14 +- .../Grav/Common/Twig/Node/TwigNodeRender.php | 37 +- .../Grav/Common/Twig/Node/TwigNodeScript.php | 59 +- .../Grav/Common/Twig/Node/TwigNodeStyle.php | 38 +- .../Grav/Common/Twig/Node/TwigNodeSwitch.php | 27 +- .../Grav/Common/Twig/Node/TwigNodeThrow.php | 21 +- .../Common/Twig/Node/TwigNodeTryCatch.php | 38 +- .../Twig/TokenParser/TwigTokenParserCache.php | 71 + .../TokenParser/TwigTokenParserMarkdown.php | 13 +- .../TokenParser/TwigTokenParserRender.php | 11 +- .../TokenParser/TwigTokenParserScript.php | 42 +- .../Twig/TokenParser/TwigTokenParserStyle.php | 38 +- .../TokenParser/TwigTokenParserSwitch.php | 20 +- .../Twig/TokenParser/TwigTokenParserThrow.php | 13 +- .../TokenParser/TwigTokenParserTryCatch.php | 27 +- system/src/Grav/Common/Twig/Twig.php | 261 +- .../Common/Twig/TwigClockworkDataSource.php | 58 + .../Grav/Common/Twig/TwigClockworkDumper.php | 72 + .../src/Grav/Common/Twig/TwigEnvironment.php | 10 +- system/src/Grav/Common/Twig/TwigExtension.php | 1440 +---- .../Grav/Common/Twig/WriteCacheFileTrait.php | 9 +- system/src/Grav/Common/Uri.php | 250 +- system/src/Grav/Common/User/Access.php | 52 + .../src/Grav/Common/User/Authentication.php | 14 +- system/src/Grav/Common/User/DataUser/User.php | 85 +- .../Common/User/DataUser/UserCollection.php | 48 +- .../User/FlexUser/Storage/UserFileStorage.php | 16 - .../FlexUser/Storage/UserFolderStorage.php | 16 - system/src/Grav/Common/User/FlexUser/User.php | 710 --- .../Common/User/FlexUser/UserCollection.php | 100 - .../Grav/Common/User/FlexUser/UserIndex.php | 133 - system/src/Grav/Common/User/Group.php | 27 +- .../User/Interfaces/AuthorizeInterface.php | 26 + .../Interfaces/UserCollectionInterface.php | 2 +- .../User/Interfaces/UserGroupInterface.php | 18 + .../Common/User/Interfaces/UserInterface.php | 33 +- .../src/Grav/Common/User/Traits/UserTrait.php | 40 +- system/src/Grav/Common/User/User.php | 8 +- system/src/Grav/Common/Utils.php | 897 ++- system/src/Grav/Common/Yaml.php | 19 +- .../Grav/Console/Application/Application.php | 106 + .../CommandLoader/PluginCommandLoader.php | 95 + .../Console/Application/GpmApplication.php | 42 + .../Console/Application/GravApplication.php | 52 + .../Console/Application/PluginApplication.php | 116 + system/src/Grav/Console/Cli/BackupCommand.php | 62 +- system/src/Grav/Console/Cli/CleanCommand.php | 175 +- .../Grav/Console/Cli/ClearCacheCommand.php | 66 +- .../src/Grav/Console/Cli/ComposerCommand.php | 38 +- .../src/Grav/Console/Cli/InstallCommand.php | 268 +- .../src/Grav/Console/Cli/LogViewerCommand.php | 83 +- .../Grav/Console/Cli/NewProjectCommand.php | 30 +- .../Cli/PageSystemValidatorCommand.php | 299 + .../src/Grav/Console/Cli/SandboxCommand.php | 220 +- .../src/Grav/Console/Cli/SchedulerCommand.php | 106 +- .../src/Grav/Console/Cli/SecurityCommand.php | 62 +- system/src/Grav/Console/Cli/ServerCommand.php | 154 + .../Grav/Console/Cli/YamlLinterCommand.php | 128 +- system/src/Grav/Console/ConsoleCommand.php | 164 +- system/src/Grav/Console/ConsoleTrait.php | 271 +- .../Grav/Console/Gpm/DirectInstallCommand.php | 257 +- system/src/Grav/Console/Gpm/IndexCommand.php | 202 +- system/src/Grav/Console/Gpm/InfoCommand.php | 109 +- .../src/Grav/Console/Gpm/InstallCommand.php | 415 +- .../Grav/Console/Gpm/SelfupgradeCommand.php | 287 +- .../src/Grav/Console/Gpm/UninstallCommand.php | 185 +- system/src/Grav/Console/Gpm/UpdateCommand.php | 147 +- .../src/Grav/Console/Gpm/VersionCommand.php | 37 +- system/src/Grav/Console/GpmCommand.php | 68 + system/src/Grav/Console/GravCommand.php | 52 + .../Grav/Console/Plugin/PluginListCommand.php | 69 + .../Grav/Console/TerminalObjects/Table.php | 10 +- system/src/Grav/Events/FlexRegisterEvent.php | 45 + .../Grav/Events/PermissionsRegisterEvent.php | 45 + system/src/Grav/Events/PluginsLoadedEvent.php | 53 + system/src/Grav/Events/SessionStartEvent.php | 36 + system/src/Grav/Framework/Acl/Access.php | 236 + system/src/Grav/Framework/Acl/Action.php | 203 + system/src/Grav/Framework/Acl/Permissions.php | 244 + .../Grav/Framework/Acl/PermissionsReader.php | 187 + .../Framework/Acl/RecursiveActionIterator.php | 60 + .../Grav/Framework/Cache/AbstractCache.php | 5 +- .../Framework/Cache/Adapter/ChainCache.php | 30 +- .../Framework/Cache/Adapter/DoctrineCache.php | 10 +- .../Framework/Cache/Adapter/FileCache.php | 46 +- .../Framework/Cache/Adapter/MemoryCache.php | 29 +- .../Framework/Cache/Adapter/SessionCache.php | 32 +- .../Grav/Framework/Cache/CacheInterface.php | 45 +- .../src/Grav/Framework/Cache/CacheTrait.php | 125 +- .../Cache/Exception/CacheException.php | 5 +- .../Exception/InvalidArgumentException.php | 2 +- .../Collection/AbstractFileCollection.php | 72 +- .../Collection/AbstractIndexCollection.php | 102 +- .../Collection/AbstractLazyCollection.php | 20 +- .../Framework/Collection/ArrayCollection.php | 17 +- .../Collection/CollectionInterface.php | 24 +- .../Framework/Collection/FileCollection.php | 5 +- .../Collection/FileCollectionInterface.php | 6 +- .../Grav/Framework/Compat/Serializable.php | 47 + .../Framework/ContentBlock/ContentBlock.php | 60 +- .../ContentBlock/ContentBlockInterface.php | 11 +- .../Grav/Framework/ContentBlock/HtmlBlock.php | 37 +- .../ContentBlock/HtmlBlockInterface.php | 2 +- .../Traits/ControllerResponseTrait.php | 297 + system/src/Grav/Framework/DI/Container.php | 12 +- .../src/Grav/Framework/File/AbstractFile.php | 74 +- system/src/Grav/Framework/File/CsvFile.php | 2 +- system/src/Grav/Framework/File/DataFile.php | 15 +- system/src/Grav/Framework/File/File.php | 13 +- .../File/Formatter/AbstractFormatter.php | 58 +- .../Framework/File/Formatter/CsvFormatter.php | 66 +- .../File/Formatter/FormatterInterface.php | 4 +- .../Framework/File/Formatter/IniFormatter.php | 14 +- .../File/Formatter/JsonFormatter.php | 77 +- .../File/Formatter/MarkdownFormatter.php | 32 +- .../File/Formatter/SerializeFormatter.php | 25 +- .../File/Formatter/YamlFormatter.php | 23 +- system/src/Grav/Framework/File/IniFile.php | 2 +- .../Interfaces/FileFormatterInterface.php | 14 +- .../File/Interfaces/FileInterface.php | 11 +- system/src/Grav/Framework/File/JsonFile.php | 2 +- .../src/Grav/Framework/File/MarkdownFile.php | 2 +- system/src/Grav/Framework/File/YamlFile.php | 2 +- .../Grav/Framework/Filesystem/Filesystem.php | 101 +- .../Interfaces/FilesystemInterface.php | 27 +- system/src/Grav/Framework/Flex/Flex.php | 49 +- .../Grav/Framework/Flex/FlexCollection.php | 289 +- .../src/Grav/Framework/Flex/FlexDirectory.php | 708 ++- .../Grav/Framework/Flex/FlexDirectoryForm.php | 482 ++ system/src/Grav/Framework/Flex/FlexForm.php | 296 +- .../src/Grav/Framework/Flex/FlexFormFlash.php | 130 + system/src/Grav/Framework/Flex/FlexIndex.php | 423 +- system/src/Grav/Framework/Flex/FlexObject.php | 700 ++- .../Interfaces/FlexAuthorizeInterface.php | 17 +- .../Interfaces/FlexCollectionInterface.php | 36 +- .../Flex/Interfaces/FlexCommonInterface.php | 17 +- .../Interfaces/FlexDirectoryFormInterface.php | 27 + .../Interfaces/FlexDirectoryInterface.php | 223 + .../Flex/Interfaces/FlexFormInterface.php | 14 +- .../Flex/Interfaces/FlexIndexInterface.php | 11 +- .../Flex/Interfaces/FlexInterface.php | 98 + .../Interfaces/FlexObjectFormInterface.php | 27 + .../Flex/Interfaces/FlexObjectInterface.php | 44 +- .../Flex/Interfaces/FlexStorageInterface.php | 36 +- .../Interfaces/FlexTranslateInterface.php | 51 + .../Flex/Pages/FlexPageCollection.php | 203 + .../Framework/Flex/Pages/FlexPageIndex.php | 48 + .../Framework/Flex/Pages/FlexPageObject.php | 495 ++ .../Flex/Pages/Traits/PageAuthorsTrait.php | 249 + .../Flex/Pages/Traits/PageContentTrait.php | 840 +++ .../Flex/Pages/Traits/PageLegacyTrait.php | 1118 ++++ .../Flex/Pages/Traits/PageRoutableTrait.php | 550 ++ .../Flex/Pages/Traits/PageTranslateTrait.php | 283 + .../Storage/AbstractFilesystemStorage.php | 96 +- .../Framework/Flex/Storage/FileStorage.php | 103 +- .../Framework/Flex/Storage/FolderStorage.php | 590 +- .../Framework/Flex/Storage/SimpleStorage.php | 249 +- .../Flex/Traits/FlexAuthorizeTrait.php | 106 +- .../Framework/Flex/Traits/FlexMediaTrait.php | 549 +- .../Flex/Traits/FlexRelatedDirectoryTrait.php | 59 + system/src/Grav/Framework/Form/FormFlash.php | 95 +- .../src/Grav/Framework/Form/FormFlashFile.php | 106 +- .../Form/Interfaces/FormFactoryInterface.php | 6 +- .../Form/Interfaces/FormFlashInterface.php | 15 +- .../Form/Interfaces/FormInterface.php | 13 +- .../Grav/Framework/Form/Traits/FormTrait.php | 302 +- .../Framework/Interfaces/RenderInterface.php | 2 +- .../Interfaces/MediaCollectionInterface.php | 2 +- .../Media/Interfaces/MediaInterface.php | 2 +- .../Interfaces/MediaManipulationInterface.php | 7 +- .../Media/Interfaces/MediaObjectInterface.php | 2 +- .../Object/Access/ArrayAccessTrait.php | 9 +- .../Object/Access/NestedArrayAccessTrait.php | 9 +- .../Access/NestedPropertyCollectionTrait.php | 21 +- .../Object/Access/NestedPropertyTrait.php | 47 +- .../Object/Access/OverloadedPropertyTrait.php | 9 +- .../src/Grav/Framework/Object/ArrayObject.php | 5 +- .../Object/Base/ObjectCollectionTrait.php | 209 +- .../Framework/Object/Base/ObjectTrait.php | 48 +- .../Collection/ObjectExpressionVisitor.php | 68 +- .../NestedObjectCollectionInterface.php | 64 + .../Interfaces/NestedObjectInterface.php | 10 +- .../Interfaces/ObjectCollectionInterface.php | 65 +- .../Object/Interfaces/ObjectInterface.php | 11 +- .../src/Grav/Framework/Object/LazyObject.php | 5 +- .../Framework/Object/ObjectCollection.php | 35 +- .../src/Grav/Framework/Object/ObjectIndex.php | 51 +- .../Object/Property/ArrayPropertyTrait.php | 21 +- .../Object/Property/LazyPropertyTrait.php | 11 +- .../Object/Property/MixedPropertyTrait.php | 12 +- .../Object/Property/ObjectPropertyTrait.php | 36 +- .../Grav/Framework/Object/PropertyObject.php | 5 +- .../Pagination/AbstractPagination.php | 145 +- .../Pagination/AbstractPaginationPage.php | 27 +- .../Interfaces/PaginationInterface.php | 64 +- .../Interfaces/PaginationPageInterface.php | 24 +- .../Grav/Framework/Pagination/Pagination.php | 14 +- .../Framework/Pagination/PaginationPage.php | 10 +- .../Grav/Framework/Parsedown/Parsedown.php | 1554 ----- .../Framework/Parsedown/ParsedownExtra.php | 532 -- .../src/Grav/Framework/Psr7/AbstractUri.php | 35 +- system/src/Grav/Framework/Psr7/Request.php | 2 +- system/src/Grav/Framework/Psr7/Response.php | 13 +- .../src/Grav/Framework/Psr7/ServerRequest.php | 6 +- system/src/Grav/Framework/Psr7/Stream.php | 20 +- .../Psr7/Traits/MessageDecoratorTrait.php | 2 +- .../Psr7/Traits/RequestDecoratorTrait.php | 2 +- .../Psr7/Traits/ResponseDecoratorTrait.php | 2 +- .../Traits/ServerRequestDecoratorTrait.php | 10 +- .../Psr7/Traits/StreamDecoratorTrait.php | 9 +- .../Traits/UploadedFileDecoratorTrait.php | 24 +- .../Psr7/Traits/UriDecorationTrait.php | 76 +- .../src/Grav/Framework/Psr7/UploadedFile.php | 6 +- system/src/Grav/Framework/Psr7/Uri.php | 6 +- .../Exception/InvalidArgumentException.php | 10 +- .../Exception/NotFoundException.php | 14 +- .../Exception/NotHandledException.php | 6 +- .../Exception/PageExpiredException.php | 11 +- .../Exception/RequestException.php | 17 +- .../RequestHandler/Middlewares/Exceptions.php | 25 +- .../RequestHandler/RequestHandler.php | 8 +- .../Traits/RequestHandlerTrait.php | 13 +- system/src/Grav/Framework/Route/Route.php | 106 +- .../src/Grav/Framework/Route/RouteFactory.php | 121 +- .../Session/Exceptions/SessionException.php | 12 +- .../src/Grav/Framework/Session/Messages.php | 134 + system/src/Grav/Framework/Session/Session.php | 253 +- .../Framework/Session/SessionInterface.php | 20 +- system/src/Grav/Framework/Uri/Uri.php | 6 +- system/src/Grav/Framework/Uri/UriFactory.php | 27 +- .../src/Grav/Framework/Uri/UriPartsFilter.php | 44 +- system/src/Grav/Installer/Install.php | 199 +- .../src/Grav/Installer/InstallException.php | 29 + system/src/Grav/Installer/VersionUpdate.php | 82 + system/src/Grav/Installer/VersionUpdater.php | 133 + system/src/Grav/Installer/Versions.php | 329 ++ system/src/Grav/Installer/YamlUpdater.php | 430 ++ .../Installer/updates/1.7.0_2020-11-20_1.php | 24 + system/templates/default.html.twig | 4 + system/templates/external.html.twig | 1 + system/templates/flex/404.html.twig | 4 + .../flex/_default/collection/debug.html.twig | 5 + .../flex/_default/object/debug.html.twig | 4 + system/templates/modular/default.html.twig | 4 + system/templates/partials/metadata.html.twig | 4 +- user/config/system.yaml | 1 + user/config/versions.yaml | 6 + webserver-configs/Caddyfile | 50 +- webserver-configs/htaccess.txt | 3 + webserver-configs/lighttpd.conf | 2 +- webserver-configs/nginx-ddev-site.conf | 142 - 553 files changed, 53214 insertions(+), 18091 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .phan/config.php create mode 100644 .phan/internal_stubs/Redis.phan_php create mode 100644 .phan/internal_stubs/memcache.phan_php create mode 100644 .phan/internal_stubs/memcached.phan_php create mode 100644 SECURITY.md delete mode 100644 system/aliases.php delete mode 100644 system/assets/debugger.css create mode 100644 system/assets/debugger/clockwork.css create mode 100644 system/assets/debugger/clockwork.js create mode 100644 system/assets/debugger/phpdebugbar.css create mode 100644 system/blueprints/flex/accounts.yaml create mode 100644 system/blueprints/flex/configure/compat.yaml create mode 100644 system/blueprints/flex/pages.yaml create mode 100644 system/blueprints/flex/shared/configure.yaml create mode 100644 system/blueprints/flex/user-accounts.yaml create mode 100644 system/blueprints/flex/user-groups.yaml create mode 100644 system/blueprints/pages/partials/security.yaml create mode 100644 system/blueprints/pages/root.yaml delete mode 100644 system/blueprints/user/accounts.yaml create mode 100644 system/config/permissions.yaml delete mode 100644 system/config/streams.yaml create mode 100644 system/languages/gl.yaml create mode 100644 system/languages/sr.yaml create mode 100644 system/languages/zh-cn.yaml create mode 100644 system/src/Grav/Common/Flex/FlexCollection.php create mode 100644 system/src/Grav/Common/Flex/FlexIndex.php create mode 100644 system/src/Grav/Common/Flex/FlexObject.php create mode 100644 system/src/Grav/Common/Flex/Traits/FlexCollectionTrait.php create mode 100644 system/src/Grav/Common/Flex/Traits/FlexCommonTrait.php create mode 100644 system/src/Grav/Common/Flex/Traits/FlexGravTrait.php create mode 100644 system/src/Grav/Common/Flex/Traits/FlexIndexTrait.php create mode 100644 system/src/Grav/Common/Flex/Traits/FlexObjectTrait.php create mode 100644 system/src/Grav/Common/Flex/Types/Generic/GenericCollection.php create mode 100644 system/src/Grav/Common/Flex/Types/Generic/GenericIndex.php create mode 100644 system/src/Grav/Common/Flex/Types/Generic/GenericObject.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/PageCollection.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/PageIndex.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/PageObject.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/Traits/PageContentTrait.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/Traits/PageRoutableTrait.php create mode 100644 system/src/Grav/Common/Flex/Types/Pages/Traits/PageTranslateTrait.php create mode 100644 system/src/Grav/Common/Flex/Types/UserGroups/UserGroupCollection.php create mode 100644 system/src/Grav/Common/Flex/Types/UserGroups/UserGroupIndex.php create mode 100644 system/src/Grav/Common/Flex/Types/UserGroups/UserGroupObject.php create mode 100644 system/src/Grav/Common/Flex/Types/Users/Storage/UserFileStorage.php create mode 100644 system/src/Grav/Common/Flex/Types/Users/Storage/UserFolderStorage.php create mode 100644 system/src/Grav/Common/Flex/Types/Users/Traits/UserObjectLegacyTrait.php create mode 100644 system/src/Grav/Common/Flex/Types/Users/UserCollection.php create mode 100644 system/src/Grav/Common/Flex/Types/Users/UserIndex.php create mode 100644 system/src/Grav/Common/Flex/Types/Users/UserObject.php create mode 100644 system/src/Grav/Common/Media/Interfaces/AudioMediaInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/ImageManipulateInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/ImageMediaInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/MediaFileInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/MediaLinkInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/MediaPlayerInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/MediaUploadInterface.php create mode 100644 system/src/Grav/Common/Media/Interfaces/VideoMediaInterface.php create mode 100644 system/src/Grav/Common/Media/Traits/AudioMediaTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/ImageLoadingTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/ImageMediaTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/MediaFileTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/MediaObjectTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/MediaPlayerTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/MediaUploadTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/StaticResizeTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/ThumbnailMediaTrait.php create mode 100644 system/src/Grav/Common/Media/Traits/VideoMediaTrait.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageCollectionInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageFormInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PagesSourceInterface.php create mode 100644 system/src/Grav/Common/Page/Traits/PageFormTrait.php delete mode 100644 system/src/Grav/Common/Processors/ConfigurationProcessor.php delete mode 100644 system/src/Grav/Common/Processors/DebuggerProcessor.php delete mode 100644 system/src/Grav/Common/Processors/ErrorsProcessor.php delete mode 100644 system/src/Grav/Common/Processors/LoggerProcessor.php create mode 100644 system/src/Grav/Common/Service/FlexServiceProvider.php create mode 100644 system/src/Grav/Common/Twig/Extension/FilesystemExtension.php create mode 100644 system/src/Grav/Common/Twig/Extension/GravExtension.php create mode 100644 system/src/Grav/Common/Twig/Node/TwigNodeCache.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserCache.php create mode 100644 system/src/Grav/Common/Twig/TwigClockworkDataSource.php create mode 100644 system/src/Grav/Common/Twig/TwigClockworkDumper.php create mode 100644 system/src/Grav/Common/User/Access.php delete mode 100644 system/src/Grav/Common/User/FlexUser/Storage/UserFileStorage.php delete mode 100644 system/src/Grav/Common/User/FlexUser/Storage/UserFolderStorage.php delete mode 100644 system/src/Grav/Common/User/FlexUser/User.php delete mode 100644 system/src/Grav/Common/User/FlexUser/UserCollection.php delete mode 100644 system/src/Grav/Common/User/FlexUser/UserIndex.php create mode 100644 system/src/Grav/Common/User/Interfaces/AuthorizeInterface.php create mode 100644 system/src/Grav/Common/User/Interfaces/UserGroupInterface.php create mode 100644 system/src/Grav/Console/Application/Application.php create mode 100644 system/src/Grav/Console/Application/CommandLoader/PluginCommandLoader.php create mode 100644 system/src/Grav/Console/Application/GpmApplication.php create mode 100644 system/src/Grav/Console/Application/GravApplication.php create mode 100644 system/src/Grav/Console/Application/PluginApplication.php create mode 100644 system/src/Grav/Console/Cli/PageSystemValidatorCommand.php create mode 100644 system/src/Grav/Console/Cli/ServerCommand.php create mode 100644 system/src/Grav/Console/GpmCommand.php create mode 100644 system/src/Grav/Console/GravCommand.php create mode 100644 system/src/Grav/Console/Plugin/PluginListCommand.php create mode 100644 system/src/Grav/Events/FlexRegisterEvent.php create mode 100644 system/src/Grav/Events/PermissionsRegisterEvent.php create mode 100644 system/src/Grav/Events/PluginsLoadedEvent.php create mode 100644 system/src/Grav/Events/SessionStartEvent.php create mode 100644 system/src/Grav/Framework/Acl/Access.php create mode 100644 system/src/Grav/Framework/Acl/Action.php create mode 100644 system/src/Grav/Framework/Acl/Permissions.php create mode 100644 system/src/Grav/Framework/Acl/PermissionsReader.php create mode 100644 system/src/Grav/Framework/Acl/RecursiveActionIterator.php create mode 100644 system/src/Grav/Framework/Compat/Serializable.php create mode 100644 system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php create mode 100644 system/src/Grav/Framework/Flex/FlexDirectoryForm.php create mode 100644 system/src/Grav/Framework/Flex/FlexFormFlash.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexDirectoryFormInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexDirectoryInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexObjectFormInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexTranslateInterface.php create mode 100644 system/src/Grav/Framework/Flex/Pages/FlexPageCollection.php create mode 100644 system/src/Grav/Framework/Flex/Pages/FlexPageIndex.php create mode 100644 system/src/Grav/Framework/Flex/Pages/FlexPageObject.php create mode 100644 system/src/Grav/Framework/Flex/Pages/Traits/PageAuthorsTrait.php create mode 100644 system/src/Grav/Framework/Flex/Pages/Traits/PageContentTrait.php create mode 100644 system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php create mode 100644 system/src/Grav/Framework/Flex/Pages/Traits/PageRoutableTrait.php create mode 100644 system/src/Grav/Framework/Flex/Pages/Traits/PageTranslateTrait.php create mode 100644 system/src/Grav/Framework/Flex/Traits/FlexRelatedDirectoryTrait.php create mode 100644 system/src/Grav/Framework/Object/Interfaces/NestedObjectCollectionInterface.php delete mode 100644 system/src/Grav/Framework/Parsedown/Parsedown.php delete mode 100644 system/src/Grav/Framework/Parsedown/ParsedownExtra.php create mode 100644 system/src/Grav/Framework/Session/Messages.php create mode 100644 system/src/Grav/Installer/InstallException.php create mode 100644 system/src/Grav/Installer/VersionUpdate.php create mode 100644 system/src/Grav/Installer/VersionUpdater.php create mode 100644 system/src/Grav/Installer/Versions.php create mode 100644 system/src/Grav/Installer/YamlUpdater.php create mode 100644 system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php create mode 100644 system/templates/default.html.twig create mode 100644 system/templates/external.html.twig create mode 100644 system/templates/flex/404.html.twig create mode 100644 system/templates/flex/_default/collection/debug.html.twig create mode 100644 system/templates/flex/_default/object/debug.html.twig create mode 100644 system/templates/modular/default.html.twig create mode 100644 user/config/versions.yaml delete mode 100644 webserver-configs/nginx-ddev-site.conf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e84f52b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,8 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: grav +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +custom: # Replace with a single custom sponsorship URL diff --git a/.phan/config.php b/.phan/config.php new file mode 100644 index 0000000..efdf5b1 --- /dev/null +++ b/.phan/config.php @@ -0,0 +1,44 @@ + null, + 'pretend_newer_core_functions_exist' => true, + 'allow_missing_properties' => false, + 'null_casts_as_any_type' => false, + 'null_casts_as_array' => false, + 'array_casts_as_null' => false, + 'strict_method_checking' => true, + 'quick_mode' => false, + 'simplify_ast' => false, + 'directory_list' => [ + '.', + ], + "exclude_analysis_directory_list" => [ + 'vendor/' + ], + 'exclude_file_list' => [ + 'system/src/Grav/Common/Errors/Resources/layout.html.php', + 'tests/_support/AcceptanceTester.php', + 'tests/_support/FunctionalTester.php', + 'tests/_support/UnitTester.php', + ], + 'autoload_internal_extension_signatures' => [ + 'memcached' => '.phan/internal_stubs/memcached.phan_php', + 'memcache' => '.phan/internal_stubs/memcache.phan_php', + 'redis' => '.phan/internal_stubs/Redis.phan_php', + ], + 'plugins' => [ + 'AlwaysReturnPlugin', + 'UnreachableCodePlugin', + 'DuplicateArrayKeyPlugin', + 'PregRegexCheckerPlugin', + 'PrintfCheckerPlugin', + ], + 'suppress_issue_types' => [ + 'PhanUnreferencedUseNormal', + 'PhanTypeObjectUnsetDeclaredProperty', + 'PhanTraitParentReference', + 'PhanTypeInvalidThrowsIsInterface', + 'PhanRequiredTraitNotAdded', + 'PhanDeprecatedFunction', // Uncomment this to see all the deprecated calls + ] +]; diff --git a/.phan/internal_stubs/Redis.phan_php b/.phan/internal_stubs/Redis.phan_php new file mode 100644 index 0000000..ed349f2 --- /dev/null +++ b/.phan/internal_stubs/Redis.phan_php @@ -0,0 +1,5153 @@ + + * @link https://github.com/ukko/phpredis-phpdoc + */ +class Redis +{ + const AFTER = 'after'; + const BEFORE = 'before'; + + /** + * Options + */ + const OPT_SERIALIZER = 1; + const OPT_PREFIX = 2; + const OPT_READ_TIMEOUT = 3; + const OPT_SCAN = 4; + const OPT_SLAVE_FAILOVER = 5; + + /** + * Cluster options + */ + const FAILOVER_NONE = 0; + const FAILOVER_ERROR = 1; + const FAILOVER_DISTRIBUTE = 2; + + /** + * SCAN options + */ + const SCAN_NORETRY = 0; + const SCAN_RETRY = 1; + + /** + * Serializers + */ + const SERIALIZER_NONE = 0; + const SERIALIZER_PHP = 1; + const SERIALIZER_IGBINARY = 2; + const SERIALIZER_MSGPACK = 3; + const SERIALIZER_JSON = 4; + + /** + * Multi + */ + const ATOMIC = 0; + const MULTI = 1; + const PIPELINE = 2; + + /** + * Type + */ + const REDIS_NOT_FOUND = 0; + const REDIS_STRING = 1; + const REDIS_SET = 2; + const REDIS_LIST = 3; + const REDIS_ZSET = 4; + const REDIS_HASH = 5; + + /** + * Creates a Redis client + * + * @example $redis = new Redis(); + */ + public function __construct() + { + } + + /** + * Connects to a Redis instance. + * + * @param string $host can be a host, or the path to a unix domain socket + * @param int $port optional + * @param float $timeout value in seconds (optional, default is 0.0 meaning unlimited) + * @param null $reserved should be null if $retryInterval is specified + * @param int $retryInterval retry interval in milliseconds. + * @param float $readTimeout value in seconds (optional, default is 0 meaning unlimited) + * + * @return bool TRUE on success, FALSE on error + * + * @example + *
+     * $redis->connect('127.0.0.1', 6379);
+     * $redis->connect('127.0.0.1');            // port 6379 by default
+     * $redis->connect('127.0.0.1', 6379, 2.5); // 2.5 sec timeout.
+     * $redis->connect('/tmp/redis.sock');      // unix domain socket.
+     * 
+ */ + public function connect( + $host, + $port = 6379, + $timeout = 0.0, + $reserved = null, + $retryInterval = 0, + $readTimeout = 0.0 + ) { + } + + /** + * Connects to a Redis instance. + * + * @param string $host can be a host, or the path to a unix domain socket + * @param int $port optional + * @param float $timeout value in seconds (optional, default is 0.0 meaning unlimited) + * @param null $reserved should be null if $retry_interval is specified + * @param int $retryInterval retry interval in milliseconds. + * @param float $readTimeout value in seconds (optional, default is 0 meaning unlimited) + * + * @return bool TRUE on success, FALSE on error + * + * @see connect() + * @deprecated use Redis::connect() + */ + public function open( + $host, + $port = 6379, + $timeout = 0.0, + $reserved = null, + $retryInterval = 0, + $readTimeout = 0.0 + ) { + } + + /** + * A method to determine if a phpredis object thinks it's connected to a server + * + * @return bool Returns TRUE if phpredis thinks it's connected and FALSE if not + */ + public function isConnected() + { + } + + /** + * Retrieve our host or unix socket that we're connected to + * + * @return string|bool The host or unix socket we're connected to or FALSE if we're not connected + */ + public function getHost() + { + } + + /** + * Get the port we're connected to + * + * @return int|bool Returns the port we're connected to or FALSE if we're not connected + */ + public function getPort() + { + } + + /** + * Get the database number phpredis is pointed to + * + * @return int|bool Returns the database number (int) phpredis thinks it's pointing to + * or FALSE if we're not connected + */ + public function getDbNum() + { + } + + /** + * Get the (write) timeout in use for phpredis + * + * @return float|bool The timeout (DOUBLE) specified in our connect call or FALSE if we're not connected + */ + public function getTimeout() + { + } + + /** + * Get the read timeout specified to phpredis or FALSE if we're not connected + * + * @return float|bool Returns the read timeout (which can be set using setOption and Redis::OPT_READ_TIMEOUT) + * or FALSE if we're not connected + */ + public function getReadTimeout() + { + } + + /** + * Gets the persistent ID that phpredis is using + * + * @return string|null|bool Returns the persistent id phpredis is using + * (which will only be set if connected with pconnect), + * NULL if we're not using a persistent ID, + * and FALSE if we're not connected + */ + public function getPersistentID() + { + } + + /** + * Get the password used to authenticate the phpredis connection + * + * @return string|null|bool Returns the password used to authenticate a phpredis session or NULL if none was used, + * and FALSE if we're not connected + */ + public function getAuth() + { + } + + /** + * Connects to a Redis instance or reuse a connection already established with pconnect/popen. + * + * The connection will not be closed on close or end of request until the php process ends. + * So be patient on to many open FD's (specially on redis server side) when using persistent connections on + * many servers connecting to one redis server. + * + * Also more than one persistent connection can be made identified by either host + port + timeout + * or host + persistentId or unix socket + timeout. + * + * This feature is not available in threaded versions. pconnect and popen then working like their non persistent + * equivalents. + * + * @param string $host can be a host, or the path to a unix domain socket + * @param int $port optional + * @param float $timeout value in seconds (optional, default is 0 meaning unlimited) + * @param string $persistentId identity for the requested persistent connection + * @param int $retryInterval retry interval in milliseconds. + * @param float $readTimeout value in seconds (optional, default is 0 meaning unlimited) + * + * @return bool TRUE on success, FALSE on ertcnror. + * + * @example + *
+     * $redis->pconnect('127.0.0.1', 6379);
+     *
+     * // port 6379 by default - same connection like before
+     * $redis->pconnect('127.0.0.1');
+     *
+     * // 2.5 sec timeout and would be another connection than the two before.
+     * $redis->pconnect('127.0.0.1', 6379, 2.5);
+     *
+     * // x is sent as persistent_id and would be another connection than the three before.
+     * $redis->pconnect('127.0.0.1', 6379, 2.5, 'x');
+     *
+     * // unix domain socket - would be another connection than the four before.
+     * $redis->pconnect('/tmp/redis.sock');
+     * 
+ */ + public function pconnect( + $host, + $port = 6379, + $timeout = 0.0, + $persistentId = null, + $retryInterval = 0, + $readTimeout = 0.0 + ) { + } + + /** + * @param string $host + * @param int $port + * @param float $timeout + * @param string $persistentId + * @param int $retryInterval + * @param float $readTimeout + * + * @return bool + * + * @deprecated use Redis::pconnect() + * @see pconnect() + */ + public function popen( + $host, + $port = 6379, + $timeout = 0.0, + $persistentId = '', + $retryInterval = 0, + $readTimeout = 0.0 + ) { + } + + /** + * Disconnects from the Redis instance. + * + * Note: Closing a persistent connection requires PhpRedis >= 4.2.0 + * + * @since >= 4.2 Closing a persistent connection requires PhpRedis + * + * @return bool TRUE on success, FALSE on error + */ + public function close() + { + } + + /** + * Swap one Redis database with another atomically + * + * Note: Requires Redis >= 4.0.0 + * + * @param int $db1 + * @param int $db2 + * + * @return bool TRUE on success and FALSE on failure + * + * @link https://redis.io/commands/swapdb + * @since >= 4.0 + * @example + *
+     * // Swaps DB 0 with DB 1 atomically
+     * $redis->swapdb(0, 1);
+     * 
+ */ + public function swapdb(int $db1, int $db2) + { + } + + /** + * Set client option + * + * @param int $option option name + * @param mixed $value option value + * + * @return bool TRUE on success, FALSE on error + * + * @example + *
+     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE);        // don't serialize data
+     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP);         // use built-in serialize/unserialize
+     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_IGBINARY);    // use igBinary serialize/unserialize
+     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_MSGPACK);     // Use msgpack serialize/unserialize
+     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON);        // Use json serialize/unserialize
+     *
+     * $redis->setOption(Redis::OPT_PREFIX, 'myAppName:');                      // use custom prefix on all keys
+     *
+     * // Options for the SCAN family of commands, indicating whether to abstract
+     * // empty results from the user.  If set to SCAN_NORETRY (the default), phpredis
+     * // will just issue one SCAN command at a time, sometimes returning an empty
+     * // array of results.  If set to SCAN_RETRY, phpredis will retry the scan command
+     * // until keys come back OR Redis returns an iterator of zero
+     * $redis->setOption(Redis::OPT_SCAN, Redis::SCAN_NORETRY);
+     * $redis->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY);
+     * 
+ */ + public function setOption($option, $value) + { + } + + /** + * Get client option + * + * @param int $option parameter name + * + * @return mixed|null Parameter value + * + * @see setOption() + * @example + * // return option value + * $redis->getOption(Redis::OPT_SERIALIZER); + */ + public function getOption($option) + { + } + + /** + * Check the current connection status + * + * @return string STRING: +PONG on success. + * Throws a RedisException object on connectivity error, as described above. + * @throws RedisException + * @link https://redis.io/commands/ping + */ + public function ping() + { + } + + /** + * Echo the given string + * + * @param string $message + * + * @return string Returns message + * + * @link https://redis.io/commands/echo + */ + public function echo($message) + { + } + + /** + * Get the value related to the specified key + * + * @param string $key + * + * @return string|mixed|bool If key didn't exist, FALSE is returned. + * Otherwise, the value related to this key is returned + * + * @link https://redis.io/commands/get + * @example + *
+     * $redis->set('key', 'hello');
+     * $redis->get('key');
+     *
+     * // set and get with serializer
+     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON);
+     *
+     * $redis->set('key', ['asd' => 'as', 'dd' => 123, 'b' => true]);
+     * var_dump($redis->get('key'));
+     * // Output:
+     * array(3) {
+     *  'asd' => string(2) "as"
+     *  'dd' => int(123)
+     *  'b' => bool(true)
+     * }
+     * 
+ */ + public function get($key) + { + } + + /** + * Set the string value in argument as value of the key. + * + * @since If you're using Redis >= 2.6.12, you can pass extended options as explained in example + * + * @param string $key + * @param string|mixed $value string if not used serializer + * @param int|array $timeout [optional] Calling setex() is preferred if you want a timeout.
+ * Since 2.6.12 it also supports different flags inside an array. Example ['NX', 'EX' => 60]
+ * - EX seconds -- Set the specified expire time, in seconds.
+ * - PX milliseconds -- Set the specified expire time, in milliseconds.
+ * - PX milliseconds -- Set the specified expire time, in milliseconds.
+ * - NX -- Only set the key if it does not already exist.
+ * - XX -- Only set the key if it already exist.
+ *
+     * // Simple key -> value set
+     * $redis->set('key', 'value');
+     *
+     * // Will redirect, and actually make an SETEX call
+     * $redis->set('key','value', 10);
+     *
+     * // Will set the key, if it doesn't exist, with a ttl of 10 seconds
+     * $redis->set('key', 'value', ['nx', 'ex' => 10]);
+     *
+     * // Will set a key, if it does exist, with a ttl of 1000 miliseconds
+     * $redis->set('key', 'value', ['xx', 'px' => 1000]);
+     * 
+ * + * @return bool TRUE if the command is successful + * + * @link https://redis.io/commands/set + */ + public function set($key, $value, $timeout = null) + { + } + + /** + * Set the string value in argument as value of the key, with a time to live. + * + * @param string $key + * @param int $ttl + * @param string|mixed $value + * + * @return bool TRUE if the command is successful + * + * @link https://redis.io/commands/setex + * @example $redis->setex('key', 3600, 'value'); // sets key → value, with 1h TTL. + */ + public function setex($key, $ttl, $value) + { + } + + /** + * Set the value and expiration in milliseconds of a key. + * + * @see setex() + * @param string $key + * @param int $ttl, in milliseconds. + * @param string|mixed $value + * + * @return bool TRUE if the command is successful + * + * @link https://redis.io/commands/psetex + * @example $redis->psetex('key', 1000, 'value'); // sets key → value, with 1sec TTL. + */ + public function psetex($key, $ttl, $value) + { + } + + /** + * Set the string value in argument as value of the key if the key doesn't already exist in the database. + * + * @param string $key + * @param string|mixed $value + * + * @return bool TRUE in case of success, FALSE in case of failure + * + * @link https://redis.io/commands/setnx + * @example + *
+     * $redis->setnx('key', 'value');   // return TRUE
+     * $redis->setnx('key', 'value');   // return FALSE
+     * 
+ */ + public function setnx($key, $value) + { + } + + /** + * Remove specified keys. + * + * @param int|string|array $key1 An array of keys, or an undefined number of parameters, each a key: key1 key2 key3 ... keyN + * @param int|string ...$otherKeys + * + * @return int Number of keys deleted + * + * @link https://redis.io/commands/del + * @example + *
+     * $redis->set('key1', 'val1');
+     * $redis->set('key2', 'val2');
+     * $redis->set('key3', 'val3');
+     * $redis->set('key4', 'val4');
+     *
+     * $redis->del('key1', 'key2');     // return 2
+     * $redis->del(['key3', 'key4']);   // return 2
+     * 
+ */ + public function del($key1, ...$otherKeys) + { + } + + /** + * @see del() + * @deprecated use Redis::del() + * + * @param string|string[] $key1 + * @param string $key2 + * @param string $key3 + * + * @return int Number of keys deleted + */ + public function delete($key1, $key2 = null, $key3 = null) + { + } + + /** + * Delete a key asynchronously in another thread. Otherwise it is just as DEL, but non blocking. + * + * @see del() + * @param string|string[] $key1 + * @param string $key2 + * @param string $key3 + * + * @return int Number of keys unlinked. + * + * @link https://redis.io/commands/unlink + * @example + *
+     * $redis->set('key1', 'val1');
+     * $redis->set('key2', 'val2');
+     * $redis->set('key3', 'val3');
+     * $redis->set('key4', 'val4');
+     * $redis->unlink('key1', 'key2');          // return 2
+     * $redis->unlink(array('key3', 'key4'));   // return 2
+     * 
+ */ + public function unlink($key1, $key2 = null, $key3 = null) + { + } + + /** + * Enter and exit transactional mode. + * + * @param int $mode Redis::MULTI|Redis::PIPELINE + * Defaults to Redis::MULTI. + * A Redis::MULTI block of commands runs as a single transaction; + * a Redis::PIPELINE block is simply transmitted faster to the server, but without any guarantee of atomicity. + * discard cancels a transaction. + * + * @return Redis returns the Redis instance and enters multi-mode. + * Once in multi-mode, all subsequent method calls return the same object until exec() is called. + * + * @link https://redis.io/commands/multi + * @example + *
+     * $ret = $redis->multi()
+     *      ->set('key1', 'val1')
+     *      ->get('key1')
+     *      ->set('key2', 'val2')
+     *      ->get('key2')
+     *      ->exec();
+     *
+     * //$ret == array (
+     * //    0 => TRUE,
+     * //    1 => 'val1',
+     * //    2 => TRUE,
+     * //    3 => 'val2');
+     * 
+ */ + public function multi($mode = Redis::MULTI) + { + } + + /** + * @return void|array + * + * @see multi() + * @link https://redis.io/commands/exec + */ + public function exec() + { + } + + /** + * @see multi() + * @link https://redis.io/commands/discard + */ + public function discard() + { + } + + /** + * Watches a key for modifications by another client. If the key is modified between WATCH and EXEC, + * the MULTI/EXEC transaction will fail (return FALSE). unwatch cancels all the watching of all keys by this client. + * @param string|string[] $key a list of keys + * + * @return void + * + * @link https://redis.io/commands/watch + * @example + *
+     * $redis->watch('x');
+     * // long code here during the execution of which other clients could well modify `x`
+     * $ret = $redis->multi()
+     *          ->incr('x')
+     *          ->exec();
+     * // $ret = FALSE if x has been modified between the call to WATCH and the call to EXEC.
+     * 
+ */ + public function watch($key) + { + } + + /** + * @see watch() + * @link https://redis.io/commands/unwatch + */ + public function unwatch() + { + } + + /** + * Subscribe to channels. + * + * Warning: this function will probably change in the future. + * + * @param string[] $channels an array of channels to subscribe + * @param string|array $callback either a string or an array($instance, 'method_name'). + * The callback function receives 3 parameters: the redis instance, the channel name, and the message. + * + * @return mixed|null Any non-null return value in the callback will be returned to the caller. + * + * @link https://redis.io/commands/subscribe + * @example + *
+     * function f($redis, $chan, $msg) {
+     *  switch($chan) {
+     *      case 'chan-1':
+     *          ...
+     *          break;
+     *
+     *      case 'chan-2':
+     *                     ...
+     *          break;
+     *
+     *      case 'chan-2':
+     *          ...
+     *          break;
+     *      }
+     * }
+     *
+     * $redis->subscribe(array('chan-1', 'chan-2', 'chan-3'), 'f'); // subscribe to 3 chans
+     * 
+ */ + public function subscribe($channels, $callback) + { + } + + /** + * Subscribe to channels by pattern + * + * @param array $patterns an array of glob-style patterns to subscribe + * @param string|array $callback Either a string or an array with an object and method. + * The callback will get four arguments ($redis, $pattern, $channel, $message) + * @param mixed $chan Any non-null return value in the callback will be returned to the caller + * @param string $msg + * + * @link https://redis.io/commands/psubscribe + * @example + *
+     * function psubscribe($redis, $pattern, $chan, $msg) {
+     *  echo "Pattern: $pattern\n";
+     *  echo "Channel: $chan\n";
+     *  echo "Payload: $msg\n";
+     * }
+     * 
+ */ + public function psubscribe($patterns, $callback, $chan, $msg) + { + } + + /** + * Publish messages to channels. + * + * Warning: this function will probably change in the future. + * + * @param string $channel a channel to publish to + * @param string $message string + * + * @return int Number of clients that received the message + * + * @link https://redis.io/commands/publish + * @example $redis->publish('chan-1', 'hello, world!'); // send message. + */ + public function publish($channel, $message) + { + } + + /** + * A command allowing you to get information on the Redis pub/sub system + * + * @param string $keyword String, which can be: "channels", "numsub", or "numpat" + * @param string|array $argument Optional, variant. + * For the "channels" subcommand, you can pass a string pattern. + * For "numsub" an array of channel names + * + * @return array|int Either an integer or an array. + * - channels Returns an array where the members are the matching channels. + * - numsub Returns a key/value array where the keys are channel names and + * values are their counts. + * - numpat Integer return containing the number active pattern subscriptions + * + * @link https://redis.io/commands/pubsub + * @example + *
+     * $redis->pubsub('channels'); // All channels
+     * $redis->pubsub('channels', '*pattern*'); // Just channels matching your pattern
+     * $redis->pubsub('numsub', array('chan1', 'chan2')); // Get subscriber counts for 'chan1' and 'chan2'
+     * $redis->pubsub('numpat'); // Get the number of pattern subscribers
+     * 
+ */ + public function pubsub($keyword, $argument) + { + } + + /** + * Stop listening for messages posted to the given channels. + * + * @param array $channels an array of channels to usubscribe + * + * @link https://redis.io/commands/unsubscribe + */ + public function unsubscribe($channels = null) + { + } + + /** + * Stop listening for messages posted to the given channels. + * + * @param array $patterns an array of glob-style patterns to unsubscribe + * + * @link https://redis.io/commands/punsubscribe + */ + public function punsubscribe($patterns = null) + { + } + + /** + * Verify if the specified key/keys exists + * + * This function took a single argument and returned TRUE or FALSE in phpredis versions < 4.0.0. + * + * @since >= 4.0 Returned int, if < 4.0 returned bool + * + * @param string|string[] $key + * + * @return int|bool The number of keys tested that do exist + * + * @link https://redis.io/commands/exists + * @link https://github.com/phpredis/phpredis#exists + * @example + *
+     * $redis->exists('key'); // 1
+     * $redis->exists('NonExistingKey'); // 0
+     *
+     * $redis->mset(['foo' => 'foo', 'bar' => 'bar', 'baz' => 'baz']);
+     * $redis->exists(['foo', 'bar', 'baz]); // 3
+     * $redis->exists('foo', 'bar', 'baz'); // 3
+     * 
+ */ + public function exists($key) + { + } + + /** + * Increment the number stored at key by one. + * + * @param string $key + * + * @return int the new value + * + * @link https://redis.io/commands/incr + * @example + *
+     * $redis->incr('key1'); // key1 didn't exists, set to 0 before the increment and now has the value 1
+     * $redis->incr('key1'); // 2
+     * $redis->incr('key1'); // 3
+     * $redis->incr('key1'); // 4
+     * 
+ */ + public function incr($key) + { + } + + /** + * Increment the float value of a key by the given amount + * + * @param string $key + * @param float $increment + * + * @return float + * + * @link https://redis.io/commands/incrbyfloat + * @example + *
+     * $redis->set('x', 3);
+     * $redis->incrByFloat('x', 1.5);   // float(4.5)
+     * $redis->get('x');                // float(4.5)
+     * 
+ */ + public function incrByFloat($key, $increment) + { + } + + /** + * Increment the number stored at key by one. + * If the second argument is filled, it will be used as the integer value of the increment. + * + * @param string $key key + * @param int $value value that will be added to key (only for incrBy) + * + * @return int the new value + * + * @link https://redis.io/commands/incrby + * @example + *
+     * $redis->incr('key1');        // key1 didn't exists, set to 0 before the increment and now has the value 1
+     * $redis->incr('key1');        // 2
+     * $redis->incr('key1');        // 3
+     * $redis->incr('key1');        // 4
+     * $redis->incrBy('key1', 10);  // 14
+     * 
+ */ + public function incrBy($key, $value) + { + } + + /** + * Decrement the number stored at key by one. + * + * @param string $key + * + * @return int the new value + * + * @link https://redis.io/commands/decr + * @example + *
+     * $redis->decr('key1'); // key1 didn't exists, set to 0 before the increment and now has the value -1
+     * $redis->decr('key1'); // -2
+     * $redis->decr('key1'); // -3
+     * 
+ */ + public function decr($key) + { + } + + /** + * Decrement the number stored at key by one. + * If the second argument is filled, it will be used as the integer value of the decrement. + * + * @param string $key + * @param int $value that will be substracted to key (only for decrBy) + * + * @return int the new value + * + * @link https://redis.io/commands/decrby + * @example + *
+     * $redis->decr('key1');        // key1 didn't exists, set to 0 before the increment and now has the value -1
+     * $redis->decr('key1');        // -2
+     * $redis->decr('key1');        // -3
+     * $redis->decrBy('key1', 10);  // -13
+     * 
+ */ + public function decrBy($key, $value) + { + } + + /** + * Adds the string values to the head (left) of the list. + * Creates the list if the key didn't exist. + * If the key exists and is not a list, FALSE is returned. + * + * @param string $key + * @param string|mixed $value1... Variadic list of values to push in key, if dont used serialized, used string + * + * @return int|bool The new length of the list in case of success, FALSE in case of Failure + * + * @link https://redis.io/commands/lpush + * @example + *
+     * $redis->lPush('l', 'v1', 'v2', 'v3', 'v4')   // int(4)
+     * var_dump( $redis->lRange('l', 0, -1) );
+     * // Output:
+     * // array(4) {
+     * //   [0]=> string(2) "v4"
+     * //   [1]=> string(2) "v3"
+     * //   [2]=> string(2) "v2"
+     * //   [3]=> string(2) "v1"
+     * // }
+     * 
+ */ + public function lPush($key, ...$value1) + { + } + + /** + * Adds the string values to the tail (right) of the list. + * Creates the list if the key didn't exist. + * If the key exists and is not a list, FALSE is returned. + * + * @param string $key + * @param string|mixed $value1... Variadic list of values to push in key, if dont used serialized, used string + * + * @return int|bool The new length of the list in case of success, FALSE in case of Failure + * + * @link https://redis.io/commands/rpush + * @example + *
+     * $redis->rPush('l', 'v1', 'v2', 'v3', 'v4');    // int(4)
+     * var_dump( $redis->lRange('l', 0, -1) );
+     * // Output:
+     * // array(4) {
+     * //   [0]=> string(2) "v1"
+     * //   [1]=> string(2) "v2"
+     * //   [2]=> string(2) "v3"
+     * //   [3]=> string(2) "v4"
+     * // }
+     * 
+ */ + public function rPush($key, ...$value1) + { + } + + /** + * Adds the string value to the head (left) of the list if the list exists. + * + * @param string $key + * @param string|mixed $value String, value to push in key + * + * @return int|bool The new length of the list in case of success, FALSE in case of Failure. + * + * @link https://redis.io/commands/lpushx + * @example + *
+     * $redis->del('key1');
+     * $redis->lPushx('key1', 'A');     // returns 0
+     * $redis->lPush('key1', 'A');      // returns 1
+     * $redis->lPushx('key1', 'B');     // returns 2
+     * $redis->lPushx('key1', 'C');     // returns 3
+     * // key1 now points to the following list: [ 'A', 'B', 'C' ]
+     * 
+ */ + public function lPushx($key, $value) + { + } + + /** + * Adds the string value to the tail (right) of the list if the ist exists. FALSE in case of Failure. + * + * @param string $key + * @param string|mixed $value String, value to push in key + * + * @return int|bool The new length of the list in case of success, FALSE in case of Failure. + * + * @link https://redis.io/commands/rpushx + * @example + *
+     * $redis->del('key1');
+     * $redis->rPushx('key1', 'A'); // returns 0
+     * $redis->rPush('key1', 'A'); // returns 1
+     * $redis->rPushx('key1', 'B'); // returns 2
+     * $redis->rPushx('key1', 'C'); // returns 3
+     * // key1 now points to the following list: [ 'A', 'B', 'C' ]
+     * 
+ */ + public function rPushx($key, $value) + { + } + + /** + * Returns and removes the first element of the list. + * + * @param string $key + * + * @return mixed|bool if command executed successfully BOOL FALSE in case of failure (empty list) + * + * @link https://redis.io/commands/lpop + * @example + *
+     * $redis->rPush('key1', 'A');
+     * $redis->rPush('key1', 'B');
+     * $redis->rPush('key1', 'C');  // key1 => [ 'A', 'B', 'C' ]
+     * $redis->lPop('key1');        // key1 => [ 'B', 'C' ]
+     * 
+ */ + public function lPop($key) + { + } + + /** + * Returns and removes the last element of the list. + * + * @param string $key + * + * @return mixed|bool if command executed successfully BOOL FALSE in case of failure (empty list) + * + * @link https://redis.io/commands/rpop + * @example + *
+     * $redis->rPush('key1', 'A');
+     * $redis->rPush('key1', 'B');
+     * $redis->rPush('key1', 'C');  // key1 => [ 'A', 'B', 'C' ]
+     * $redis->rPop('key1');        // key1 => [ 'A', 'B' ]
+     * 
+ */ + public function rPop($key) + { + } + + /** + * Is a blocking lPop primitive. If at least one of the lists contains at least one element, + * the element will be popped from the head of the list and returned to the caller. + * Il all the list identified by the keys passed in arguments are empty, blPop will block + * during the specified timeout until an element is pushed to one of those lists. This element will be popped. + * + * @param string|string[] $keys String array containing the keys of the lists OR variadic list of strings + * @param int $timeout Timeout is always the required final parameter + * + * @return array ['listName', 'element'] + * + * @link https://redis.io/commands/blpop + * @example + *
+     * // Non blocking feature
+     * $redis->lPush('key1', 'A');
+     * $redis->del('key2');
+     *
+     * $redis->blPop('key1', 'key2', 10);        // array('key1', 'A')
+     * // OR
+     * $redis->blPop(['key1', 'key2'], 10);      // array('key1', 'A')
+     *
+     * $redis->brPop('key1', 'key2', 10);        // array('key1', 'A')
+     * // OR
+     * $redis->brPop(['key1', 'key2'], 10); // array('key1', 'A')
+     *
+     * // Blocking feature
+     *
+     * // process 1
+     * $redis->del('key1');
+     * $redis->blPop('key1', 10);
+     * // blocking for 10 seconds
+     *
+     * // process 2
+     * $redis->lPush('key1', 'A');
+     *
+     * // process 1
+     * // array('key1', 'A') is returned
+     * 
+ */ + public function blPop($keys, $timeout) + { + } + + /** + * Is a blocking rPop primitive. If at least one of the lists contains at least one element, + * the element will be popped from the head of the list and returned to the caller. + * Il all the list identified by the keys passed in arguments are empty, brPop will + * block during the specified timeout until an element is pushed to one of those lists. T + * his element will be popped. + * + * @param string|string[] $keys String array containing the keys of the lists OR variadic list of strings + * @param int $timeout Timeout is always the required final parameter + * + * @return array ['listName', 'element'] + * + * @link https://redis.io/commands/brpop + * @example + *
+     * // Non blocking feature
+     * $redis->lPush('key1', 'A');
+     * $redis->del('key2');
+     *
+     * $redis->blPop('key1', 'key2', 10); // array('key1', 'A')
+     * // OR
+     * $redis->blPop(array('key1', 'key2'), 10); // array('key1', 'A')
+     *
+     * $redis->brPop('key1', 'key2', 10); // array('key1', 'A')
+     * // OR
+     * $redis->brPop(array('key1', 'key2'), 10); // array('key1', 'A')
+     *
+     * // Blocking feature
+     *
+     * // process 1
+     * $redis->del('key1');
+     * $redis->blPop('key1', 10);
+     * // blocking for 10 seconds
+     *
+     * // process 2
+     * $redis->lPush('key1', 'A');
+     *
+     * // process 1
+     * // array('key1', 'A') is returned
+     * 
+ */ + public function brPop(array $keys, $timeout) + { + } + + /** + * Returns the size of a list identified by Key. If the list didn't exist or is empty, + * the command returns 0. If the data type identified by Key is not a list, the command return FALSE. + * + * @param string $key + * + * @return int|bool The size of the list identified by Key exists. + * bool FALSE if the data type identified by Key is not list + * + * @link https://redis.io/commands/llen + * @example + *
+     * $redis->rPush('key1', 'A');
+     * $redis->rPush('key1', 'B');
+     * $redis->rPush('key1', 'C'); // key1 => [ 'A', 'B', 'C' ]
+     * $redis->lLen('key1');       // 3
+     * $redis->rPop('key1');
+     * $redis->lLen('key1');       // 2
+     * 
+ */ + public function lLen($key) + { + } + + /** + * @see lLen() + * @link https://redis.io/commands/llen + * @deprecated use Redis::lLen() + * + * @param string $key + * + * @return int The size of the list identified by Key exists + */ + public function lSize($key) + { + } + + /** + * Return the specified element of the list stored at the specified key. + * 0 the first element, 1 the second ... -1 the last element, -2 the penultimate ... + * Return FALSE in case of a bad index or a key that doesn't point to a list. + * + * @param string $key + * @param int $index + * + * @return mixed|bool the element at this index + * + * Bool FALSE if the key identifies a non-string data type, or no value corresponds to this index in the list Key. + * + * @link https://redis.io/commands/lindex + * @example + *
+     * $redis->rPush('key1', 'A');
+     * $redis->rPush('key1', 'B');
+     * $redis->rPush('key1', 'C');  // key1 => [ 'A', 'B', 'C' ]
+     * $redis->lIndex('key1', 0);     // 'A'
+     * $redis->lIndex('key1', -1);    // 'C'
+     * $redis->lIndex('key1', 10);    // `FALSE`
+     * 
+ */ + public function lIndex($key, $index) + { + } + + /** + * @see lIndex() + * @link https://redis.io/commands/lindex + * @deprecated use Redis::lIndex() + * + * @param string $key + * @param int $index + * @return mixed|bool the element at this index + */ + public function lGet($key, $index) + { + } + + /** + * Set the list at index with the new value. + * + * @param string $key + * @param int $index + * @param string $value + * + * @return bool TRUE if the new value is setted. + * FALSE if the index is out of range, or data type identified by key is not a list. + * + * @link https://redis.io/commands/lset + * @example + *
+     * $redis->rPush('key1', 'A');
+     * $redis->rPush('key1', 'B');
+     * $redis->rPush('key1', 'C');    // key1 => [ 'A', 'B', 'C' ]
+     * $redis->lIndex('key1', 0);     // 'A'
+     * $redis->lSet('key1', 0, 'X');
+     * $redis->lIndex('key1', 0);     // 'X'
+     * 
+ */ + public function lSet($key, $index, $value) + { + } + + /** + * Returns the specified elements of the list stored at the specified key in + * the range [start, end]. start and stop are interpretated as indices: 0 the first element, + * 1 the second ... -1 the last element, -2 the penultimate ... + * + * @param string $key + * @param int $start + * @param int $end + * + * @return array containing the values in specified range. + * + * @link https://redis.io/commands/lrange + * @example + *
+     * $redis->rPush('key1', 'A');
+     * $redis->rPush('key1', 'B');
+     * $redis->rPush('key1', 'C');
+     * $redis->lRange('key1', 0, -1); // array('A', 'B', 'C')
+     * 
+ */ + public function lRange($key, $start, $end) + { + } + + /** + * @see lRange() + * @link https://redis.io/commands/lrange + * @deprecated use Redis::lRange() + * + * @param string $key + * @param int $start + * @param int $end + * @return array + */ + public function lGetRange($key, $start, $end) + { + } + + /** + * Trims an existing list so that it will contain only a specified range of elements. + * + * @param string $key + * @param int $start + * @param int $stop + * + * @return array|bool Bool return FALSE if the key identify a non-list value + * + * @link https://redis.io/commands/ltrim + * @example + *
+     * $redis->rPush('key1', 'A');
+     * $redis->rPush('key1', 'B');
+     * $redis->rPush('key1', 'C');
+     * $redis->lRange('key1', 0, -1); // array('A', 'B', 'C')
+     * $redis->lTrim('key1', 0, 1);
+     * $redis->lRange('key1', 0, -1); // array('A', 'B')
+     * 
+ */ + public function lTrim($key, $start, $stop) + { + } + + /** + * @see lTrim() + * @link https://redis.io/commands/ltrim + * @deprecated use Redis::lTrim() + * + * @param string $key + * @param int $start + * @param int $stop + */ + public function listTrim($key, $start, $stop) + { + } + + /** + * Removes the first count occurences of the value element from the list. + * If count is zero, all the matching elements are removed. If count is negative, + * elements are removed from tail to head. + * + * @param string $key + * @param string $value + * @param int $count + * + * @return int|bool the number of elements to remove + * bool FALSE if the value identified by key is not a list. + * + * @link https://redis.io/commands/lrem + * @example + *
+     * $redis->lPush('key1', 'A');
+     * $redis->lPush('key1', 'B');
+     * $redis->lPush('key1', 'C');
+     * $redis->lPush('key1', 'A');
+     * $redis->lPush('key1', 'A');
+     *
+     * $redis->lRange('key1', 0, -1);   // array('A', 'A', 'C', 'B', 'A')
+     * $redis->lRem('key1', 'A', 2);    // 2
+     * $redis->lRange('key1', 0, -1);   // array('C', 'B', 'A')
+     * 
+ */ + public function lRem($key, $value, $count) + { + } + + /** + * @see lRem + * @link https://redis.io/commands/lremove + * @deprecated use Redis::lRem() + * + * @param string $key + * @param string $value + * @param int $count + */ + public function lRemove($key, $value, $count) + { + } + + /** + * Insert value in the list before or after the pivot value. the parameter options + * specify the position of the insert (before or after). If the list didn't exists, + * or the pivot didn't exists, the value is not inserted. + * + * @param string $key + * @param int $position Redis::BEFORE | Redis::AFTER + * @param string $pivot + * @param string|mixed $value + * + * @return int The number of the elements in the list, -1 if the pivot didn't exists. + * + * @link https://redis.io/commands/linsert + * @example + *
+     * $redis->del('key1');
+     * $redis->lInsert('key1', Redis::AFTER, 'A', 'X');     // 0
+     *
+     * $redis->lPush('key1', 'A');
+     * $redis->lPush('key1', 'B');
+     * $redis->lPush('key1', 'C');
+     *
+     * $redis->lInsert('key1', Redis::BEFORE, 'C', 'X');    // 4
+     * $redis->lRange('key1', 0, -1);                       // array('A', 'B', 'X', 'C')
+     *
+     * $redis->lInsert('key1', Redis::AFTER, 'C', 'Y');     // 5
+     * $redis->lRange('key1', 0, -1);                       // array('A', 'B', 'X', 'C', 'Y')
+     *
+     * $redis->lInsert('key1', Redis::AFTER, 'W', 'value'); // -1
+     * 
+ */ + public function lInsert($key, $position, $pivot, $value) + { + } + + /** + * Adds a values to the set value stored at key. + * + * @param string $key Required key + * @param string|mixed ...$value1 Variadic list of values + * + * @return int|bool The number of elements added to the set. + * If this value is already in the set, FALSE is returned + * + * @link https://redis.io/commands/sadd + * @example + *
+     * $redis->sAdd('k', 'v1');                // int(1)
+     * $redis->sAdd('k', 'v1', 'v2', 'v3');    // int(2)
+     * 
+ */ + public function sAdd($key, ...$value1) + { + } + + /** + * Removes the specified members from the set value stored at key. + * + * @param string $key + * @param string|mixed ...$member1 Variadic list of members + * + * @return int The number of elements removed from the set + * + * @link https://redis.io/commands/srem + * @example + *
+     * var_dump( $redis->sAdd('k', 'v1', 'v2', 'v3') );    // int(3)
+     * var_dump( $redis->sRem('k', 'v2', 'v3') );          // int(2)
+     * var_dump( $redis->sMembers('k') );
+     * //// Output:
+     * // array(1) {
+     * //   [0]=> string(2) "v1"
+     * // }
+     * 
+ */ + public function sRem($key, ...$member1) + { + } + + /** + * @see sRem() + * @link https://redis.io/commands/srem + * @deprecated use Redis::sRem() + * + * @param string $key + * @param string|mixed ...$member1 + */ + public function sRemove($key, ...$member1) + { + } + + /** + * Moves the specified member from the set at srcKey to the set at dstKey. + * + * @param string $srcKey + * @param string $dstKey + * @param string|mixed $member + * + * @return bool If the operation is successful, return TRUE. + * If the srcKey and/or dstKey didn't exist, and/or the member didn't exist in srcKey, FALSE is returned. + * + * @link https://redis.io/commands/smove + * @example + *
+     * $redis->sAdd('key1' , 'set11');
+     * $redis->sAdd('key1' , 'set12');
+     * $redis->sAdd('key1' , 'set13');          // 'key1' => {'set11', 'set12', 'set13'}
+     * $redis->sAdd('key2' , 'set21');
+     * $redis->sAdd('key2' , 'set22');          // 'key2' => {'set21', 'set22'}
+     * $redis->sMove('key1', 'key2', 'set13');  // 'key1' =>  {'set11', 'set12'}
+     *                                          // 'key2' =>  {'set21', 'set22', 'set13'}
+     * 
+ */ + public function sMove($srcKey, $dstKey, $member) + { + } + + /** + * Checks if value is a member of the set stored at the key key. + * + * @param string $key + * @param string|mixed $value + * + * @return bool TRUE if value is a member of the set at key key, FALSE otherwise + * + * @link https://redis.io/commands/sismember + * @example + *
+     * $redis->sAdd('key1' , 'set1');
+     * $redis->sAdd('key1' , 'set2');
+     * $redis->sAdd('key1' , 'set3'); // 'key1' => {'set1', 'set2', 'set3'}
+     *
+     * $redis->sIsMember('key1', 'set1'); // TRUE
+     * $redis->sIsMember('key1', 'setX'); // FALSE
+     * 
+ */ + public function sIsMember($key, $value) + { + } + + /** + * @see sIsMember() + * @link https://redis.io/commands/sismember + * @deprecated use Redis::sIsMember() + * + * @param string $key + * @param string|mixed $value + */ + public function sContains($key, $value) + { + } + + /** + * Returns the cardinality of the set identified by key. + * + * @param string $key + * + * @return int the cardinality of the set identified by key, 0 if the set doesn't exist. + * + * @link https://redis.io/commands/scard + * @example + *
+     * $redis->sAdd('key1' , 'set1');
+     * $redis->sAdd('key1' , 'set2');
+     * $redis->sAdd('key1' , 'set3');   // 'key1' => {'set1', 'set2', 'set3'}
+     * $redis->sCard('key1');           // 3
+     * $redis->sCard('keyX');           // 0
+     * 
+ */ + public function sCard($key) + { + } + + /** + * Removes and returns a random element from the set value at Key. + * + * @param string $key + * + * @return string|mixed|bool "popped" value + * bool FALSE if set identified by key is empty or doesn't exist. + * + * @link https://redis.io/commands/spop + * @example + *
+     * $redis->sAdd('key1' , 'set1');
+     * $redis->sAdd('key1' , 'set2');
+     * $redis->sAdd('key1' , 'set3');   // 'key1' => {'set3', 'set1', 'set2'}
+     * $redis->sPop('key1');            // 'set1', 'key1' => {'set3', 'set2'}
+     * $redis->sPop('key1');            // 'set3', 'key1' => {'set2'}
+     * 
+ */ + public function sPop($key) + { + } + + /** + * Returns a random element(s) from the set value at Key, without removing it. + * + * @param string $key + * @param int $count [optional] + * + * @return string|mixed|array|bool value(s) from the set + * bool FALSE if set identified by key is empty or doesn't exist and count argument isn't passed. + * + * @link https://redis.io/commands/srandmember + * @example + *
+     * $redis->sAdd('key1' , 'one');
+     * $redis->sAdd('key1' , 'two');
+     * $redis->sAdd('key1' , 'three');              // 'key1' => {'one', 'two', 'three'}
+     *
+     * var_dump( $redis->sRandMember('key1') );     // 'key1' => {'one', 'two', 'three'}
+     *
+     * // string(5) "three"
+     *
+     * var_dump( $redis->sRandMember('key1', 2) );  // 'key1' => {'one', 'two', 'three'}
+     *
+     * // array(2) {
+     * //   [0]=> string(2) "one"
+     * //   [1]=> string(2) "three"
+     * // }
+     * 
+ */ + public function sRandMember($key, $count = 1) + { + } + + /** + * Returns the members of a set resulting from the intersection of all the sets + * held at the specified keys. If just a single key is specified, then this command + * produces the members of this set. If one of the keys is missing, FALSE is returned. + * + * @param string $key1 keys identifying the different sets on which we will apply the intersection. + * @param string ...$otherKeys variadic list of keys + * + * @return array contain the result of the intersection between those keys + * If the intersection between the different sets is empty, the return value will be empty array. + * + * @link https://redis.io/commands/sinter + * @example + *
+     * $redis->sAdd('key1', 'val1');
+     * $redis->sAdd('key1', 'val2');
+     * $redis->sAdd('key1', 'val3');
+     * $redis->sAdd('key1', 'val4');
+     *
+     * $redis->sAdd('key2', 'val3');
+     * $redis->sAdd('key2', 'val4');
+     *
+     * $redis->sAdd('key3', 'val3');
+     * $redis->sAdd('key3', 'val4');
+     *
+     * var_dump($redis->sInter('key1', 'key2', 'key3'));
+     *
+     * //array(2) {
+     * //  [0]=>
+     * //  string(4) "val4"
+     * //  [1]=>
+     * //  string(4) "val3"
+     * //}
+     * 
+ */ + public function sInter($key1, ...$otherKeys) + { + } + + /** + * Performs a sInter command and stores the result in a new set. + * + * @param string $dstKey the key to store the diff into. + * @param string $key1 keys identifying the different sets on which we will apply the intersection. + * @param string ...$otherKeys variadic list of keys + * + * @return int|bool The cardinality of the resulting set, or FALSE in case of a missing key + * + * @link https://redis.io/commands/sinterstore + * @example + *
+     * $redis->sAdd('key1', 'val1');
+     * $redis->sAdd('key1', 'val2');
+     * $redis->sAdd('key1', 'val3');
+     * $redis->sAdd('key1', 'val4');
+     *
+     * $redis->sAdd('key2', 'val3');
+     * $redis->sAdd('key2', 'val4');
+     *
+     * $redis->sAdd('key3', 'val3');
+     * $redis->sAdd('key3', 'val4');
+     *
+     * var_dump($redis->sInterStore('output', 'key1', 'key2', 'key3'));
+     * var_dump($redis->sMembers('output'));
+     *
+     * //int(2)
+     * //
+     * //array(2) {
+     * //  [0]=>
+     * //  string(4) "val4"
+     * //  [1]=>
+     * //  string(4) "val3"
+     * //}
+     * 
+ */ + public function sInterStore($dstKey, $key1, ...$otherKeys) + { + } + + /** + * Performs the union between N sets and returns it. + * + * @param string $key1 first key for union + * @param string ...$otherKeys variadic list of keys corresponding to sets in redis + * + * @return array string[] The union of all these sets + * + * @link https://redis.io/commands/sunionstore + * @example + *
+     * $redis->sAdd('s0', '1');
+     * $redis->sAdd('s0', '2');
+     * $redis->sAdd('s1', '3');
+     * $redis->sAdd('s1', '1');
+     * $redis->sAdd('s2', '3');
+     * $redis->sAdd('s2', '4');
+     *
+     * var_dump($redis->sUnion('s0', 's1', 's2'));
+     *
+     * array(4) {
+     * //  [0]=>
+     * //  string(1) "3"
+     * //  [1]=>
+     * //  string(1) "4"
+     * //  [2]=>
+     * //  string(1) "1"
+     * //  [3]=>
+     * //  string(1) "2"
+     * //}
+     * 
+ */ + public function sUnion($key1, ...$otherKeys) + { + } + + /** + * Performs the same action as sUnion, but stores the result in the first key + * + * @param string $dstKey the key to store the diff into. + * @param string $key1 first key for union + * @param string ...$otherKeys variadic list of keys corresponding to sets in redis + * + * @return int Any number of keys corresponding to sets in redis + * + * @link https://redis.io/commands/sunionstore + * @example + *
+     * $redis->del('s0', 's1', 's2');
+     *
+     * $redis->sAdd('s0', '1');
+     * $redis->sAdd('s0', '2');
+     * $redis->sAdd('s1', '3');
+     * $redis->sAdd('s1', '1');
+     * $redis->sAdd('s2', '3');
+     * $redis->sAdd('s2', '4');
+     *
+     * var_dump($redis->sUnionStore('dst', 's0', 's1', 's2'));
+     * var_dump($redis->sMembers('dst'));
+     *
+     * //int(4)
+     * //array(4) {
+     * //  [0]=>
+     * //  string(1) "3"
+     * //  [1]=>
+     * //  string(1) "4"
+     * //  [2]=>
+     * //  string(1) "1"
+     * //  [3]=>
+     * //  string(1) "2"
+     * //}
+     * 
+ */ + public function sUnionStore($dstKey, $key1, ...$otherKeys) + { + } + + /** + * Performs the difference between N sets and returns it. + * + * @param string $key1 first key for diff + * @param string ...$otherKeys variadic list of keys corresponding to sets in redis + * + * @return array string[] The difference of the first set will all the others + * + * @link https://redis.io/commands/sdiff + * @example + *
+     * $redis->del('s0', 's1', 's2');
+     *
+     * $redis->sAdd('s0', '1');
+     * $redis->sAdd('s0', '2');
+     * $redis->sAdd('s0', '3');
+     * $redis->sAdd('s0', '4');
+     *
+     * $redis->sAdd('s1', '1');
+     * $redis->sAdd('s2', '3');
+     *
+     * var_dump($redis->sDiff('s0', 's1', 's2'));
+     *
+     * //array(2) {
+     * //  [0]=>
+     * //  string(1) "4"
+     * //  [1]=>
+     * //  string(1) "2"
+     * //}
+     * 
+ */ + public function sDiff($key1, ...$otherKeys) + { + } + + /** + * Performs the same action as sDiff, but stores the result in the first key + * + * @param string $dstKey the key to store the diff into. + * @param string $key1 first key for diff + * @param string ...$otherKeys variadic list of keys corresponding to sets in redis + * + * @return int|bool The cardinality of the resulting set, or FALSE in case of a missing key + * + * @link https://redis.io/commands/sdiffstore + * @example + *
+     * $redis->del('s0', 's1', 's2');
+     *
+     * $redis->sAdd('s0', '1');
+     * $redis->sAdd('s0', '2');
+     * $redis->sAdd('s0', '3');
+     * $redis->sAdd('s0', '4');
+     *
+     * $redis->sAdd('s1', '1');
+     * $redis->sAdd('s2', '3');
+     *
+     * var_dump($redis->sDiffStore('dst', 's0', 's1', 's2'));
+     * var_dump($redis->sMembers('dst'));
+     *
+     * //int(2)
+     * //array(2) {
+     * //  [0]=>
+     * //  string(1) "4"
+     * //  [1]=>
+     * //  string(1) "2"
+     * //}
+     * 
+ */ + public function sDiffStore($dstKey, $key1, ...$otherKeys) + { + } + + /** + * Returns the contents of a set. + * + * @param string $key + * + * @return array An array of elements, the contents of the set + * + * @link https://redis.io/commands/smembers + * @example + *
+     * $redis->del('s');
+     * $redis->sAdd('s', 'a');
+     * $redis->sAdd('s', 'b');
+     * $redis->sAdd('s', 'a');
+     * $redis->sAdd('s', 'c');
+     * var_dump($redis->sMembers('s'));
+     *
+     * //array(3) {
+     * //  [0]=>
+     * //  string(1) "c"
+     * //  [1]=>
+     * //  string(1) "a"
+     * //  [2]=>
+     * //  string(1) "b"
+     * //}
+     * // The order is random and corresponds to redis' own internal representation of the set structure.
+     * 
+ */ + public function sMembers($key) + { + } + + /** + * @see sMembers() + * @link https://redis.io/commands/smembers + * @deprecated use Redis::sMembers() + * + * @param string $key + * @return array An array of elements, the contents of the set + */ + public function sGetMembers($key) + { + } + + /** + * Scan a set for members + * + * @param string $key The set to search. + * @param int $iterator LONG (reference) to the iterator as we go. + * @param string $pattern String, optional pattern to match against. + * @param int $count How many members to return at a time (Redis might return a different amount) + * + * @return array|bool PHPRedis will return an array of keys or FALSE when we're done iterating + * + * @link https://redis.io/commands/sscan + * @example + *
+     * $iterator = null;
+     * while ($members = $redis->sScan('set', $iterator)) {
+     *     foreach ($members as $member) {
+     *         echo $member . PHP_EOL;
+     *     }
+     * }
+     * 
+ */ + public function sScan($key, &$iterator, $pattern = null, $count = 0) + { + } + + /** + * Sets a value and returns the previous entry at that key. + * + * @param string $key + * @param string|mixed $value + * + * @return string|mixed A string (mixed, if used serializer), the previous value located at this key + * + * @link https://redis.io/commands/getset + * @example + *
+     * $redis->set('x', '42');
+     * $exValue = $redis->getSet('x', 'lol');   // return '42', replaces x by 'lol'
+     * $newValue = $redis->get('x')'            // return 'lol'
+     * 
+ */ + public function getSet($key, $value) + { + } + + /** + * Returns a random key + * + * @return string an existing key in redis + * + * @link https://redis.io/commands/randomkey + * @example + *
+     * $key = $redis->randomKey();
+     * $surprise = $redis->get($key);  // who knows what's in there.
+     * 
+ */ + public function randomKey() + { + } + + /** + * Switches to a given database + * + * @param int $dbIndex + * + * @return bool TRUE in case of success, FALSE in case of failure + * + * @link https://redis.io/commands/select + * @example + *
+     * $redis->select(0);       // switch to DB 0
+     * $redis->set('x', '42');  // write 42 to x
+     * $redis->move('x', 1);    // move to DB 1
+     * $redis->select(1);       // switch to DB 1
+     * $redis->get('x');        // will return 42
+     * 
+ */ + public function select($dbIndex) + { + } + + /** + * Moves a key to a different database. + * + * @param string $key + * @param int $dbIndex + * + * @return bool TRUE in case of success, FALSE in case of failure + * + * @link https://redis.io/commands/move + * @example + *
+     * $redis->select(0);       // switch to DB 0
+     * $redis->set('x', '42');  // write 42 to x
+     * $redis->move('x', 1);    // move to DB 1
+     * $redis->select(1);       // switch to DB 1
+     * $redis->get('x');        // will return 42
+     * 
+ */ + public function move($key, $dbIndex) + { + } + + /** + * Renames a key + * + * @param string $srcKey + * @param string $dstKey + * + * @return bool TRUE in case of success, FALSE in case of failure + * + * @link https://redis.io/commands/rename + * @example + *
+     * $redis->set('x', '42');
+     * $redis->rename('x', 'y');
+     * $redis->get('y');   // → 42
+     * $redis->get('x');   // → `FALSE`
+     * 
+ */ + public function rename($srcKey, $dstKey) + { + } + + /** + * @see rename() + * @link https://redis.io/commands/rename + * @deprecated use Redis::rename() + * + * @param string $srcKey + * @param string $dstKey + */ + public function renameKey($srcKey, $dstKey) + { + } + + /** + * Renames a key + * + * Same as rename, but will not replace a key if the destination already exists. + * This is the same behaviour as setNx. + * + * @param string $srcKey + * @param string $dstKey + * + * @return bool TRUE in case of success, FALSE in case of failure + * + * @link https://redis.io/commands/renamenx + * @example + *
+     * $redis->set('x', '42');
+     * $redis->rename('x', 'y');
+     * $redis->get('y');   // → 42
+     * $redis->get('x');   // → `FALSE`
+     * 
+ */ + public function renameNx($srcKey, $dstKey) + { + } + + /** + * Sets an expiration date (a timeout) on an item + * + * @param string $key The key that will disappear + * @param int $ttl The key's remaining Time To Live, in seconds + * + * @return bool TRUE in case of success, FALSE in case of failure + * + * @link https://redis.io/commands/expire + * @example + *
+     * $redis->set('x', '42');
+     * $redis->expire('x', 3);  // x will disappear in 3 seconds.
+     * sleep(5);                    // wait 5 seconds
+     * $redis->get('x');            // will return `FALSE`, as 'x' has expired.
+     * 
+ */ + public function expire($key, $ttl) + { + } + + /** + * Sets an expiration date (a timeout in milliseconds) on an item + * + * @param string $key The key that will disappear. + * @param int $ttl The key's remaining Time To Live, in milliseconds + * + * @return bool TRUE in case of success, FALSE in case of failure + * + * @link https://redis.io/commands/pexpire + * @example + *
+     * $redis->set('x', '42');
+     * $redis->pExpire('x', 11500); // x will disappear in 11500 milliseconds.
+     * $redis->ttl('x');            // 12
+     * $redis->pttl('x');           // 11500
+     * 
+ */ + public function pExpire($key, $ttl) + { + } + + /** + * @see expire() + * @link https://redis.io/commands/expire + * @deprecated use Redis::expire() + * + * @param string $key + * @param int $ttl + * @return bool + */ + public function setTimeout($key, $ttl) + { + } + + /** + * Sets an expiration date (a timestamp) on an item. + * + * @param string $key The key that will disappear. + * @param int $timestamp Unix timestamp. The key's date of death, in seconds from Epoch time. + * + * @return bool TRUE in case of success, FALSE in case of failure + * + * @link https://redis.io/commands/expireat + * @example + *
+     * $redis->set('x', '42');
+     * $now = time(NULL);               // current timestamp
+     * $redis->expireAt('x', $now + 3); // x will disappear in 3 seconds.
+     * sleep(5);                        // wait 5 seconds
+     * $redis->get('x');                // will return `FALSE`, as 'x' has expired.
+     * 
+ */ + public function expireAt($key, $timestamp) + { + } + + /** + * Sets an expiration date (a timestamp) on an item. Requires a timestamp in milliseconds + * + * @param string $key The key that will disappear + * @param int $timestamp Unix timestamp. The key's date of death, in seconds from Epoch time + * + * @return bool TRUE in case of success, FALSE in case of failure + * + * @link https://redis.io/commands/pexpireat + * @example + *
+     * $redis->set('x', '42');
+     * $redis->pExpireAt('x', 1555555555005);
+     * echo $redis->ttl('x');                       // 218270121
+     * echo $redis->pttl('x');                      // 218270120575
+     * 
+ */ + public function pExpireAt($key, $timestamp) + { + } + + /** + * Returns the keys that match a certain pattern. + * + * @param string $pattern pattern, using '*' as a wildcard + * + * @return array string[] The keys that match a certain pattern. + * + * @link https://redis.io/commands/keys + * @example + *
+     * $allKeys = $redis->keys('*');   // all keys will match this.
+     * $keyWithUserPrefix = $redis->keys('user*');
+     * 
+ */ + public function keys($pattern) + { + } + + /** + * @see keys() + * @deprecated use Redis::keys() + * + * @param string $pattern + * @link https://redis.io/commands/keys + */ + public function getKeys($pattern) + { + } + + /** + * Returns the current database's size + * + * @return int DB size, in number of keys + * + * @link https://redis.io/commands/dbsize + * @example + *
+     * $count = $redis->dbSize();
+     * echo "Redis has $count keys\n";
+     * 
+ */ + public function dbSize() + { + } + + /** + * Authenticate the connection using a password. + * Warning: The password is sent in plain-text over the network. + * + * @param string $password + * + * @return bool TRUE if the connection is authenticated, FALSE otherwise + * + * @link https://redis.io/commands/auth + * @example $redis->auth('foobared'); + */ + public function auth($password) + { + } + + /** + * Starts the background rewrite of AOF (Append-Only File) + * + * @return bool TRUE in case of success, FALSE in case of failure + * + * @link https://redis.io/commands/bgrewriteaof + * @example $redis->bgrewriteaof(); + */ + public function bgrewriteaof() + { + } + + /** + * Changes the slave status + * Either host and port, or no parameter to stop being a slave. + * + * @param string $host [optional] + * @param int $port [optional] + * + * @return bool TRUE in case of success, FALSE in case of failure + * + * @link https://redis.io/commands/slaveof + * @example + *
+     * $redis->slaveof('10.0.1.7', 6379);
+     * // ...
+     * $redis->slaveof();
+     * 
+ */ + public function slaveof($host = '127.0.0.1', $port = 6379) + { + } + + /** + * Access the Redis slowLog + * + * @param string $operation This can be either GET, LEN, or RESET + * @param int|null $length If executing a SLOWLOG GET command, you can pass an optional length. + * + * @return mixed The return value of SLOWLOG will depend on which operation was performed. + * - SLOWLOG GET: Array of slowLog entries, as provided by Redis + * - SLOGLOG LEN: Integer, the length of the slowLog + * - SLOWLOG RESET: Boolean, depending on success + * + * @example + *
+     * // Get ten slowLog entries
+     * $redis->slowLog('get', 10);
+     * // Get the default number of slowLog entries
+     *
+     * $redis->slowLog('get');
+     * // Reset our slowLog
+     * $redis->slowLog('reset');
+     *
+     * // Retrieve slowLog length
+     * $redis->slowLog('len');
+     * 
+ * + * @link https://redis.io/commands/slowlog + */ + public function slowLog(string $operation, int $length = null) + { + } + + + /** + * Describes the object pointed to by a key. + * The information to retrieve (string) and the key (string). + * Info can be one of the following: + * - "encoding" + * - "refcount" + * - "idletime" + * + * @param string $string + * @param string $key + * + * @return string|int|bool for "encoding", int for "refcount" and "idletime", FALSE if the key doesn't exist. + * + * @link https://redis.io/commands/object + * @example + *
+     * $redis->lPush('l', 'Hello, world!');
+     * $redis->object("encoding", "l"); // → ziplist
+     * $redis->object("refcount", "l"); // → 1
+     * $redis->object("idletime", "l"); // → 400 (in seconds, with a precision of 10 seconds).
+     * 
+ */ + public function object($string = '', $key = '') + { + } + + /** + * Performs a synchronous save. + * + * @return bool TRUE in case of success, FALSE in case of failure + * If a save is already running, this command will fail and return FALSE. + * + * @link https://redis.io/commands/save + * @example $redis->save(); + */ + public function save() + { + } + + /** + * Performs a background save. + * + * @return bool TRUE in case of success, FALSE in case of failure + * If a save is already running, this command will fail and return FALSE + * + * @link https://redis.io/commands/bgsave + * @example $redis->bgSave(); + */ + public function bgsave() + { + } + + /** + * Returns the timestamp of the last disk save. + * + * @return int timestamp + * + * @link https://redis.io/commands/lastsave + * @example $redis->lastSave(); + */ + public function lastSave() + { + } + + /** + * Blocks the current client until all the previous write commands are successfully transferred and + * acknowledged by at least the specified number of slaves. + * + * @param int $numSlaves Number of slaves that need to acknowledge previous write commands. + * @param int $timeout Timeout in milliseconds. + * + * @return int The command returns the number of slaves reached by all the writes performed in the + * context of the current connection + * + * @link https://redis.io/commands/wait + * @example $redis->wait(2, 1000); + */ + public function wait($numSlaves, $timeout) + { + } + + /** + * Returns the type of data pointed by a given key. + * + * @param string $key + * + * @return int + * Depending on the type of the data pointed by the key, + * this method will return the following value: + * - string: Redis::REDIS_STRING + * - set: Redis::REDIS_SET + * - list: Redis::REDIS_LIST + * - zset: Redis::REDIS_ZSET + * - hash: Redis::REDIS_HASH + * - other: Redis::REDIS_NOT_FOUND + * + * @link https://redis.io/commands/type + * @example $redis->type('key'); + */ + public function type($key) + { + } + + /** + * Append specified string to the string stored in specified key. + * + * @param string $key + * @param string|mixed $value + * + * @return int Size of the value after the append + * + * @link https://redis.io/commands/append + * @example + *
+     * $redis->set('key', 'value1');
+     * $redis->append('key', 'value2'); // 12
+     * $redis->get('key');              // 'value1value2'
+     * 
+ */ + public function append($key, $value) + { + } + + /** + * Return a substring of a larger string + * + * @param string $key + * @param int $start + * @param int $end + * + * @return string the substring + * + * @link https://redis.io/commands/getrange + * @example + *
+     * $redis->set('key', 'string value');
+     * $redis->getRange('key', 0, 5);   // 'string'
+     * $redis->getRange('key', -5, -1); // 'value'
+     * 
+ */ + public function getRange($key, $start, $end) + { + } + + /** + * Return a substring of a larger string + * + * @deprecated + * @param string $key + * @param int $start + * @param int $end + */ + public function substr($key, $start, $end) + { + } + + /** + * Changes a substring of a larger string. + * + * @param string $key + * @param int $offset + * @param string $value + * + * @return int the length of the string after it was modified + * + * @link https://redis.io/commands/setrange + * @example + *
+     * $redis->set('key', 'Hello world');
+     * $redis->setRange('key', 6, "redis"); // returns 11
+     * $redis->get('key');                  // "Hello redis"
+     * 
+ */ + public function setRange($key, $offset, $value) + { + } + + /** + * Get the length of a string value. + * + * @param string $key + * @return int + * + * @link https://redis.io/commands/strlen + * @example + *
+     * $redis->set('key', 'value');
+     * $redis->strlen('key'); // 5
+     * 
+ */ + public function strlen($key) + { + } + + /** + * Return the position of the first bit set to 1 or 0 in a string. The position is returned, thinking of the + * string as an array of bits from left to right, where the first byte's most significant bit is at position 0, + * the second byte's most significant bit is at position 8, and so forth. + * + * @param string $key + * @param int $bit + * @param int $start + * @param int $end + * + * @return int The command returns the position of the first bit set to 1 or 0 according to the request. + * If we look for set bits (the bit argument is 1) and the string is empty or composed of just + * zero bytes, -1 is returned. If we look for clear bits (the bit argument is 0) and the string + * only contains bit set to 1, the function returns the first bit not part of the string on the + * right. So if the string is three bytes set to the value 0xff the command BITPOS key 0 will + * return 24, since up to bit 23 all the bits are 1. Basically, the function considers the right + * of the string as padded with zeros if you look for clear bits and specify no range or the + * start argument only. However, this behavior changes if you are looking for clear bits and + * specify a range with both start and end. If no clear bit is found in the specified range, the + * function returns -1 as the user specified a clear range and there are no 0 bits in that range. + * + * @link https://redis.io/commands/bitpos + * @example + *
+     * $redis->set('key', '\xff\xff');
+     * $redis->bitpos('key', 1); // int(0)
+     * $redis->bitpos('key', 1, 1); // int(8)
+     * $redis->bitpos('key', 1, 3); // int(-1)
+     * $redis->bitpos('key', 0); // int(16)
+     * $redis->bitpos('key', 0, 1); // int(16)
+     * $redis->bitpos('key', 0, 1, 5); // int(-1)
+     * 
+ */ + public function bitpos($key, $bit, $start = 0, $end = null) + { + } + + /** + * Return a single bit out of a larger string + * + * @param string $key + * @param int $offset + * + * @return int the bit value (0 or 1) + * + * @link https://redis.io/commands/getbit + * @example + *
+     * $redis->set('key', "\x7f");  // this is 0111 1111
+     * $redis->getBit('key', 0);    // 0
+     * $redis->getBit('key', 1);    // 1
+     * 
+ */ + public function getBit($key, $offset) + { + } + + /** + * Changes a single bit of a string. + * + * @param string $key + * @param int $offset + * @param bool|int $value bool or int (1 or 0) + * + * @return int 0 or 1, the value of the bit before it was set + * + * @link https://redis.io/commands/setbit + * @example + *
+     * $redis->set('key', "*");     // ord("*") = 42 = 0x2f = "0010 1010"
+     * $redis->setBit('key', 5, 1); // returns 0
+     * $redis->setBit('key', 7, 1); // returns 0
+     * $redis->get('key');          // chr(0x2f) = "/" = b("0010 1111")
+     * 
+ */ + public function setBit($key, $offset, $value) + { + } + + /** + * Count bits in a string + * + * @param string $key + * + * @return int The number of bits set to 1 in the value behind the input key + * + * @link https://redis.io/commands/bitcount + * @example + *
+     * $redis->set('bit', '345'); // // 11 0011  0011 0100  0011 0101
+     * var_dump( $redis->bitCount('bit', 0, 0) ); // int(4)
+     * var_dump( $redis->bitCount('bit', 1, 1) ); // int(3)
+     * var_dump( $redis->bitCount('bit', 2, 2) ); // int(4)
+     * var_dump( $redis->bitCount('bit', 0, 2) ); // int(11)
+     * 
+ */ + public function bitCount($key) + { + } + + /** + * Bitwise operation on multiple keys. + * + * @param string $operation either "AND", "OR", "NOT", "XOR" + * @param string $retKey return key + * @param string $key1 first key + * @param string ...$otherKeys variadic list of keys + * + * @return int The size of the string stored in the destination key + * + * @link https://redis.io/commands/bitop + * @example + *
+     * $redis->set('bit1', '1'); // 11 0001
+     * $redis->set('bit2', '2'); // 11 0010
+     *
+     * $redis->bitOp('AND', 'bit', 'bit1', 'bit2'); // bit = 110000
+     * $redis->bitOp('OR',  'bit', 'bit1', 'bit2'); // bit = 110011
+     * $redis->bitOp('NOT', 'bit', 'bit1', 'bit2'); // bit = 110011
+     * $redis->bitOp('XOR', 'bit', 'bit1', 'bit2'); // bit = 11
+     * 
+ */ + public function bitOp($operation, $retKey, $key1, ...$otherKeys) + { + } + + /** + * Removes all entries from the current database. + * + * @return bool Always TRUE + * @link https://redis.io/commands/flushdb + * @example $redis->flushDB(); + */ + public function flushDB() + { + } + + /** + * Removes all entries from all databases. + * + * @return bool Always TRUE + * + * @link https://redis.io/commands/flushall + * @example $redis->flushAll(); + */ + public function flushAll() + { + } + + /** + * Sort + * + * @param string $key + * @param array $option array(key => value, ...) - optional, with the following keys and values: + * - 'by' => 'some_pattern_*', + * - 'limit' => array(0, 1), + * - 'get' => 'some_other_pattern_*' or an array of patterns, + * - 'sort' => 'asc' or 'desc', + * - 'alpha' => TRUE, + * - 'store' => 'external-key' + * + * @return array + * An array of values, or a number corresponding to the number of elements stored if that was used + * + * @link https://redis.io/commands/sort + * @example + *
+     * $redis->del('s');
+     * $redis->sadd('s', 5);
+     * $redis->sadd('s', 4);
+     * $redis->sadd('s', 2);
+     * $redis->sadd('s', 1);
+     * $redis->sadd('s', 3);
+     *
+     * var_dump($redis->sort('s')); // 1,2,3,4,5
+     * var_dump($redis->sort('s', array('sort' => 'desc'))); // 5,4,3,2,1
+     * var_dump($redis->sort('s', array('sort' => 'desc', 'store' => 'out'))); // (int)5
+     * 
+ */ + public function sort($key, $option = null) + { + } + + /** + * Returns an associative array of strings and integers + * + * @param string $option Optional. The option to provide redis. + * SERVER | CLIENTS | MEMORY | PERSISTENCE | STATS | REPLICATION | CPU | CLASTER | KEYSPACE | COMANDSTATS + * + * Returns an associative array of strings and integers, with the following keys: + * - redis_version + * - redis_git_sha1 + * - redis_git_dirty + * - arch_bits + * - multiplexing_api + * - process_id + * - uptime_in_seconds + * - uptime_in_days + * - lru_clock + * - used_cpu_sys + * - used_cpu_user + * - used_cpu_sys_children + * - used_cpu_user_children + * - connected_clients + * - connected_slaves + * - client_longest_output_list + * - client_biggest_input_buf + * - blocked_clients + * - used_memory + * - used_memory_human + * - used_memory_peak + * - used_memory_peak_human + * - mem_fragmentation_ratio + * - mem_allocator + * - loading + * - aof_enabled + * - changes_since_last_save + * - bgsave_in_progress + * - last_save_time + * - total_connections_received + * - total_commands_processed + * - expired_keys + * - evicted_keys + * - keyspace_hits + * - keyspace_misses + * - hash_max_zipmap_entries + * - hash_max_zipmap_value + * - pubsub_channels + * - pubsub_patterns + * - latest_fork_usec + * - vm_enabled + * - role + * + * @return string + * + * @link https://redis.io/commands/info + * @example + *
+     * $redis->info();
+     *
+     * or
+     *
+     * $redis->info("COMMANDSTATS"); //Information on the commands that have been run (>=2.6 only)
+     * $redis->info("CPU"); // just CPU information from Redis INFO
+     * 
+ */ + public function info($option = null) + { + } + + /** + * Resets the statistics reported by Redis using the INFO command (`info()` function). + * These are the counters that are reset: + * - Keyspace hits + * - Keyspace misses + * - Number of commands processed + * - Number of connections received + * - Number of expired keys + * + * @return bool `TRUE` in case of success, `FALSE` in case of failure. + * + * @example $redis->resetStat(); + * @link https://redis.io/commands/config-resetstat + */ + public function resetStat() + { + } + + /** + * Returns the time to live left for a given key, in seconds. If the key doesn't exist, FALSE is returned. + * + * @param string $key + * + * @return int|bool the time left to live in seconds + * + * @link https://redis.io/commands/ttl + * @example + *
+     * $redis->setex('key', 123, 'test');
+     * $redis->ttl('key'); // int(123)
+     * 
+ */ + public function ttl($key) + { + } + + /** + * Returns a time to live left for a given key, in milliseconds. + * + * If the key doesn't exist, FALSE is returned. + * + * @param string $key + * + * @return int|bool the time left to live in milliseconds + * + * @link https://redis.io/commands/pttl + * @example + *
+     * $redis->setex('key', 123, 'test');
+     * $redis->pttl('key'); // int(122999)
+     * 
+ */ + public function pttl($key) + { + } + + /** + * Remove the expiration timer from a key. + * + * @param string $key + * + * @return bool TRUE if a timeout was removed, FALSE if the key didn’t exist or didn’t have an expiration timer. + * + * @link https://redis.io/commands/persist + * @example $redis->persist('key'); + */ + public function persist($key) + { + } + + /** + * Sets multiple key-value pairs in one atomic command. + * MSETNX only returns TRUE if all the keys were set (see SETNX). + * + * @param array $array Pairs: array(key => value, ...) + * + * @return bool TRUE in case of success, FALSE in case of failure + * + * @link https://redis.io/commands/mset + * @example + *
+     * $redis->mset(array('key0' => 'value0', 'key1' => 'value1'));
+     * var_dump($redis->get('key0'));
+     * var_dump($redis->get('key1'));
+     * // Output:
+     * // string(6) "value0"
+     * // string(6) "value1"
+     * 
+ */ + public function mset(array $array) + { + } + + /** + * Get the values of all the specified keys. + * If one or more keys dont exist, the array will contain FALSE at the position of the key. + * + * @param array $keys Array containing the list of the keys + * + * @return array Array containing the values related to keys in argument + * + * @deprecated use Redis::mGet() + * @example + *
+     * $redis->set('key1', 'value1');
+     * $redis->set('key2', 'value2');
+     * $redis->set('key3', 'value3');
+     * $redis->getMultiple(array('key1', 'key2', 'key3')); // array('value1', 'value2', 'value3');
+     * $redis->getMultiple(array('key0', 'key1', 'key5')); // array(`FALSE`, 'value2', `FALSE`);
+     * 
+ */ + public function getMultiple(array $keys) + { + } + + /** + * Returns the values of all specified keys. + * + * For every key that does not hold a string value or does not exist, + * the special value false is returned. Because of this, the operation never fails. + * + * @param array $array + * + * @return array + * + * @link https://redis.io/commands/mget + * @example + *
+     * $redis->del('x', 'y', 'z', 'h');  // remove x y z
+     * $redis->mset(array('x' => 'a', 'y' => 'b', 'z' => 'c'));
+     * $redis->hset('h', 'field', 'value');
+     * var_dump($redis->mget(array('x', 'y', 'z', 'h')));
+     * // Output:
+     * // array(3) {
+     * //   [0]=> string(1) "a"
+     * //   [1]=> string(1) "b"
+     * //   [2]=> string(1) "c"
+     * //   [3]=> bool(false)
+     * // }
+     * 
+ */ + public function mget(array $array) + { + } + + /** + * @see mset() + * @param array $array + * @return int 1 (if the keys were set) or 0 (no key was set) + * + * @link https://redis.io/commands/msetnx + */ + public function msetnx(array $array) + { + } + + /** + * Pops a value from the tail of a list, and pushes it to the front of another list. + * Also return this value. + * + * @since redis >= 1.1 + * + * @param string $srcKey + * @param string $dstKey + * + * @return string|mixed|bool The element that was moved in case of success, FALSE in case of failure. + * + * @link https://redis.io/commands/rpoplpush + * @example + *
+     * $redis->del('x', 'y');
+     *
+     * $redis->lPush('x', 'abc');
+     * $redis->lPush('x', 'def');
+     * $redis->lPush('y', '123');
+     * $redis->lPush('y', '456');
+     *
+     * // move the last of x to the front of y.
+     * var_dump($redis->rpoplpush('x', 'y'));
+     * var_dump($redis->lRange('x', 0, -1));
+     * var_dump($redis->lRange('y', 0, -1));
+     *
+     * //Output:
+     * //
+     * //string(3) "abc"
+     * //array(1) {
+     * //  [0]=>
+     * //  string(3) "def"
+     * //}
+     * //array(3) {
+     * //  [0]=>
+     * //  string(3) "abc"
+     * //  [1]=>
+     * //  string(3) "456"
+     * //  [2]=>
+     * //  string(3) "123"
+     * //}
+     * 
+ */ + public function rpoplpush($srcKey, $dstKey) + { + } + + /** + * A blocking version of rpoplpush, with an integral timeout in the third parameter. + * + * @param string $srcKey + * @param string $dstKey + * @param int $timeout + * + * @return string|mixed|bool The element that was moved in case of success, FALSE in case of timeout + * + * @link https://redis.io/commands/brpoplpush + */ + public function brpoplpush($srcKey, $dstKey, $timeout) + { + } + + /** + * Adds the specified member with a given score to the sorted set stored at key + * + * @param string $key Required key + * @param array $options Options if needed + * @param float $score1 Required score + * @param string|mixed $value1 Required value + * @param float $score2 Optional score + * @param string|mixed $value2 Optional value + * @param float $scoreN Optional score + * @param string|mixed $valueN Optional value + * + * @return int Number of values added + * + * @link https://redis.io/commands/zadd + * @example + *
+     * 
+     * $redis->zAdd('z', 1, 'v1', 2, 'v2', 3, 'v3', 4, 'v4' );  // int(2)
+     * $redis->zRem('z', 'v2', 'v3');                           // int(2)
+     * $redis->zAdd('z', ['NX'], 5, 'v5');                      // int(1)
+     * $redis->zAdd('z', ['NX'], 6, 'v5');                      // int(0)
+     * $redis->zAdd('z', 7, 'v6');                              // int(1)
+     * $redis->zAdd('z', 8, 'v6');                              // int(0)
+     *
+     * var_dump( $redis->zRange('z', 0, -1) );
+     * // Output:
+     * // array(4) {
+     * //   [0]=> string(2) "v1"
+     * //   [1]=> string(2) "v4"
+     * //   [2]=> string(2) "v5"
+     * //   [3]=> string(2) "v8"
+     * // }
+     *
+     * var_dump( $redis->zRange('z', 0, -1, true) );
+     * // Output:
+     * // array(4) {
+     * //   ["v1"]=> float(1)
+     * //   ["v4"]=> float(4)
+     * //   ["v5"]=> float(5)
+     * //   ["v6"]=> float(8)
+     * 
+ *
+ */ + public function zAdd($key, $options, $score1, $value1, $score2 = null, $value2 = null, $scoreN = null, $valueN = null) + { + } + + /** + * Returns a range of elements from the ordered set stored at the specified key, + * with values in the range [start, end]. start and stop are interpreted as zero-based indices: + * 0 the first element, + * 1 the second ... + * -1 the last element, + * -2 the penultimate ... + * + * @param string $key + * @param int $start + * @param int $end + * @param bool $withscores + * + * @return array Array containing the values in specified range. + * + * @link https://redis.io/commands/zrange + * @example + *
+     * $redis->zAdd('key1', 0, 'val0');
+     * $redis->zAdd('key1', 2, 'val2');
+     * $redis->zAdd('key1', 10, 'val10');
+     * $redis->zRange('key1', 0, -1); // array('val0', 'val2', 'val10')
+     * // with scores
+     * $redis->zRange('key1', 0, -1, true); // array('val0' => 0, 'val2' => 2, 'val10' => 10)
+     * 
+ */ + public function zRange($key, $start, $end, $withscores = null) + { + } + + /** + * Deletes a specified member from the ordered set. + * + * @param string $key + * @param string|mixed $member1 + * @param string|mixed ...$otherMembers + * + * @return int Number of deleted values + * + * @link https://redis.io/commands/zrem + * @example + *
+     * $redis->zAdd('z', 1, 'v1', 2, 'v2', 3, 'v3', 4, 'v4' );  // int(2)
+     * $redis->zRem('z', 'v2', 'v3');                           // int(2)
+     * var_dump( $redis->zRange('z', 0, -1) );
+     * //// Output:
+     * // array(2) {
+     * //   [0]=> string(2) "v1"
+     * //   [1]=> string(2) "v4"
+     * // }
+     * 
+ */ + public function zRem($key, $member1, ...$otherMembers) + { + } + + /** + * @see zRem() + * @link https://redis.io/commands/zrem + * @deprecated use Redis::zRem() + * + * @param string $key + * @param string|mixed $member1 + * @param string|mixed ...$otherMembers + * + * @return int Number of deleted values + */ + public function zDelete($key, $member1, ...$otherMembers) + { + } + + /** + * Returns the elements of the sorted set stored at the specified key in the range [start, end] + * in reverse order. start and stop are interpretated as zero-based indices: + * 0 the first element, + * 1 the second ... + * -1 the last element, + * -2 the penultimate ... + * + * @param string $key + * @param int $start + * @param int $end + * @param bool $withscore + * + * @return array Array containing the values in specified range. + * + * @link https://redis.io/commands/zrevrange + * @example + *
+     * $redis->zAdd('key', 0, 'val0');
+     * $redis->zAdd('key', 2, 'val2');
+     * $redis->zAdd('key', 10, 'val10');
+     * $redis->zRevRange('key', 0, -1); // array('val10', 'val2', 'val0')
+     *
+     * // with scores
+     * $redis->zRevRange('key', 0, -1, true); // array('val10' => 10, 'val2' => 2, 'val0' => 0)
+     * 
+ */ + public function zRevRange($key, $start, $end, $withscore = null) + { + } + + /** + * Returns the elements of the sorted set stored at the specified key which have scores in the + * range [start,end]. Adding a parenthesis before start or end excludes it from the range. + * +inf and -inf are also valid limits. + * + * zRevRangeByScore returns the same items in reverse order, when the start and end parameters are swapped. + * + * @param string $key + * @param int $start + * @param int $end + * @param array $options Two options are available: + * - withscores => TRUE, + * - and limit => array($offset, $count) + * + * @return array Array containing the values in specified range. + * + * @link https://redis.io/commands/zrangebyscore + * @example + *
+     * $redis->zAdd('key', 0, 'val0');
+     * $redis->zAdd('key', 2, 'val2');
+     * $redis->zAdd('key', 10, 'val10');
+     * $redis->zRangeByScore('key', 0, 3);                                          // array('val0', 'val2')
+     * $redis->zRangeByScore('key', 0, 3, array('withscores' => TRUE);              // array('val0' => 0, 'val2' => 2)
+     * $redis->zRangeByScore('key', 0, 3, array('limit' => array(1, 1));                        // array('val2')
+     * $redis->zRangeByScore('key', 0, 3, array('withscores' => TRUE, 'limit' => array(1, 1));  // array('val2' => 2)
+     * 
+ */ + public function zRangeByScore($key, $start, $end, array $options = array()) + { + } + + /** + * @see zRangeByScore() + * @param string $key + * @param int $start + * @param int $end + * @param array $options + * + * @return array + */ + public function zRevRangeByScore($key, $start, $end, array $options = array()) + { + } + + /** + * Returns a lexigraphical range of members in a sorted set, assuming the members have the same score. The + * min and max values are required to start with '(' (exclusive), '[' (inclusive), or be exactly the values + * '-' (negative inf) or '+' (positive inf). The command must be called with either three *or* five + * arguments or will return FALSE. + * + * @param string $key The ZSET you wish to run against. + * @param int $min The minimum alphanumeric value you wish to get. + * @param int $max The maximum alphanumeric value you wish to get. + * @param int $offset Optional argument if you wish to start somewhere other than the first element. + * @param int $limit Optional argument if you wish to limit the number of elements returned. + * + * @return array|bool Array containing the values in the specified range. + * + * @link https://redis.io/commands/zrangebylex + * @example + *
+     * foreach (array('a', 'b', 'c', 'd', 'e', 'f', 'g') as $char) {
+     *     $redis->zAdd('key', $char);
+     * }
+     *
+     * $redis->zRangeByLex('key', '-', '[c'); // array('a', 'b', 'c')
+     * $redis->zRangeByLex('key', '-', '(c'); // array('a', 'b')
+     * $redis->zRangeByLex('key', '-', '[c'); // array('b', 'c')
+     * 
+ */ + public function zRangeByLex($key, $min, $max, $offset = null, $limit = null) + { + } + + /** + * @see zRangeByLex() + * @param string $key + * @param int $min + * @param int $max + * @param int $offset + * @param int $limit + * + * @return array + * + * @link https://redis.io/commands/zrevrangebylex + */ + public function zRevRangeByLex($key, $min, $max, $offset = null, $limit = null) + { + } + + /** + * Returns the number of elements of the sorted set stored at the specified key which have + * scores in the range [start,end]. Adding a parenthesis before start or end excludes it + * from the range. +inf and -inf are also valid limits. + * + * @param string $key + * @param string $start + * @param string $end + * + * @return int the size of a corresponding zRangeByScore + * + * @link https://redis.io/commands/zcount + * @example + *
+     * $redis->zAdd('key', 0, 'val0');
+     * $redis->zAdd('key', 2, 'val2');
+     * $redis->zAdd('key', 10, 'val10');
+     * $redis->zCount('key', 0, 3); // 2, corresponding to array('val0', 'val2')
+     * 
+ */ + public function zCount($key, $start, $end) + { + } + + /** + * Deletes the elements of the sorted set stored at the specified key which have scores in the range [start,end]. + * + * @param string $key + * @param float|string $start double or "+inf" or "-inf" string + * @param float|string $end double or "+inf" or "-inf" string + * + * @return int The number of values deleted from the sorted set + * + * @link https://redis.io/commands/zremrangebyscore + * @example + *
+     * $redis->zAdd('key', 0, 'val0');
+     * $redis->zAdd('key', 2, 'val2');
+     * $redis->zAdd('key', 10, 'val10');
+     * $redis->zRemRangeByScore('key', 0, 3); // 2
+     * 
+ */ + public function zRemRangeByScore($key, $start, $end) + { + } + + /** + * @see zRemRangeByScore() + * @deprecated use Redis::zRemRangeByScore() + * + * @param string $key + * @param float $start + * @param float $end + */ + public function zDeleteRangeByScore($key, $start, $end) + { + } + + /** + * Deletes the elements of the sorted set stored at the specified key which have rank in the range [start,end]. + * + * @param string $key + * @param int $start + * @param int $end + * + * @return int The number of values deleted from the sorted set + * + * @link https://redis.io/commands/zremrangebyrank + * @example + *
+     * $redis->zAdd('key', 1, 'one');
+     * $redis->zAdd('key', 2, 'two');
+     * $redis->zAdd('key', 3, 'three');
+     * $redis->zRemRangeByRank('key', 0, 1); // 2
+     * $redis->zRange('key', 0, -1, array('withscores' => TRUE)); // array('three' => 3)
+     * 
+ */ + public function zRemRangeByRank($key, $start, $end) + { + } + + /** + * @see zRemRangeByRank() + * @link https://redis.io/commands/zremrangebyscore + * @deprecated use Redis::zRemRangeByRank() + * + * @param string $key + * @param int $start + * @param int $end + */ + public function zDeleteRangeByRank($key, $start, $end) + { + } + + /** + * Returns the cardinality of an ordered set. + * + * @param string $key + * + * @return int the set's cardinality + * + * @link https://redis.io/commands/zsize + * @example + *
+     * $redis->zAdd('key', 0, 'val0');
+     * $redis->zAdd('key', 2, 'val2');
+     * $redis->zAdd('key', 10, 'val10');
+     * $redis->zCard('key');            // 3
+     * 
+ */ + public function zCard($key) + { + } + + /** + * @see zCard() + * @deprecated use Redis::zCard() + * + * @param string $key + * @return int + */ + public function zSize($key) + { + } + + /** + * Returns the score of a given member in the specified sorted set. + * + * @param string $key + * @param string|mixed $member + * + * @return float|bool false if member or key not exists + * + * @link https://redis.io/commands/zscore + * @example + *
+     * $redis->zAdd('key', 2.5, 'val2');
+     * $redis->zScore('key', 'val2'); // 2.5
+     * 
+ */ + public function zScore($key, $member) + { + } + + /** + * Returns the rank of a given member in the specified sorted set, starting at 0 for the item + * with the smallest score. zRevRank starts at 0 for the item with the largest score. + * + * @param string $key + * @param string|mixed $member + * + * @return int|bool the item's score, or false if key or member is not exists + * + * @link https://redis.io/commands/zrank + * @example + *
+     * $redis->del('z');
+     * $redis->zAdd('key', 1, 'one');
+     * $redis->zAdd('key', 2, 'two');
+     * $redis->zRank('key', 'one');     // 0
+     * $redis->zRank('key', 'two');     // 1
+     * $redis->zRevRank('key', 'one');  // 1
+     * $redis->zRevRank('key', 'two');  // 0
+     * 
+ */ + public function zRank($key, $member) + { + } + + /** + * @see zRank() + * @param string $key + * @param string|mixed $member + * + * @return int|bool the item's score, false - if key or member is not exists + * + * @link https://redis.io/commands/zrevrank + */ + public function zRevRank($key, $member) + { + } + + /** + * Increments the score of a member from a sorted set by a given amount. + * + * @param string $key + * @param float $value (double) value that will be added to the member's score + * @param string $member + * + * @return float the new value + * + * @link https://redis.io/commands/zincrby + * @example + *
+     * $redis->del('key');
+     * $redis->zIncrBy('key', 2.5, 'member1');  // key or member1 didn't exist, so member1's score is to 0
+     *                                          // before the increment and now has the value 2.5
+     * $redis->zIncrBy('key', 1, 'member1');    // 3.5
+     * 
+ */ + public function zIncrBy($key, $value, $member) + { + } + + /** + * Creates an union of sorted sets given in second argument. + * The result of the union will be stored in the sorted set defined by the first argument. + * The third optionnel argument defines weights to apply to the sorted sets in input. + * In this case, the weights will be multiplied by the score of each element in the sorted set + * before applying the aggregation. The forth argument defines the AGGREGATE option which + * specify how the results of the union are aggregated. + * + * @param string $output + * @param array $zSetKeys + * @param array $weights + * @param string $aggregateFunction Either "SUM", "MIN", or "MAX": defines the behaviour to use on + * duplicate entries during the zUnionStore + * + * @return int The number of values in the new sorted set + * + * @link https://redis.io/commands/zunionstore + * @example + *
+     * $redis->del('k1');
+     * $redis->del('k2');
+     * $redis->del('k3');
+     * $redis->del('ko1');
+     * $redis->del('ko2');
+     * $redis->del('ko3');
+     *
+     * $redis->zAdd('k1', 0, 'val0');
+     * $redis->zAdd('k1', 1, 'val1');
+     *
+     * $redis->zAdd('k2', 2, 'val2');
+     * $redis->zAdd('k2', 3, 'val3');
+     *
+     * $redis->zUnionStore('ko1', array('k1', 'k2')); // 4, 'ko1' => array('val0', 'val1', 'val2', 'val3')
+     *
+     * // Weighted zUnionStore
+     * $redis->zUnionStore('ko2', array('k1', 'k2'), array(1, 1)); // 4, 'ko2' => array('val0', 'val1', 'val2', 'val3')
+     * $redis->zUnionStore('ko3', array('k1', 'k2'), array(5, 1)); // 4, 'ko3' => array('val0', 'val2', 'val3', 'val1')
+     * 
+ */ + public function zUnionStore($output, $zSetKeys, array $weights = null, $aggregateFunction = 'SUM') + { + } + + /** + * @see zUnionStore + * @deprecated use Redis::zUnionStore() + * + * @param string $Output + * @param array $ZSetKeys + * @param array|null $Weights + * @param string $aggregateFunction + */ + public function zUnion($Output, $ZSetKeys, array $Weights = null, $aggregateFunction = 'SUM') + { + } + + /** + * Creates an intersection of sorted sets given in second argument. + * The result of the union will be stored in the sorted set defined by the first argument. + * The third optional argument defines weights to apply to the sorted sets in input. + * In this case, the weights will be multiplied by the score of each element in the sorted set + * before applying the aggregation. The forth argument defines the AGGREGATE option which + * specify how the results of the union are aggregated. + * + * @param string $output + * @param array $zSetKeys + * @param array $weights + * @param string $aggregateFunction Either "SUM", "MIN", or "MAX": + * defines the behaviour to use on duplicate entries during the zInterStore. + * + * @return int The number of values in the new sorted set. + * + * @link https://redis.io/commands/zinterstore + * @example + *
+     * $redis->del('k1');
+     * $redis->del('k2');
+     * $redis->del('k3');
+     *
+     * $redis->del('ko1');
+     * $redis->del('ko2');
+     * $redis->del('ko3');
+     * $redis->del('ko4');
+     *
+     * $redis->zAdd('k1', 0, 'val0');
+     * $redis->zAdd('k1', 1, 'val1');
+     * $redis->zAdd('k1', 3, 'val3');
+     *
+     * $redis->zAdd('k2', 2, 'val1');
+     * $redis->zAdd('k2', 3, 'val3');
+     *
+     * $redis->zInterStore('ko1', array('k1', 'k2'));               // 2, 'ko1' => array('val1', 'val3')
+     * $redis->zInterStore('ko2', array('k1', 'k2'), array(1, 1));  // 2, 'ko2' => array('val1', 'val3')
+     *
+     * // Weighted zInterStore
+     * $redis->zInterStore('ko3', array('k1', 'k2'), array(1, 5), 'min'); // 2, 'ko3' => array('val1', 'val3')
+     * $redis->zInterStore('ko4', array('k1', 'k2'), array(1, 5), 'max'); // 2, 'ko4' => array('val3', 'val1')
+     * 
+ */ + public function zInterStore($output, $zSetKeys, array $weights = null, $aggregateFunction = 'SUM') + { + } + + /** + * @see zInterStore + * @deprecated use Redis::zInterStore() + * + * @param $Output + * @param $ZSetKeys + * @param array|null $Weights + * @param string $aggregateFunction + */ + public function zInter($Output, $ZSetKeys, array $Weights = null, $aggregateFunction = 'SUM') + { + } + + /** + * Scan a sorted set for members, with optional pattern and count + * + * @param string $key String, the set to scan. + * @param int $iterator Long (reference), initialized to NULL. + * @param string $pattern String (optional), the pattern to match. + * @param int $count How many keys to return per iteration (Redis might return a different number). + * + * @return array|bool PHPRedis will return matching keys from Redis, or FALSE when iteration is complete + * + * @link https://redis.io/commands/zscan + * @example + *
+     * $iterator = null;
+     * while ($members = $redis-zscan('zset', $iterator)) {
+     *     foreach ($members as $member => $score) {
+     *         echo $member . ' => ' . $score . PHP_EOL;
+     *     }
+     * }
+     * 
+ */ + public function zScan($key, &$iterator, $pattern = null, $count = 0) + { + } + + /** + * Block until Redis can pop the highest or lowest scoring members from one or more ZSETs. + * There are two commands (BZPOPMIN and BZPOPMAX for popping the lowest and highest scoring elements respectively.) + * + * @param string|array $key1 + * @param string|array $key2 ... + * @param int $timeout + * + * @return array Either an array with the key member and score of the higest or lowest element or an empty array + * if the timeout was reached without an element to pop. + * + * @since >= 5.0 + * @link https://redis.io/commands/bzpopmax + * @example + *
+     * // Wait up to 5 seconds to pop the *lowest* scoring member from sets `zs1` and `zs2`.
+     * $redis->bzPopMin(['zs1', 'zs2'], 5);
+     * $redis->bzPopMin('zs1', 'zs2', 5);
+     *
+     * // Wait up to 5 seconds to pop the *highest* scoring member from sets `zs1` and `zs2`
+     * $redis->bzPopMax(['zs1', 'zs2'], 5);
+     * $redis->bzPopMax('zs1', 'zs2', 5);
+     * 
+ */ + public function bzPopMax($key1, $key2, $timeout) + { + } + + /** + * @param string|array $key1 + * @param string|array $key2 ... + * @param int $timeout + * + * @return array Either an array with the key member and score of the higest or lowest element or an empty array + * if the timeout was reached without an element to pop. + * + * @see bzPopMax + * @since >= 5.0 + * @link https://redis.io/commands/bzpopmin + */ + public function bzPopMin($key1, $key2, $timeout) + { + } + + /** + * Adds a value to the hash stored at key. If this value is already in the hash, FALSE is returned. + * + * @param string $key + * @param string $hashKey + * @param string $value + * + * @return int|bool + * - 1 if value didn't exist and was added successfully, + * - 0 if the value was already present and was replaced, FALSE if there was an error. + * + * @link https://redis.io/commands/hset + * @example + *
+     * $redis->del('h')
+     * $redis->hSet('h', 'key1', 'hello');  // 1, 'key1' => 'hello' in the hash at "h"
+     * $redis->hGet('h', 'key1');           // returns "hello"
+     *
+     * $redis->hSet('h', 'key1', 'plop');   // 0, value was replaced.
+     * $redis->hGet('h', 'key1');           // returns "plop"
+     * 
+ */ + public function hSet($key, $hashKey, $value) + { + } + + /** + * Adds a value to the hash stored at key only if this field isn't already in the hash. + * + * @param string $key + * @param string $hashKey + * @param string $value + * + * @return bool TRUE if the field was set, FALSE if it was already present. + * + * @link https://redis.io/commands/hsetnx + * @example + *
+     * $redis->del('h')
+     * $redis->hSetNx('h', 'key1', 'hello'); // TRUE, 'key1' => 'hello' in the hash at "h"
+     * $redis->hSetNx('h', 'key1', 'world'); // FALSE, 'key1' => 'hello' in the hash at "h". No change since the field
+     * wasn't replaced.
+     * 
+ */ + public function hSetNx($key, $hashKey, $value) + { + } + + /** + * Gets a value from the hash stored at key. + * If the hash table doesn't exist, or the key doesn't exist, FALSE is returned. + * + * @param string $key + * @param string $hashKey + * + * @return string The value, if the command executed successfully BOOL FALSE in case of failure + * + * @link https://redis.io/commands/hget + */ + public function hGet($key, $hashKey) + { + } + + /** + * Returns the length of a hash, in number of items + * + * @param string $key + * + * @return int|false the number of items in a hash, FALSE if the key doesn't exist or isn't a hash + * + * @link https://redis.io/commands/hlen + * @example + *
+     * $redis->del('h')
+     * $redis->hSet('h', 'key1', 'hello');
+     * $redis->hSet('h', 'key2', 'plop');
+     * $redis->hLen('h'); // returns 2
+     * 
+ */ + public function hLen($key) + { + } + + /** + * Removes a values from the hash stored at key. + * If the hash table doesn't exist, or the key doesn't exist, FALSE is returned. + * + * @param string $key + * @param string $hashKey1 + * @param string ...$otherHashKeys + * + * @return int|false Number of deleted fields + * + * @link https://redis.io/commands/hdel + * @example + *
+     * $redis->hMSet('h',
+     *               array(
+     *                    'f1' => 'v1',
+     *                    'f2' => 'v2',
+     *                    'f3' => 'v3',
+     *                    'f4' => 'v4',
+     *               ));
+     *
+     * var_dump( $redis->hDel('h', 'f1') );        // int(1)
+     * var_dump( $redis->hDel('h', 'f2', 'f3') );  // int(2)
+     * s
+     * var_dump( $redis->hGetAll('h') );
+     * //// Output:
+     * //  array(1) {
+     * //    ["f4"]=> string(2) "v4"
+     * //  }
+     * 
+ */ + public function hDel($key, $hashKey1, ...$otherHashKeys) + { + } + + /** + * Returns the keys in a hash, as an array of strings. + * + * @param string $key + * + * @return array An array of elements, the keys of the hash. This works like PHP's array_keys(). + * + * @link https://redis.io/commands/hkeys + * @example + *
+     * $redis->del('h');
+     * $redis->hSet('h', 'a', 'x');
+     * $redis->hSet('h', 'b', 'y');
+     * $redis->hSet('h', 'c', 'z');
+     * $redis->hSet('h', 'd', 't');
+     * var_dump($redis->hKeys('h'));
+     *
+     * // Output:
+     * // array(4) {
+     * // [0]=>
+     * // string(1) "a"
+     * // [1]=>
+     * // string(1) "b"
+     * // [2]=>
+     * // string(1) "c"
+     * // [3]=>
+     * // string(1) "d"
+     * // }
+     * // The order is random and corresponds to redis' own internal representation of the set structure.
+     * 
+ */ + public function hKeys($key) + { + } + + /** + * Returns the values in a hash, as an array of strings. + * + * @param string $key + * + * @return array An array of elements, the values of the hash. This works like PHP's array_values(). + * + * @link https://redis.io/commands/hvals + * @example + *
+     * $redis->del('h');
+     * $redis->hSet('h', 'a', 'x');
+     * $redis->hSet('h', 'b', 'y');
+     * $redis->hSet('h', 'c', 'z');
+     * $redis->hSet('h', 'd', 't');
+     * var_dump($redis->hVals('h'));
+     *
+     * // Output
+     * // array(4) {
+     * //   [0]=>
+     * //   string(1) "x"
+     * //   [1]=>
+     * //   string(1) "y"
+     * //   [2]=>
+     * //   string(1) "z"
+     * //   [3]=>
+     * //   string(1) "t"
+     * // }
+     * // The order is random and corresponds to redis' own internal representation of the set structure.
+     * 
+ */ + public function hVals($key) + { + } + + /** + * Returns the whole hash, as an array of strings indexed by strings. + * + * @param string $key + * + * @return array An array of elements, the contents of the hash. + * + * @link https://redis.io/commands/hgetall + * @example + *
+     * $redis->del('h');
+     * $redis->hSet('h', 'a', 'x');
+     * $redis->hSet('h', 'b', 'y');
+     * $redis->hSet('h', 'c', 'z');
+     * $redis->hSet('h', 'd', 't');
+     * var_dump($redis->hGetAll('h'));
+     *
+     * // Output:
+     * // array(4) {
+     * //   ["a"]=>
+     * //   string(1) "x"
+     * //   ["b"]=>
+     * //   string(1) "y"
+     * //   ["c"]=>
+     * //   string(1) "z"
+     * //   ["d"]=>
+     * //   string(1) "t"
+     * // }
+     * // The order is random and corresponds to redis' own internal representation of the set structure.
+     * 
+ */ + public function hGetAll($key) + { + } + + /** + * Verify if the specified member exists in a key. + * + * @param string $key + * @param string $hashKey + * + * @return bool If the member exists in the hash table, return TRUE, otherwise return FALSE. + * + * @link https://redis.io/commands/hexists + * @example + *
+     * $redis->hSet('h', 'a', 'x');
+     * $redis->hExists('h', 'a');               //  TRUE
+     * $redis->hExists('h', 'NonExistingKey');  // FALSE
+     * 
+ */ + public function hExists($key, $hashKey) + { + } + + /** + * Increments the value of a member from a hash by a given amount. + * + * @param string $key + * @param string $hashKey + * @param int $value (integer) value that will be added to the member's value + * + * @return int the new value + * + * @link https://redis.io/commands/hincrby + * @example + *
+     * $redis->del('h');
+     * $redis->hIncrBy('h', 'x', 2); // returns 2: h[x] = 2 now.
+     * $redis->hIncrBy('h', 'x', 1); // h[x] ← 2 + 1. Returns 3
+     * 
+ */ + public function hIncrBy($key, $hashKey, $value) + { + } + + /** + * Increment the float value of a hash field by the given amount + * + * @param string $key + * @param string $field + * @param float $increment + * + * @return float + * + * @link https://redis.io/commands/hincrbyfloat + * @example + *
+     * $redis = new Redis();
+     * $redis->connect('127.0.0.1');
+     * $redis->hset('h', 'float', 3);
+     * $redis->hset('h', 'int',   3);
+     * var_dump( $redis->hIncrByFloat('h', 'float', 1.5) ); // float(4.5)
+     *
+     * var_dump( $redis->hGetAll('h') );
+     *
+     * // Output
+     *  array(2) {
+     *    ["float"]=>
+     *    string(3) "4.5"
+     *    ["int"]=>
+     *    string(1) "3"
+     *  }
+     * 
+ */ + public function hIncrByFloat($key, $field, $increment) + { + } + + /** + * Fills in a whole hash. Non-string values are converted to string, using the standard (string) cast. + * NULL values are stored as empty strings + * + * @param string $key + * @param array $hashKeys key → value array + * + * @return bool + * + * @link https://redis.io/commands/hmset + * @example + *
+     * $redis->del('user:1');
+     * $redis->hMSet('user:1', array('name' => 'Joe', 'salary' => 2000));
+     * $redis->hIncrBy('user:1', 'salary', 100); // Joe earns 100 more now.
+     * 
+ */ + public function hMSet($key, $hashKeys) + { + } + + /** + * Retirieve the values associated to the specified fields in the hash. + * + * @param string $key + * @param array $hashKeys + * + * @return array Array An array of elements, the values of the specified fields in the hash, + * with the hash keys as array keys. + * + * @link https://redis.io/commands/hmget + * @example + *
+     * $redis->del('h');
+     * $redis->hSet('h', 'field1', 'value1');
+     * $redis->hSet('h', 'field2', 'value2');
+     * $redis->hmGet('h', array('field1', 'field2')); // returns array('field1' => 'value1', 'field2' => 'value2')
+     * 
+ */ + public function hMGet($key, $hashKeys) + { + } + + /** + * Scan a HASH value for members, with an optional pattern and count. + * + * @param string $key + * @param int $iterator + * @param string $pattern Optional pattern to match against. + * @param int $count How many keys to return in a go (only a sugestion to Redis). + * + * @return array An array of members that match our pattern. + * + * @link https://redis.io/commands/hscan + * @example + *
+     * // $iterator = null;
+     * // while($elements = $redis->hscan('hash', $iterator)) {
+     * //     foreach($elements as $key => $value) {
+     * //         echo $key . ' => ' . $value . PHP_EOL;
+     * //     }
+     * // }
+     * 
+ */ + public function hScan($key, &$iterator, $pattern = null, $count = 0) + { + } + + /** + * Get the string length of the value associated with field in the hash stored at key + * + * @param string $key + * @param string $field + * + * @return int the string length of the value associated with field, or zero when field is not present in the hash + * or key does not exist at all. + * + * @link https://redis.io/commands/hstrlen + * @since >= 3.2 + */ + public function hStrLen(string $key, string $field) + { + } + + /** + * Add one or more geospatial items to the specified key. + * This function must be called with at least one longitude, latitude, member triplet. + * + * @param string $key + * @param float $longitude + * @param float $latitude + * @param string $member + * + * @return int The number of elements added to the geospatial key + * + * @link https://redis.io/commands/geoadd + * @since >=3.2 + * + * @example + *
+     * $redis->del("myplaces");
+     *
+     * // Since the key will be new, $result will be 2
+     * $result = $redis->geoAdd(
+     *   "myplaces",
+     *   -122.431, 37.773, "San Francisco",
+     *   -157.858, 21.315, "Honolulu"
+     * ); // 2
+     * 
+ */ + public function geoadd($key, $longitude, $latitude, $member) + { + } + + /** + * Retrieve Geohash strings for one or more elements of a geospatial index. + + * @param string $key + * @param string ...$member variadic list of members + * + * @return array One or more Redis Geohash encoded strings + * + * @link https://redis.io/commands/geohash + * @since >=3.2 + * + * @example + *
+     * $redis->geoAdd("hawaii", -157.858, 21.306, "Honolulu", -156.331, 20.798, "Maui");
+     * $hashes = $redis->geoHash("hawaii", "Honolulu", "Maui");
+     * var_dump($hashes);
+     * // Output: array(2) {
+     * //   [0]=>
+     * //   string(11) "87z9pyek3y0"
+     * //   [1]=>
+     * //   string(11) "8e8y6d5jps0"
+     * // }
+     * 
+ */ + public function geohash($key, ...$member) + { + } + + /** + * Return longitude, latitude positions for each requested member. + * + * @param string $key + * @param string $member + * @return array One or more longitude/latitude positions + * + * @link https://redis.io/commands/geopos + * @since >=3.2 + * + * @example + *
+     * $redis->geoAdd("hawaii", -157.858, 21.306, "Honolulu", -156.331, 20.798, "Maui");
+     * $positions = $redis->geoPos("hawaii", "Honolulu", "Maui");
+     * var_dump($positions);
+     *
+     * // Output:
+     * array(2) {
+     *  [0]=> array(2) {
+     *      [0]=> string(22) "-157.85800248384475708"
+     *      [1]=> string(19) "21.3060004581273077"
+     *  }
+     *  [1]=> array(2) {
+     *      [0]=> string(22) "-156.33099943399429321"
+     *      [1]=> string(20) "20.79799924753607598"
+     *  }
+     * }
+     * 
+ */ + public function geopos(string $key, string $member) + { + } + + /** + * Return the distance between two members in a geospatial set. + * + * If units are passed it must be one of the following values: + * - 'm' => Meters + * - 'km' => Kilometers + * - 'mi' => Miles + * - 'ft' => Feet + * + * @param string $key + * @param string $member1 + * @param string $member2 + * @param string|null $unit + * + * @return float The distance between the two passed members in the units requested (meters by default) + * + * @link https://redis.io/commands/geodist + * @since >=3.2 + * + * @example + *
+     * $redis->geoAdd("hawaii", -157.858, 21.306, "Honolulu", -156.331, 20.798, "Maui");
+     *
+     * $meters = $redis->geoDist("hawaii", "Honolulu", "Maui");
+     * $kilometers = $redis->geoDist("hawaii", "Honolulu", "Maui", 'km');
+     * $miles = $redis->geoDist("hawaii", "Honolulu", "Maui", 'mi');
+     * $feet = $redis->geoDist("hawaii", "Honolulu", "Maui", 'ft');
+     *
+     * echo "Distance between Honolulu and Maui:\n";
+     * echo "  meters    : $meters\n";
+     * echo "  kilometers: $kilometers\n";
+     * echo "  miles     : $miles\n";
+     * echo "  feet      : $feet\n";
+     *
+     * // Bad unit
+     * $inches = $redis->geoDist("hawaii", "Honolulu", "Maui", 'in');
+     * echo "Invalid unit returned:\n";
+     * var_dump($inches);
+     *
+     * // Output
+     * Distance between Honolulu and Maui:
+     * meters    : 168275.204
+     * kilometers: 168.2752
+     * miles     : 104.5616
+     * feet      : 552084.0028
+     * Invalid unit returned:
+     * bool(false)
+     * 
+ */ + public function geodist($key, $member1, $member2, $unit = null) + { + } + + /** + * Return members of a set with geospatial information that are within the radius specified by the caller. + * + * @param $key + * @param $longitude + * @param $latitude + * @param $radius + * @param $unit + * @param array|null $options + *
+     * |Key         |Value          |Description                                        |
+     * |------------|---------------|---------------------------------------------------|
+     * |COUNT       |integer > 0    |Limit how many results are returned                |
+     * |            |WITHCOORD      |Return longitude and latitude of matching members  |
+     * |            |WITHDIST       |Return the distance from the center                |
+     * |            |WITHHASH       |Return the raw geohash-encoded score               |
+     * |            |ASC            |Sort results in ascending order                    |
+     * |            |DESC           |Sort results in descending order                   |
+     * |STORE       |key            |Store results in key                               |
+     * |STOREDIST   |key            |Store the results as distances in key              |
+     * 
+ * Note: It doesn't make sense to pass both ASC and DESC options but if both are passed + * the last one passed will be used. + * Note: When using STORE[DIST] in Redis Cluster, the store key must has to the same slot as + * the query key or you will get a CROSSLOT error. + * @return mixed When no STORE option is passed, this function returns an array of results. + * If it is passed this function returns the number of stored entries. + * + * @link https://redis.io/commands/georadius + * @since >= 3.2 + * @example + *
+     * // Add some cities
+     * $redis->geoAdd("hawaii", -157.858, 21.306, "Honolulu", -156.331, 20.798, "Maui");
+     *
+     * echo "Within 300 miles of Honolulu:\n";
+     * var_dump($redis->geoRadius("hawaii", -157.858, 21.306, 300, 'mi'));
+     *
+     * echo "\nWithin 300 miles of Honolulu with distances:\n";
+     * $options = ['WITHDIST'];
+     * var_dump($redis->geoRadius("hawaii", -157.858, 21.306, 300, 'mi', $options));
+     *
+     * echo "\nFirst result within 300 miles of Honolulu with distances:\n";
+     * $options['count'] = 1;
+     * var_dump($redis->geoRadius("hawaii", -157.858, 21.306, 300, 'mi', $options));
+     *
+     * echo "\nFirst result within 300 miles of Honolulu with distances in descending sort order:\n";
+     * $options[] = 'DESC';
+     * var_dump($redis->geoRadius("hawaii", -157.858, 21.306, 300, 'mi', $options));
+     *
+     * // Output
+     * Within 300 miles of Honolulu:
+     * array(2) {
+     *  [0]=> string(8) "Honolulu"
+     *  [1]=> string(4) "Maui"
+     * }
+     *
+     * Within 300 miles of Honolulu with distances:
+     * array(2) {
+     *     [0]=>
+     *   array(2) {
+     *         [0]=>
+     *     string(8) "Honolulu"
+     *         [1]=>
+     *     string(6) "0.0002"
+     *   }
+     *   [1]=>
+     *   array(2) {
+     *         [0]=>
+     *     string(4) "Maui"
+     *         [1]=>
+     *     string(8) "104.5615"
+     *   }
+     * }
+     *
+     * First result within 300 miles of Honolulu with distances:
+     * array(1) {
+     *     [0]=>
+     *   array(2) {
+     *         [0]=>
+     *     string(8) "Honolulu"
+     *         [1]=>
+     *     string(6) "0.0002"
+     *   }
+     * }
+     *
+     * First result within 300 miles of Honolulu with distances in descending sort order:
+     * array(1) {
+     *     [0]=>
+     *   array(2) {
+     *         [0]=>
+     *     string(4) "Maui"
+     *         [1]=>
+     *     string(8) "104.5615"
+     *   }
+     * }
+     * 
+ */ + public function georadius($key, $longitude, $latitude, $radius, $unit, array $options = null) + { + } + + /** + * This method is identical to geoRadius except that instead of passing a longitude and latitude as the "source" + * you pass an existing member in the geospatial set + * + * @param string $key + * @param string $member + * @param $radius + * @param $units + * @param array|null $options see georadius + * + * @return array The zero or more entries that are close enough to the member given the distance and radius specified + * + * @link https://redis.io/commands/georadiusbymember + * @since >= 3.2 + * @see georadius + * @example + *
+     * $redis->geoAdd("hawaii", -157.858, 21.306, "Honolulu", -156.331, 20.798, "Maui");
+     *
+     * echo "Within 300 miles of Honolulu:\n";
+     * var_dump($redis->geoRadiusByMember("hawaii", "Honolulu", 300, 'mi'));
+     *
+     * echo "\nFirst match within 300 miles of Honolulu:\n";
+     * var_dump($redis->geoRadiusByMember("hawaii", "Honolulu", 300, 'mi', ['count' => 1]));
+     *
+     * // Output
+     * Within 300 miles of Honolulu:
+     * array(2) {
+     *  [0]=> string(8) "Honolulu"
+     *  [1]=> string(4) "Maui"
+     * }
+     *
+     * First match within 300 miles of Honolulu:
+     * array(1) {
+     *  [0]=> string(8) "Honolulu"
+     * }
+     * 
+ */ + public function georadiusbymember($key, $member, $radius, $units, array $options = null) + { + } + + /** + * Get or Set the redis config keys. + * + * @param string $operation either `GET` or `SET` + * @param string $key for `SET`, glob-pattern for `GET` + * @param string|mixed $value optional string (only for `SET`) + * + * @return array Associative array for `GET`, key -> value + * + * @link https://redis.io/commands/config-get + * @example + *
+     * $redis->config("GET", "*max-*-entries*");
+     * $redis->config("SET", "dir", "/var/run/redis/dumps/");
+     * 
+ */ + public function config($operation, $key, $value) + { + } + + /** + * Evaluate a LUA script serverside + * + * @param string $script + * @param array $args + * @param int $numKeys + * + * @return mixed What is returned depends on what the LUA script itself returns, which could be a scalar value + * (int/string), or an array. Arrays that are returned can also contain other arrays, if that's how it was set up in + * your LUA script. If there is an error executing the LUA script, the getLastError() function can tell you the + * message that came back from Redis (e.g. compile error). + * + * @link https://redis.io/commands/eval + * @example + *
+     * $redis->eval("return 1"); // Returns an integer: 1
+     * $redis->eval("return {1,2,3}"); // Returns Array(1,2,3)
+     * $redis->del('mylist');
+     * $redis->rpush('mylist','a');
+     * $redis->rpush('mylist','b');
+     * $redis->rpush('mylist','c');
+     * // Nested response:  Array(1,2,3,Array('a','b','c'));
+     * $redis->eval("return {1,2,3,redis.call('lrange','mylist',0,-1)}}");
+     * 
+ */ + public function eval($script, $args = array(), $numKeys = 0) + { + } + + /** + * @see eval() + * @deprecated use Redis::eval() + * + * @param string $script + * @param array $args + * @param int $numKeys + * @return mixed @see eval() + */ + public function evaluate($script, $args = array(), $numKeys = 0) + { + } + + /** + * Evaluate a LUA script serverside, from the SHA1 hash of the script instead of the script itself. + * In order to run this command Redis will have to have already loaded the script, either by running it or via + * the SCRIPT LOAD command. + * + * @param string $scriptSha + * @param array $args + * @param int $numKeys + * + * @return mixed @see eval() + * + * @see eval() + * @link https://redis.io/commands/evalsha + * @example + *
+     * $script = 'return 1';
+     * $sha = $redis->script('load', $script);
+     * $redis->evalSha($sha); // Returns 1
+     * 
+ */ + public function evalSha($scriptSha, $args = array(), $numKeys = 0) + { + } + + /** + * @see evalSha() + * @deprecated use Redis::evalSha() + * + * @param string $scriptSha + * @param array $args + * @param int $numKeys + */ + public function evaluateSha($scriptSha, $args = array(), $numKeys = 0) + { + } + + /** + * Execute the Redis SCRIPT command to perform various operations on the scripting subsystem. + * @param string $command load | flush | kill | exists + * @param string $script + * + * @return mixed + * + * @link https://redis.io/commands/script-load + * @link https://redis.io/commands/script-kill + * @link https://redis.io/commands/script-flush + * @link https://redis.io/commands/script-exists + * @example + *
+     * $redis->script('load', $script);
+     * $redis->script('flush');
+     * $redis->script('kill');
+     * $redis->script('exists', $script1, [$script2, $script3, ...]);
+     * 
+ * + * SCRIPT LOAD will return the SHA1 hash of the passed script on success, and FALSE on failure. + * SCRIPT FLUSH should always return TRUE + * SCRIPT KILL will return true if a script was able to be killed and false if not + * SCRIPT EXISTS will return an array with TRUE or FALSE for each passed script + */ + public function script($command, $script) + { + } + + /** + * The last error message (if any) + * + * @return string|null A string with the last returned script based error message, or NULL if there is no error + * + * @example + *
+     * $redis->eval('this-is-not-lua');
+     * $err = $redis->getLastError();
+     * // "ERR Error compiling script (new function): user_script:1: '=' expected near '-'"
+     * 
+ */ + public function getLastError() + { + } + + /** + * Clear the last error message + * + * @return bool true + * + * @example + *
+     * $redis->set('x', 'a');
+     * $redis->incr('x');
+     * $err = $redis->getLastError();
+     * // "ERR value is not an integer or out of range"
+     * $redis->clearLastError();
+     * $err = $redis->getLastError();
+     * // NULL
+     * 
+ */ + public function clearLastError() + { + } + + /** + * Issue the CLIENT command with various arguments. + * The Redis CLIENT command can be used in four ways: + * - CLIENT LIST + * - CLIENT GETNAME + * - CLIENT SETNAME [name] + * - CLIENT KILL [ip:port] + * + * @param string $command + * @param string $value + * @return mixed This will vary depending on which client command was executed: + * - CLIENT LIST will return an array of arrays with client information. + * - CLIENT GETNAME will return the client name or false if none has been set + * - CLIENT SETNAME will return true if it can be set and false if not + * - CLIENT KILL will return true if the client can be killed, and false if not + * + * Note: phpredis will attempt to reconnect so you can actually kill your own connection but may not notice losing it! + * + * @link https://redis.io/commands/client-list + * @link https://redis.io/commands/client-getname + * @link https://redis.io/commands/client-setname + * @link https://redis.io/commands/client-kill + * + * @example + *
+     * $redis->client('list'); // Get a list of clients
+     * $redis->client('getname'); // Get the name of the current connection
+     * $redis->client('setname', 'somename'); // Set the name of the current connection
+     * $redis->client('kill', ); // Kill the process at ip:port
+     * 
+ */ + public function client($command, $value = '') + { + } + + /** + * A utility method to prefix the value with the prefix setting for phpredis. + * + * @param mixed $value The value you wish to prefix + * + * @return string If a prefix is set up, the value now prefixed. + * If there is no prefix, the value will be returned unchanged. + * + * @example + *
+     * $redis->setOption(Redis::OPT_PREFIX, 'my-prefix:');
+     * $redis->_prefix('my-value'); // Will return 'my-prefix:my-value'
+     * 
+ */ + public function _prefix($value) + { + } + + /** + * A utility method to unserialize data with whatever serializer is set up. If there is no serializer set, the + * value will be returned unchanged. If there is a serializer set up, and the data passed in is malformed, an + * exception will be thrown. This can be useful if phpredis is serializing values, and you return something from + * redis in a LUA script that is serialized. + * + * @param string $value The value to be unserialized + * + * @return mixed + * @example + *
+     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP);
+     * $redis->_unserialize('a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}'); // Will return Array(1,2,3)
+     * 
+ */ + public function _unserialize($value) + { + } + + /** + * A utility method to serialize values manually. This method allows you to serialize a value with whatever + * serializer is configured, manually. This can be useful for serialization/unserialization of data going in + * and out of EVAL commands as phpredis can't automatically do this itself. Note that if no serializer is + * set, phpredis will change Array values to 'Array', and Objects to 'Object'. + * + * @param mixed $value The value to be serialized. + * + * @return mixed + * @example + *
+     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE);
+     * $redis->_serialize("foo"); // returns "foo"
+     * $redis->_serialize(Array()); // Returns "Array"
+     * $redis->_serialize(new stdClass()); // Returns "Object"
+     *
+     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP);
+     * $redis->_serialize("foo"); // Returns 's:3:"foo";'
+     * 
+ */ + public function _serialize($value) + { + } + + /** + * Dump a key out of a redis database, the value of which can later be passed into redis using the RESTORE command. + * The data that comes out of DUMP is a binary representation of the key as Redis stores it. + * @param string $key + * + * @return string|bool The Redis encoded value of the key, or FALSE if the key doesn't exist + * + * @link https://redis.io/commands/dump + * @example + *
+     * $redis->set('foo', 'bar');
+     * $val = $redis->dump('foo'); // $val will be the Redis encoded key value
+     * 
+ */ + public function dump($key) + { + } + + /** + * Restore a key from the result of a DUMP operation. + * + * @param string $key The key name + * @param int $ttl How long the key should live (if zero, no expire will be set on the key) + * @param string $value (binary). The Redis encoded key value (from DUMP) + * + * @return bool + * + * @link https://redis.io/commands/restore + * @example + *
+     * $redis->set('foo', 'bar');
+     * $val = $redis->dump('foo');
+     * $redis->restore('bar', 0, $val); // The key 'bar', will now be equal to the key 'foo'
+     * 
+ */ + public function restore($key, $ttl, $value) + { + } + + /** + * Migrates a key to a different Redis instance. + * + * @param string $host The destination host + * @param int $port The TCP port to connect to. + * @param string $key The key to migrate. + * @param int $db The target DB. + * @param int $timeout The maximum amount of time given to this transfer. + * @param bool $copy Should we send the COPY flag to redis. + * @param bool $replace Should we send the REPLACE flag to redis. + * + * @return bool + * + * @link https://redis.io/commands/migrate + * @example + *
+     * $redis->migrate('backup', 6379, 'foo', 0, 3600);
+     * 
+ */ + public function migrate($host, $port, $key, $db, $timeout, $copy = false, $replace = false) + { + } + + /** + * Return the current Redis server time. + * + * @return array If successfull, the time will come back as an associative array with element zero being the + * unix timestamp, and element one being microseconds. + * + * @link https://redis.io/commands/time + * @example + *
+     * var_dump( $redis->time() );
+     * // array(2) {
+     * //   [0] => string(10) "1342364352"
+     * //   [1] => string(6) "253002"
+     * // }
+     * 
+ */ + public function time() + { + } + + /** + * Scan the keyspace for keys + * + * @param int $iterator Iterator, initialized to NULL. + * @param string $pattern Pattern to match. + * @param int $count Count of keys per iteration (only a suggestion to Redis). + * + * @return array|bool This function will return an array of keys or FALSE if there are no more keys. + * + * @link https://redis.io/commands/scan + * @example + *
+     * $iterator = null;
+     * while(false !== ($keys = $redis->scan($iterator))) {
+     *     foreach($keys as $key) {
+     *         echo $key . PHP_EOL;
+     *     }
+     * }
+     * 
+ */ + public function scan(&$iterator, $pattern = null, $count = 0) + { + } + + /** + * Adds all the element arguments to the HyperLogLog data structure stored at the key. + * + * @param string $key + * @param array $elements + * + * @return bool + * + * @link https://redis.io/commands/pfadd + * @example $redis->pfAdd('key', array('elem1', 'elem2')) + */ + public function pfAdd($key, array $elements) + { + } + + /** + * When called with a single key, returns the approximated cardinality computed by the HyperLogLog data + * structure stored at the specified variable, which is 0 if the variable does not exist. + * + * @param string|array $key + * + * @return int + * + * @link https://redis.io/commands/pfcount + * @example + *
+     * $redis->pfAdd('key1', array('elem1', 'elem2'));
+     * $redis->pfAdd('key2', array('elem3', 'elem2'));
+     * $redis->pfCount('key1'); // int(2)
+     * $redis->pfCount(array('key1', 'key2')); // int(3)
+     */
+    public function pfCount($key)
+    {
+    }
+
+    /**
+     * Merge multiple HyperLogLog values into an unique value that will approximate the cardinality
+     * of the union of the observed Sets of the source HyperLogLog structures.
+     *
+     * @param string $destKey
+     * @param array  $sourceKeys
+     *
+     * @return bool
+     *
+     * @link    https://redis.io/commands/pfmerge
+     * @example
+     * 
+     * $redis->pfAdd('key1', array('elem1', 'elem2'));
+     * $redis->pfAdd('key2', array('elem3', 'elem2'));
+     * $redis->pfMerge('key3', array('key1', 'key2'));
+     * $redis->pfCount('key3'); // int(3)
+     */
+    public function pfMerge($destKey, array $sourceKeys)
+    {
+    }
+
+    /**
+     * Send arbitrary things to the redis server.
+     *
+     * @param string $command   Required command to send to the server.
+     * @param mixed  $arguments Optional variable amount of arguments to send to the server.
+     *
+     * @return mixed
+     *
+     * @example
+     * 
+     * $redis->rawCommand('SET', 'key', 'value'); // bool(true)
+     * $redis->rawCommand('GET", 'key'); // string(5) "value"
+     * 
+ */ + public function rawCommand($command, $arguments) + { + } + + /** + * Detect whether we're in ATOMIC/MULTI/PIPELINE mode. + * + * @return int Either Redis::ATOMIC, Redis::MULTI or Redis::PIPELINE + * + * @example $redis->getMode(); + */ + public function getMode() + { + } + + /** + * Acknowledge one or more messages on behalf of a consumer group. + * + * @param string $stream + * @param string $group + * @param array $messages + * + * @return int The number of messages Redis reports as acknowledged. + * + * @link https://redis.io/commands/xack + * @example + *
+     * $redis->xAck('stream', 'group1', ['1530063064286-0', '1530063064286-1']);
+     * 
+ */ + public function xAck($stream, $group, $messages) + { + } + + /** + * Add a message to a stream + * + * @param string $key + * @param string $id + * @param array $messages + * @param int $maxLen + * @param bool $isApproximate + * + * @return string The added message ID. + * + * @link https://redis.io/commands/xadd + * @example + *
+     * $redis->xAdd('mystream', "*", ['field' => 'value']);
+     * $redis->xAdd('mystream', "*", ['field' => 'value'], 10);
+     * $redis->xAdd('mystream', "*", ['field' => 'value'], 10, true);
+     * 
+ */ + public function xAdd($key, $id, $messages, $maxLen = 0, $isApproximate = false) + { + } + + /** + * Claim ownership of one or more pending messages + * + * @param string $key + * @param string $group + * @param string $consumer + * @param int $minIdleTime + * @param array $ids + * @param array $options ['IDLE' => $value, 'TIME' => $value, 'RETRYCOUNT' => $value, 'FORCE', 'JUSTID'] + * + * @return array Either an array of message IDs along with corresponding data, or just an array of IDs + * (if the 'JUSTID' option was passed). + * + * @link https://redis.io/commands/xclaim + * @example + *
+     * $ids = ['1530113681011-0', '1530113681011-1', '1530113681011-2'];
+     *
+     * // Without any options
+     * $redis->xClaim('mystream', 'group1', 'myconsumer1', 0, $ids);
+     *
+     * // With options
+     * $redis->xClaim(
+     *     'mystream', 'group1', 'myconsumer2', 0, $ids,
+     *     [
+     *         'IDLE' => time() * 1000,
+     *         'RETRYCOUNT' => 5,
+     *         'FORCE',
+     *         'JUSTID'
+     *     ]
+     * );
+     * 
+ */ + public function xClaim($key, $group, $consumer, $minIdleTime, $ids, $options = []) + { + } + + /** + * Delete one or more messages from a stream + * + * @param string $key + * @param array $ids + * + * @return int The number of messages removed + * + * @link https://redis.io/commands/xdel + * @example + *
+     * $redis->xDel('mystream', ['1530115304877-0', '1530115305731-0']);
+     * 
+ */ + public function xDel($key, $ids) + { + } + + /** + * @param string $operation e.g.: 'HELP', 'SETID', 'DELGROUP', 'CREATE', 'DELCONSUMER' + * @param string $key + * @param string $group + * @param string $msgId + * @param bool $mkStream + * + * @return mixed This command returns different types depending on the specific XGROUP command executed. + * + * @link https://redis.io/commands/xgroup + * @example + *
+     * $redis->xGroup('CREATE', 'mystream', 'mygroup', 0);
+     * $redis->xGroup('CREATE', 'mystream', 'mygroup', 0, true); // create stream
+     * $redis->xGroup('DESTROY', 'mystream', 'mygroup');
+     * 
+ */ + public function xGroup($operation, $key, $group, $msgId = '', $mkStream = false) + { + } + + /** + * Get information about a stream or consumer groups + * + * @param string $operation e.g.: 'CONSUMERS', 'GROUPS', 'STREAM', 'HELP' + * @param string $stream + * @param string $group + * + * @return mixed This command returns different types depending on which subcommand is used. + * + * @link https://redis.io/commands/xinfo + * @example + *
+     * $redis->xInfo('STREAM', 'mystream');
+     * 
+ */ + public function xInfo($operation, $stream, $group) + { + } + + /** + * Get the length of a given stream. + * + * @param string $stream + * + * @return int The number of messages in the stream. + * + * @link https://redis.io/commands/xlen + * @example + *
+     * $redis->xLen('mystream');
+     * 
+ */ + public function xLen($stream) + { + } + + /** + * Get information about pending messages in a given stream + * + * @param string $stream + * @param string $group + * @param string $start + * @param string $end + * @param int $count + * @param string $consumer + * + * @return array Information about the pending messages, in various forms depending on + * the specific invocation of XPENDING. + * + * @link https://redis.io/commands/xpending + * @example + *
+     * $redis->xPending('mystream', 'mygroup');
+     * $redis->xPending('mystream', 'mygroup', '-', '+', 1, 'consumer-1');
+     * 
+ */ + public function xPending($stream, $group, $start = null, $end = null, $count = null, $consumer = null) + { + } + + /** + * Get a range of messages from a given stream + * + * @param string $stream + * @param string $start + * @param string $end + * @param int $count + * + * @return array The messages in the stream within the requested range. + * + * @link https://redis.io/commands/xrange + * @example + *
+     * // Get everything in this stream
+     * $redis->xRange('mystream', '-', '+');
+     * // Only the first two messages
+     * $redis->xRange('mystream', '-', '+', 2);
+     * 
+ */ + public function xRange($stream, $start, $end, $count = null) + { + } + + /** + * Read data from one or more streams and only return IDs greater than sent in the command. + * + * @param array $streams + * @param int|string $count + * @param int|string $block + * + * @return array The messages in the stream newer than the IDs passed to Redis (if any) + * + * @link https://redis.io/commands/xread + * @example + *
+     * $redis->xRead(['stream1' => '1535222584555-0', 'stream2' => '1535222584555-0']);
+     * 
+ */ + public function xRead($streams, $count = null, $block = null) + { + } + + /** + * This method is similar to xRead except that it supports reading messages for a specific consumer group. + * + * @param string $group + * @param string $consumer + * @param array $streams + * @param int|null $count + * @param int|null $block + * + * @return array The messages delivered to this consumer group (if any). + * + * @link https://redis.io/commands/xreadgroup + * @example + *
+     * // Consume messages for 'mygroup', 'consumer1'
+     * $redis->xReadGroup('mygroup', 'consumer1', ['s1' => 0, 's2' => 0]);
+     * // Read a single message as 'consumer2' for up to a second until a message arrives.
+     * $redis->xReadGroup('mygroup', 'consumer2', ['s1' => 0, 's2' => 0], 1, 1000);
+     * 
+ */ + public function xReadGroup($group, $consumer, $streams, $count = null, $block = null) + { + } + + /** + * This is identical to xRange except the results come back in reverse order. + * Also note that Redis reverses the order of "start" and "end". + * + * @param string $stream + * @param string $end + * @param string $start + * @param int $count + * + * @return array The messages in the range specified + * + * @link https://redis.io/commands/xrevrange + * @example + *
+     * $redis->xRevRange('mystream', '+', '-');
+     * 
+ */ + public function xRevRange($stream, $end, $start, $count = null) + { + } + + /** + * Trim the stream length to a given maximum. + * If the "approximate" flag is pasesed, Redis will use your size as a hint but only trim trees in whole nodes + * (this is more efficient) + * + * @param string $stream + * @param int $maxLen + * @param bool $isApproximate + * + * @return int The number of messages trimed from the stream. + * + * @link https://redis.io/commands/xtrim + * @example + *
+     * // Trim to exactly 100 messages
+     * $redis->xTrim('mystream', 100);
+     * // Let Redis approximate the trimming
+     * $redis->xTrim('mystream', 100, true);
+     * 
+ */ + public function xTrim($stream, $maxLen, $isApproximate) + { + } + + /** + * Adds a values to the set value stored at key. + * + * @param string $key Required key + * @param array $values Required values + * + * @return int|bool The number of elements added to the set. + * If this value is already in the set, FALSE is returned + * + * @link https://redis.io/commands/sadd + * @link https://github.com/phpredis/phpredis/commit/3491b188e0022f75b938738f7542603c7aae9077 + * @since phpredis 2.2.8 + * @example + *
+     * $redis->sAddArray('k', array('v1'));                // boolean
+     * $redis->sAddArray('k', array('v1', 'v2', 'v3'));    // boolean
+     * 
+ */ + public function sAddArray($key, array $values) + { + } +} + +class RedisException extends Exception +{ +} + +/** + * @mixin \Redis + */ +class RedisArray +{ + /** + * Constructor + * + * @param string|array $hosts Name of the redis array from redis.ini or array of hosts to construct the array with + * @param array $opts Array of options + * + * @link https://github.com/nicolasff/phpredis/blob/master/arrays.markdown + */ + public function __construct($hosts, array $opts = null) + { + } + + /** + * @return array list of hosts for the selected array + */ + public function _hosts() + { + } + + /** + * @return string the name of the function used to extract key parts during consistent hashing + */ + public function _function() + { + } + + /** + * @param string $key The key for which you want to lookup the host + * + * @return string the host to be used for a certain key + */ + public function _target($key) + { + } + + /** + * Use this function when a new node is added and keys need to be rehashed. + */ + public function _rehash() + { + } + + /** + * Returns an associative array of strings and integers, with the following keys: + * - redis_version + * - redis_git_sha1 + * - redis_git_dirty + * - redis_build_id + * - redis_mode + * - os + * - arch_bits + * - multiplexing_api + * - atomicvar_api + * - gcc_version + * - process_id + * - run_id + * - tcp_port + * - uptime_in_seconds + * - uptime_in_days + * - hz + * - lru_clock + * - executable + * - config_file + * - connected_clients + * - client_longest_output_list + * - client_biggest_input_buf + * - blocked_clients + * - used_memory + * - used_memory_human + * - used_memory_rss + * - used_memory_rss_human + * - used_memory_peak + * - used_memory_peak_human + * - used_memory_peak_perc + * - used_memory_peak + * - used_memory_overhead + * - used_memory_startup + * - used_memory_dataset + * - used_memory_dataset_perc + * - total_system_memory + * - total_system_memory_human + * - used_memory_lua + * - used_memory_lua_human + * - maxmemory + * - maxmemory_human + * - maxmemory_policy + * - mem_fragmentation_ratio + * - mem_allocator + * - active_defrag_running + * - lazyfree_pending_objects + * - mem_fragmentation_ratio + * - loading + * - rdb_changes_since_last_save + * - rdb_bgsave_in_progress + * - rdb_last_save_time + * - rdb_last_bgsave_status + * - rdb_last_bgsave_time_sec + * - rdb_current_bgsave_time_sec + * - rdb_last_cow_size + * - aof_enabled + * - aof_rewrite_in_progress + * - aof_rewrite_scheduled + * - aof_last_rewrite_time_sec + * - aof_current_rewrite_time_sec + * - aof_last_bgrewrite_status + * - aof_last_write_status + * - aof_last_cow_size + * - changes_since_last_save + * - aof_current_size + * - aof_base_size + * - aof_pending_rewrite + * - aof_buffer_length + * - aof_rewrite_buffer_length + * - aof_pending_bio_fsync + * - aof_delayed_fsync + * - loading_start_time + * - loading_total_bytes + * - loading_loaded_bytes + * - loading_loaded_perc + * - loading_eta_seconds + * - total_connections_received + * - total_commands_processed + * - instantaneous_ops_per_sec + * - total_net_input_bytes + * - total_net_output_bytes + * - instantaneous_input_kbps + * - instantaneous_output_kbps + * - rejected_connections + * - maxclients + * - sync_full + * - sync_partial_ok + * - sync_partial_err + * - expired_keys + * - evicted_keys + * - keyspace_hits + * - keyspace_misses + * - pubsub_channels + * - pubsub_patterns + * - latest_fork_usec + * - migrate_cached_sockets + * - slave_expires_tracked_keys + * - active_defrag_hits + * - active_defrag_misses + * - active_defrag_key_hits + * - active_defrag_key_misses + * - role + * - master_replid + * - master_replid2 + * - master_repl_offset + * - second_repl_offset + * - repl_backlog_active + * - repl_backlog_size + * - repl_backlog_first_byte_offset + * - repl_backlog_histlen + * - master_host + * - master_port + * - master_link_status + * - master_last_io_seconds_ago + * - master_sync_in_progress + * - slave_repl_offset + * - slave_priority + * - slave_read_only + * - master_sync_left_bytes + * - master_sync_last_io_seconds_ago + * - master_link_down_since_seconds + * - connected_slaves + * - min-slaves-to-write + * - min-replicas-to-write + * - min_slaves_good_slaves + * - used_cpu_sys + * - used_cpu_user + * - used_cpu_sys_children + * - used_cpu_user_children + * - cluster_enabled + * + * @link https://redis.io/commands/info + * @return array + * @example + *
+     * $redis->info();
+     * 
+ */ + public function info() { + } +} diff --git a/.phan/internal_stubs/memcache.phan_php b/.phan/internal_stubs/memcache.phan_php new file mode 100644 index 0000000..080129f --- /dev/null +++ b/.phan/internal_stubs/memcache.phan_php @@ -0,0 +1,460 @@ + + * Open memcached server connection + * @link https://php.net/manual/en/memcache.connect.php + * @param string $host

+ * Point to the host where memcached is listening for connections. This parameter + * may also specify other transports like unix:///path/to/memcached.sock + * to use UNIX domain sockets, in this case port must also + * be set to 0. + *

+ * @param int $port [optional]

+ * Point to the port where memcached is listening for connections. Set this + * parameter to 0 when using UNIX domain sockets. + *

+ *

+ * Please note: port defaults to + * {@link https://php.net/manual/ru/memcache.ini.php#ini.memcache.default-port memcache.default_port} + * if not specified. For this reason it is wise to specify the port + * explicitly in this method call. + *

+ * @param int $timeout [optional]

Value in seconds which will be used for connecting to the daemon. Think twice before changing the default value of 1 second - you can lose all the advantages of caching if your connection is too slow.

+ * @return bool

Returns TRUE on success or FALSE on failure.

+ */ + public function connect ($host, $port, $timeout = 1) {} + + /** + * (PECL memcache >= 2.0.0)
+ * Add a memcached server to connection pool + * @link https://php.net/manual/en/memcache.addserver.php + * @param string $host

+ * Point to the host where memcached is listening for connections. This parameter + * may also specify other transports like unix:///path/to/memcached.sock + * to use UNIX domain sockets, in this case port must also + * be set to 0. + *

+ * @param int $port [optional]

+ * Point to the port where memcached is listening for connections. + * Set this + * parameter to 0 when using UNIX domain sockets. + *

+ *

+ * Please note: port defaults to + * memcache.default_port + * if not specified. For this reason it is wise to specify the port + * explicitly in this method call. + *

+ * @param bool $persistent [optional]

+ * Controls the use of a persistent connection. Default to TRUE. + *

+ * @param int $weight [optional]

+ * Number of buckets to create for this server which in turn control its + * probability of it being selected. The probability is relative to the + * total weight of all servers. + *

+ * @param int $timeout [optional]

+ * Value in seconds which will be used for connecting to the daemon. Think + * twice before changing the default value of 1 second - you can lose all + * the advantages of caching if your connection is too slow. + *

+ * @param int $retry_interval [optional]

+ * Controls how often a failed server will be retried, the default value + * is 15 seconds. Setting this parameter to -1 disables automatic retry. + * Neither this nor the persistent parameter has any + * effect when the extension is loaded dynamically via dl. + *

+ *

+ * Each failed connection struct has its own timeout and before it has expired + * the struct will be skipped when selecting backends to serve a request. Once + * expired the connection will be successfully reconnected or marked as failed + * for another retry_interval seconds. The typical + * effect is that each web server child will retry the connection about every + * retry_interval seconds when serving a page. + *

+ * @param bool $status [optional]

+ * Controls if the server should be flagged as online. Setting this parameter + * to FALSE and retry_interval to -1 allows a failed + * server to be kept in the pool so as not to affect the key distribution + * algorithm. Requests for this server will then failover or fail immediately + * depending on the memcache.allow_failover setting. + * Default to TRUE, meaning the server should be considered online. + *

+ * @param callable $failure_callback [optional]

+ * Allows the user to specify a callback function to run upon encountering an + * error. The callback is run before failover is attempted. The function takes + * two parameters, the hostname and port of the failed server. + *

+ * @param int $timeoutms [optional]

+ *

+ * @return bool TRUE on success or FALSE on failure. + */ + public function addServer ($host, $port = 11211, $persistent = true, $weight = null, $timeout = 1, $retry_interval = 15, $status = true, callable $failure_callback = null, $timeoutms = null) {} + + /** + * (PECL memcache >= 2.1.0)
+ * Changes server parameters and status at runtime + * @link https://secure.php.net/manual/en/memcache.setserverparams.php + * @param string $host

Point to the host where memcached is listening for connections. + * Point to the port where memcached is listening for connections. + *

+ * @param int $timeout [optional]

+ * Value in seconds which will be used for connecting to the daemon. Think twice before changing the default value of 1 second - you can lose all the advantages of caching if your connection is too slow. + *

+ * @param int $retry_interval [optional]

+ * Controls how often a failed server will be retried, the default value + * is 15 seconds. Setting this parameter to -1 disables automatic retry. + * Neither this nor the persistent parameter has any + * effect when the extension is loaded dynamically via {@link https://secure.php.net/manual/en/function.dl.php dl()}. + *

+ * @param bool $status [optional]

+ * Controls if the server should be flagged as online. Setting this parameter + * to FALSE and retry_interval to -1 allows a failed + * server to be kept in the pool so as not to affect the key distribution + * algorithm. Requests for this server will then failover or fail immediately + * depending on the memcache.allow_failover setting. + * Default to TRUE, meaning the server should be considered online. + *

+ * @param callable $failure_callback [optional]

+ * Allows the user to specify a callback function to run upon encountering an error. The callback is run before failover is attempted. + * The function takes two parameters, the hostname and port of the failed server. + *

+ * @return bool

Returns TRUE on success or FALSE on failure.

+ */ + public function setServerParams ($host, $port = 11211, $timeout = 1, $retry_interval = 15, $status = true, callable $failure_callback = null) {} + + /** + * + */ + public function setFailureCallback () {} + + /** + * (PECL memcache >= 2.1.0)
+ * Returns server status + * @link https://php.net/manual/en/memcache.getserverstatus.php + * @param string $host Point to the host where memcached is listening for connections. + * @param int $port Point to the port where memcached is listening for connections. + * @return int Returns a the servers status. 0 if server is failed, non-zero otherwise + */ + public function getServerStatus ($host, $port = 11211) {} + + /** + * + */ + public function findServer () {} + + /** + * (PECL memcache >= 0.2.0)
+ * Return version of the server + * @link https://php.net/manual/en/memcache.getversion.php + * @return string|false Returns a string of server version number or FALSE on failure. + */ + public function getVersion () {} + + /** + * (PECL memcache >= 2.0.0)
+ * Add an item to the server. If the key already exists, the value will not be added and FALSE will be returned. + * @link https://php.net/manual/en/memcache.add.php + * @param string $key The key that will be associated with the item. + * @param mixed $var The variable to store. Strings and integers are stored as is, other types are stored serialized. + * @param int $flag [optional]

+ * Use MEMCACHE_COMPRESSED to store the item + * compressed (uses zlib). + *

+ * @param int $expire [optional]

Expiration time of the item. + * If it's equal to zero, the item will never expire. + * You can also use Unix timestamp or a number of seconds starting from current time, but in the latter case the number of seconds may not exceed 2592000 (30 days).

+ * @return bool Returns TRUE on success or FALSE on failure. Returns FALSE if such key already exist. For the rest Memcache::add() behaves similarly to Memcache::set(). + */ + public function add ($key , $var, $flag = null, $expire = null) {} + + /** + * (PECL memcache >= 0.2.0)
+ * Stores an item var with key on the memcached server. Parameter expire is expiration time in seconds. + * If it's 0, the item never expires (but memcached server doesn't guarantee this item to be stored all the time, + * it could be deleted from the cache to make place for other items). + * You can use MEMCACHE_COMPRESSED constant as flag value if you want to use on-the-fly compression (uses zlib). + * @link https://php.net/manual/en/memcache.set.php + * @param string $key The key that will be associated with the item. + * @param mixed $var The variable to store. Strings and integers are stored as is, other types are stored serialized. + * @param int $flag [optional] Use MEMCACHE_COMPRESSED to store the item compressed (uses zlib). + * @param int $expire [optional] Expiration time of the item. If it's equal to zero, the item will never expire. You can also use Unix timestamp or a number of seconds starting from current time, but in the latter case the number of seconds may not exceed 2592000 (30 days). + * @return bool Returns TRUE on success or FALSE on failure. + */ + public function set ($key, $var, $flag = null, $expire = null) {} + + /** + * (PECL memcache >= 0.2.0)
+ * Replace value of the existing item + * @link https://php.net/manual/en/memcache.replace.php + * @param string $key

The key that will be associated with the item.

+ * @param mixed $var

The variable to store. Strings and integers are stored as is, other types are stored serialized.

+ * @param int $flag [optional]

Use MEMCACHE_COMPRESSED to store the item compressed (uses zlib).

+ * @param int $expire [optional]

Expiration time of the item. If it's equal to zero, the item will never expire. You can also use Unix timestamp or a number of seconds starting from current time, but in the latter case the number of seconds may not exceed 2592000 (30 days).

+ * @return bool Returns TRUE on success or FALSE on failure. + */ + public function replace ($key, $var, $flag = null, $expire = null) {} + + public function cas () {} + + public function append () {} + + /** + * @return string + */ + public function prepend () {} + + /** + * (PECL memcache >= 0.2.0)
+ * Retrieve item from the server + * @link https://php.net/manual/en/memcache.get.php + * @param string|array $key

+ * The key or array of keys to fetch. + *

+ * @param int|array $flags [optional]

+ * If present, flags fetched along with the values will be written to this parameter. These + * flags are the same as the ones given to for example {@link https://php.net/manual/en/memcache.set.php Memcache::set()}. + * The lowest byte of the int is reserved for pecl/memcache internal usage (e.g. to indicate + * compression and serialization status). + *

+ * @return string|array|false

+ * Returns the string associated with the key or + * an array of found key-value pairs when key is an {@link https://php.net/manual/en/language.types.array.php array}. + * Returns FALSE on failure, key is not found or + * key is an empty {@link https://php.net/manual/en/language.types.array.php array}. + *

+ */ + public function get ($key, &$flags = null) {} + + /** + * (PECL memcache >= 0.2.0)
+ * Delete item from the server + * https://secure.php.net/manual/ru/memcache.delete.php + * @param $key string The key associated with the item to delete. + * @param $timeout int [optional] This deprecated parameter is not supported, and defaults to 0 seconds. Do not use this parameter. + * @return bool Returns TRUE on success or FALSE on failure. + */ + public function delete ($key, $timeout = 0 ) {} + + /** + * (PECL memcache >= 0.2.0)
+ * Get statistics of the server + * @link https://php.net/manual/ru/memcache.getstats.php + * @param string $type [optional]

+ * The type of statistics to fetch. + * Valid values are {reset, malloc, maps, cachedump, slabs, items, sizes}. + * According to the memcached protocol spec these additional arguments "are subject to change for the convenience of memcache developers".

+ * @param int $slabid [optional]

+ * Used in conjunction with type set to + * cachedump to identify the slab to dump from. The cachedump + * command ties up the server and is strictly to be used for + * debugging purposes. + *

+ * @param int $limit [optional]

+ * Used in conjunction with type set to cachedump to limit the number of entries to dump. + *

+ * @return array|false Returns an associative array of server statistics or FALSE on failure. + */ + public function getStats ($type = null, $slabid = null, $limit = 100) {} + + /** + * (PECL memcache >= 2.0.0)
+ * Get statistics from all servers in pool + * @link https://php.net/manual/en/memcache.getextendedstats.php + * @param string $type [optional]

The type of statistics to fetch. Valid values are {reset, malloc, maps, cachedump, slabs, items, sizes}. According to the memcached protocol spec these additional arguments "are subject to change for the convenience of memcache developers".

+ * @param int $slabid [optional]

+ * Used in conjunction with type set to + * cachedump to identify the slab to dump from. The cachedump + * command ties up the server and is strictly to be used for + * debugging purposes. + *

+ * @param int $limit Used in conjunction with type set to cachedump to limit the number of entries to dump. + * @return array|false Returns a two-dimensional associative array of server statistics or FALSE + * Returns a two-dimensional associative array of server statistics or FALSE + * on failure. + */ + public function getExtendedStats ($type = null, $slabid = null, $limit = 100) {} + + /** + * (PECL memcache >= 2.0.0)
+ * Enable automatic compression of large values + * @link https://php.net/manual/en/memcache.setcompressthreshold.php + * @param int $thresold

Controls the minimum value length before attempting to compress automatically.

+ * @param float $min_saving [optional]

Specifies the minimum amount of savings to actually store the value compressed. The supplied value must be between 0 and 1. Default value is 0.2 giving a minimum 20% compression savings.

+ * @return bool Returns TRUE on success or FALSE on failure. + */ + public function setCompressThreshold ($thresold, $min_saving = 0.2) {} + /** + * (PECL memcache >= 0.2.0)
+ * Increment item's value + * @link https://php.net/manual/en/memcache.increment.php + * @param $key string Key of the item to increment. + * @param $value int [optional] increment the item by value + * @return int|false Returns new items value on success or FALSE on failure. + */ + public function increment ($key, $value = 1) {} + + /** + * (PECL memcache >= 0.2.0)
+ * Decrement item's value + * @link https://php.net/manual/en/memcache.decrement.php + * @param $key string Key of the item do decrement. + * @param $value int Decrement the item by value. + * @return int|false Returns item's new value on success or FALSE on failure. + */ + public function decrement ($key, $value = 1) {} + + /** + * (PECL memcache >= 0.4.0)
+ * Close memcached server connection + * @link https://php.net/manual/en/memcache.close.php + * @return bool Returns TRUE on success or FALSE on failure. + */ + public function close () {} + + /** + * (PECL memcache >= 1.0.0)
+ * Flush all existing items at the server + * @link https://php.net/manual/en/memcache.flush.php + * @return bool Returns TRUE on success or FALSE on failure. + */ + public function flush () {} + +} + +/** + * Represents a connection to a set of memcache servers. + * @link https://php.net/manual/en/class.memcache.php + */ +class Memcache extends MemcachePool { + + + /** + * (PECL memcache >= 0.4.0)
+ * Open memcached server persistent connection + * @link https://php.net/manual/en/memcache.pconnect.php + * @param string $host

+ * Point to the host where memcached is listening for connections. This parameter + * may also specify other transports like unix:///path/to/memcached.sock + * to use UNIX domain sockets, in this case port must also + * be set to 0. + *

+ * @param int $port [optional]

+ * Point to the port where memcached is listening for connections. Set this + * parameter to 0 when using UNIX domain sockets. + *

+ * @param int $timeout [optional]

+ * Value in seconds which will be used for connecting to the daemon. Think + * twice before changing the default value of 1 second - you can lose all + * the advantages of caching if your connection is too slow. + *

+ * @return mixed a Memcache object or FALSE on failure. + */ + public function pconnect ($host, $port, $timeout = 1) {} +} + +// string $host [, int $port [, int $timeout ]] + +/** + * (PECL memcache >= 0.2.0)
+ * Memcache::connect — Open memcached server connection + * @link https://php.net/manual/en/memcache.connect.php + * @param string $host

+ * Point to the host where memcached is listening for connections. + * This parameter may also specify other transports like + * unix:///path/to/memcached.sock to use UNIX domain sockets, + * in this case port must also be set to 0. + *

+ * @param int $port [optional]

+ * Point to the port where memcached is listening for connections. + * Set this parameter to 0 when using UNIX domain sockets. + * Note: port defaults to memcache.default_port if not specified. + * For this reason it is wise to specify the port explicitly in this method call. + *

+ * @param int $timeout [optional]

+ * Value in seconds which will be used for connecting to the daemon. + *

+ * @return bool Returns TRUE on success or FALSE on failure. + */ +function memcache_connect ($host, $port, $timeout = 1) {} + +/** + * (PECL memcache >= 0.4.0) + * Memcache::pconnect — Open memcached server persistent connection + * + * @link https://php.net/manual/en/memcache.pconnect.php#example-5242 + * @param $host + * @param null $port + * @param int $timeout + * @return Memcache + */ +function memcache_pconnect ($host, $port=null, $timeout=1) {} + +function memcache_add_server () {} + +function memcache_set_server_params () {} + +function memcache_set_failure_callback () {} + +function memcache_get_server_status () {} + +function memcache_get_version () {} + +function memcache_add () {} + +function memcache_set () {} + +function memcache_replace () {} + +function memcache_cas () {} + +function memcache_append () {} + +function memcache_prepend () {} + +function memcache_get () {} + +function memcache_delete () {} + +/** + * (PECL memcache >= 0.2.0)
+ * Turn debug output on/off + * @link https://php.net/manual/en/function.memcache-debug.php + * @param bool $on_off

+ * Turns debug output on if equals to TRUE. + * Turns debug output off if equals to FALSE. + *

+ * @return bool TRUE if PHP was built with --enable-debug option, otherwise + * returns FALSE. + */ +function memcache_debug ($on_off) {} + +function memcache_get_stats () {} + +function memcache_get_extended_stats () {} + +function memcache_set_compress_threshold () {} + +function memcache_increment () {} + +function memcache_decrement () {} + +function memcache_close () {} + +function memcache_flush () {} + +define ('MEMCACHE_COMPRESSED', 2); +define ('MEMCACHE_USER1', 65536); +define ('MEMCACHE_USER2', 131072); +define ('MEMCACHE_USER3', 262144); +define ('MEMCACHE_USER4', 524288); +define ('MEMCACHE_HAVE_SESSION', 1); + +// End of memcache v.3.0.8 +?> diff --git a/.phan/internal_stubs/memcached.phan_php b/.phan/internal_stubs/memcached.phan_php new file mode 100644 index 0000000..f734bcb --- /dev/null +++ b/.phan/internal_stubs/memcached.phan_php @@ -0,0 +1,1308 @@ +Enables or disables payload compression. When enabled, + * item values longer than a certain threshold (currently 100 bytes) will be + * compressed during storage and decompressed during retrieval + * transparently.

+ *

Type: boolean, default: TRUE.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_COMPRESSION = -1001; + const OPT_COMPRESSION_TYPE = -1004; + + /** + *

This can be used to create a "domain" for your item keys. The value + * specified here will be prefixed to each of the keys. It cannot be + * longer than 128 characters and will reduce the + * maximum available key size. The prefix is applied only to the item keys, + * not to the server keys.

+ *

Type: string, default: "".

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_PREFIX_KEY = -1002; + + /** + *

+ * Specifies the serializer to use for serializing non-scalar values. + * The valid serializers are Memcached::SERIALIZER_PHP + * or Memcached::SERIALIZER_IGBINARY. The latter is + * supported only when memcached is configured with + * --enable-memcached-igbinary option and the + * igbinary extension is loaded. + *

+ *

Type: integer, default: Memcached::SERIALIZER_PHP.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_SERIALIZER = -1003; + + /** + *

Indicates whether igbinary serializer support is available.

+ *

Type: boolean.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const HAVE_IGBINARY = 0; + + /** + *

Indicates whether JSON serializer support is available.

+ *

Type: boolean.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const HAVE_JSON = 0; + const HAVE_SESSION = 1; + const HAVE_SASL = 0; + + /** + *

Specifies the hashing algorithm used for the item keys. The valid + * values are supplied via Memcached::HASH_* constants. + * Each hash algorithm has its advantages and its disadvantages. Go with the + * default if you don't know or don't care.

+ *

Type: integer, default: Memcached::HASH_DEFAULT

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_HASH = 2; + + /** + *

The default (Jenkins one-at-a-time) item key hashing algorithm.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const HASH_DEFAULT = 0; + + /** + *

MD5 item key hashing algorithm.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const HASH_MD5 = 1; + + /** + *

CRC item key hashing algorithm.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const HASH_CRC = 2; + + /** + *

FNV1_64 item key hashing algorithm.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const HASH_FNV1_64 = 3; + + /** + *

FNV1_64A item key hashing algorithm.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const HASH_FNV1A_64 = 4; + + /** + *

FNV1_32 item key hashing algorithm.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const HASH_FNV1_32 = 5; + + /** + *

FNV1_32A item key hashing algorithm.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const HASH_FNV1A_32 = 6; + + /** + *

Hsieh item key hashing algorithm.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const HASH_HSIEH = 7; + + /** + *

Murmur item key hashing algorithm.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const HASH_MURMUR = 8; + + /** + *

Specifies the method of distributing item keys to the servers. + * Currently supported methods are modulo and consistent hashing. Consistent + * hashing delivers better distribution and allows servers to be added to + * the cluster with minimal cache losses.

+ *

Type: integer, default: Memcached::DISTRIBUTION_MODULA.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_DISTRIBUTION = 9; + + /** + *

Modulo-based key distribution algorithm.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const DISTRIBUTION_MODULA = 0; + + /** + *

Consistent hashing key distribution algorithm (based on libketama).

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const DISTRIBUTION_CONSISTENT = 1; + const DISTRIBUTION_VIRTUAL_BUCKET = 6; + + /** + *

Enables or disables compatibility with libketama-like behavior. When + * enabled, the item key hashing algorithm is set to MD5 and distribution is + * set to be weighted consistent hashing distribution. This is useful + * because other libketama-based clients (Python, Ruby, etc.) with the same + * server configuration will be able to access the keys transparently. + *

+ *

+ * It is highly recommended to enable this option if you want to use + * consistent hashing, and it may be enabled by default in future + * releases. + *

+ *

Type: boolean, default: FALSE.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_LIBKETAMA_COMPATIBLE = 16; + const OPT_LIBKETAMA_HASH = 17; + const OPT_TCP_KEEPALIVE = 32; + + /** + *

Enables or disables buffered I/O. Enabling buffered I/O causes + * storage commands to "buffer" instead of being sent. Any action that + * retrieves data causes this buffer to be sent to the remote connection. + * Quitting the connection or closing down the connection will also cause + * the buffered data to be pushed to the remote connection.

+ *

Type: boolean, default: FALSE.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_BUFFER_WRITES = 10; + + /** + *

Enable the use of the binary protocol. Please note that you cannot + * toggle this option on an open connection.

+ *

Type: boolean, default: FALSE.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_BINARY_PROTOCOL = 18; + + /** + *

Enables or disables asynchronous I/O. This is the fastest transport + * available for storage functions.

+ *

Type: boolean, default: FALSE.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_NO_BLOCK = 0; + + /** + *

Enables or disables the no-delay feature for connecting sockets (may + * be faster in some environments).

+ *

Type: boolean, default: FALSE.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_TCP_NODELAY = 1; + + /** + *

The maximum socket send buffer in bytes.

+ *

Type: integer, default: varies by platform/kernel + * configuration.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_SOCKET_SEND_SIZE = 4; + + /** + *

The maximum socket receive buffer in bytes.

+ *

Type: integer, default: varies by platform/kernel + * configuration.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_SOCKET_RECV_SIZE = 5; + + /** + *

In non-blocking mode this set the value of the timeout during socket + * connection, in milliseconds.

+ *

Type: integer, default: 1000.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_CONNECT_TIMEOUT = 14; + + /** + *

The amount of time, in seconds, to wait until retrying a failed + * connection attempt.

+ *

Type: integer, default: 0.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_RETRY_TIMEOUT = 15; + + /** + *

Socket sending timeout, in microseconds. In cases where you cannot + * use non-blocking I/O this will allow you to still have timeouts on the + * sending of data.

+ *

Type: integer, default: 0.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_SEND_TIMEOUT = 19; + + /** + *

Socket reading timeout, in microseconds. In cases where you cannot + * use non-blocking I/O this will allow you to still have timeouts on the + * reading of data.

+ *

Type: integer, default: 0.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_RECV_TIMEOUT = 20; + + /** + *

Timeout for connection polling, in milliseconds.

+ *

Type: integer, default: 1000.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_POLL_TIMEOUT = 8; + + /** + *

Enables or disables caching of DNS lookups.

+ *

Type: boolean, default: FALSE.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_CACHE_LOOKUPS = 6; + + /** + *

Specifies the failure limit for server connection attempts. The + * server will be removed after this many continuous connection + * failures.

+ *

Type: integer, default: 0.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const OPT_SERVER_FAILURE_LIMIT = 21; + const OPT_AUTO_EJECT_HOSTS = 28; + const OPT_HASH_WITH_PREFIX_KEY = 25; + const OPT_NOREPLY = 26; + const OPT_SORT_HOSTS = 12; + const OPT_VERIFY_KEY = 13; + const OPT_USE_UDP = 27; + const OPT_NUMBER_OF_REPLICAS = 29; + const OPT_RANDOMIZE_REPLICA_READ = 30; + const OPT_CORK = 31; + const OPT_REMOVE_FAILED_SERVERS = 35; + const OPT_DEAD_TIMEOUT = 36; + const OPT_SERVER_TIMEOUT_LIMIT = 37; + const OPT_MAX = 38; + const OPT_IO_BYTES_WATERMARK = 23; + const OPT_IO_KEY_PREFETCH = 24; + const OPT_IO_MSG_WATERMARK = 22; + const OPT_LOAD_FROM_FILE = 34; + const OPT_SUPPORT_CAS = 7; + const OPT_TCP_KEEPIDLE = 33; + const OPT_USER_DATA = 11; + + + /** + *

The operation was successful.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_SUCCESS = 0; + + /** + *

The operation failed in some fashion.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_FAILURE = 1; + + /** + *

DNS lookup failed.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_HOST_LOOKUP_FAILURE = 2; + + /** + *

Failed to read network data.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_UNKNOWN_READ_FAILURE = 7; + + /** + *

Bad command in memcached protocol.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_PROTOCOL_ERROR = 8; + + /** + *

Error on the client side.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_CLIENT_ERROR = 9; + + /** + *

Error on the server side.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_SERVER_ERROR = 10; + + /** + *

Failed to write network data.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_WRITE_FAILURE = 5; + + /** + *

Failed to do compare-and-swap: item you are trying to store has been + * modified since you last fetched it.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_DATA_EXISTS = 12; + + /** + *

Item was not stored: but not because of an error. This normally + * means that either the condition for an "add" or a "replace" command + * wasn't met, or that the item is in a delete queue.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_NOTSTORED = 14; + + /** + *

Item with this key was not found (with "get" operation or "cas" + * operations).

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_NOTFOUND = 16; + + /** + *

Partial network data read error.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_PARTIAL_READ = 18; + + /** + *

Some errors occurred during multi-get.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_SOME_ERRORS = 19; + + /** + *

Server list is empty.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_NO_SERVERS = 20; + + /** + *

End of result set.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_END = 21; + + /** + *

System error.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_ERRNO = 26; + + /** + *

The operation was buffered.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_BUFFERED = 32; + + /** + *

The operation timed out.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_TIMEOUT = 31; + + /** + *

Bad key.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_BAD_KEY_PROVIDED = 33; + const RES_STORED = 15; + const RES_DELETED = 22; + const RES_STAT = 24; + const RES_ITEM = 25; + const RES_NOT_SUPPORTED = 28; + const RES_FETCH_NOTFINISHED = 30; + const RES_SERVER_MARKED_DEAD = 35; + const RES_UNKNOWN_STAT_KEY = 36; + const RES_INVALID_HOST_PROTOCOL = 34; + const RES_MEMORY_ALLOCATION_FAILURE = 17; + const RES_E2BIG = 37; + const RES_KEY_TOO_BIG = 39; + const RES_SERVER_TEMPORARILY_DISABLED = 47; + const RES_SERVER_MEMORY_ALLOCATION_FAILURE = 48; + const RES_AUTH_PROBLEM = 40; + const RES_AUTH_FAILURE = 41; + const RES_AUTH_CONTINUE = 42; + const RES_CONNECTION_FAILURE = 3; + const RES_CONNECTION_BIND_FAILURE = 4; + const RES_READ_FAILURE = 6; + const RES_DATA_DOES_NOT_EXIST = 13; + const RES_VALUE = 23; + const RES_FAIL_UNIX_SOCKET = 27; + const RES_NO_KEY_PROVIDED = 29; + const RES_INVALID_ARGUMENTS = 38; + const RES_PARSE_ERROR = 43; + const RES_PARSE_USER_ERROR = 44; + const RES_DEPRECATED = 45; + const RES_IN_PROGRESS = 46; + const RES_MAXIMUM_RETURN = 49; + + + + /** + *

Failed to create network socket.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_CONNECTION_SOCKET_CREATE_FAILURE = 11; + + /** + *

Payload failure: could not compress/decompress or serialize/unserialize the value.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const RES_PAYLOAD_FAILURE = -1001; + + /** + *

The default PHP serializer.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const SERIALIZER_PHP = 1; + + /** + *

The igbinary serializer. + * Instead of textual representation it stores PHP data structures in a + * compact binary form, resulting in space and time gains.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const SERIALIZER_IGBINARY = 2; + + /** + *

The JSON serializer. Requires PHP 5.2.10+.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const SERIALIZER_JSON = 3; + const SERIALIZER_JSON_ARRAY = 4; + const COMPRESSION_FASTLZ = 2; + const COMPRESSION_ZLIB = 1; + + /** + *

A flag for Memcached::getMulti and + * Memcached::getMultiByKey to ensure that the keys are + * returned in the same order as they were requested in. Non-existing keys + * get a default value of NULL.

+ * @link https://php.net/manual/en/memcached.constants.php + */ + const GET_PRESERVE_ORDER = 1; + const GET_ERROR_RETURN_VALUE = false; + + + /** + * (PECL memcached >= 0.1.0)
+ * Create a Memcached instance + * @link https://php.net/manual/en/memcached.construct.php + * @param $persistent_id [optional] + * @param $callback [optional] + */ + public function __construct ($persistent_id = '', $on_new_object_cb = null) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Return the result code of the last operation + * @link https://php.net/manual/en/memcached.getresultcode.php + * @return int Result code of the last Memcached operation. + */ + public function getResultCode () {} + + /** + * (PECL memcached >= 1.0.0)
+ * Return the message describing the result of the last operation + * @link https://php.net/manual/en/memcached.getresultmessage.php + * @return string Message describing the result of the last Memcached operation. + */ + public function getResultMessage () {} + + /** + * (PECL memcached >= 0.1.0)
+ * Retrieve an item + * @link https://php.net/manual/en/memcached.get.php + * @param string $key

+ * The key of the item to retrieve. + *

+ * @param callable $cache_cb [optional]

+ * Read-through caching callback or NULL. + *

+ * @param int $flags [optional]

+ * The flags for the get operation. + *

+ * @return mixed the value stored in the cache or FALSE otherwise. + * The Memcached::getResultCode will return + * Memcached::RES_NOTFOUND if the key does not exist. + */ + public function get ($key, callable $cache_cb = null, $flags = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Retrieve an item from a specific server + * @link https://php.net/manual/en/memcached.getbykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param string $key

+ * The key of the item to fetch. + *

+ * @param callable $cache_cb [optional]

+ * Read-through caching callback or NULL + *

+ * @param int $flags [optional]

+ * The flags for the get operation. + *

+ * @return mixed the value stored in the cache or FALSE otherwise. + * The Memcached::getResultCode will return + * Memcached::RES_NOTFOUND if the key does not exist. + */ + public function getByKey ($server_key, $key, callable $cache_cb = null, $flags = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Retrieve multiple items + * @link https://php.net/manual/en/memcached.getmulti.php + * @param array $keys

+ * Array of keys to retrieve. + *

+ * @param int $flags [optional]

+ * The flags for the get operation. + *

+ * @return mixed the array of found items or FALSE on failure. + * Use Memcached::getResultCode if necessary. + */ + public function getMulti (array $keys, $flags = null) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Retrieve multiple items from a specific server + * @link https://php.net/manual/en/memcached.getmultibykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param array $keys

+ * Array of keys to retrieve. + *

+ * @param int $flags [optional]

+ * The flags for the get operation. + *

+ * @return array|false the array of found items or FALSE on failure. + * Use Memcached::getResultCode if necessary. + */ + public function getMultiByKey ($server_key, array $keys, $flags = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Request multiple items + * @link https://php.net/manual/en/memcached.getdelayed.php + * @param array $keys

+ * Array of keys to request. + *

+ * @param bool $with_cas [optional]

+ * Whether to request CAS token values also. + *

+ * @param callable $value_cb [optional]

+ * The result callback or NULL. + *

+ * @return bool TRUE on success or FALSE on failure. + * Use Memcached::getResultCode if necessary. + */ + public function getDelayed (array $keys, $with_cas = null, callable $value_cb = null) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Request multiple items from a specific server + * @link https://php.net/manual/en/memcached.getdelayedbykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param array $keys

+ * Array of keys to request. + *

+ * @param bool $with_cas [optional]

+ * Whether to request CAS token values also. + *

+ * @param callable $value_cb [optional]

+ * The result callback or NULL. + *

+ * @return bool TRUE on success or FALSE on failure. + * Use Memcached::getResultCode if necessary. + */ + public function getDelayedByKey ($server_key, array $keys, $with_cas = null, callable $value_cb = null) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Fetch the next result + * @link https://php.net/manual/en/memcached.fetch.php + * @return array|false the next result or FALSE otherwise. + * The Memcached::getResultCode will return + * Memcached::RES_END if result set is exhausted. + */ + public function fetch () {} + + /** + * (PECL memcached >= 0.1.0)
+ * Fetch all the remaining results + * @link https://php.net/manual/en/memcached.fetchall.php + * @return array|false the results or FALSE on failure. + * Use Memcached::getResultCode if necessary. + */ + public function fetchAll () {} + + /** + * (PECL memcached >= 0.1.0)
+ * Store an item + * @link https://php.net/manual/en/memcached.set.php + * @param string $key

+ * The key under which to store the value. + *

+ * @param mixed $value

+ * The value to store. + *

+ * @param int $expiration [optional]

+ * The expiration time, defaults to 0. See Expiration Times for more info. + *

+ * @return bool TRUE on success or FALSE on failure. + * Use Memcached::getResultCode if necessary. + */ + public function set ($key, $value, $expiration = 0, $udf_flags = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Store an item on a specific server + * @link https://php.net/manual/en/memcached.setbykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param string $key

+ * The key under which to store the value. + *

+ * @param mixed $value

+ * The value to store. + *

+ * @param int $expiration [optional]

+ * The expiration time, defaults to 0. See Expiration Times for more info. + *

+ * @return bool TRUE on success or FALSE on failure. + * Use Memcached::getResultCode if necessary. + */ + public function setByKey ($server_key, $key, $value, $expiration = 0, $udf_flags = 0) {} + + /** + * (PECL memcached >= 2.0.0)
+ * Set a new expiration on an item + * @link https://php.net/manual/en/memcached.touch.php + * @param string $key

+ * The key under which to store the value. + *

+ * @param int $expiration

+ * The expiration time, defaults to 0. See Expiration Times for more info. + *

+ * @return bool TRUE on success or FALSE on failure. + * Use Memcached::getResultCode if necessary. + */ + public function touch ($key, $expiration = 0) {} + + /** + * (PECL memcached >= 2.0.0)
+ * Set a new expiration on an item on a specific server + * @link https://php.net/manual/en/memcached.touchbykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param string $key

+ * The key under which to store the value. + *

+ * @param int $expiration

+ * The expiration time, defaults to 0. See Expiration Times for more info. + *

+ * @return bool TRUE on success or FALSE on failure. + * Use Memcached::getResultCode if necessary. + */ + public function touchByKey ($server_key, $key, $expiration) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Store multiple items + * @link https://php.net/manual/en/memcached.setmulti.php + * @param array $items

+ * An array of key/value pairs to store on the server. + *

+ * @param int $expiration [optional]

+ * The expiration time, defaults to 0. See Expiration Times for more info. + *

+ * @return bool TRUE on success or FALSE on failure. + * Use Memcached::getResultCode if necessary. + */ + public function setMulti (array $items, $expiration = 0, $udf_flags = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Store multiple items on a specific server + * @link https://php.net/manual/en/memcached.setmultibykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param array $items

+ * An array of key/value pairs to store on the server. + *

+ * @param int $expiration [optional]

+ * The expiration time, defaults to 0. See Expiration Times for more info. + *

+ * @return bool TRUE on success or FALSE on failure. + * Use Memcached::getResultCode if necessary. + */ + public function setMultiByKey ($server_key, array $items, $expiration = 0, $udf_flags = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Compare and swap an item + * @link https://php.net/manual/en/memcached.cas.php + * @param float $cas_token

+ * Unique value associated with the existing item. Generated by memcache. + *

+ * @param string $key

+ * The key under which to store the value. + *

+ * @param mixed $value

+ * The value to store. + *

+ * @param int $expiration [optional]

+ * The expiration time, defaults to 0. See Expiration Times for more info. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_DATA_EXISTS if the item you are trying + * to store has been modified since you last fetched it. + */ + public function cas ($cas_token, $key, $value, $expiration = 0, $udf_flags = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Compare and swap an item on a specific server + * @link https://php.net/manual/en/memcached.casbykey.php + * @param float $cas_token

+ * Unique value associated with the existing item. Generated by memcache. + *

+ * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param string $key

+ * The key under which to store the value. + *

+ * @param mixed $value

+ * The value to store. + *

+ * @param int $expiration [optional]

+ * The expiration time, defaults to 0. See Expiration Times for more info. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_DATA_EXISTS if the item you are trying + * to store has been modified since you last fetched it. + */ + public function casByKey ($cas_token, $server_key, $key, $value, $expiration = 0, $udf_flags = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Add an item under a new key + * @link https://php.net/manual/en/memcached.add.php + * @param string $key

+ * The key under which to store the value. + *

+ * @param mixed $value

+ * The value to store. + *

+ * @param int $expiration [optional]

+ * The expiration time, defaults to 0. See Expiration Times for more info. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_NOTSTORED if the key already exists. + */ + public function add ($key, $value, $expiration = 0, $udf_flags = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Add an item under a new key on a specific server + * @link https://php.net/manual/en/memcached.addbykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param string $key

+ * The key under which to store the value. + *

+ * @param mixed $value

+ * The value to store. + *

+ * @param int $expiration [optional]

+ * The expiration time, defaults to 0. See Expiration Times for more info. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_NOTSTORED if the key already exists. + */ + public function addByKey ($server_key, $key, $value, $expiration = 0, $udf_flags = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Append data to an existing item + * @link https://php.net/manual/en/memcached.append.php + * @param string $key

+ * The key under which to store the value. + *

+ * @param string $value

+ * The string to append. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_NOTSTORED if the key does not exist. + */ + public function append ($key, $value) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Append data to an existing item on a specific server + * @link https://php.net/manual/en/memcached.appendbykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param string $key

+ * The key under which to store the value. + *

+ * @param string $value

+ * The string to append. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_NOTSTORED if the key does not exist. + */ + public function appendByKey ($server_key, $key, $value) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Prepend data to an existing item + * @link https://php.net/manual/en/memcached.prepend.php + * @param string $key

+ * The key of the item to prepend the data to. + *

+ * @param string $value

+ * The string to prepend. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_NOTSTORED if the key does not exist. + */ + public function prepend ($key, $value) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Prepend data to an existing item on a specific server + * @link https://php.net/manual/en/memcached.prependbykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param string $key

+ * The key of the item to prepend the data to. + *

+ * @param string $value

+ * The string to prepend. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_NOTSTORED if the key does not exist. + */ + public function prependByKey ($server_key, $key, $value) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Replace the item under an existing key + * @link https://php.net/manual/en/memcached.replace.php + * @param string $key

+ * The key under which to store the value. + *

+ * @param mixed $value

+ * The value to store. + *

+ * @param int $expiration [optional]

+ * The expiration time, defaults to 0. See Expiration Times for more info. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_NOTSTORED if the key does not exist. + */ + public function replace ($key, $value, $expiration = null, $udf_flags = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Replace the item under an existing key on a specific server + * @link https://php.net/manual/en/memcached.replacebykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param string $key

+ * The key under which to store the value. + *

+ * @param mixed $value

+ * The value to store. + *

+ * @param int $expiration [optional]

+ * The expiration time, defaults to 0. See Expiration Times for more info. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_NOTSTORED if the key does not exist. + */ + public function replaceByKey ($server_key, $key, $value, $expiration = null, $udf_flags = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Delete an item + * @link https://php.net/manual/en/memcached.delete.php + * @param string $key

+ * The key to be deleted. + *

+ * @param int $time [optional]

+ * The amount of time the server will wait to delete the item. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_NOTFOUND if the key does not exist. + */ + public function delete ($key, $time = 0) {} + + /** + * (PECL memcached >= 2.0.0)
+ * Delete multiple items + * @link https://php.net/manual/en/memcached.deletemulti.php + * @param array $keys

+ * The keys to be deleted. + *

+ * @param int $time [optional]

+ * The amount of time the server will wait to delete the items. + *

+ * @return array Returns array indexed by keys and where values are indicating whether operation succeeded or not. + * The Memcached::getResultCode will return + * Memcached::RES_NOTFOUND if the key does not exist. + */ + public function deleteMulti (array $keys, $time = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Delete an item from a specific server + * @link https://php.net/manual/en/memcached.deletebykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param string $key

+ * The key to be deleted. + *

+ * @param int $time [optional]

+ * The amount of time the server will wait to delete the item. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_NOTFOUND if the key does not exist. + */ + public function deleteByKey ($server_key, $key, $time = 0) {} + + /** + * (PECL memcached >= 2.0.0)
+ * Delete multiple items from a specific server + * @link https://php.net/manual/en/memcached.deletemultibykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param array $keys

+ * The keys to be deleted. + *

+ * @param int $time [optional]

+ * The amount of time the server will wait to delete the items. + *

+ * @return bool TRUE on success or FALSE on failure. + * The Memcached::getResultCode will return + * Memcached::RES_NOTFOUND if the key does not exist. + */ + public function deleteMultiByKey ($server_key, array $keys, $time = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Increment numeric item's value + * @link https://php.net/manual/en/memcached.increment.php + * @param string $key

+ * The key of the item to increment. + *

+ * @param int $offset [optional]

+ * The amount by which to increment the item's value. + *

+ * @param int $initial_value [optional]

+ * The value to set the item to if it doesn't currently exist. + *

+ * @param int $expiry [optional]

+ * The expiry time to set on the item. + *

+ * @return int|false new item's value on success or FALSE on failure. + */ + public function increment ($key, $offset = 1, $initial_value = 0, $expiry = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Decrement numeric item's value + * @link https://php.net/manual/en/memcached.decrement.php + * @param string $key

+ * The key of the item to decrement. + *

+ * @param int $offset [optional]

+ * The amount by which to decrement the item's value. + *

+ * @param int $initial_value [optional]

+ * The value to set the item to if it doesn't currently exist. + *

+ * @param int $expiry [optional]

+ * The expiry time to set on the item. + *

+ * @return int|false item's new value on success or FALSE on failure. + */ + public function decrement ($key, $offset = 1, $initial_value = 0, $expiry = 0) {} + + /** + * (PECL memcached >= 2.0.0)
+ * Increment numeric item's value, stored on a specific server + * @link https://php.net/manual/en/memcached.incrementbykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param string $key

+ * The key of the item to increment. + *

+ * @param int $offset [optional]

+ * The amount by which to increment the item's value. + *

+ * @param int $initial_value [optional]

+ * The value to set the item to if it doesn't currently exist. + *

+ * @param int $expiry [optional]

+ * The expiry time to set on the item. + *

+ * @return int|false new item's value on success or FALSE on failure. + */ + public function incrementByKey ($server_key, $key, $offset = 1, $initial_value = 0, $expiry = 0) {} + + /** + * (PECL memcached >= 2.0.0)
+ * Decrement numeric item's value, stored on a specific server + * @link https://php.net/manual/en/memcached.decrementbykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @param string $key

+ * The key of the item to decrement. + *

+ * @param int $offset [optional]

+ * The amount by which to decrement the item's value. + *

+ * @param int $initial_value [optional]

+ * The value to set the item to if it doesn't currently exist. + *

+ * @param int $expiry [optional]

+ * The expiry time to set on the item. + *

+ * @return int|false item's new value on success or FALSE on failure. + */ + public function decrementByKey ($server_key, $key, $offset = 1, $initial_value = 0, $expiry = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Add a server to the server pool + * @link https://php.net/manual/en/memcached.addserver.php + * @param string $host

+ * The hostname of the memcache server. If the hostname is invalid, data-related + * operations will set + * Memcached::RES_HOST_LOOKUP_FAILURE result code. + *

+ * @param int $port

+ * The port on which memcache is running. Usually, this is + * 11211. + *

+ * @param int $weight [optional]

+ * The weight of the server relative to the total weight of all the + * servers in the pool. This controls the probability of the server being + * selected for operations. This is used only with consistent distribution + * option and usually corresponds to the amount of memory available to + * memcache on that server. + *

+ * @return bool TRUE on success or FALSE on failure. + */ + public function addServer ($host, $port, $weight = 0) {} + + /** + * (PECL memcached >= 0.1.1)
+ * Add multiple servers to the server pool + * @link https://php.net/manual/en/memcached.addservers.php + * @param array $servers + * @return bool TRUE on success or FALSE on failure. + */ + public function addServers (array $servers) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Get the list of the servers in the pool + * @link https://php.net/manual/en/memcached.getserverlist.php + * @return array The list of all servers in the server pool. + */ + public function getServerList () {} + + /** + * (PECL memcached >= 0.1.0)
+ * Map a key to a server + * @link https://php.net/manual/en/memcached.getserverbykey.php + * @param string $server_key

+ * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations. + *

+ * @return array an array containing three keys of host, + * port, and weight on success or FALSE + * on failure. + * Use Memcached::getResultCode if necessary. + */ + public function getServerByKey ($server_key) {} + + /** + * (PECL memcached >= 2.0.0)
+ * Clears all servers from the server list + * @link https://php.net/manual/en/memcached.resetserverlist.php + * @return bool TRUE on success or FALSE on failure. + */ + public function resetServerList () {} + + /** + * (PECL memcached >= 2.0.0)
+ * Close any open connections + * @link https://php.net/manual/en/memcached.quit.php + * @return bool TRUE on success or FALSE on failure. + */ + public function quit () {} + + /** + * (PECL memcached >= 0.1.0)
+ * Get server pool statistics + * @link https://php.net/manual/en/memcached.getstats.php + * @param string $type + * @return array Array of server statistics, one entry per server. + */ + public function getStats ($type = null) {} + + /** + * (PECL memcached >= 0.1.5)
+ * Get server pool version info + * @link https://php.net/manual/en/memcached.getversion.php + * @return array Array of server versions, one entry per server. + */ + public function getVersion () {} + + /** + * (PECL memcached >= 2.0.0)
+ * Gets the keys stored on all the servers + * @link https://php.net/manual/en/memcached.getallkeys.php + * @return array|false the keys stored on all the servers on success or FALSE on failure. + */ + public function getAllKeys () {} + + /** + * (PECL memcached >= 0.1.0)
+ * Invalidate all items in the cache + * @link https://php.net/manual/en/memcached.flush.php + * @param int $delay [optional]

+ * Numer of seconds to wait before invalidating the items. + *

+ * @return bool TRUE on success or FALSE on failure. + * Use Memcached::getResultCode if necessary. + */ + public function flush ($delay = 0) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Retrieve a Memcached option value + * @link https://php.net/manual/en/memcached.getoption.php + * @param int $option

+ * One of the Memcached::OPT_* constants. + *

+ * @return mixed the value of the requested option, or FALSE on + * error. + */ + public function getOption ($option) {} + + /** + * (PECL memcached >= 0.1.0)
+ * Set a Memcached option + * @link https://php.net/manual/en/memcached.setoption.php + * @param int $option + * @param mixed $value + * @return bool TRUE on success or FALSE on failure. + */ + public function setOption ($option, $value) {} + + /** + * (PECL memcached >= 2.0.0)
+ * Set Memcached options + * @link https://php.net/manual/en/memcached.setoptions.php + * @param array $options

+ * An associative array of options where the key is the option to set and + * the value is the new value for the option. + *

+ * @return bool TRUE on success or FALSE on failure. + */ + public function setOptions (array $options) {} + + /** + * (PECL memcached >= 2.0.0)
+ * Set the credentials to use for authentication + * @link https://secure.php.net/manual/en/memcached.setsaslauthdata.php + * @param string $username

+ * The username to use for authentication. + *

+ * @param string $password

+ * The password to use for authentication. + *

+ * @return bool TRUE on success or FALSE on failure. + */ + public function setSaslAuthData (string $username , string $password) {} + + /** + * (PECL memcached >= 2.0.0)
+ * Check if a persitent connection to memcache is being used + * @link https://php.net/manual/en/memcached.ispersistent.php + * @return bool true if Memcache instance uses a persistent connection, false otherwise. + */ + public function isPersistent () {} + + /** + * (PECL memcached >= 2.0.0)
+ * Check if the instance was recently created + * @link https://php.net/manual/en/memcached.ispristine.php + * @return bool the true if instance is recently created, false otherwise. + */ + public function isPristine () {} + + public function flushBuffers () {} + + public function setEncodingKey ( $key ) {} + + public function getLastDisconnectedServer () {} + + public function getLastErrorErrno () {} + + public function getLastErrorCode () {} + + public function getLastErrorMessage () {} + + public function setBucket (array $host_map, array $forward_map, $replicas) {} + +} + +/** + * @link https://php.net/manual/en/class.memcachedexception.php + */ +class MemcachedException extends RuntimeException { + +} +// End of memcached v.3.0.4 +?> diff --git a/CHANGELOG.md b/CHANGELOG.md index bf61192..27799cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,880 @@ +# v1.7.14 +## 04/29/2021 + +1. [](#new) + * Added `MediaUploadTrait::checkFileMetadata()` method +1. [](#improved) + * Updating a theme should always keep the custom files [getgrav/grav-plugin-admin#2135](https://github.com/getgrav/grav-plugin-admin/issues/2135) +1. [](#bugfix) + * Fixed broken numeric language codes in Flex Pages [#3332](https://github.com/getgrav/grav/issues/3332) + * Fixed broken `exif_imagetype()` twig function + +# v1.7.13 +## 04/23/2021 + +1. [](#new) + * Added support for getting translated collection of Flex Pages using `$collection->withTranslated('de')` +1. [](#improved) + * Moved `gregwar/Image` and `gregwar/Cache` in-house to official `getgrav/Image` and `getgrav/Cache` packagist packages. This will help environments with very strict proxy setups that don't allow VCS setup. [#3289](https://github.com/getgrav/grav/issues/3289) + * Improved XSS Invalid Protocol detection regex [#3298](https://github.com/getgrav/grav/issues/3298) + * Added support for user provided folder in Flex `$page->copy()` +1. [](#bugfix) + * Fixed `The "Grav/Common/Twig/TwigExtension" extension is not enabled` when using markdown twig tag [#3317](https://github.com/getgrav/grav/issues/3317) + * Fixed text field maxlength validation newline issue [#3324](https://github.com/getgrav/grav/issues/3324) + * Fixed a bug in Flex Object `refresh()` method + +# v1.7.12 +## 04/15/2021 + +1. [](#improved) + * Improve JSON support for the request +1. [](#bugfix) + * Fixed absolute path support for Windows [#3297](https://github.com/getgrav/grav/issues/3297) + * Fixed adding tags in admin after upgrading Grav [#3315](https://github.com/getgrav/grav/issues/3315) + +# v1.7.11 +## 04/13/2021 + +1. [](#new) + * Added configuration options to allow PHP methods to be used in Twig functions (`system.twig.safe_functions`) and filters (`system.twig.safe_filters`) + * Deprecated using PHP methods in Twig without them being in the safe lists + * Prevent dangerous PHP methods from being used as Twig functions and filters + * Restrict filesystem Twig functions to accept only local filesystem and grav streams +1. [](#improved) + * Better GPM detection of unauthorized installations +1. [](#bugfix) + * **IMPORTANT** Fixed security vulnerability with Twig allowing dangerous PHP functions by default [GHSA-g8r4-p96j-xfxc](https://github.com/getgrav/grav/security/advisories/GHSA-g8r4-p96j-xfxc) + * Fixed nxinx appending repeating `?_url=` in some redirects + * Fixed deleting page with language code not removing the folder if it was the last language [#3305](https://github.com/getgrav/grav/issues/3305) + * Fixed fatal error when using markdown links with `image://` stream [#3285](https://github.com/getgrav/grav/issues/3285) + * Fixed `system.languages.session_store_active` not having any effect [#3269](https://github.com/getgrav/grav/issues/3269) + * Fixed fatal error if `system.pages.types` is not an array [#2984](https://github.com/getgrav/grav/issues/2984) + +# v1.7.10 +## 04/06/2021 + +1. [](#new) + * Added initial support for running Grav library from outside the webroot [#3297](https://github.com/getgrav/grav/issues/3297) +1. [](#improved) + * Improved password handling when saving a user +1. [](#bugfix) + * Ignore errors when using `set_time_limit` in `Archiver` and `GPM\Response` classes [#3023](https://github.com/getgrav/grav/issues/3023) + * Fixed `Folder::move()` deleting the folder if you move folder into itself, created empty file instead + * Fixed moving `Flex Page` to itself causing the page to be lost [#3227](https://github.com/getgrav/grav/issues/3227) + * Fixed `PageStorage` from detecting files as pages + * Fixed `UserIndex` not implementing `UserCollectionInterface` + * Fixed missing `onAdminAfterDelete` event call in `Flex Pages` + * Fixed system templates not getting scanned [#3296](https://github.com/getgrav/grav/issues/3296) + * Fixed incorrect routing if url path looks like a domain name [#2184](https://github.com/getgrav/grav/issues/2184) + +# v1.7.9 +## 03/19/2021 + +1. [](#new) + * Added `Media::hide()` method to hide files from media + * Added `Utils::getPathFromToken()` method which works also with `Flex Objects` + * Added `FlexMediaTrait::getMediaField()`, which can be used to access custom media set in the blueprint fields + * Added `FlexMediaTrait::getFieldSettings()`, which can be used to get media field settings +1. [](#improved) + * Method `Utils::getPagePathFromToken()` now calls the more generic `Utils::getPathFromToken()` + * Updated `SECURITY.md` to use security@getgrav.org +1. [](#bugfix) + * Fixed broken media upload in `Flex` with `@self/path`, `@page` and `@theme` destinations [#3275](https://github.com/getgrav/grav/issues/3275) + * Fixed media fields excluding newly deleted files before saving the object + * Fixed method `$pages->find()` should never redirect [#3266](https://github.com/getgrav/grav/pull/3266) + * Fixed `Page::activeChild()` throwing an error [#3276](https://github.com/getgrav/grav/issues/3276) + * Fixed `Flex Page` CRUD ACL when creating a new page (needs Flex Objects plugin update) [grav-plugin-flex-objects#115](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/115) + * Fixed the list of pages not showing up in admin [#3280](https://github.com/getgrav/grav/issues/3280) + * Fixed text field min/max validation for UTF8 characters [#3281](https://github.com/getgrav/grav/issues/3281) + * Fixed redirects using wrong redirect code + +# v1.7.8 +## 03/17/2021 + +1. [](#new) + * Added `ControllerResponseTrait::createDownloadResponse()` method + * Added full blueprint support to theme if you move existing files in `blueprints/` to `blueprints/pages/` folder [#3255](https://github.com/getgrav/grav/issues/3255) + * Added support for `Theme::getFormFieldTypes()` just like in plugins +1. [](#improved) + * Optimized `Flex Pages` for speed + * Optimized saving visible/ordered pages when there are a lot of siblings [#3231](https://github.com/getgrav/grav/issues/3231) + * Clearing cache now deletes all clockwork files + * Improved `system.pages.redirect_default_route` and `system.pages.redirect_trailing_slash` configuration options to accept redirect code +1. [](#bugfix) + * Fixed clockwork error when clearing cache + * Fixed missing method `translated()` in `Flex Pages` + * Fixed missing `Flex Pages` in site if multi-language support has been enabled + * Fixed Grav using blueprints and form fields from disabled plugins + * Fixed `FlexIndex::sortBy(['key' => 'ASC'])` having no effect + * Fixed default Flex Pages collection ordering to order by filesystem path + * Fixed disappearing pages on save if `pages://` stream resolves to multiple folders where the preferred folder doesn't exist + * Fixed Markdown image attribute `loading` [#3251](https://github.com/getgrav/grav/pull/3251) + * Fixed `Uri::isValidExtension()` returning false positives + * Fixed `page.html` returning duplicated content with `system.pages.redirect_default_route` turned on [#3130](https://github.com/getgrav/grav/issues/3130) + * Fixed site redirect with redirect code failing when redirecting to sub-pages [#3035](https://github.com/getgrav/grav/pull/3035/files) + * Fixed `Uncaught ValueError: Path cannot be empty` when failing to upload a file [#3265](https://github.com/getgrav/grav/issues/3265) + * Fixed `Path cannot be empty` when viewing non-existent log file [#3270](https://github.com/getgrav/grav/issues/3270) + * Fixed `onAdminSave` original page having empty header [#3259](https://github.com/getgrav/grav/issues/3259) + +# v1.7.7 +## 02/23/2021 + +1. [](#new) + * Added `Utils::arrayToQueryParams()` to convert an array into query params +1. [](#improved) + * Added original image support for all flex objects and media fields + * Improved `Pagination` class to allow custom pagination query parameter +1. [](#bugfix) + * Fixed avatar of the user not being saved [grav-plugin-flex-objects#111](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/111) + * Replaced special space character with regular space in `system/blueprints/user/account_new.yaml` + +# v1.7.6 +## 02/17/2021 + +1. [](#new) + * Added `Medium::attribute()` to pass arbitrary attributes [#3065](https://github.com/getgrav/grav/pull/3065) + * Added `Plugins::getPlugins()` and `Plugins::getPlugin($name)` to make it easier to access plugin instances [#2277](https://github.com/getgrav/grav/pull/2277) + * Added `regex_match` and `regex_split` twig functions [#2788](https://github.com/getgrav/grav/pull/2788) + * Updated all languages from [Crowdin](https://crowdin.com/project/grav-core) - Please update any translations here +1. [](#improved) + * Added abstract `FlexObject`, `FlexCollection` and `FlexIndex` classes to `\Grav\Common\Flex` namespace (extend those instead of Framework or Generic classes) + * Updated bundled `composer.phar` binary to latest version `2.0.9` + * Improved session fixation handling in PHP 7.4+ (cannot fix it in PHP 7.3 due to PHP bug) + * Added optional password/database attributes for redis in `system.yaml` + * Added ability to filter enabled or disabled with bin/gpm index [#3187](https://github.com/getgrav/grav/pull/3187) + * Added `$grav->getVersion()` or `grav.version` in twig to get the current Grav version [#3142](https://github.com/getgrav/grav/issues/3142) + * Added second parameter to `$blueprint->flattenData()` to include every field, including those which have no data + * Added support for setting session domain [#2040](https://github.com/getgrav/grav/pull/2040) + * Better support inheriting languages when using child themes [#3226](https://github.com/getgrav/grav/pull/3226) + * Added option for `FlexForm` constructor to reset the form +1. [](#bugfix) + * Fixed issue with `content-security-policy` not being properly supported with `http-equiv` + support single quotes + * Fixed CLI progressbar in `backup` and `security` commands to use styled output [#3198](https://github.com/getgrav/grav/issues/3198) + * Fixed page save failing because of uploaded images [#3191](https://github.com/getgrav/grav/issues/3191) + * Fixed `Flex Pages` using only default language in frontend [#106](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/106) + * Fixed empty `route()` and `raw_route()` when getting translated pages [#3184](https://github.com/getgrav/grav/pull/3184) + * Fixed error on `bin/gpm plugin uninstall` [#3207](https://github.com/getgrav/grav/issues/3207) + * Fixed broken min/max validation for field `type: int` + * Fixed lowering uppercase characters in usernames when saving from frontend [#2565](https://github.com/getgrav/grav/pull/2565) + * Fixed save error when editing accounts that have been created with capital letters in their username [#3211](https://github.com/getgrav/grav/issues/3211) + * Fixed renaming flex objects key when using file storage + * Fixed wrong values in Admin pages list [#3214](https://github.com/getgrav/grav/issues/3214) + * Fixed pipelined asset using different hash when extra asset is added to before/after position [#2781](https://github.com/getgrav/grav/issues/2781) + * Fixed trailing slash redirect to only apply to GET/HEAD requests and use 301 status code [#3127](https://github.com/getgrav/grav/issues/3127) + * Fixed root page to always contain trailing slash [#3127](https://github.com/getgrav/grav/issues/3127) + * Fixed `` to use name instead property [#3010](https://github.com/getgrav/grav/pull/3010) + * Fixed behavior of opposite filters in `Pages::getCollection()` to match Grav 1.6 [#3216](https://github.com/getgrav/grav/pull/3216) + * Fixed modular content with missing template file ending up using non-modular template [#3218](https://github.com/getgrav/grav/issues/3218) + * Fixed broken attachment image in Flex Objects Admin when `destination: self@` used [#3225](https://github.com/getgrav/grav/issues/3225) + * Fixed bug in page content with both markdown and twig enabled [#3223](https://github.com/getgrav/grav/issues/3223) + +# v1.7.5 +## 02/01/2021 + +1. [](#bugfix) + * Revert: Fixed page save failing because of uploaded images [#3191](https://github.com/getgrav/grav/issues/3191) - breaking save + +# v1.7.4 +## 02/01/2021 + +1. [](#new) + * Added `FlexForm::setSubmitMethod()` to customize form submit action +1. [](#improved) + * Improved GPM error handling +1. [](#bugfix) + * Fixed `bin/gpm uninstall` script not working because of bad typehint [#3172](https://github.com/getgrav/grav/issues/3172) + * Fixed `login: visibility_requires_access` not working in pages [#3176](https://github.com/getgrav/grav/issues/3176) + * Fixed cannot change image format [#3173](https://github.com/getgrav/grav/issues/3173) + * Fixed saving page in expert mode [#3174](https://github.com/getgrav/grav/issues/3174) + * Fixed exception in `$flexPage->frontmatter()` method when setting value + * Fixed `onBlueprintCreated` event being called multiple times in `Flex Pages` [grav-plugin-flex-objects#97](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/97) + * Fixed wrong ordering in page collections if `intl` extension has been enabled [#3167](https://github.com/getgrav/grav/issues/3167) + * Fixed page redirect to the first visible child page (needs to be routable and published, too) + * Fixed untranslated module pages showing up in the menu + * Fixed page save failing because of uploaded images [#3191](https://github.com/getgrav/grav/issues/3191) + * Fixed incorrect config lookup for loading in `ImageLoadingTrait` [#3192](https://github.com/getgrav/grav/issues/3192) + +# v1.7.3 +## 01/21/2021 + +1. [](#improved) + * IMPORTANT - Please [checkout the process](https://getgrav.org/blog/grav-170-cli-self-upgrade-bug) to `self-upgrade` from CLI if you are on **Grav 1.7.0 or 1.7.1** + * Added support for symlinking individual plugins and themes by using `bin/grav install -p myplugin` or `-t mytheme` + * Added support for symlinking plugins and themes with `hebe.json` file to support custom folder structures + * Added support for running post-install scripts in `bin/gpm selfupgrade` if Grav was updated manually +1. [](#bugfix) + * Fixed default GPM Channel back to 'stable' - this was inadvertently left as 'testing' [#3163](https://github.com/getgrav/grav/issues/3163) + * Fixed broken stream initialization if `environment://` paths aren't streams + * Fixed Clockwork debugger in sub-folder multi-site setups + * Fixed `Unsupported option "curl" passed to "Symfony\Component\HttpClient\CurlHttpClient"` in `bin/gpm selfupgrade` [#3165](https://github.com/getgrav/grav/issues/3165) + +# v1.7.2 +## 01/21/2021 + +1. [](#improved) + * This release was pulled due to a bug in the installer, 1.7.3 replaces it. + +# v1.7.1 +## 01/20/2021 + +1. [](#bugfix) + * Fixed fatal error when `site.taxonomies` contains a bad value + * Sanitize valid Page extensions from `Page::template_format()` + * Fixed `bin/gpm index` erroring out [#3158](https://github.com/getgrav/grav/issues/3158) + * Fixed `bin/gpm selfupgrade` failing to report failed Grav update [#3116](https://github.com/getgrav/grav/issues/3116) + * Fixed `bin/gpm selfupgrade` error on `Call to undefined method` [#3160](https://github.com/getgrav/grav/issues/3160) + * Flex Pages: Fixed fatal error when trying to move a page to Root (/) [#3161](https://github.com/getgrav/grav/issues/3161) + * Fixed twig parsing errors in pages where twig is parsed after markdown [#3162](https://github.com/getgrav/grav/issues/3162) + * Fixed `lighttpd.conf` access-deny rule [#1876](https://github.com/getgrav/grav/issues/1876) + * Fixed page metadata being double-escaped [#3121](https://github.com/getgrav/grav/issues/3121) + +# v1.7.0 +## 01/19/2021 + +1. [](#new) + * Requires **PHP 7.3.6** + * Read about this release in the [Grav 1.7 Released](https://getgrav.org/blog/grav-1.7-released) blog post + * Read the full list of all changes in the [Changelog on GitHub](https://github.com/getgrav/grav/blob/1.7.0/CHANGELOG.md) + * Please read [Grav 1.7 Upgrade Guide](https://learn.getgrav.org/17/advanced/grav-development/grav-17-upgrade-guide) before upgrading! + * Added support for overriding configuration by using environment variables + * Use PHP 7.4 serialization (the old `Serializable` methods are now final and cannot be overridden) + * Enabled `ETag` setting by default for 304 responses + * Added `FlexCollection::getDistinctValues()` to get all the assigned values from the field + * `Flex Pages` method `$page->header()` returns `\Grav\Common\Page\Header` object, old `Page` class still returns `stdClass` +1. [](#improved) + * Make it possible to use an absolute path when loading a blueprint + * Make serialize methods final in `ContentBlock`, `AbstractFile`, `FormTrait`, `ObjectCollectionTrait` and `ObjectTrait` + * Added support for relative paths in `PageObject::getLevelListing()` [#3110](https://github.com/getgrav/grav/issues/3110) + * Better `--env` and `--lang` support for `bin/grav`, `bin/gpm` and `bin/plugin` console commands + * **BC BREAK** Shorthand for `--env`: `-e` will not work anymore as it conflicts with some plugins + * Added support for locking the `start` and `limit` in a Page Collection +1. [](#bugfix) + * Fixed port issue with `system.custom_base_url` + * Hide errors with `exif_read_data` in `ImageFile` + * Fixed unserialize in `MarkdownFormatter` and `Framework\File` classes + * Fixed pages with session messages should never be cached [#3108](https://github.com/getgrav/grav/issues/3108) + * Fixed `Filesystem::normalize()` with dot-dot paths + * Fixed Flex sorting issues [grav-plugin-flex-objects#92](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/92) + * Fixed Clockwork missing dumped arrays and objects + * Fixed fatal error in PHP 8 when trying to access root page + * Fixed Array->String conversion error when `languages:translations: false` [admin#1896](https://github.com/getgrav/grav-plugin-admin/issues/1896) + * Fixed `Inflector` methods when translation is missing `GRAV.INFLECTOR_*` translations + * Fixed exception when changing parent of new page [grav-plugin-admin#2018](https://github.com/getgrav/grav-plugin-admin/issues/2018) + * Fixed ordering issue with moving pages [grav-plugin-admin#2015](https://github.com/getgrav/grav-plugin-admin/issues/2015) + * Fixed Flex Pages cache not invalidating if saving an old `Page` object [#3152](https://github.com/getgrav/grav/issues/3152) + * Fixed multiple issues with `system.language.translations: false` + * Fixed page collections containing dummy items for untranslated default language [#2985](https://github.com/getgrav/grav/issues/2985) + * Fixed streams in `setup.php` being overridden by `system/streams.yaml` [#2450](https://github.com/getgrav/grav/issues/2450) + * Fixed `ERR_TOO_MANY_REDIRECTS` with HTTPS = 'On' [#3155](https://github.com/getgrav/grav/issues/3155) + * Fixed page collection pagination not behaving as it did in Grav 1.6 + +# v1.7.0-rc.20 +## 12/15/2020 + +1. [](#new) + * Update phpstan to version 0.12 + * Auto-Escape enabled by default. Manually enable **Twig Compatibility** and disable **Auto-Escape** to use the old setting. + * Updated unit tests to use codeception 4.1 + * Added support for setting `GRAV_ENVIRONMENT` by using environment variable or a constant + * Added support for setting `GRAV_SETUP_PATH` by using environment variable (constant already worked) + * Added support for setting `GRAV_ENVIRONMENTS_PATH` by using environment variable or a constant + * Added support for setting `GRAV_ENVIRONMENT_PATH` by using environment variable or a constant +1. [](#improved) + * Improved `bin/grav install` command +1. [](#bugfix) + * Fixed potential error when upgrading Grav + * Fixed broken list in `bin/gpm index` [#3092](https://github.com/getgrav/grav/issues/3092) + * Fixed CLI/GPM command failures returning 0 (success) value [#3017](https://github.com/getgrav/grav/issues/3017) + * Fixed unimplemented `PageObject::getOriginal()` call [#3098](https://github.com/getgrav/grav/issues/3098) + * Fixed `Argument 1 passed to Grav\Common\User\DataUser\User::filterUsername() must be of the type string` [#3101](https://github.com/getgrav/grav/issues/3101) + * Fixed broken check if php exif module is enabled in `ImageFile::fixOrientation()` + * Fixed `StaticResizeTrait::resize()` bad image height/width attributes if `null` values are passed to the method + * Fixed twig script/style tag `{% script 'file.js' at 'bottom' %}`, replaces broken `in` operator [#3084](https://github.com/getgrav/grav/issues/3084) + * Fixed dropped query params when `?` is preceded with `/` [#2964](https://github.com/getgrav/grav/issues/2964) + +# v1.7.0-rc.19 +## 12/02/2020 + +1. [](#bugfix) + * Updated composer libraries with latest Toolbox v1.5.6 that contains critical fixes + +# v1.7.0-rc.18 +## 12/02/2020 + +1. [](#new) + * Set minimum requirements to **PHP 7.3.6** + * Updated Clockwork to v5.0 + * Added `FlexDirectoryInterface` interface + * Renamed `PageCollectionInterface::nonModular()` into `PageCollectionInterface::pages()` and deprecated the old method + * Renamed `PageCollectionInterface::modular()` into `PageCollectionInterface::modules()` and deprecated the old method' + * Upgraded `bin/composer.phar` to `2.0.2` which is all new and much faster + * Added search option `same_as` to Flex Objects + * Added PHP 8 compatible `function_exists()`: `Utils::functionExists()` + * New sites have `compatibility` features turned off by default, upgrading from older versions will keep the settings on +1. [](#improved) + * Updated bundled JQuery to latest version `3.5.1` + * Forward a `sid` to GPM when downloading a premium package via CLI + * Allow `JsonFormatter` options to be passed as a string + * Hide Flex Pages frontend configuration (not ready for production use) + * Improve Flex configuration: gather views together in blueprint + * Added XSS detection to all forms. See [documentation](https://learn.getgrav.org/17/forms/forms/form-options#xss-checks) + * Better handling of missing repository index [grav-plugin-admin#1916](https://github.com/getgrav/grav-plugin-admin/issues/1916) + * Added support for having all sites / environments under `user/env` folder [#3072](https://github.com/getgrav/grav/issues/3072) + * Added `FlexObject::refresh()` method to make sure object is up to date +1. [](#bugfix) + * *Menu Visibility Requires Access* Security option setting wrong frontmatter [login#265](https://github.com/getgrav/grav-plugin-login/issues/265) + * Accessing page with unsupported file extension (jpg, pdf, xsl) will use wrong mime type [#3031](https://github.com/getgrav/grav/issues/3031) + * Fixed media crashing on a bad image + * Fixed bug in collections where filter `type: false` did not work + * Fixed `print_r()` in twig + * Fixed sorting by groups in `Flex Users` + * Changing `Flex Page` template causes the other language versions of that page to lose their content [admin#1958](https://github.com/getgrav/grav-plugin-admin/issues/1958) + * Fixed plugins getting initialized multiple times (by CLI commands for example) + * Fixed `header.admin.children_display_order` in Flex Pages to work just like with regular pages + * Fixed `Utils::isFunctionDisabled()` method if there are spaces in `disable_functions` [#3023](https://github.com/getgrav/grav/issues/3023) + * Fixed potential fatal error when creating flex index using cache [#3062](https://github.com/getgrav/grav/issues/3062) + * Fixed fatal error in `CompiledFile` if the cached version is broken + * Fixed updated media missing from media when editing Flex Object after page reload + * Fixed issue with `config-default@` breaking on set [#1972](https://github.com/getgrav/grav-plugin-admin/issues/1971) + * Escape titles in Flex pages list [flex-objects#84](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/84) + * Fixed Purge successful message only working in Scheduler but broken in CLI and Admin [#1935](https://github.com/getgrav/grav-plugin-admin/issues/1935) + * Fixed `system://` stream is causing issues in Admin, making Media tab to disappear and possibly causing other issues [#3072](https://github.com/getgrav/grav/issues/3072) + * Fixed CLI self-upgrade from Grav 1.6 [#3079](https://github.com/getgrav/grav/issues/3079) + * Fixed `bin/grav yamllinter -a` and `-f` not following symlinks [#3080](https://github.com/getgrav/grav/issues/3080) + * Fixed `|safe_email` filter to return safe and escaped UTF-8 HTML [#3072](https://github.com/getgrav/grav/issues/3072) + * Fixed exception in CLI GPM and backup commands when `php-zip` is not enabled [#3075](https://github.com/getgrav/grav/issues/3075) + * Fix for XSS advisory [GHSA-cvmr-6428-87w9](https://github.com/getgrav/grav/security/advisories/GHSA-cvmr-6428-87w9) + * Fixed Flex and Page ordering to be natural and case insensitive [flex-objects#87](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/87) + * Fixed plugin/theme priority ordering to be numeric + +# v1.7.0-rc.17 +## 10/07/2020 + +1. [](#new) + * Added a `Uri::getAllHeaders()` compatibility function +1. [](#improved) + * Fall back through various templates scenarios if they don't exist in theme to avoid unhelpful error. + * Added default templates for `external.html.twig`, `default.html.twig`, and `modular.html.twig` + * Improve Media classes + * _POTENTIAL BREAKING CHANGE:_ Added reload argument to `FlexStorageInterface::getMetaData()` +1. [](#bugfix) + * Fixed `Security::sanitizeSVG()` creating an empty file if SVG file cannot be parsed + * Fixed infinite loop in blueprints with `extend@` to a parent stream + * Added missing `Stream::create()` method + * Added missing `onBlueprintCreated` event for Flex Pages + * Fixed `onBlueprintCreated` firing multiple times recursively + * Fixed media upload failing with custom folders + * Fixed `unset()` in `ObjectProperty` class + * Fixed `FlexObject::freeMedia()` method causing media to become null + * Fixed bug in `Flex Form` making it impossible to set nested values + * Fixed `Flex User` avatar when using folder storage, also allow multiple images + * Fixed Referer reference during GPM calls. + * Fixed fatal error with toggled lists + +# v1.7.0-rc.16 +## 09/01/2020 + +1. [](#new) + * Added a new `svg_image()` twig function to make it easier to 'include' SVG source in Twig + * Added a helper `Utils::fullPath()` to get the full path to a file be it stream, relative, etc. +1. [](#improved) + * Added `themes` to cached blueprints and configuration +1. [](#bugfix) + * Fixed `Flex Pages` issue with `getRoute()` returning path with language prefix for default language if set not to do that + * Fixed `Flex Pages` bug where reordering pages causes page content to disappear if default language uses wrong extension (`.md` vs `.en.md`) + * Fixed `Flex Pages` bug where `onAdminSave` passes page as `$event['page']` instead of `$event['object']` [#2995](https://github.com/getgrav/grav/issues/2995) + * Fixed `Flex Pages` bug where changing a modular page template added duplicate file [admin#1899](https://github.com/getgrav/grav-plugin-admin/issues/1899) + * Fixed `Flex Pages` bug where renaming slug causes bad ordering range after save [#2997](https://github.com/getgrav/grav/issues/2997) + +# v1.7.0-rc.15 +## 07/22/2020 + +1. [](#bugfix) + * Fixed Flex index file caching [#2962](https://github.com/getgrav/grav/issues/2962) + * Fixed various issues with Exif data reading and images being incorrectly rotated [#1923](https://github.com/getgrav/grav-plugin-admin/issues/1923) + +# v1.7.0-rc.14 +## 07/09/2020 + +1. [](#improved) + * Added ability to `noprocess` specific items only in Link/Image Excerpts, e.g. `http://foo.com/page?id=foo&target=_blank&noprocess=id` [#2954](https://github.com/getgrav/grav/pull/2954) +1. [](#bugfix) + * Regression: Default language fix broke `Language::getLanguageURLPrefix()` and `Language::isIncludeDefaultLanguage()` methods when not using multi-language + * Reverted `Language::getDefault()` and `Language::getLanguage()` to return false again because of plugin compatibility (updated docblocks) + * Fixed UTF-8 issue in `Excerpts::getExcerptsFromHtml` + * Fixed some compatibility issues with recent Changes to `Assets` handling + * Fixed issue with `CSS_IMPORTS_REGEX` breaking with complex URLs [#2958](https://github.com/getgrav/grav/issues/2958) + * Moved duplicated `CSS_IMPORT_REGEX` to local variable in `AssetUtilsTrait::moveImports()` + * Fixed page media only accepting images [#2943](https://github.com/getgrav/grav/issues/2943) + +# v1.7.0-rc.13 +## 07/01/2020 + +1. [](#new) + * Added support for uploading and deleting images directly in `Media` + * Added new `onAfterCacheClear` event +1. [](#improved) + * Improved `CvsFormatter` to attempt to encode non-scalar variables into JSON before giving up + * Moved image loading into its own trait to be used by images+static images + * Adjusted asset types to enable extension of assets in class [#2937](https://github.com/getgrav/grav/pull/2937) + * Composer update for vendor library updates + * Updated bundled `composer.phar` to `2.0.0-dev` +1. [](#bugfix) + * Fixed `MediaUploadTrait::copyUploadedFile()` not adding uploaded media to the collection + * Fixed regression in saving media to a new Flex Object [admin#1867](https://github.com/getgrav/grav-plugin-admin/issues/1867) + * Fixed `Trying to get property 'username' of non-object` error in Flex [flex-objects#62](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/62) + * Fixed retina images not working in Flex [flex-objects#64](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/64) + * Fixed plugin initialization in CLI + * Fixed broken logic in `Page::topParent()` when dealing with first-level pages + * Fixed broken `Flex Page` authorization for groups + * Fixed missing `onAdminSave` and `onAdminAfterSave` events when using `Flex Pages` and `Flex Users` [flex-objects#58](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/58) + * Fixed new `User Group` allowing bad group name to be saved [admin#1917](https://github.com/getgrav/grav-plugin-admin/issues/1917) + * Fixed `Language::getDefault()` returning false and not 'en' + * Fixed non-text links in `Excerpts::getExcerptFromHtml` + * Fixed CLI commands not properly intializing Plugins so events can fire + +# v1.7.0-rc.12 +## 06/08/2020 + +1. [](#improved) + * Changed `Folder::hasChildren` to `Folder::countChildren` + * Added `Content Editor` option to user account blueprint +1. [](#bugfix) + * Fixed new `Flex Page` not having correct form fields for the page type + * Fixed new `Flex User` erroring out on save (thanks @mikebi42) + * Fixed `Flex Object` request cache clear when saving object + * Fixed blueprint value filtering in lists [#2923](https://github.com/getgrav/grav/issues/2923) + * Fixed blueprint for `system.pages.hide_empty_folders` [#1925](https://github.com/getgrav/grav/issues/2925) + * Fixed file field in `Flex Objects` (use `Grav\Common\Flex\Types\GenericObject` instead of `FlexObject`) [flex-objects#37](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/37) + * Fixed saving nested file fields in `Flex Objects` [flex-objects#34](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/34) + * JSON Route of homepage with no ‘route’ set is valid [form#425](https://github.com/getgrav/grav-plugin-form/issues/425) + +# v1.7.0-rc.11 +## 05/14/2020 + +1. [](#new) + * Added support for native `loading=lazy` attributes on images. Can be set in `system.images.defaults` or per md image with `?loading=lazy` [#2910](https://github.com/getgrav/grav/issues/2910) +1. [](#improved) + * Added `PageCollection::all()` to mimic Pages class + * Added system configuration support for `HTTP_X_Forwarded` headers (host disabled by default) + * Updated `PHPUserAgentParser` to 1.0.0 + * Improved docblocks + * Fixed some phpstan issues + * Tighten vendor requirements +1. [](#bugfix) + * Fix for uppercase image extensions + * Fix for `&` errors in HTML when passed to `Excerpts.php` + +# v1.7.0-rc.10 +## 04/30/2020 + +1. [](#new) + * Changed `Response::get()` used by **GPM/Admin** to use [Symfony HttpClient v4.4](https://symfony.com/doc/current/components/http_client.html) (`composer install --nodev` required for Git installations) + * Added new `Excerpts::processLinkHtml()` method +1. [](#bugfix) + * Fixed `Flex Pages` admin with PHP `intl` extension enabled when using custom page order + * Fixed saving non-numeric-prefix `Flex Page` changing to numeric-prefix [flex-objects#56](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/56) + * Copying `Flex Page` in admin does nothing [flex-objects#55](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/55) + * Force GPM progress to be between 0-100% + +# v1.7.0-rc.9 +## 04/27/2020 + +1. [](#new) + * Support for `webp` image format in Page Media [#1168](https://github.com/getgrav/grav/issues/1168) + * Added `Route::getBase()` method +1. [](#improved) + * Support symlinks when saving `File` +1. [](#bugfix) + * Fixed flex objects with integer keys not working [#2863](https://github.com/getgrav/grav/issues/2863) + * Fixed `Pages::instances()` returning null values when using `Flex Pages` [#2889](https://github.com/getgrav/grav/issues/2889) + * Fixed Flex Page parent `header.admin.children_display_order` setting being ignored in Admin [#2881](https://github.com/getgrav/grav/issues/2881) + * Implemented missing Flex `$pageCollection->batch()` and `$pageCollection->order()` methods + * Fixed user avatar creation for new `Flex Users` when using folder storage + * Fixed `Trying to access array offset on value of type null` PHP 7.4 error in `Plugin.php` + * Fixed Gregwar Image library using `.jpeg` for cached images, rather use `.jpg` + * Fixed `Flex Pages` with `00.home` page not having ordering set + * Fixed `Flex Pages` not updating empty content on save [#2890](https://github.com/getgrav/grav/issues/2890) + * Fixed creating new Flex User with file storage + * Fixed saving new `Flex Object` with custom key + * Fixed broken `Plugin::config()` method + +# v1.7.0-rc.8 +## 03/19/2020 + +1. [](#new) + * Added `MediaTrait::freeMedia()` method to free media (and memory) + * Added `Folder::hasChildren()` method to determine if a folder has child folders +1. [](#improved) + * Save memory when updating large flex indexes + * Better `Content-Encoding` handling in Apache when content compression is disabled [#2619](https://github.com/getgrav/grav/issues/2619) +1. [](#bugfix) + * Fixed creating new `Flex User` when folder storage has been selected + * Fixed some bugs in Flex root page methods + * Fixed bad default redirect code in `ControllerResponseTrait::createRedirectResponse()` + * Fixed issue with PHP `HTTP_X_HTTP_METHOD_OVERRIDE` [#2847](https://github.com/getgrav/grav/issues/2847) + * Fixed numeric usernames not working in `Flex Users` + * Implemented missing Flex `$page->move()` method + +# v1.7.0-rc.7 +## 03/05/2020 + +1. [](#new) + * Added `Session::regenerateId()` method to properly prevent session fixation issues + * Added configuration option `system.strict_mode.blueprint_compat` to maintain old `validation: strict` behavior [#1273](https://github.com/getgrav/grav/issues/1273) +1. [](#improved) + * Improved Flex events + * Updated CLI commands to use the new methods to initialize Grav +1. [](#bugfix) + * Fixed Flex Pages having broken `isFirst()`, `isLast()`, `prevSibling()`, `nextSibling()` and `adjacentSibling()` + * Fixed broken ordering sometimes when saving/moving visible `Flex Page` [#2837](https://github.com/getgrav/grav/issues/2837) + * Fixed ordering being lost when saving modular `Flex Page` + * Fixed `validation: strict` not working in blueprints (see `system.strict_mode.blueprint_compat` setting) [#1273](https://github.com/getgrav/grav/issues/1273) + * Fixed `Blueprint::extend()` and `Blueprint::embed()` not initializing dynamic properties + * Fixed fatal error on storing flex flash using new object without a key + * Regression: Fixed unchecking toggleable having no effect in Flex forms + * Fixed changing page template in Flex Pages [#2828](https://github.com/getgrav/grav/issues/2828) + +# v1.7.0-rc.6 +## 02/11/2020 + +1. [](#new) + * Plugins & Themes: Call `$plugin->autoload()` and `$theme->autoload()` automatically when object gets initialized + * CLI: Added `$grav->initializeCli()` method + * Flex Directory: Implemented customizable configuration + * Flex Storages: Added support for renaming directory entries +1. [](#improved) + * Vendor updates to latest +1. [](#bugfix) + * Regression: Fixed fatal error in blueprints [#2811](https://github.com/getgrav/grav/issues/2811) + * Regression: Fixed bad method call in FlexDirectory::getAuthorizeRule() + * Regression: Fixed fatal error in admin if the site has custom permissions in `onAdminRegisterPermissions` + * Regression: Fixed flex user index with folder storage + * Regression: Fixed fatal error in `bin/plugin` command + * Fixed `FlexObject::triggerEvent()` not emitting events [#2816](https://github.com/getgrav/grav/issues/2816) + * Grav 1.7: Fixed saving Flex configuration with ignored values becoming null + * Grav 1.7: Fixed `bin/plugin` initialization + * Grav 1.7: Fixed Flex Page cache key not taking account active language + +# v1.7.0-rc.5 +## 02/03/2020 + +1. [](#bugfix) + * Regression: Flex not working in PHP 7.2 or older + * Fixed creating first user from admin not clearing Flex User directory cache [#2809](https://github.com/getgrav/grav/issues/2809) + * Fixed Flex Pages allowing root page to be deleted + +# v1.7.0-rc.4 +## 02/03/2020 + +1. [](#new) + * _POTENTIAL BREAKING CHANGE:_ Upgraded Parsedown to 1.7 for Parsedown-Extra 0.8. Plugins that extend Parsedown may need a fix to render as HTML + * Added `$grav['flex']` to access all registered Flex Directories + * Added `$grav->dispatchEvent()` method for PSR-14 events + * Added `FlexRegisterEvent` which triggers when `$grav['flex']` is being accessed the first time + * Added Flex cache configuration options + * Added `PluginsLoadedEvent` which triggers after plugins have been loaded but not yet initialized + * Added `SessionStartEvent` which triggers when session is started + * Added `PermissionsRegisterEvent` which triggers when `$grav['permissions']` is being accessed the first time + * Added support for Flex Directory specific configuration + * Added support for more advanced ACL + * Added `flatten_array` filter to form field validation + * Added support for `security@: or: [admin.super, admin.pages]` in blueprints (nested AND/OR mode support) +1. [](#improved) + * Blueprint validation: Added `validate: value_type: bool|int|float|string|trim` to `array` to filter all the values inside the array + * Twig `url()` takes now third parameter (`true`) to return URL on non-existing file instead of returning false +1. [](#bugfix) + * Grav 1.7: Fixed blueprint loading issues [#2782](https://github.com/getgrav/grav/issues/2782) + * Fixed PHP 7.4 compatibility issue with `Stream` + * Fixed new `Flex Users` being stored with wrong filename, login issues [#2785](https://github.com/getgrav/grav/issues/2785) + * Fixed `ignore_empty: true` not removing empty values in blueprint filtering + * Fixed `{{ false|string }}` twig to return '0' instead of '' + * Fixed twig `url()` failing if stream has extra slash in it (e.g. `user:///data`) + * Fixed `Blueprint::filter()` returning null instead of array if there is nothing to return + * Fixed `Cannot use a scalar value as an array` error in `Utils::arrayUnflattenDotNotation()`, ignore nested structure instead + * Fixed `Route` instance in multi-site setups + * Fixed `system.translations: false` breaking `Inflector` methods + * Fixed filtering ignored (eg. `security@: admin.super`) fields causing `Flex Objects` to lose data on save + * Grav 1.7: Fixed `Flex Pages` unserialize issues if Flex-Objects Plugin has not been installed + * Grav 1.7: Require Flex-Objects Plugin to edit Flex Accounts + * Grav 1.7: Fixed bad result on testing `isPage()` when using Flex Pages + +# v1.7.0-rc.3 +## 01/02/2020 + +1. [](#new) + * Added root page support for `Flex Pages` +1. [](#improved) + * Twig filter `|yaml_serialize`: added support for `JsonSerializable` objects and other array-like objects + * Added support for returning Flex Page specific permissions for admin and testing + * Updated copyright dates to `2020` + * Various vendor updates +1. [](#bugfix) + * Grav 1.7: Fixed error on page initialization [#2753](https://github.com/getgrav/grav/issues/2753) + * Fixed checking ACL for another user (who is not currently logged in) in a Flex Object or Directory + * Fixed bug in Windows where `Filesystem::dirname()` returns backslashes + * Fixed Flex object issues in Windows [#2773](https://github.com/getgrav/grav/issues/2773) + +# v1.7.0-rc.2 +## 12/04/2019 + +1. [](#new) + * Updated Symfony Components to 4.4 + * Added support for page specific CRUD permissions (`Flex Pages` only) + * Added new `-r ` option for Scheduler CLI command to force-run a job [#2720](https://github.com/getgrav/grav/issues/2720) + * Added `Utils::isAssoc()` and `Utils::isNegative()` helper methods + * Changed `UserInterface::authorize()` to return `null` having the same meaning as `false` if access is denied because of no matching rule + * Changed `FlexAuthorizeInterface::isAuthorized()` to return `null` having the same meaning as `false` if access is denied because of no matching rule + * Moved all Flex type classes under `Grav\Common\Flex` + * DEPRECATED `Grav\Common\User\Group` in favor of `$grav['user_groups']`, which contains Flex UserGroup collection + * DEPRECATED `$page->modular()` in favor of `$page->isModule()` for better readability + * Fixed phpstan issues in all code up to level 3 +1. [](#improved) + * Improved twig `|array` filter to work with iterators and objects with `toArray()` method + * Updated Flex `SimpleStorage` code to feature match the other storages + * Improved user and group ACL to support deny permissions (`Flex Users` only) + * Improved twig `authorize()` function to work better with nested rule parameters + * Output the current username that Scheduler is using if crontab not setup + * Translations: rename MODULAR to MODULE everywhere + * Optimized `Flex Pages` collection filtering + * Frontend optimizations for `Flex Pages` +1. [](#bugfix) + * Regression: Fixed Grav update bug [#2722](https://github.com/getgrav/grav/issues/2722) + * Fixed fatal error when calling `{{ grav.undefined }}` + * Grav 1.7: Reverted `$object->getStorageKey()` interface as it was not a good idea, added `getMasterKey()` for pages + * Grav 1.7: Fixed logged in user being able to delete his own account from admin account manager + +# v1.7.0-rc.1 +## 11/06/2019 + +1. [](#new) + * Added `Flex Pages` to Grav core and removed Flex Objects plugin dependency + * Added `Utils::simpleTemplate()` method for very simple variable templating + * Added `array_diff()` twig function + * Added `template_from_string()` twig function + * Updated Symfony Components to 4.3 +1. [](#improved) + * Improved `Scheduler` cron command check and more useful CLI information + * Improved `Flex Users`: obey blueprints and allow Flex to be used in admin only + * Improved `Flex` to support custom site template paths + * Changed Twig `{% cache %}` tag to not need unique key, and `lifetime` is now optional + * Added mime support for file formatters + * Updated built-in `composer.phar` to latest `1.9.0` + * Updated vendor libraries + * Use `Symfony EventDispatcher` directly and not rockettheme/toolbox wrapper +1. [](#bugfix) + * Fixed exception caused by missing template type based on `Accept:` header [#2705](https://github.com/getgrav/grav/issues/2705) + * Fixed `Page::untranslatedLanguages()` not being symmetrical to `Page::translatedLanguages()` + * Fixed `Flex Pages` not calling `onPageProcessed` event when cached + * Fixed phpstan issues in Framework up to level 7 + * Fixed issue with duplicate configuration settings in Flex Directory + * Fixed fatal error if there are numeric folders in `Flex Pages` + * Fixed error on missing `Flex` templates in if `Flex Objects` plugin isn't installed + * Fixed `PageTranslateTrait::getAllLanguages()` and `getAllLanguages()` to include default language + * Fixed multi-language saving issues with default language in `Flex Pages` + * Selfupgrade CLI: Fixed broken selfupgrade assets reference [#2681](https://github.com/getgrav/grav/issues/2681) + * Grav 1.7: Fixed PHP 7.1 compatibility issues + * Grav 1.7: Fixed fatal error in multi-site setups + * Grav 1.7: Fixed `Flex Pages` routing if using translated slugs or `system.hide_in_urls` setting + * Grav 1.7: Fixed bug where Flex index file couldn't be disabled + +# v1.7.0-beta.10 +## 10/03/2019 + +1. [](#improved) + * Flex: Removed extra exists check when creating object (messes up "non-existing" pages) + * Support customizable null character replacement in `CSVFormatter::decode()` +1. [](#bugfix) + * Fixed wrong Grav param separator when using `Route` class + * Fixed Flex User Avatar not fully backwards compatible with old user + * Grav 1.7: Fixed prev/next page missing pages if pagination was turned on in page header + * Grav 1.7: Reverted setting language for every page during initialization + * Grav 1.7: Fixed numeric language inconsistencies + +# v1.7.0-beta.9 +## 09/26/2019 + +1. [](#new) + * Added a new `{% cache %}` Twig tag eliminating need for `twigcache` extension. +1. [](#improved) + * Improved blueprint initialization in Flex Objects (fixes content aware fields) + * Improved Flex FolderStorage class to better hide storage specific logic + * Exception will output a badly formatted line in `CsvFormatter::decode()` +1. [](#bugfix) + * Fixed error when activating Flex Accounts in GRAV system configuration (PHP 7.1) + * Fixed Grav parameter handling in `RouteFactory::createFromString()` + +# v1.7.0-beta.8 +## 09/19/2019 + +1. [](#new) + * Added new `Security::sanitizeSVG()` function + * Backwards compatibility break: `FlexStorageInterface::getStoragePath()` and `getMediaPath()` can now return null +1. [](#improved) + * Several FlexObject loading improvements + * Added `bin/grav page-system-validator [-r|--record] [-c|--check]` to test Flex Pages + * Improved language support for `Route` class +1. [](#bugfix) + * Regression: Fixed language fallback + * Regression: Fixed translations when language code is used for non-language purposes + * Regression: Allow SVG avatar images for users + * Fixed error in `Session::getFlashObject()` if Flex Form is being used + * Fixed broken Twig `dump()` + * Fixed `Page::modular()` and `Page::modularTwig()` returning `null` for folders and other non-initialized pages + * Fixed 404 error when you click to non-routable menu item with children: redirect to the first child instead + * Fixed wrong `Pages::dispatch()` calls (with redirect) when we really meant to call `Pages::find()` + * Fixed avatars not being displayed with flex users [#2431](https://github.com/getgrav/grav/issues/2431) + * Fixed initial Flex Object state when creating a new objects in a form + +# v1.7.0-beta.7 +## 08/30/2019 + +1. [](#improved) + * Improved language support +1. [](#bugfix) + * `FlexForm`: Fixed some compatibility issues with Form plugin + +# v1.7.0-beta.6 +## 08/29/2019 + +1. [](#new) + * Added experimental support for `Flex Pages` (**Flex Objects** plugin required) +1. [](#improved) + * Improved `bin/grav yamllinter` CLI command by adding an option to find YAML Linting issues from the whole site or custom folder + * Added support for not instantiating pages, useful to speed up tasks + * Greatly improved speed of loading Flex collections +1. [](#bugfix) + * Fixed `$page->summary()` always striping HTML tags if the summary was set by `$page->setSummary()` + * Fixed `Flex->getObject()` when using Flex Key + * Grav 1.7: Fixed enabling PHP Debug Bar causes fatal error in Gantry [#2634](https://github.com/getgrav/grav/issues/2634) + * Grav 1.7: Fixed broken taxonomies [#2633](https://github.com/getgrav/grav/issues/2633) + * Grav 1.7: Fixed unpublished blog posts being displayed on the front-end [#2650](https://github.com/getgrav/grav/issues/2650) + +# v1.7.0-beta.5 +## 08/11/2019 + +1. [](#new) + * Added a new `bin/grav server` CLI command to easily run Symfony or PHP built-in webservers + * Added `hasFlexFeature()` method to test if `FlexObject` or `FlexCollection` implements a given feature + * Added `getFlexFeatures()` method to return all features that `FlexObject` or `FlexCollection` implements + * DEPRECATED `FlexDirectory::update()` and `FlexDirectory::remove()` + * Added `FlexStorage::getMetaData()` to get updated object meta information for listed keys + * Added `Language::getPageExtensions()` to get full list of supported page language extensions + * Added `$grav->close()` method to properly terminate the request with a response + * Added `Pages::getCollection()` method +1. [](#improved) + * Better support for Symfony local server `symfony server:start` + * Make `Route` objects immutable + * `FlexDirectory::getObject()` can now be called without any parameters to create a new object + * Flex objects no longer return temporary key if they do not have one; empty key is returned instead + * Updated vendor libraries + * Moved `collection()` and `evaluate()` logic from `Page` class into `Pages` class +1. [](#bugfix) + * Fixed `Form` not to use deleted flash object until the end of the request fixing issues with reset + * Fixed `FlexForm` to allow multiple form instances with non-existing objects + * Fixed `FlexObject` search by using `key` + * Grav 1.7: Fixed clockwork messages with arrays and objects + +# v1.7.0-beta.4 +## 07/01/2019 + +1. [](#new) + * Updated with Grav 1.6.12 features, improvements & fixes + * Added new configuration option `system.debugger.censored` to hide potentially sensitive information + * Added new configuration option `system.languages.include_default_lang_file_extension` to keep default language in `.md` files if set to `false` + * Added configuration option to set fallback content languages individually for every language +1. [](#improved) + * Updated Vendor libraries +1. [](#bugfix) + * Fixed `.md` page to be assigned to the default language and to be listed in translated/untranslated page list + * Fixed `Language::getFallbackPageExtensions()` to fall back only to default language instead of going through all languages + * Fixed `Language::getFallbackPageExtensions()` returning wrong file extensions when passing custom page extension + +# v1.7.0-beta.3 +## 06/24/2019 + +1. [](#bugfix) + * Fixed Clockwork on Windows machines + * Fixed parent field issues on Windows machines + * Fixed unreliable Clockwork calls in sub-folders + +# v1.7.0-beta.2 +## 06/21/2019 + +1. [](#new) + * Updated with Grav 1.6.11 fixes +1. [](#improved) + * Updated the Clockwork text + +# v1.7.0-beta.1 +## 06/14/2019 + +1. [](#new) + * Added support for [Clockwork](https://underground.works/clockwork) developer tools (now default debugger) + * Added support for [Tideways XHProf](https://github.com/tideways/php-xhprof-extension) PHP Extension for profiling method calls + * Added Twig profiling for Clockwork debugger + * Added support for Twig 2.11 (compatible with Twig 1.40+) + * Optimization: Initialize debugbar only after the configuration has been loaded + * Optimization: Combine some early Grav processors into a single one + +# v1.6.31 +## 12/14/2020 + +1. [](#improved) + * Allow all CSS and JS via `robots.txt` [#2006](https://github.com/getgrav/grav/issues/2006) [#3067](https://github.com/getgrav/grav/issues/3067) +1. [](#bugfix) + * Fixed `pages` field escaping issues, needs admin update, too [admin#1990](https://github.com/getgrav/grav-plugin-admin/issues/1990) + * Fix `svg-image` issue with classes applied to all elements [#3068](https://github.com/getgrav/grav/issues/3068) + +# v1.6.30 +## 12/03/2020 + +1. [](#bugfix) + * Rollback `samesite` cookie logic as it causes issues with PHP < 7.3 [#309](https://github.com/getgrav/grav/issues/3089) + * Fixed issue with `.travis.yml` due to GitHub API deprecated functionality + +# v1.6.29 +## 12/02/2020 + +1. [](#new) + * Added basic support for `user/config/versions.yaml` +1. [](#improved) + * Updated bundled JQuery to latest version `3.5.1` + * Forward a `sid` to GPM when downloading a premium package via CLI + * Better handling of missing repository index [grav-plugin-admin#1916](https://github.com/getgrav/grav-plugin-admin/issues/1916) + * Set `grav_cli` as referrer when using `Response` from CLI + * Add option for timeout in `self-upgrade` command [#3013](https://github.com/getgrav/grav/pull/3013) + * Allow to set SameSite from system.yaml [#3063](https://github.com/getgrav/grav/pull/3063) + * Update media.yaml with some MS Office mimetypes [#3070](https://github.com/getgrav/grav/pull/3070) +1. [](#bugfix) + * Fixed hardcoded system folder in blueprints, config and language streams + * Added `.htaccess` rule to block attempts to use Twig in the request URL + * Fix compatibility with Symfony 4.2 and up. [#3048](https://github.com/getgrav/grav/pull/3048) + * Fix failing example custom shceduled job. [#3050](https://github.com/getgrav/grav/pull/3050) + * Fix for XSS advisory [GHSA-cvmr-6428-87w9](https://github.com/getgrav/grav/security/advisories/GHSA-cvmr-6428-87w9) + * Fix uploads_dangerous_extensions checking [#3060](https://github.com/getgrav/grav/pull/3060) + * Remove redundant prefixing of `.` to extension [#3060](https://github.com/getgrav/grav/pull/3060) + * Check exact extension in checkFilename utility [#3061](https://github.com/getgrav/grav/pull/3061) + +# v1.6.28 +## 10/07/2020 + +1. [](#new) + * Back-ported twig `{% cache %}` tag from Grav 1.7 + * Back-ported `Utils::fullPath()` helper function from Grav 1.7 + * Back-ported `{{ svg_image() }}` Twig function from Grav 1.7 + * Back-ported `Folder::countChildren()` function from Grav 1.7 +1. [](#improved) + * Use new `{{ theme_var() }}` enhanced logic from Grav 1.7 + * Improved `Excerpts` class with fixes and functionality from Grav 1.7 + * Ensure `onBlueprintCreated()` is initialized first + * Do not cache default `404` error page + * Composer update of vendor libraries + * Switched `Caddyfile` to use new Caddy2 syntax + improved usability +1. [](#bugfix) + * Fixed Referer reference during GPM calls. + * Fixed fatal error with toggled lists + # v1.6.27 ## 09/01/2020 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 42ef22d..99ab478 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,46 +1,133 @@ + # Contributor Covenant Code of Conduct ## Our Pledge -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to creating a positive environment include: +Examples of behavior that contributes to a positive environment for our +community include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community -Examples of unacceptable behavior by participants include: +Examples of unacceptable behavior include: -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting -## Our Responsibilities +## Enforcement Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. ## Scope -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contact@getgrav.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/LICENSE.txt b/LICENSE.txt index cb8634f..771734e 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2018 Grav +Copyright (c) 2021 Grav Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index bf3a482..562349f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![PHPStan](https://img.shields.io/badge/PHPStan-enabled-brightgreen.svg?style=flat)](https://github.com/phpstan/phpstan) [![SensioLabsInsight](https://insight.sensiolabs.com/projects/cfd20465-d0f8-4a0a-8444-467f5b5f16ad/mini.png)](https://insight.sensiolabs.com/projects/cfd20465-d0f8-4a0a-8444-467f5b5f16ad) [![Discord](https://img.shields.io/discord/501836936584101899.svg?logo=discord&colorB=728ADA&label=Discord%20Chat)](https://chat.getgrav.org) - [![Build Status](https://travis-ci.org/getgrav/grav.svg?branch=develop)](https://travis-ci.org/getgrav/grav) [![OpenCollective](https://opencollective.com/grav/backers/badge.svg)](#backers) [![OpenCollective](https://opencollective.com/grav/sponsors/badge.svg)](#sponsors) + [![PHP Tests](https://github.com/getgrav/grav/workflows/PHP%20Tests/badge.svg?branch=develop)](https://github.com/getgrav/grav/actions?query=workflow%3A%22PHP+Tests%22) [![OpenCollective](https://opencollective.com/grav/backers/badge.svg)](#backers) [![OpenCollective](https://opencollective.com/grav/sponsors/badge.svg)](#sponsors) Grav is a **Fast**, **Simple**, and **Flexible**, file-based Web-platform. There is **Zero** installation required. Just extract the ZIP archive, and you are already up and running. It follows similar principles to other flat-file CMS platforms, but has a different design philosophy than most. Grav comes with a powerful **Package Management System** to allow for simple installation and upgrading of plugins and themes, as well as simple updating of Grav itself. @@ -21,9 +21,13 @@ The underlying architecture of Grav is designed to use well-established and _bes # Requirements -- PHP 7.1.3 or higher. Check the [required modules list](https://learn.getgrav.org/basics/requirements#php-requirements) +- PHP 7.3.6 or higher. Check the [required modules list](https://learn.getgrav.org/basics/requirements#php-requirements) - Check the [Apache](https://learn.getgrav.org/basics/requirements#apache-requirements) or [IIS](https://learn.getgrav.org/basics/requirements#iis-requirements) requirements +# Documentation + +The full documentation can be found from [learn.getgrav.org](https://learn.getgrav.org). + # QuickStart These are the options to get Grav: @@ -84,6 +88,11 @@ To update plugins and themes: $ bin/gpm update ``` +## Upgrading from older version + +* [Upgrading to Grav 1.7](https://learn.getgrav.org/16/advanced/grav-development/grav-17-upgrade-guide) +* [Upgrading to Grav 1.6](https://learn.getgrav.org/16/advanced/grav-development/grav-16-upgrade-guide) +* [Upgrading from Grav <1.6](https://learn.getgrav.org/16/advanced/grav-development/grav-15-upgrade-guide) # Contributing We appreciate any contribution to Grav, whether it is related to bugs, grammar, or simply a suggestion or improvement! Please refer to the [Contributing guide](CONTRIBUTING.md) for more guidance on this topic. @@ -128,7 +137,14 @@ See [LICENSE](LICENSE.txt) # Running Tests -First install the dev dependencies by running `composer update` from the Grav root. +First install the dev dependencies by running `composer install` from the Grav root. + Then `composer test` will run the Unit Tests, which should be always executed successfully on any site. Windows users should use the `composer test-windows` command. You can also run a single unit test file, e.g. `composer test tests/unit/Grav/Common/AssetsTest.php` + +To run phpstan tests, you should run: + +* `composer phpstan` for global tests +* `composer phpstan-framework` for more strict tests +* `composer phpstan-plugins` to test all installed plugins diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..30830c7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +We are focusing our security updates on the following versions + +| Version | Supported | +| ------- | ------------------ | +| 1.7.x | :white_check_mark: | +| 1.6.x | :warning: | +| < 1.6 | :x: | + +## :warning: Versions + +Versions with :warning: will be supported for security issues, however you won't be able to update to them, you will need to manually update through the [`direct-install` command](https://learn.getgrav.org/17/admin-panel/tools). + +If you cannot update to the latest stable version available because, for example, your server does not meet the minimum PHP requirements, you can manually install a previous version by downloading the package from our Releases directory (https://github.com/getgrav/grav/releases). + +## Reporting a Vulnerability + +Please contact security@getgrav.org with a detailed explaination of the security issue found and we will work with you to get it resolved as fast as possible. diff --git a/assets/.gitkeep b/assets/.gitkeep index e69de29..33a9aed 100644 --- a/assets/.gitkeep +++ b/assets/.gitkeep @@ -0,0 +1 @@ +/* @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. */ diff --git a/bin/composer.phar b/bin/composer.phar index b8ce13f23ed5f124a5a19ead8ef849989eb87088..3791ce36ed26e033e485226ed288050ead25032b 100755 GIT binary patch delta 386166 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

F|_=LE=8{+y_i$4RhG@B3=b525x%<&=EEHmzK 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

IfeQqvKb2e_(yu5Do8 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

    i-I z3g@Pym0;j4FL4eXxDQ4)%h-khG9hS}BR(i&!?Q;U7 zNuOx($I);ln`jnXdZ|#SSZ?i^awkB$>r(cxVJHn}p_m$TgSD@IDh~z`F6SM020bzo zz;RSE$3&xX+W)#YV2SJ+Z5ZkDl4LVyuCbXDV@6=6;hfs-FzP*N;L;Ah440N=$1STV zDBvW})I}l|82bxhQ6O;T@*d7vc8Bxv#c7mo5a4C$sw%Uk#oX}RH?D$BQNtHf?v(=8 z-jR*a-Y@M*rY2cvhYV*hOOL`sb|1J7Wc+70<#adrRvq1JWbsehaPL&yAHKl&=;M2Z z6a?wqF3|%!Uq4TjA`{sJ(D?(FZp6H!Uh2Lo2Le5$oQRlRTNV5p~~?_1p5J=MZ` zb2CccYIT{lnA}_gnW=@h&|F6)ei|>+xz1wZ-)M=$e^fhK@tI_zO$RZYr(416<@*QW z%ID=nme#Fw<|oA6J=5MORYOZZP9zdM8cv8JST?>6C1m*$Q!Wk5nR1$-*?r*qIZ&Is$6S zJLy$b0p;(~P#|gWGa`DWhed39-v1a;AG-;=>fwE19Y6r6h*N<*0T8Sud_Wdxe<6*4ddBY+k zl!WGuHvDdNEo?Fq4C{wOaW`p5pCg`yxxM@s-C@9Gf;&B?anJT9prx8AYiJhv*kdDL zB#t!XOVxHQa)hoCdYQ=m%-HkXHi_|bfF3W&f^(mCj(~As&Ub=&A^7%sZZNgk6K4_Q zMmC!7>y&wVqw}H0J@0SE(`eN5Tf;K(Z?iU9iTPRc#Lz;_&#s$x4SYGS>p=Foq9F$0NB?h=Uxmh0!gm8uD>^nMKWuwI#yC7Q?c<#tahxErj_ZzB8ptE*#0i zCi|P&VQ0=IY5J<6_}@ZVB{wV8GD2B_(eIZVEEW!6t1Vqb&*uM2piIGp^T}#(A^(4S zRgC)TQTL}TF4*ISQ6q=IGPLv>9uHrnbGxW&-M9Fdo{b#*v9Lg@svUCvxxYX|E;0d_;_LONO%=gN*4 zN|Ux3^P_3+c#8{pi;>ch%a2m4fPt7MJPeC_AsP$mLTGnJ*7+vlhLSKt2f4#9Jo`dE z*e3Zy+nY%ZLeH0}oEyh9T#)}KFyN~d=KShM>y;5i$ew-ooR`Q zVRKCTez69Vn_w0Vxl;8=M{;JvwLgTaxHB#eGaBwIFPmIbTr-!ME-&w8_E;O8Lo_tT zao0+1CZXjn`0dFh7gaPLEgh{hrg5G_B2jG0q#BWyv{7mV#` z(#2S~g~*P!QpVHpWr0|Q=~xznK!sajVm&D<)=LS8B_H*F+lO$91+s}+kF>~S84B+z z+n=^RL?<(hrZ_~kDeIj^23+^(Hs+K5x#(a51zdt}&9l}H^!|%-OL}^NG10L#3(ZJ`eeJN(Ib&`d-gc{Rl#K-}|h9c1D3-HqO)t* zY~3vAWZV>XRVbZ8*{@gwavC1%imK}%u?3;hxpNxb^osQz+I^M9#RKiU`Ij z0E=R3Ipj=KF%CgHDjC4=7(NalQl7xEN{9sqApP`JtB;;|4X&gVN`K9|Ar{TUV3t={ z*%n6Tv46QmZyNI%0Q|u!#C-uHP(oX}3!Qt->a>=Cu4ed}{f?nMQxR0+MIpfjiQ(TV|xv;trC7 u7fHDX;nQ`KPtmf1* zJeLmP?+r7-zamGIwSn?s7#vwQ8j33EO=_l}K7%uKwgTfp z`*D!w+|v2k4R3i9*cR6%;QG0aRfU2+Tgax%rF&24J#5sdKD|S$K!cY=c_@3Y6>ePS zxZ%U*E2}$k9D*vc&ZV;eixXNhKXkNghRK=87F6f0@E(oJn?SiQ2pR}WBiu;~CtlN% z7t(@Qg)-@yY&v4gVMvX8?8nl9PKUulx;8@8r=eyjPc_VLI`iSLWGRS5*;2`C-v!_e z>>ZH>Y6Mj^;6hXs7vL)@85b%6F+!HosXcC_dfR$Cxf8MxZGqjc8>&IC;G}Hv*KqZd zdk9d#p6pGhw;B!=(>hZoyRk=q1IB$}uMvGhlZn1JUW|I?bN4b!0n9#2XQHR^JeQQ5 zQ~S4TICAi)qVOW7bM)2FF1DMd@6@0-sN}Kvrj6;AJX-O+wKM(aH2h@mzW}#tFTQC> zOaR27d8`O|M#FD-$!Ks3gK~7UHDfF>u~?zhspK1DL9{s*jDd$ThqLNqSqLulB0kes zV=VcUafKx+SruexfnX@})mGnI&ogg+i+-|t)DAydTN+X!HpxzfXZ8Bx@=-Xf z;FW}_QtB(idjMZQ$bYjT@iJLe`!Cj>F}%ysC{4ld;psB>PisLeM`~g2J|aMxVI*BW z#*nw>J;dC?2^AZsxw_E2?!#x1sE#)Xbp|^7oa9qaMG0RUveM>h>;=d%0g{YV2)P;1 zBHrmB%)j@doGZ;f)gCKk7-&MAu#`5ksFr&&f_8Emg$_}WP0TY2Et1)6Wj6ID&s&WK zm7?x93ZEMOArpO=AS~l9_LG|W>HxEFD!pV9I?(2W@ixwHU1<@#YJpkU5=VU!g*k>A zdNol{sD({_2u*APFO99! z+9{+`&kU0-PIJaUd-p&H3yjkf!VFj1SQCT*fW@oXPt;fSA~bAYi5M1R;*TRfI=rZv_HEP6LIls+wOwZKru^&Y^u%|j!>No zD*LDJ2D5wFKDhhOJ|(nK&lL!ok4ld@!to_ZJ%#30^cebeRrT=T3OZzG0UWC#T1tXi z(^I%Hill7uI_3nn;7-KN?M|prS%WB-N?(D;67AJbxGIW%$`&)Jq>nI-PJS*5l;aTn zwERt>Kx@2WpfJ|Zv|T@aZ#22qTrc>j^+2IJb$*A1v)m+DD02vQBHU?S(pA5O@vlOC zRnHHBBeH*!F?xjXeAK_lNWYB|5DQODA1%O7Sxz2;cVL3-R5V7|E!8v$Go@x+Aq+5p z%;Qpd%6UTY;p+6+mBJ3H*(Rk^hhc)C?jI{`Gejw-H9g>y?d4@K%iT0i_~GAl<=8Zw zUtf$DZlDzoY{(TBu?MYaW{~K}1mP}HTOv^5nKBUJ=ei3bTb_F-3J9h`6Mq!C#&E)M z$Y=yF6pMtRaV+dC6hs$qMI6$qNkU09hWK8Ia0f%{1wEte>NTZ8AkLmzG6RGdCKw6{ zj9mxkAhpHQ)b%rjw+%G^f{;Vi?JULz5kQhfgGBYsSHC6B!eb8uJj}5)YCi?C=XFcizQ;UpmRw`$=7l-YPnn5wp^5q>g6gJvRwL3GoiKkW!J^_|dLT~;SJ z456z6ltT0k)no;j#wHaG6A+sEtk9E`S;8Sg80aNb)GR)hGCqL!yytHd`bL-4bfKqO ziYW~fM$eu{aOw_ogoSkBZL3k8HCL#Nrlmg#v+2lu;iUwaTtW!OAb0jf1xcqiDN9Of~AqBAtIX3f^^>2pvqAT8-e% zp!_JYgbF9fX6E;!8^X+Vr=r`0_7MZXe$QZ|sGyfxB4!NV^et}(Hm<&1m_E4-Z4?mHgx!K)9l2cS60HUPIV1$t9d`&<^S!+| zNn=&(tTuKL1=qh}x2xFg@TB-a0fH{9%@%ji?QaSz*gjp!p_rzBDA?6bn}u(<2V6uA z!MizPmlAeqiaO%~!EcDKE1p){n|V8gYg7NFIX`?5L)!ij2E5=#K-h*(9DUg!q8x_tTLqi%}{)mvF7Ca){Vj$-%p@llut~z&%IthvfQQK5?if4$b`ah!JMR{P zMt&0oP)ytnI;DD>)RK-I68fu_=LE^Xyfd*2{on3V+V?3U-}Q6HB=*6{GZZ9WAyzD% zD>i5zcO#^OO_3}=j|an>tmcTAo%YmuoHs}lo|0@ajKfKXcS&iq=Xt?y(rziaxO&im zfZ4aoavbc77lhuF+y&8lm|1@WGGbU28!lIKeW~ri7lalO6sA9}Us>JY;>l{|9(KMi zXcixDMhL{3x}o(IlS^yVb$f*qQAT}&Y3a*CH>O0(iII@yhSsArR!G-`0&RnfH3)EM z-&fXGz>7gi=*|?%#SfS88D(SXnnUJ*{(d z=F3X2IU*CYjY-4STU75#4uE$8u_5EbhP#7HWVmV7{Jtt%uUYixAbVF1QkX6$^gM$&~mYl^6!jym^!~hfd1RH$#jT^zq`oR<)!d;22zX~7huNgaGl_`NX}B4wA*T0Q)w@SK5O zdZAH7B&;b_&#$=OX?9HjlML9FEHXVMGIj6N0it*O1%&(Ziq@q z)$RoVsZH_XEkc<6!avh_!QrLML@}3EEDFTXRqYUQeI=sKxx?a5rj#)NidA*Y7dTT= zq4leTWc6Z#cqobHP3iWU9kxoD0!n3uknOK#m`yN&pg3?(YRQ;IGE`_MpaQ2msgd{3R6@AIr z?D}otUzXa4QXI*w%yH$>(W$uMe|DuKK{YiO(_^$)gkXZeqwG1MS>(Y*1+SPa^n5cz z939UPpTBf}KhaWLL)$ip!HlLy#3d-6SvGK$CBq!Fw5gT&8+Cq3&Y^c7vKsp}ISyFf zrbk4@ztpe_5FEHCk}pZjr`B&_WBQGvSFO$xyT;O_Lt>0GGQ_WTo7epQ(D|2zq(p|? zbVs|ublil5_I`qp6@_Netp1QEJ{iMUNzG}(aY!~Mb`)Yg!ml03+Dm*q{CW%ZyFc2-{AZ`xz)YMBy(l(~+95Yfy{nHX z8*~>|j8doH`ikvi7>z);4H6;m=qFx>{v5js7s8w^@XNh@q8P7s>Mt4%+Gl0W7|}|< zzYQnmf4HE+vSEN|1GGOjK&&$A?a|UJ0s3MLf;oIXP;4El?%W`ti-(4a5|s=ROC!GK z)7deifJ#aSi`U@i$Y2qN6SWv3J{rsAH&i@K^ic9p(HT)HKzYN&N>6hLt+jjBV9&|>$IfH3|7wP7aWbe z*O)rkew8!Q7#m3E8fl4|+BkEM31W@SFfXXjEha;z7siN5g(bz6+*2udOIWJORdCu< z0SXn^t+;XuLkQ3r2vx{L(sk2I=W5v)=c0U8sZv*^bkhE1O(~s4!Qo(@VB+Ix%os5z zV&uHR?p^tma?j|ES)05AQ+o2d5od0;&EOW&5p}20pew|FUY!@J^-AZ1YEv;QjUK*2 zT;CWHT8$M4H0s9o31TV*1_KhSsG?d{BwB%y)mf9oi3Xa}LKN1Vwi{{3U@U>s5<16R(EQeqUJ`T3+Vd(PZJ#Qp zCtPO5Ro67p6-6sfAuPbvW#R-{+C~)NqF;=oz0DKcWSM~;D(clU*mhIYndKt5Y%9hX z!Rp(H05P9cfa_4b2O+e1k!F9IT@Ass3Bj}xulG-c*IiaA-bBe$5Llpl9oT29r&yZN ze~dvJt*8=Dl6|Vtq3);_?=#St7a`6?o&?%fg>CfilL*gqZg#vyWPsKa%Fenn0Ubz( z^X!MVn9Q+#plMV`D~&<;wx|+)YQH*hTNJ%83*h!t7DCn>fk$I1oF!T)G0T+4p5kdw z7S4|=v*7$Bo+T{FGUd>&EK`A&FFM;aN_Ef1=EkPanqmSO6D@y;3Pj?xdRJGt5@ks-9ng{yBcN_FC_aF9o z==07|@znKNv4w{zaF^q1UtLo=tE_Z39NbnGS3|uVyk%N?jo6c3yH@NH9}=Wf_Ixqj z%I`lRhr_ge1frkrUl3)YrH3pwGp;9qH)e}l%dZ7mOp^$HYI+)8Li!mzrWJTwGZu;& z@gXPhq~x0IhHWv}m=M8iU6SGTbBO(Yb}#wP8t0 znAwy>`}#m5d--}Sui;tTpubSfHjpQcSrHK$@^#F$Iu-ANixleyaj)xd2?yGl4_CN9 zZxCnyZ;}U^0ye$1QM?GCTkMZVklO*0g?_nDyn{|3hXzA|U`(L9H;IW>-IYC?12}6J zr$yOqO$P=Z%XgDyCmr4-2I9Ei`Ak~$L&6XmuvzR)U8kDdl(bz)(QdBqY!>ScpuQ2D z!}KdIJ#Q>Wq}tmHq_$+fUqtxg*$FXJ^oY2QZtZWd(BYMGJRSZHeC$cbaQ+v3DcTTc zL#ih47Ev4o`NmWPQ>rW}%`UEEGKaF_%4|SP>&azxlj}=nKtr{vxV~Ho4i;>mVAY^AqBobS@!2L0!E?JP>0@rM-`X%`AQ=m>5TR{x-26 zExrL{Zr811l6rERxZR-VQSaX_W<{KaQi~S1c`QMNd;pTG&&L zrG2~I676_CHkNYkbj8rPXT(1A{4-*IV7jhcSJk&bS;TT( zb$Vs9wH^KTHo{ZayaPrjDizh??}+aiXxaUUu+rsSu_cO^zAJW*rjH);iRzyB#JL7q zI?~dCW_}>T)WIz{DgSq1>x?I`)esH_A&fWqW7R_+h#mv|_#xP@H;*;RW{nmoVhkm< z_9Jm%6dnHonlhD#MN+SiJ#p8Htr!bYI9 zlN=T%pn$6iR%MWi598Q7dkA|_8E!CA#kc4I$k{mh{7WGEHJ^&_b4K3PpNZCJD?3Jm z1tDh6;@?G2oW=~Iy}o$2!a9;(Ut3;UTAkjeT@L>7>rdknhK$vJ_yg*Iqhe=+Q>(D1 zVoDh#p^%YJ*QKAlz7~I5U!ww{>O6)A0n1U;<7;tD4CCL>k(Y3#-u<H7%bX#Q8nOVxz16Fv@W{8v@hiqC>}VqMrL!%;Gm4UL^0h zSo}W}@shI|!jOI60G@m&#Lb38jFEA25i1Us37YhdJ~}DBO^eQolPLN$NY7*EAZ57y zG+yzPq#DlPuBal}X`s8#iTzZ3O*HT{O86dwN&8-0;MQp;HhYaIr`Bn-`+E?`dw08i zs^bR`rvQMFKZ>`kI{j5Vef6Vg`-h^c^(S$j0VZ^2Bb|FlmejVth<8U(-}|ExW&LJL z3_Y_lXeMhc4sFa@JXMbZyiNOE{DS8HAr4pH{X?7*rMA5&Mn}`4S)eus8zeyc3)rO3 z7^H4&?qdv6(3i@rUU8c!XTRyGSJzjTurT!6^s9+c(v1ch@`>2V(vSq^=_-TN3|4Ji zu9Z%=9~T3w=SWs+^MxG|07pw#5a=5w>)Kn8kf<&K^zy+P)5BzLu6T;8j^ zsbzbo!f8h7^gAz$YAq3X=4k?#bzQmOPWKbbg31+utYG@EiJKp zOVg#U)Z=PMh>m_~jHO-~QX%EvDqy2Rr(}pBDWaL@R~zHh!j@91f!3s9@wR43cNwZ^ zV7AnOcD8~7z=&<|p?}1y#8c}@P!Dvh#ES38mPUgyL$P^MZ#thRCCNQT4l3+1awJfH zh(l79Cv8C%M;oc5wu)+18)>0|UfUIKx7QXkv!`pA*bOXkE_{TNCGDLDBDH)5z-Mi5 zS#%XlXqBJQIX%CyD+^3M8~Q@sv+<>1KE2ah?rg{z`nXUEzt_Y zI8GHZ>`Kpfz*4_=!k9({py#X%foppSJ%C{$Xrrker1tcD5#*(>c8~7GQWzqV@d%Ia51JqcQg(0!~yM};+Dh>2nn%cWS$~SN-M_t`R>J&p#2JS`S>qopM!ukK(j16J)luJAQBPG%6 z%S=ER4O5m#Z}*p~Fo#12O5^Bwf6(qb21*Wy@({(aY!+nqSsJZAv!{eh^>BksM^nM_ z?RKj-nGvEsb1#MQ*>X0kV@uY5KVGlaVK3cI2?4H88U|xkZAWW)pj5LZN^7Tnl7Tt1%bS3cU z(JQ3bX!`Yp>;GG15+wULsrc_u$$jIb|G54ZmC%NHUKii;DzvZ1MbWyC;lTZ!(^7z> zGcY@y(A6R$D4~^xwGDu6pA|5t)zp_GRt}8um{MwOH5}ikr?eh7zBY40tJVmv#ppaR zqblkAmmp!z?E+2U{Z2}tsQ2VVdiS`T6vG5vwCA|&rF+gw%c##1tnLvRXTr#Vadsp4 zLd?emk3_mqlQp6-zG4s_G4VT;>d=M$RkaXCc7ywKJrqx8CU#>fK=)cU#PyZRG$$=V|q~h$4=YAzJq)8D;iKZrmaT3Y7$nRb62K2 zdn$u_2L+dJ7RYzs^?&;)GgJN65U?Z#xWd~gg=hliWW>O%?}&q6*_ zVDFG`1}3x9C>1>uZz!_3K3fqb!ZmAKWAK7J zy?8DNIIhPGZC9LWS{ypoEJgwSOLoj>wQH}!V|0W(8xyXRDQ7xjZ4Pe6{g8*uI=; zE<-PY29J<$RQI%yq10BL16feOEqm45)8((DsI3#+BWxue?2*SDr^VZyN$ToW^72@- zt|7#5FSX2-hl*igsK!^P-~vP=R(8cfY4Z=r6)9J~2x%;xUmfdHAL}fagO%Mg3ZZAa z$~Jni4nX?bd&F)Df<&-l73X;=)G=og&A84`ttD91VGNMH@4#ZL8jOX zC(9MEo0-%Cr($M-oJ~6lfXmxmg{wkw4|xRnABt@SFDzy=EgEGq(ta1Xi4z|}NU846 zK!xn{mt|bVn}ce6u&0bG+xwDH9ot)uHqhuYocizgk-PJo_`c!td|KXD&HzpQbYGcW zo-lQ>2DNX08BFesCry(2;~4n{1D$ylC(E)cWv9CJO1S`WS#`CB(_`gywPbZLjh)j4 zYjBrczb4oU&u^{?{y?+G%OA7z+!y9a1`(@XcF=NPLSRkD_&BxQL^;<0-32g%dVnR? zx45jlzJ{(UlH1e$MRH$C9A#=ACT%U0Gs_dCp1Y-1n*QUq-BNZ~JLmjv+&nvGc@TKE zSazTbXG`SXw6CSdNJY;{HY&N_$@20j8kcC6sc4r}t}CT~woB>;Mv^s_ zw%gq@RX!!frD(CkG!-VDuY&EsogZP7ahd75r@-LYP%8WByD9P_KCM-y^6UIz*;7&m zOT(3YM1TbtmS-kMo>O6^7}|YKPVLLMnap+~mp9eG;zUzv)5_|m*H30_M0U;5toB(| zA;n3?bS#_3?4oJdI>>Am7U5D@Jy0^z>GRM%Tz_8nFg7whg9n<|88_G!=K)*4oRkvOP!yRR)1zjmGp4^*j)r74X*n!4Ewg96>rt_nYj!cz5i_>UE zdUl#TjM2K?PXM6PUX}btW>UzyHvBGA65}7F(9&0N3+*{w25mcC9;ug9Yp2Vf8*G>b zjri3CczRVWm?;ZvVX`K)8lRofQk$;vob5WF5iOxc#$7E>OpU06O{U&&%_$-Llkmdy z>D6*Gb;#A&4k0FwUI%9psfL%?ATLQ6Z;TIQZ##=zN7- z;BQYE!C}M}~WgwsUB0tvm*}qgC^%`K`@z3tBr%-p=spY}sjOTxj_mc_fXim(8kct~@L{ zGO>O51&z0-udbDoBEO_l%lR_&F{$%>90_mV1-|q0`SKvTag~%xjs@}ae zB>^5-35NP}3*>c?U)9wM0ntdkdy!m0Td$M*QMbkNVb-4n+FXZYYx&K%B356Ay$0=~ z>*dT&I(IY@`a#e%zzgSsgQ%?STUCzpokbc2qZYpJXx$ds5!wG7I=e+KiN>PSu&wf7 zG;q&Wxy}FZ{M4-+&~;n0LQ)pvt@mqF(!?YdE3YM_xf z%O_Ap*IVQn0sTDEPNhr#(vqWcn)>-I^0iS^G!;CGE1OGs>a5%3#nE(eICN6X%jLo( zCK%*W2~#SguNivpLU(V)%I{w;yP^PP1uNu^>Wr201OvzK$L8az{mmWnHBn&*>UabA z#qTeG4DZ_iU>>&c$(vjy7vsfX^yDbCdXBqYsmBM#OfO zneMq+{B+Hg0%U?KAU51|3-mz>pTcYKc5zseR!0~+XDV%Ta%sU+33i&r_zI!cQ|X!| zV!Ah?#hP+PutI7Km4>-63sLCkC+O)*OGKNAIbs zHvp#(K2y5ef;+-QlqEEhWl8%D!7 z1g4)!Tp|WZ|5+|x4=8o45EVAgIP^9>2=?tt$SEeSz$%AUa|j*#1RDKkSBUlu?e2L= z<=A!+oJOwZ*<**09=T!@S7O}f?*NUpbftKOq+OdDDxzQY^fp9yh;8JC6t)^bT1a4NY~OlL&X znzQjq41#>|j38nIqV@SeBI#?yERXKGJw!Hyi4$~-40l2@TXGChtoU~Pd;xI!PJ%$P z=uQa!pT1LUZwavh_z^k)7(EefFfvXbqJ|%?zDvwNAOCZgIFeEb<2iqq*oo%cEiT4c zJG@G=tG(})?`b$^aC-2w0(PS7AA=`m(qNOc(TuHoj@G(}q3p%T*$)dyBqMT3s`JV~Hq!e4aPX1J1CT^AoVO_UwmX}78qs-x?yVr{M z@>T7yPVCPg7OfLk()>=cS$+H=c_p)T>b*@KOIfXB5+VFQaLk*;!W$M94ec?c&qxG3 z?Ne05_%Gu}R!^Cu`^BIhc0W9BLdMWK#!ri0_29tG4VaXOEa9+$5#MSxH66&8fd;N1MwkDUYPhsvI!YPgV!qEr7 zXW{oz_5u9{6g5ixn_e_j)vIZVP<6N_I2xij*RvZ^!h?yidh)q^fGb;uK$Ks@w-8x#{LcS z)WRzgSx@8|Hhgc?%=~pBUNbIB_d4EFU!Z~vz;A`}V z!RTekMweYlB0a;QH@tAeH@2ae)CWD{(LH!Ft0cQ=EsP1upuM3E?Mo~#N>AS}r6p+P zQaY#5iTfqrLcR-l?YP#2ZatwE_?4$aldhxHl+GP{lvZ0?-dXcmLI>6vz4XjuxSFr{ z6~e^q^~MZGJrq%k^{ZYLgpmq6x@*&DSQTW*1sfz;95I-qz>t3_CTw<+cst}mhtEpV zLg03&t<3BTs&;fuS)a;TSS=v-(#l!sX$_mTXhfgkqx*~;Rn&L%;K8Fu3}9Aw`~YuK z6!XEkvz*+yG!1cBm?jJOT6I7GaJR2(1zOi ze;R?l`TiG%(460HE*ZmrGXzg)2pkF;po!`C(&DoaAhV@u$uVP?^8grNVJ1cn9Z*zS z$ut|M@aPB{ePbv#;V$t_d_3gz;+--)jvSW*X2y64UBRXMQEmMOxO=!~gES*vJ3q>5 z>BjB2kZ$O0jiG}bV@0}kJ4hY8#2`S~wS7{4lH#hszPaux`Cg%UZAMzA3EWyp9!hIy zOtaWPupkY4I88G*Vf#Lm+PSmt+lRWV@{M#r1@%(9L+(P2xcO$a*B;!LaXLbypf4SI zNiM~6W2F>&vJ8rpS+8Jr9(b9bq`{>q+OGGa-5#uXh(kir51QE6W6?q4(cvVjJ zaN<3*zbDsEomvV(K~AQU+bKhRlkRJh~m`2%vCdV0Tn4J=?L zfQz6U22<>TM`9(~@{rv_Yukwqb^Kv@TPz5ggsVcAJ}35`90CBO0{C-m@vUFPvqMgzVBH}J?Q`I%U5EJnE-M7OG*mV+p% z9y%j`YoPca!P!u{LzV61JTFvia$=nSNEBT;rF3%rwEkeqm($9Nemf(I{`_1@Pf%De z82Fscq~RuLt`z1566_I~fy2NJgD1CIxPCnh#hPcUoCzcPz)umviVwR2=qat&upx!R zK-g$simv2tp1=Ub%?~EREUOMUjWTQ5LP=>9jPkXo9Un%=&|l9P;>j0nu_Pk!fd-kl zpQdGH6(jr(4gb^VqIQ-&Rn%U9@32r-^QN23Omf@vlKDV#v==(D zVU_I%pIY^V&1XEkQ-6M%lrw zQSdLNO~+2O@-~N4-ExCtLJY;<>KLGIyw!0&nhKXYpr^FlaSKf%M~Zs)3dgv3wdO8I zeH;*^V+oyG=SZP#8y#jU+~|0MHay{gI{sz{EL}G`zyiO|F(BgGqWc`>l)c#zuTI+J z0Q-p*R)zZ=vkmI`2OLoD8ot%>j+*qaV@{M>`KTi^R?T?A@lqVsKI`~_zTEAYs~&pJ z@oJ3v<;#xvD0R?lj+=~X%DWEHpx(aU@m!3$_@HB;L5=>D{k`$9!yTm>jyXO}psuGJ z)6{vV9UsJ~Pn~x>VpKDKcT~j24k@b{Hj?CxjyCFte>uJ~sE#P-`e^m8IOi6F`e3|s zmqE=lI{^h}1?M33UeWmzq(287B3+&6bkTG7I9jPIlAMc;G~VV+QS-b`cQn}*=NTFw zbf!>h&^eCkQ=B8!*3F#xMzv!r=daOf*BoboK|Pl1TpvZ#+BpR}-`06(^>$~FPPB9K zNe-$j+dETC>QCK$qf*tJ3GPf0cx8OK`*|6^?gj2049~o|(7hl57-?-%&`VR6xL?M% z^jqDha1m1zm%3L2-O-ji+_&MU%_=w8sPx4u_hKZL{Ksu)^0vA6x?z;0{&26mG*Ne= zW_`vpm>$^S-ihQZcDje*=YgH>Gsv~-G50tK5$U_<-5uhz%O-7X?(yj=s_Nhu-1DHw z%(#Z?8!x&ai3&-#)H$!X(=t)R`t$BJ8I&1?JKk*pPr7=o$8#>4zpHqUW@b{sWKUQ1 z%`DH&ad;Wt*7GciS=xE7XBvphr+b*`a8#M+ZYr$wB&plWJSTGS<<7f3Ows!2-JV0t z!^FCd-cV+X z$6dUP`FwhlXDWV>HJPSu_GD?#NyZK-?1rq+qnkZl^>1`>v&W3*zq)x_scrA~JjpJ? zr@MP^P%E~0axl;8+8v&c*yw6s@TBAQo)aOOKhV zzVUpIgyL^K$vCIgStmWcS)Ictd0~uj+QU|XKYw@H!{Fk=X-{|7^*vtrBizxh`hApljzq=jwq#n6=v^75Kq>yT$DyVr zd1Fy))7nNh^A1YJ=l`wQ_0zeoUdBxNrzwV;=x*LZEmKm&JTrL23?F3>j>-K@-O|T< zINHcB`oZzEV>3k28wPlHG5O`QRo-57PqDWIWiA$b+XF<@wo|>&$5?T6SHiErG_cyM zW>fGv$*a0&cpt}9s_&E|%&k@PoxwGSN=hlW!s}3fuJ9(a{bjE99%a9$YP`cu`0YC1 z+t-cX>&RQrXshCTydKqdkGB*K#W?$6_#UsFx^D2A>E88Tm@bodgZFh5_-cc<1zTj( zW^XKKhpW>4-j1P-G;oXeIZ#b(QxTqfr~@DJ&Kio+d#~|i(&=Bl*QqZq^Jm1+_jXTP z^_>gep;%84|`guU7Py`F({s&55ar&@9tDu)(<)-zi+Yhq0A0`i#jIVH;FAM zwsQs@$?^pa%2V0CpCm9gy)a*#;0{v3bsiHv(=wP&1y{l!r*bjA6%{@d?^gGf_-~CtlHt1es^T(-u z9`U`%hG2ZmHv~VU9`p6b&%KZNfMIC&W45-}61r zCh7hUeedF@`*EL(x_{(zsGC3X-Ga@mW`5<{#`f{YCw*1;>3@n>Qg+JMLr1-joboYb zpq@PKYo)=H{X5?ufQFDbhmz0vmZIo==X_V-XWa##6Aby9s@V*&0n7^SfEIDic^|xM zs=d$q`dE@-mx*KrX5J;GTwcli5vuoI@SR|g-eZMqr+W;3^p3U|{8s_);9eT6${IRq z^4nD<%DbmZoE$L_*&#?6vc)Ndgl)eX3F8Z@6f3+XM#Mk&uYWG$C&PMcM&fWgckaN^M{(Lt6 zRck@p<9F+Ne-*<;&dvU4HVV%+|4F>YKI-?d1zy?Ro2K@A!q40rsE<79Z;mp!()m~5 zr+Am2VUT0H{MASt{@2?`QpYv%#?MD1vWa z#{{6uq-i8&buXSWB|S~asz979B`b&fD*`?9tN$9d_Df>|tuU$TE^}a_oS=lcG{awV zC8>G7z*I*xmYa^nnw{!nZ3AboLf(zb1N|AKp132x;AHTs!0q^v=b2ozJr^ceQU3|N z&Q{{hI|IqA^GEIt^uUj@Ht-~VK3p4MZn(Ct3w*`eUmtiRL7%Yub_I&nSGEP<^F~qM zeBHv9y06;c!cWd z#c;tNPWVkvaET?A&}wQ(qTF`_j!;z|aY`A~D zk*N~*4%1N9L>pew`s&K4W>zr~NmF$z8KtBS&f0NKVrTJWHe8R?@UD;A|C(M?%lc}B zG5`CT<}?hCSJgb?gI3pk@IN^-P;KWn?Uj&%>je6~ufeg0V_kSB04)cEX=idT3F*N#OAfJw12qez9 zl)5!m=@&z5&qJShc!Cm)Vf-5Mnv@w7h*tz!Z&rq@$4yG9jmCc;u+ptQrHD@Yl))D5 z0MW{DC!ZDeKgs2UxMsKSeQm;R|Ir6`ocs^xy?WqD~j^H2s3&^=ZzB z)6<6ZEb7%`WS^o@14a$*gUfjy!|o)X4kVHCaX>OMT^<&fX7Op5-enyQ*hsP~F+yFH zo}hv0Lx%%F`r@SGp$=auwpi_>>qsEq0!{%F>?3#iR66>V(x(+3;b(969Zcq;`*lFlE5GLJKpSgA@UXhS`8xGJi?vgw}q+AAux8Eft+pyeu&_j_Yp2?AoF@0kIg? z#xv;s!f0qqvUECoMltc%)XOzihp51OZ!$kfXoQzZZu(VgdSZ;yEE;Rh@}yASStU7v zfq<6ybBvNfPo4#I^3>C3VFP83Rb=MXPwzbSIH%Yn`)=e4)~Qwibg9#9ACo~FZ%2l#hjP#2n$Ci7QQGMyFBs*t}|TDJLb3YRr0-8@DA@(&%LBj$#I zp~dEn2Bxh_;K#;UBG=7*xs}m|t&Jl1lak^6r%_OXpfsb`eo`_i@UsRP!DJ=?DAGJC zriUksCdi79&n%t&S&5Hao{qmLdD`l&{6$ICx_#ysu3YA01gWJD`!e`NPhhfMWHi)C;?HcE?g-cIPZqf&H@=q#eV@z z8Tm&0CU|;j{3yPNEgQVD~6al)a0a7)r}m%E_uh34R(&Jq866V=_^W z&NmMpL>jIFIrM9b;4Qqs;GNo8p-HNl4|P}^fQVwXHYO5KUWS4 zKFAt9q&^o>z(*va&T@u?^nf);18&4Qh9JMqw#}_f~WPti;IJ8*m2u)Rq&^V zW1F} zspi9vNwTo&?`9_?Q_$nyto7^@kGq*2hsxZF$yF>?Un;DAMhqLlE@Bb=@9K3|rm!1r zNMsdq+zFji7&DK{(R@AA+g|rWXnU5=eG7gr`rH{>Pp+qj{O&wFf97}hg=7Ot^|U_d zZf49yoMqOlVF9ND zm5v~Yw75Xyr@~>qPO;I3)^631L&Z7n0y^-iHIbgoaYMg~J)O&Or)WA4JNcuf|{lf?SIhe%+vzvK|zIeh@BGJ0yOFjgKngbp-DTP z*+%vV4huZnICF3v=fzlWL#;4H1HD&3pF9I{|FXrI5}i7uh#pFD1-+Q|8GO^QYC|xX z)t%d>(RU9x?WP$;4A_vbjLtufRXqQIGqFv>M-&cQkIVw)3s}~W3phc2*n+|qRqJ!` zF_bP+otgCYMp$cSnPItQ-RiW^lMgsku|~_YTnJzFpwmo$KHy~13i(0j3H(%Tal(n| z7UwIL2y;<|e%s?s2Uv~~d?nAFTf5!!f6JHZPL`P45;FOT(&kvmJn8i+AQjisMDw}**5$7fCt<85+)%=!ud zx#E~JP91&B`3fUv*k=`=KMcK4^0y3{RU~vZG}BfS2LtrUF|Yb7Tfd5`DfQ*`u-RL4 z0?fOyC!FFqmL(^f`Oo41a#^-1WmBi3box}pYpu(GJ!YsJ3@f$TIIn@Bku1h|Eh%Ge zz?81A9$_V!PA+>uKnAA*C&x&yC@!yM9nx;Q{Q9bQOSjq0*OU195$Z9{hQBQU>o0&S z>~F*M@_KgpW6UI%=w*rW>Aw=_0zMzI|ViWcV~N+ zt^R^@4t~yDa6W0}qAIXhxRMgWZ#W(F%0=f$26|kGwbJWylk|>LR{Q_uEJtIjpKxtP z?N=IH>`i?+%GHmJ3==A>d>G2rTV92Y*=WTzk$u_eg7$UJ)37wb zTI5kT$(5}7gRVbWZ`{pX`S=;z%+-Y!J6%ciU^Ca1w10=wO&2@5j3(9rX7YkA6z5~u z-BMkko%wX_$#pdgZ`(&xU5=0f)cI>63_Q}@)dm&+6~LTkwr~OAXj{6rg=b*}3MR{Q)h0K!FWTmu*ozR}KA!=ScHM+N|@rIYJy45Opf zKfAeJV7X^zxZ2X$d>7&QrUDKnfZ~8UtB0%FYvH(2KhtRCQdf$pYzjX+*a+PdU7DNm z^t)VL)$J2qtx%!*Ns;Sj2IR#huETiEpW?cP-S8lg^wNh{yBuusXYRFlXkVR6R2?&1 z)p4A#zh$N?SI}J&amPkzVPzel;^UdFT3m_M!PmHcg=S#dwG+8kWqw%iXcD!(!PSG_ zxW?xu^Np@*v^MWXSA~NCM2K{*uPNgMb6y)nteEKX+E{cP3_>TB8BtH$M|th5VkbD& z+ir5*$Bvq;+gyx3dF?iAHAQW6yX!RTM_{$<&nW70jn5W#KgWOyv(b!hx)YNC0sEb< z*~p1`IUc2!Epm7voA}tCsvazGLeq(VQ1Rj8Xga#o>Y|~CBqI!&H_r2>(4H%z;bJ4s zk;IkxU0j*J*I;-m(iHjG5duyxOw^bbe=4j9r zX!vWMBs(JxDcO>H>%~9*A>vH}jZPN}q~q5agGx=@43; z4YA7#0qm6WIM|tVIb(m6p0$Y*YOqJAjhjG=gC+-SF^5K295wW@1^QP%t?{;?L&=U* z^-;lrTi3^bL6HUB$e_<<#})MDQx?0irfOE%6v&~6+Z^NR=0j3)JT3;>r`+Z6S9-+e zI7s`>Kt)teSJIJi-<&yozL3x`MOlhZIi5MG}v95d-lmuOSpxWaM9K<|!qbO!JZD|57^ zZdW<7@yvYAF}KTE8Tsk?oi)$R*8(|J&bU?&y2oOiMm9@BOC7#8|EIHek9Hk9>jbH! z9w}9YD(*S=oLf~%<=#A~N|}^x$+E2gNhDd4Wm~fKwv88`$&2iEjrGOL@0^gPxu&~#^dX7#|(L(+t1fCgsU=iXblD(Tg| z=8t=QByDN$E$#j7@B4kf@7tHk!kt6?dH(t>5qNW)-E7tCtDk)9`5P{)zgGRvZ#^f# z-|OW3{Nm|n&hI^}4Bz#~fQ3JL+xahozyAXKR1ZIUes(WN`vEpFss7;E^F28K*0-O( z`IrM4B+7m871f6$Ko0N{#&3arK41G* zkeFY1>Afcp-QKnxXvK%5jqxA7{OsZ<>~rC+57z&ed#b-b15f|v&w)IjZ(jhB@Vnr@ z>x<}SMxS?rw{V%S=8#CxT7XnTi&^se>g$X1gM;ID{PSzCIAkJv!B&!>=?DAjDZt^b z{>TR$%8&Zz$j#DM52bIS4hQrNEaM=Qyn1$i{#f|sJ~eh>|ArSrN4dg z=6|mEA*l+mO29)7o;i>(>)7#qoE$KnL)&hRyi3W9+zh>b>zls+nBM%!%P@??hL0fs z=PJR$!@#qS#t69MiSGj+7-UttwDLg;ns2QN)C4~0oizc;z6X=;zf=`*M^)gK7gTje zqc2-kyADi{H-LRX58vq4Rd81eOn%T<0vIjmB@A%bwLGbwd=!Xd-UDmk$FlQRjBecl zx^(jn{CEcl{W}Ckkq1Kf@oPSLY{sqPpn}V%->Uu)Dp-9A0gLR@kAgg{pn~r@x%KUD zC2$5~s`}^N`FpGPycT%n|F0!T1`B2m^dv|@2>2aO|5r_(X7$HD;1yH;Ia&S8rym57 z#0f|&`<=l#NR4vk{K1DVZ(7%4cs{;yGbsQ0d2tj-!1g=5-Y4GurYEY;yC6OI`@b8cEB>?a9Pm}DuO{aw z)elAIkAMUZ=Tvny1^+FV=XJn4Nvd@Y&i}hd9(wuR0Cq&H>nk8!HqV~F_DK}X^{1at z-*gErCU0$D#$!Ey_@TF-zv`~e;l|fp=A^wXPqq>Ws92Oh?WulqeSWoSZO&g+HMXF> zf03SFIR_0t)bQfs)qk@(N00izKV6;w!5u8HpN`K*pf_KS&);xp{=*fuK5rj-^KkX< z<@q%TIm#S>B^^8UWc4R&P}8ed=bt)0?AJHvpTA|<90c|IZ@-79FFu(9k@*5311uz` z6_kTj^m-NM=k&!_^7EJ71(eLM7U$Q#AMAkpf)LDGFQa+bn(~g&%-iR|OFJlkhnz=V z^^F|pAa;3fzXX7@zgnI@bx1JqrOkPxVz=k70Cmi_=kEmd{_op!q5234^6HMu^AqT} z?e1}ntlskj=a1ixi~hks2OFS&=?BgSufAQ~+YXI*;vgpugW^&qUnZfv_Re#?`rz*T zy`hNv&Zj${>i7$ojvi$?Za=W_>G`q{M%FvI&^L5r_XA@9_gEQc=-d^SrycKJHbG#j%bonN3Mz0^=gBz}Z&;C}? zr=O^U6pG*-yuSMAb7uhfs=n)2A9!u`H_x42#+cKeJA1miTL-$h`1!Ld)sKActo0I* zyZ^i1`O=e%?dQ)vbIEai;tOYQxT|{Nm(JQ3yI(qspLa0-`(Hi-Y?60@9VOqAgKS>C zi?h=Q&_?GLQeOSw7eU4BdtU+=#e+xhK4uU7BIxNG|LNHS)!$y6{coT>|Ld=SUV~2QaA?Wwz}8?%?*I2br5he{s4C}-Ecc>i7H1-!cU2w+%NAIN~~ z#LTY=6}AUnlUKhBfPuf59}&vqrKgH(b5QIji)%LyukBwH*M0;%S}L!79BftmZh&>e z`)jYP{^Z@)p18QWzxJ^Mgz=Am=-P*G0Y}0QUwcjU58n&w_^BVh_I7abn?HQ*{ov<4 zKXO3qFBX6A+F#yviAUZ5zCJMhpRB&R0i}QVN3Ut1*zf-+fa|Ycw14c{Zyn5o|McV6 zJ`H|;?tRxj1%BA~U;D8KZ|6(}1GKtfLZ=tsc>gsN$mEOf{)ubsOYS|$VbE^ya{d8$ z<4`6^J;jojh25rg8H6`)}V`{p-fbFI2yT zfPmJYZJxaMjtfor#H>CGpHMG6ceVO1_UXm=dSos+-21Q zo-6dl@7;Uyn}?#`^T5fgsxQ9mB&dF(aitC-NWa_!K!nhw@(H(SWu#!wS$wOb}^q4TbTHoM&!jMylOb}Sgi zVCUkMqm!S2?oq);ok{g`-+$V!KKbg?di8bnq;Z|`NfIwuk`hR3?iz#D4qar-J(M2~QJmB2Z9LK~qSg|ewR z8B6hrKIM08k8_p2y0DngXK7+RImz>f5`MdV zAduw^r&qE6pN({(>aPD2jE2Vjr(k5ZPG4UA#cNMNBI*6f<9jY^FX>TtMs&@7)U|kI zjMcbs6B2A8*Kt^^PlZ6(H0kal)NM)=D5SH97y+H?ZXr_49Hf1>)>adu6lPpo!09le zjhL|`neVM~dSKZkPH8;d-kM^Zv)t4eHt0oXCum%o_b1c+rb`V5BnnRlu)Vc1CJRc% zhU`({%2HQH;vw5+wzOJOW_#N42Oh1g0!>r($&h20D}-P89chhE5DYa`yGBbyM3OZ% z=+xHYwzd-Ec>z(`s8+bEjWRP}bmK5-jpgHHyDA!`VXFEvYSX;kAFrJ*!)j!EKA0A} z0!aq&Ze2&reAE_9bHSDJ0G-A@)IiY%k7=}Fw8m)21rCwU`r<&=x^{xL_`$f;vPHB& zRTOl+pz!Jg&z)Ya-k_Y^w}&WE(h4(^$cbGab)#)P6U`+o&)_XR@#3K2_{J1*aCOwx z(s`-Od3dm!r1EmwZB213Y1S6GwTbY-v_$8S54Sty4q0m+H|sG(p4w!S-Le@`AJK=qBNuvWQ{G_MPKMI6ExcxRMQef4!$AFuw#Jh|`U3+l<> zffL+uL4!dAgbwOiB+(NjLb712!4a)#PsV{bQFcJ2 z6nxL!jp-FQ$KE~%-8@-XJ8Hz?!LiMR$B{}yDd`?AT{d$ilZ`u@Nt2wU7X#27Rd`ky zQDyVk9O%%V7d1ywY21|~wc)_n*4$AGj>M0(;3a3=`C`PW*D$7oOxFrmXizSsL|E?h zs=M|l-(CIbH%{)^kLC#DPol1iQzLol%3+@NJ7v=A!cC~Q7{j}?ZBg=g8j~S6H^%c^ zS$Bh4qsL_9$*!iR3cb<7ZKP-`vXG`5YZOnhj9b_FotyUkwkVj*LQTM&mv{9Q7o&}B zvJlkql9?7*YiaCbe1f+V-Hc~6Tbp@pdadxgX%w_`!Wu`mA=$RY3p&2(Q)r__v~+Sk zj#iK<>3T3sxrFNa>s>tYcz?c&>z=9AI?cvtYBuDcY$KVa&~rPCOR_iH%``30Oj6~crz{6U^pr#h8pl!Ou!TefGD-L={PO;9 zb33FGYtL?rCC7A2a)%Ethnp_rAy0FnP*Co1+3+ImJrOISeG| zIK?C|$L<_TDlH0kE|jc~yO*_aGV`CCdlXMVEIr6AL?Us4s=j8Qp6@9FACXpzRl*|G z{gzwPh;`#Q^u$t2iLX7et#(B-m)Yq5oy zb#^|P^`il6Y!)(Z4?SP&c2iU=`Jt9#Wlhe|qAnIF)Gi8A5ruu~ZyH*Q>(9mIbU7Fz z2}TVYp1~IsGixsey$P0md5CN{MHvEG+eAb+nO90V$!}V6Z)RdTf}+r@Z4Y`1xiM3C zvPdQ{wkk})RmCPXZ7$o+g4JA=rMPZ1bGo@C>uZCW+AX)PzTE>_2C-<^Le1RS?s&Os zWe`(ixQLyM`n!H9Qags|^8+WsCuMzs(Jf*#%9fmrqh_=WCM>?nng$A%VN?xbS$93h zoMB|QY++;16{_ES=H!(ZKm@-Y z_yOS6tbg|8{_0~N0p7G)Cv?W)yT=*a61WnS$vn=yDv_l_5Jd zka{~p%H>#*8x}%Q0a9CghNL$4Ygcfz?rMWVMA}1aa=@n}hsfk%E18pJ0mhy^Y08Ms ztYL<4VW}ruYbG&QLSTmyQ>Mf1)+@#{y|1NHVgdBDOb)tZs3|h}+N_7srf%<8Cmm$+ zKy8mYVZP*AGA8>>$<5;p496K~w9w7gj+xeIbrKU?Uz?Uq zs#&aZXn@8**z4fb7zBnpBy!-o@cGtcB7vTdL-AG&6JC(h0c{&m`E^X6t?p zOmUkgg$cS8J-an(*!#L|0NqFG4|S=TLPUE(%}FFvq-m+6?j5~~!`nxt44JR8?QO7%+J4_C! zFLD$i<3OZHKT8 zNX|kjwU#cq6c_8rAI^pZ7p+ z*SlP*BieM^mVL-5{5opmTB^9je70YrWZtz$HT$wRE~_4oxnb#h%NqCylw}j7PI|!|CnfUR&5fy%HC5f+L!lGH!Hg zO=>;zM36RfhkKcB%?9h%#2@U5PBazf%l;Ox?ch0!g5}GSoRP_(qhVN(WKXtgeJg+r zFgljK+JZ|4!&0YoP9(JTO3p{Zrk)WP(%95OZAdIo-Wdf`jbG@bK97)*(m-dMy15No z+B9=wmDmIU)Jf;em_UJa*|VK+M(dl&XzuaqJd(P+s^-y{PqGOziY0bB%C>>*m#vm+ zEq6&G6p_S_r}F{35?Kx*h5|$ygc1l`SFBCdtu}SjMv8T)xttV9ZDVu<2gk?lPE*|2 zz1fKC^~}7JBY3u~1=bwX`OJ>PP;ewZw$bqxs?7_s(U?cJ-4gJuwe(rJzM&MJYHoX6 zhKI4PC-aFQUEKJUlMjQPEx&N}?$h|db3LX2xDH>RYybllENSrBGGCSP3rJ_;6Ed+%H2+AO~ zGq_5Z(i?LRgWm3-mL-6v7mjYQ3pM_Pur*4vM-~&vuQGLRFz*|l9#5+T%Fh^jXvu^PU?_$36izxa0IREh`2?BMsP`va{IX&;_!^&>1wV z)s(K7)W8Dg6}G11>-7Q)QzWXZHIO&5(Px~vn9yXaF-z*CYa5|*#3K#{f)5M?ps^lD z&!Y!{4v{@z(gA^vyVQ`wlglNc!aG;b_br4=k!~^`;=37Qjf3qnYWbUd#j!y_3Hkui zSp%50R!acikHCcX>YiqQ~XugC>6h`ff=$J?VVhB%0w>0s2{95mo> zEQLl5UXn@7KMvX*o9Wf&p40Q{&Hw$$y?b)0ay3>YmLmic>o`DRIC$8^niLU?^By$8 z7*-qy!E{y!kXFM@(N!0ppy?9J1caYA^wb>LG_phmkp-wpcRQ!aE?m~jY259r44b9w zaI$b5WYH8MIpt)7Sq7tQBBN+Bh)iqmhDJVSQm3B#yRow2MB1h%bhpt1dVWY->xHvg zc8Q$Q8v}mSTW-m;J#AoNy#{p@GFs|lV{9$${z}o{MPPXg4hM+WU>>#c1<}gsktWkd1x_lgC%VuUkm)AO}tIu3!*J)P`%z{xnkoXLyZUqDUVD+r#d5k%6aP$ zgy3K+gqluWHeV)8GgwX-*$~)mi$&uWEi1O(6swM zqDpAECDp^nw?q}ubOs8nj*Mw=hnz@cASZIW9|C06Sve%W(zk)j`S?MVKOzoLHL0I{ zPxXbLzj|GTECv{20A@HM3?$J9hzSrVM*#M41JL)WlK1f)-xEy$%I^5x?Q12EgG8uhcsp0+sR5=0?OP7+|Om`w+<0ej$696{MFuoIdGEC)J(ITia0 zSNm5XmU0pc9vsk{BXR?diR$COczV5x{^sQ2{kBm9rg4HndHMe5`%tirw=fm`V=PUmoeA$&fRM(N%Ny$oj-D@^2@xPk>H(n@i!G=^Bw70b;$ zw|bC+HUb;TgC4u{VA=8_J)t8ZPXQJ-*d{Cv*PZQZV6H=bF8Ij|)~6irL?&2ovxeRt z&gUp6a}$6KVz~j;x23umFm8+L_7n1Lp5_y-6*i>t!a-ab?ktB{Z88>CrN@qRfe;6y zWlW&Md94M@T%m@gv@N{2nPM#h5uwqFu?oyvVI5dw+GUZ5x9n@KtV5~}8>uRdMf z`!7$fK9&@GZR4BWq`wrAfe@u0Q)8_Gh;kFEul>Tw)#|Blf)$6#yn2!Kt<3~2lyNCd zxK%+kvaJvjwOGLWLn&ATN$&3&vreO#=himBayEiEE)cdJ%+?UW=(kilw}?cU2k<=2 zn{tnB3kqh5-TI2$!f;7Mj>D<_(ofgDcA>z#UBgDVG2wLgIMNqeP%U?xIvtQy>2>o- z$|od|3Z2yD`tg!$?&!$rw?=X(*>!+$#dbLDj|`G%Z)AVxyD~G!+g7KTwBiLlH6frV z33$g^c*AkTuUF#O?RyNBYa+AQP3EEOu+mP9X(n_@GQtHD6Yz=2|yBZOCktyN6l}VG!&S>no2em zLn}idaDdz97DmehlOCY0KIL;H#Xtv?7hsv@&4=z^{M4H>nkU#Ed(@h4DotR(vn4Ql z?o^sk=u&F}C;k6&KfY}@%scvVY}TsPUp)nk^OMK+8?wzc zTWk{3BTDhiZkHMhC}izaM_P9rVD|8#vIIzmt4v>}nDzqYSH6v9!CNgL6g}Z8*LrAh_s7&ZxL*}P5 ztshy9Xk`oQoNk))iHX-VbWD3u-|)5ry4yTVk(d^|8sm_enT8nalzxnDJ3ABK$tvWB z34v*=ep&(#W)K5q>R@MAYxnFF5y}DytSndMW;u5k(^X2t#W2}s4)7Zu6|tjbeTN=K zSHjm$&a2Vy0ax8>bPDZ5Ml?2Z7@*tb2BPYyS+o*a?Yh8_BpObaa9i-a)7GQN?bI}P zS!PX+!#Zdiw>C+f4!m|Er%HPl0Cz;o^xeGM-_ZsqNLy&<&37ZtS@7X>!KRCWYDu~@ zjDuRL+kEO#Gdece{Vtw{b+ye=wQLE;oV3lxD}3TlGBR%RgHB^JUbU8N7XtQG-KCd7 z)X*B3pA|bfFrwZlY%gdp7z-tYO&SmRb+JlZj=tkh+5^K79ID-Rd8_`|4Ts z+xgW8s`ov4`pW7zq^nP!Z4c&JXLw6eQTSk?`5{Zq8{HxQK?>W_gP#9~jeRQ-OX|2tm zH9{v#W4lfrzoVcOJDP5qeRo`=aEaMW;EZB~k}=kv5K`;LqK$)=2!j2}cO-V8+)SbrVVxZ#*lwJ~3I7y6$A%UQWf_T)?J6Emj*Q zIT>05X9RC>A)16rTVE_fXx3qiP2EL`lxa78AH&Aje!%r+#$Z9Sp1{PYXf2Tep&~_n zHRfQ02foqiTv=G~hNe8jrJK{Op_x&5Bca>}6_E6FaNzr1UwhB{#e9K5dl!L8CPrk5<$gjA}h^uNzHx(UTYK z!0Qt10&I4*6Pl)+Y?=}~kc4gwGz@zY)FlK+P)?vrQDmv%j7NvNP^iyMwK6v__kJ2k{WvhK1kfP>H z!Y|y0FQi?4Z82XZAS@p2ds2{$CNmNVCrUC`+ktV*hc*xAC^pr5gx zpQGt$E0KnYVhOonVVs%ig(!!8X{O^lsD?J#JSz#{ahuNr2k(#7Hpv0A908ClCzEYP z?}D9mmgr(eYNa3b_Hf5gdhMa$xpBD>wK}Z}z|}U*g~EsCbxH0Rg{fE_M^u=4AW4mW z3&`Lu)?tlk9vWSA9O$++S@JVmr!WCp8;S!hTTXE}cyS)9=Z< zWta#2zL{>Mw411{7}tEWjZ9UF>^Y>3?`jC$@}SjlJS+yJZSt{z28tje3s?qHacgr3 zQlm+|LjV$wlFnLd;K!P-4OQ7;yFe%$)*=ytn%q^lgOv(_J+oCkwo=RBg?ucn zXG0t@wnZFzZ6hjdiC7_aTO(Sd&T2ebiXBN=%fR_jD1#fDPEpqnOzp*dVIMPGfFqFOaaMi)-B+JH5+7cRA9U_4yS0)D zD%(R>{Yg402yVMt>Mf)nfe_`BvpvzgQw=|O;b@?T-}e5KZea*2U_~7u943s+skk#V zFmJVHYy)o!_}8>|)QThZqu!1e#T*bK+)JSFaD&Es;6@<3h@PRL4Lee9+4I1br}ls{ zqBEqlW+c`^dIB{7(qdpUX)N^l%_vesj%4-T4jAG<2S2Q>#jRh>b0j8&{I$9S_E1dIGCr?Jb3D)o&aVAFbwPk+W^d#sI^cjcBn;=`nGg8l1ny1!g~Rk*rc^Z@&z z`k8-r(%uuJt>$;us<*B01T*Q*DF8=>qb4C45C<4;2S?dJ5NcenU`P_6q=G^d0N=c0 zP3z%;&JK$ME}`@RbLY--)U9$5Sp0eTiNl5^o!iL&(2nm=e zUYL5?bT)DLu;s)}LRoRj8Xc}VC}iWwQl8eGH9)~oW)CuHG;pzpsf`xfWm473ftA26 zP)2C20)a$!)W+@D#5xE!fw^fp1QV_Fo?uMM9hyOz6!JYK8c@2u)oR9w;QU}ZHURb^ zI&=&eKl4rp?$8t}$Hk`SY|st2vlsp#Y0g~CWwpgSaD9}njS)-w3WWIZXaoGN3L@96 zWZM-=r<~?XdPl5debzx7cek^1al5gxE$-`mfWG96N)B{x7Y_!?q(~B{8)0z3%{j=i z8Lc!f5sHiGk4{HlKH?NFTP#-*h$nx;K}f!`y8emN8}}W(GsybD4tor2fO_?wgOdO7 z{^0bGIfA-{LkyvVFat6yvtS_fcGQ$3j-%v&J&XZPSa1ux0ayrt6zZY}?Y}SM)-A^f zFn$g`o7jr5)SPlI1X!fEp@vIy0+1Mvga#Zy4R(3;lKTaocgM+bb=R{eC+@)^1W`L& zyalt8!3&;V(mPzeyq3s40IPC6*5!M^-zx{Lu?%FT*eI9-59mlv6MN7>2AUe0A4DHZ zHZDbT^(RY!2_LN%UIz4?RRT@W5j-!Y8HsL9(`(`!BV#}l9`{|tS~f|#oxuFM!7q_b z518nrwp@v=An>);Zkg>e(a;9-aI~1|Z7`()2noC`W2^ySstrffny@{?mkhfMbia^R zO9x^7h@LD}e7mpLLnp(QHQ>?n1XVMXVHr4GQ(U0H?*VLNGlq6uzzCpQbUo`3%4X2y zR`aAIh0(aq59!qie6x*Z$9|0D!0a4qAcA^Dv{prC^s&II!~Vb+kG4a0Ea{B~)FtYP zMkyITTvN&j*)Nk|C1)divhh7&KLi%KoD8&e-!KAly;k6e$A_ucZ*SRdzcWmXCDxPR zWT39O23A7iNK1=;uv?7DddD2Q^hmNv3m>*%vR0dT(SmDQ-KkBtM(WN=XiQwR$I^Bd z?PO(GpQ-$OZ?4v>?Xs^$E(Nv6Q!QwViz1mu7*%I#M%VQ9!GO^>Lrvk@ZM?Ast1kyA zgt@iW>%nv~+AjH=T9BhoLXyB-7>$y_peT_PqH{gKNZXnMP7Fd%vo!$*!Em<_?Q#lK zrKVag-BjH#48ScaCq0g^L}R$K+_l@1n5Z7Q4G&8LVIu28ONA!J#&J*_<4tMaYj&DS zAjX+Zwh<1)5M*bR!!5)5L%{UT#167)(eR>Zg}kbha6UMaf`)CrzUvqq-SXMY)PeK3IbV5GjA$4$M<}gs zmI=vdV`&B5)+L|ci;LMf*37IJHiBMnf-40y31B5fu_5>z*r1WY^*|oiykN&fa3G|8 z2XLm?W@$)nquUHeoTN^Xuoa9p=wK@YM--=l{ozWuAp%y%6bi}lDiziMjv2HI4Nt~g zVa8!z@47pG?REDyGFrI7c0SWMS+w>TNj z@T?7(lZ4n+t<)?~V(9|aY$)TTvn};pR0+(H5*`l`RE?KAf-3-{MQjdOCKxfs*s(%) zfi-ZT`0Wc{uXAW)iNT6FZ)}JCve(xa5Zf@Mp@b|}4XUq3kq#v5AZHE}8Q|G1+NBj3 z7zVw?+!lrIs_0n3re9_zKHP1IoM3R;!JAj@)heyb?BtQ-I#~nGcM@;kHYOBu3Ak#Flv<|odcbNk zbmOXkWe?;pRej*mv#WdYrg!j&YafUVG7VVDKCawcwgA()Y|d^0^)#0WQmgFmF1f5A zHiM&sJFee!x#g+Nl7Xmn1+q7A(;0Zdm};AAS{J zi@))avwQaZj1D_pF&ZxmeZ_MVc8)6W3L)$41pGa=up)wn7D6wq&A{CuNGFM{GHMR& zjyw?8oCS=FdZCB3T9=Jvp1_8ZcgXR8wZ%Sb`5D-(tu`>^Sp?MQjz}zEsPyeZ2Pi@a zhnV2+@c;#?$wEtgB$;|hqYb=6rQBlrW6ZPjz6?kPivia+CQS-NqW~;ujU;MdI84$}LaY^7+ctN3C5?wAh?v;#P!7c$^w5 zI^A+!Py_B5Y}}v0M#q7hxdJfj2%^PgCJ)%yjXP?vVkBXT8(13v=YU1!z+g=icNn*y z;yjH8TumrYMp{i%nJ869nQTJen@HV8)UD02<${Kam~Y!Y8u@6UQ5rc}uEN~tbX>8y z>huQja?!<@a@}gJ>cb^4rZi7Wc7r~?kh%#Qb#k_bvvJ>Qxjo9PLuP9R)Yv!z?_XuC z;kvz`&3OJ~B&`DNwS{8IB)M zvZ3iH;Ho4*9NAu_V*=#wmXe%J$4#WyE#<}7Ag7)zN5U?N`X23-x0X-WbPLy14w&E7 zXQBFz0A|o(fy61m6$HvIfc>@^F)V;RBdm&14`1Ihe-6+eu;ph*i$RJw-AbK_& z0*dPZg+>vWm!vcUAw)_SBl1;fX;6pb zkP)Nmd1&KI&NgdV+Tm0Lw2sBX6~l(BB>~X9HoC+Lemv{7Hhr&Ud3pzvR1{!h-GMo^ zSG$devl(RQwIIa?bpu0K5W-}y*U>&$k%~gZc_ITk0Eu)RXHM*dGXpOU4Sq*3>!`s2)Qv z9n%@cc#hBJ9Qc}8$xe~kq(0bFxHF43fNN8_!*z^1?FK$W+SE)OutJm%`IbYE#JIB( zmEp7<01&`o8_=3*JP_6`B}COCvlWQ^*Ppw=4#l<31i62r@=rA zsFoCaTz|Do&}IO0X5QPao7!HOkEE3}wymD#k5Un(wBDv@6>4whcUSA6C)iLYV3Z}@ zGDf*aOGelTES$1x9HoKOFT-_SD>1;gO*ftt35*pHe69?}b;LIZ+{#M%nKlMt8eO5i zj*%t{Ew%)=gJmrPI$=Tq#B&(x?bTq6ZCZHH!s8m+-GFea1+6*-Jj@uy98CJgazOQ3 zxW`3BSZ>F-3w)&2UC*7q@`C>3tMAG#e*D8X?7QwNtN-UCH{MY(I0*1;B=_83efP(1 zyawzSbvKCS{QAdkG^^Kr{034z^YI(6s`eiTTlszB<2N3ye*NP&zz%$$xY4Mtf)w0$ z9KP@7;kD`k_{syvO*5H+=F&r}92|L#r-6cH@!i4?cP0 zJ3-j~_+vMIsyhADjVt%FO9G_C2Ko0dUiGOPU%mSkADsO9M;Wv8i diff --git a/bin/gpm b/bin/gpm index 592bb83..a2eebc6 100755 --- a/bin/gpm +++ b/bin/gpm @@ -1,9 +1,14 @@ #!/usr/bin/env php arguments->add([ - 'environment' => [ - 'prefix' => 'e', - 'longPrefix' => 'env', - 'description' => 'Configuration Environment', - 'defaultValue' => 'localhost' - ] -]); -$climate->arguments->parse(); - -// Set up environment based on params. -$environment = $climate->arguments->get('environment'); - $grav = Grav::instance(array('loader' => $autoload)); -$grav->setup($environment); - -$grav['config']->init(); -$grav['uri']->init(); -$grav['accounts']; - -$app = new Application('Grav Package Manager', GRAV_VERSION); -$app->addCommands(array( - new \Grav\Console\Gpm\IndexCommand(), - new \Grav\Console\Gpm\VersionCommand(), - new \Grav\Console\Gpm\InfoCommand(), - new \Grav\Console\Gpm\InstallCommand(), - new \Grav\Console\Gpm\UninstallCommand(), - new \Grav\Console\Gpm\UpdateCommand(), - new \Grav\Console\Gpm\SelfupgradeCommand(), - new \Grav\Console\Gpm\DirectInstallCommand(), -)); +$app = new GpmApplication('Grav Package Manager', GRAV_VERSION); $app->run(); diff --git a/bin/grav b/bin/grav index b1e4c90..f3f77ef 100755 --- a/bin/grav +++ b/bin/grav @@ -1,10 +1,14 @@ #!/usr/bin/env php arguments->add([ - 'environment' => [ - 'prefix' => 'e', - 'longPrefix' => 'env', - 'description' => 'Configuration Environment', - 'defaultValue' => 'localhost' - ] -]); -$climate->arguments->parse(); - -// Set up environment based on params. -$environment = $climate->arguments->get('environment'); - $grav = Grav::instance(array('loader' => $autoload)); -$grav->setup($environment); if (!file_exists(GRAV_ROOT . '/index.php')) { exit('FATAL: Must be run from ROOT directory of Grav!'); } -$app = new Application('Grav CLI Application', GRAV_VERSION); -$app->addCommands(array( - new \Grav\Console\Cli\InstallCommand(), - new \Grav\Console\Cli\ComposerCommand(), - new \Grav\Console\Cli\SandboxCommand(), - new \Grav\Console\Cli\CleanCommand(), - new \Grav\Console\Cli\ClearCacheCommand(), - new \Grav\Console\Cli\BackupCommand(), - new \Grav\Console\Cli\NewProjectCommand(), - new \Grav\Console\Cli\SchedulerCommand(), - new \Grav\Console\Cli\SecurityCommand(), - new \Grav\Console\Cli\LogViewerCommand(), - new \Grav\Console\Cli\YamlLinterCommand(), -)); +$app = new GravApplication('Grav CLI Application', GRAV_VERSION); $app->run(); diff --git a/bin/plugin b/bin/plugin index 737eb91..b401c03 100755 --- a/bin/plugin +++ b/bin/plugin @@ -1,13 +1,14 @@ #!/usr/bin/env php arguments->add([ - 'environment' => [ - 'prefix' => 'e', - 'longPrefix' => 'env', - 'description' => 'Configuration Environment', - 'defaultValue' => 'localhost' - ] -]); -$climate->arguments->parse(); - -$environment = $climate->arguments->get('environment'); - +// Bootstrap Grav container. $grav = Grav::instance(array('loader' => $autoload)); -$grav->setup($environment); -$grav->initializeCli(); -$app = new Application('Grav Plugins Commands', GRAV_VERSION); -$pattern = '([A-Z]\w+Command\.php)'; - -// get arguments and strip the application name -if (null === $argv) { - $argv = $_SERVER['argv']; -} - -$bin = array_shift($argv); -$name = array_shift($argv); -$argv = array_merge([$bin], $argv); - -$input = new ArgvInput($argv); - -/** @var \Grav\Common\Data\Data $plugin */ -$plugin = $grav['plugins']->get($name); - -$output = new ConsoleOutput(); -$output->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, array('bold'))); -$output->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, array('bold'))); - -if (!$name) { - $output->writeln(''); - $output->writeln('Usage:'); - $output->writeln(" {$bin} [slug] [command] [arguments]"); - $output->writeln(''); - $output->writeln('Example:'); - $output->writeln(" {$bin} error log -l 1 --trace"); - $list = Folder::all('plugins://', ['compare' => 'Pathname', 'pattern' => '/\/cli\/' . $pattern . '$/usm', 'levels' => 2]); - - $total = 0; - if (count($list)) { - $available = []; - $output->writeln(''); - $output->writeln('Plugins with CLI available:'); - foreach ($list as $index => $entry) { - $split = explode('/', $entry); - $entry = array_shift($split); - $index = str_pad($index++ + 1, 2, '0', STR_PAD_LEFT); - $total = str_pad($total++ + 1, 2, '0', STR_PAD_LEFT); - if (\in_array($entry, $available, true)) { - $total--; - continue; - } - - $available[] = $entry; - $commands_count = $index - $total + 1; - $output->writeln(' ' . $total . '. ' . str_pad($entry, 15) . " {$bin} {$entry} list"); - } - } - - exit; -} else { - if (is_null($plugin)) { - $output->writeln(''); - $output->writeln("$name plugin not found"); - die; - } - - if (!$plugin->enabled) { - $output->writeln(''); - $output->writeln("$name not enabled"); - die; - } -} - -if ($plugin === null) { - $output->writeln("Grav Plugin '{$name}' is not installed"); - exit; -} - -$path = 'plugins://' . $name . '/cli'; - -try { - $commands = Folder::all($path, ['compare' => 'Filename', 'pattern' => '/' . $pattern . '$/usm', 'levels' => 1]); -} catch (\RuntimeException $e) { - $output->writeln("No Console Commands for '{$name}' where found in '{$path}'"); - exit; -} - -foreach ($commands as $command_path) { - $full_path = $grav['locator']->findResource("plugins://{$name}/cli/{$command_path}"); - require_once $full_path; - - $command_class = 'Grav\Plugin\Console\\' . preg_replace('/.php$/', '', $command_path); - $command = new $command_class(); - $app->add($command); -} - -$app->run($input); +$app = new PluginApplication('Grav Plugins Commands', GRAV_VERSION); +$app->run(); diff --git a/composer.json b/composer.json index e6aa022..c3fb23e 100644 --- a/composer.json +++ b/composer.json @@ -12,55 +12,73 @@ "homepage": "https://getgrav.org", "license": "MIT", "require": { - "php": ">=7.1.3", + "php": "^7.3.6 || ^8.0", "ext-json": "*", - "ext-mbstring": "*", "ext-openssl": "*", "ext-curl": "*", "ext-zip": "*", "ext-dom": "*", - "symfony/polyfill-iconv": "^1.9", - "symfony/polyfill-php72": "^1.9", - "symfony/polyfill-php73": "^1.9", + "ext-libxml": "*", + "symfony/polyfill-mbstring": "~1.20", + "symfony/polyfill-iconv": "^1.20", + "symfony/polyfill-php74": "^1.20", + "symfony/polyfill-php80": "^1.20", "psr/simple-cache": "^1.0", "psr/http-message": "^1.0", "psr/http-server-middleware": "^1.0", - "kodus/psr7-server": "*", - "nyholm/psr7": "^1.0", - "twig/twig": "~1.40", - "symfony/yaml": "~4.2.0", - "symfony/console": "~4.2.0", - "symfony/event-dispatcher": "~4.2.0", - "symfony/var-dumper": "~4.2.0", - "symfony/process": "~4.2.0", - "doctrine/cache": "^1.8", - "doctrine/collections": "^1.5", - "guzzlehttp/psr7": "^1.4", - "filp/whoops": "~2.2", + "psr/container": "~1.0.0", + "nyholm/psr7-server": "^1.0", + "nyholm/psr7": "^1.3", + "twig/twig": "~1.44", + "erusev/parsedown": "^1.7", + "erusev/parsedown-extra": "~0.8", + "symfony/contracts": "~1.1", + "symfony/yaml": "~4.4", + "symfony/console": "~4.4", + "symfony/event-dispatcher": "~4.4", + "symfony/var-dumper": "~4.4", + "symfony/process": "~4.4", + "doctrine/cache": "^1.10", + "doctrine/collections": "^1.6", + "guzzlehttp/psr7": "^1.7", + "filp/whoops": "~2.9", "matthiasmullie/minify": "^1.3", - "monolog/monolog": "~1.0", - "gregwar/image": "2.*", - "donatj/phpuseragentparser": "~1.0", - "pimple/pimple": "~3.2", - "rockettheme/toolbox": "~1.4.0", - "maximebf/debugbar": "~1.15", - "league/climate": "^3.4", + "monolog/monolog": "~1.25", + "getgrav/image": "^3.0", + "getgrav/cache": "^2.0", + "donatj/phpuseragentparser": "~1.1", + "pimple/pimple": "~3.3.0", + "rockettheme/toolbox": "~1.5", + "maximebf/debugbar": "~1.16", + "league/climate": "^3.6", "antoligy/dom-string-iterators": "^1.0", - "miljar/php-exif": "^0.6.4", - "composer/ca-bundle": "^1.0", + "miljar/php-exif": "^0.6", + "composer/ca-bundle": "^1.2", "dragonmantank/cron-expression": "^1.2", "phive/twig-extensions-deferred": "^1.0", - "willdurand/negotiation": "2.x-dev" + "willdurand/negotiation": "^3.0", + "itsgoingd/clockwork": "^5.0", + "enshrined/svg-sanitize": "~0.13", + "symfony/http-client": "^4.4", + "composer/semver": "^1.4" }, "require-dev": { - "codeception/codeception": "^2.4", - "phpstan/phpstan": "^0.11", - "phpstan/phpstan-deprecation-rules": "^0.11.0", - "phpunit/php-code-coverage": "~6.0", - "fzaninotto/faker": "^1.8", - "victorjonsson/markdowndocs": "dev-master" + "codeception/codeception": "^4.1", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpunit/php-code-coverage": "~9.2", + "getgrav/markdowndocs": "^2.0", + "codeception/module-asserts": "^1.3", + "codeception/module-phpbrowser": "^1.0", + "symfony/service-contracts": "*" + }, + "replace": { + "symfony/polyfill-php72": "*", + "symfony/polyfill-php73": "*" }, "suggest": { + "ext-mbstring": "Recommended for better performance", + "ext-iconv": "Recommended for better performance", "ext-zend-opcache": "Recommended for better performance", "ext-intl": "Recommended for multi-language sites", "ext-memcache": "Needed to support Memcache servers", @@ -70,22 +88,15 @@ "config": { "apcu-autoloader": true, "platform": { - "php": "7.1.3" + "php": "7.3.6" } }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/trilbymedia/PHP-Markdown-Documentation-Generator" - } - ], "autoload": { "psr-4": { "Grav\\": "system/src/Grav" }, "files": [ - "system/defines.php", - "system/aliases.php" + "system/defines.php" ] }, "archive": { @@ -94,12 +105,11 @@ ] }, "scripts": { - "api-16": "vendor/bin/phpdoc-md generate system/src > user/pages/14.api/default.16.md", - "api-15": "vendor/bin/phpdoc-md generate system/src > user/pages/14.api/default.md", + "api-17": "vendor/bin/phpdoc-md generate system/src > user/pages/14.api/default.17.md", "post-create-project-cmd": "bin/grav install", - "phpstan": "vendor/bin/phpstan analyse -l 2 -c ./tests/phpstan/phpstan.neon system/src --memory-limit=256M", - "phpstan-framework": "vendor/bin/phpstan analyse -l 5 -c ./tests/phpstan/phpstan.neon system/src/Grav/Framework --memory-limit=256M", - "phpstan-plugins": "vendor/bin/phpstan analyse -l 0 -c ./tests/phpstan/plugins.neon user/plugins --memory-limit=256M", + "phpstan": "vendor/bin/phpstan analyse -l 1 -c ./tests/phpstan/phpstan.neon --memory-limit=480M system/src", + "phpstan-framework": "vendor/bin/phpstan analyse -l 1 -c ./tests/phpstan/phpstan.neon --memory-limit=480M system/src/Grav/Framework system/src/Grav/Events system/src/Grav/Installer", + "phpstan-plugins": "vendor/bin/phpstan analyse -l 1 -c ./tests/phpstan/plugins.neon --memory-limit=400M user/plugins", "test": "vendor/bin/codecept run unit", "test-windows": "vendor\\bin\\codecept run unit" }, diff --git a/composer.lock b/composer.lock index 10f35c0..1d51262 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "40dbb242443086c99aa47ea8ca366d67", + "content-hash": "36375d8a5daf3aef0341f9a2b023f4e9", "packages": [ { "name": "antoligy/dom-string-iterators", @@ -48,20 +48,24 @@ } ], "description": "Composer package for DOMWordsIterator and DOMLettersIterator", + "support": { + "issues": "https://github.com/antoligy/dom-string-iterators/issues", + "source": "https://github.com/antoligy/dom-string-iterators/tree/v1.0.1" + }, "time": "2018-02-03T16:01:11+00:00" }, { "name": "composer/ca-bundle", - "version": "1.2.6", + "version": "1.2.9", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "47fe531de31fca4a1b997f87308e7d7804348f7e" + "reference": "78a0e288fdcebf92aa2318a8d3656168da6ac1a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/47fe531de31fca4a1b997f87308e7d7804348f7e", - "reference": "47fe531de31fca4a1b997f87308e7d7804348f7e", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/78a0e288fdcebf92aa2318a8d3656168da6ac1a5", + "reference": "78a0e288fdcebf92aa2318a8d3656168da6ac1a5", "shasum": "" }, "require": { @@ -70,14 +74,15 @@ "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 8", + "phpstan/phpstan": "^0.12.55", "psr/log": "^1.0", + "symfony/phpunit-bridge": "^4.2 || ^5", "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.x-dev" + "dev-main": "1.x-dev" } }, "autoload": { @@ -104,44 +109,142 @@ "ssl", "tls" ], - "time": "2020-01-13T10:02:55+00:00" + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/ca-bundle/issues", + "source": "https://github.com/composer/ca-bundle/tree/1.2.9" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2021-01-12T12:10:35+00:00" }, { - "name": "doctrine/cache", - "version": "1.10.0", + "name": "composer/semver", + "version": "1.7.2", "source": { "type": "git", - "url": "https://github.com/doctrine/cache.git", - "reference": "382e7f4db9a12dc6c19431743a2b096041bcdd62" + "url": "https://github.com/composer/semver.git", + "reference": "647490bbcaf7fc4891c58f47b825eb99d19c377a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/cache/zipball/382e7f4db9a12dc6c19431743a2b096041bcdd62", - "reference": "382e7f4db9a12dc6c19431743a2b096041bcdd62", + "url": "https://api.github.com/repos/composer/semver/zipball/647490bbcaf7fc4891c58f47b825eb99d19c377a", + "reference": "647490bbcaf7fc4891c58f47b825eb99d19c377a", "shasum": "" }, "require": { - "php": "~7.1" + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.5 || ^5.0.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/1.7.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2020-12-03T15:47:16+00:00" + }, + { + "name": "doctrine/cache", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/cache.git", + "reference": "a9c1b59eba5a08ca2770a76eddb88922f504e8e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/cache/zipball/a9c1b59eba5a08ca2770a76eddb88922f504e8e0", + "reference": "a9c1b59eba5a08ca2770a76eddb88922f504e8e0", + "shasum": "" + }, + "require": { + "php": "~7.1 || ^8.0" }, "conflict": { - "doctrine/common": ">2.2,<2.4" + "doctrine/common": ">2.2,<2.4", + "psr/cache": ">=3" }, "require-dev": { "alcaeus/mongo-php-adapter": "^1.1", - "doctrine/coding-standard": "^6.0", + "cache/integration-tests": "dev-master", + "doctrine/coding-standard": "^8.0", "mongodb/mongodb": "^1.1", - "phpunit/phpunit": "^7.0", - "predis/predis": "~1.0" + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "predis/predis": "~1.0", + "psr/cache": "^1.0 || ^2.0", + "symfony/cache": "^4.4 || ^5.2" }, "suggest": { "alcaeus/mongo-php-adapter": "Required to use legacy MongoDB driver" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.9.x-dev" - } - }, "autoload": { "psr-4": { "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" @@ -186,37 +289,50 @@ "redis", "xcache" ], - "time": "2019-11-29T15:36:20+00:00" + "support": { + "issues": "https://github.com/doctrine/cache/issues", + "source": "https://github.com/doctrine/cache/tree/1.11.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache", + "type": "tidelift" + } + ], + "time": "2021-04-13T14:46:17+00:00" }, { "name": "doctrine/collections", - "version": "1.6.4", + "version": "1.6.7", "source": { "type": "git", "url": "https://github.com/doctrine/collections.git", - "reference": "6b1e4b2b66f6d6e49983cebfe23a21b7ccc5b0d7" + "reference": "55f8b799269a1a472457bd1a41b4f379d4cfba4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/6b1e4b2b66f6d6e49983cebfe23a21b7ccc5b0d7", - "reference": "6b1e4b2b66f6d6e49983cebfe23a21b7ccc5b0d7", + "url": "https://api.github.com/repos/doctrine/collections/zipball/55f8b799269a1a472457bd1a41b4f379d4cfba4a", + "reference": "55f8b799269a1a472457bd1a41b4f379d4cfba4a", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^7.1.3 || ^8.0" }, "require-dev": { "doctrine/coding-standard": "^6.0", "phpstan/phpstan-shim": "^0.9.2", "phpunit/phpunit": "^7.0", - "vimeo/psalm": "^3.2.2" + "vimeo/psalm": "^3.8.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.6.x-dev" - } - }, "autoload": { "psr-4": { "Doctrine\\Common\\Collections\\": "lib/Doctrine/Common/Collections" @@ -256,20 +372,24 @@ "iterators", "php" ], - "time": "2019-11-13T13:07:11+00:00" + "support": { + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/1.6.7" + }, + "time": "2020-07-27T17:53:49+00:00" }, { "name": "donatj/phpuseragentparser", - "version": "v1.0.0", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/donatj/PhpUserAgent.git", - "reference": "1431382850017ac017d194f2a6f6cacb35212888" + "reference": "246c1cf0a44f07168c702203bf30d5f48f17bab0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/donatj/PhpUserAgent/zipball/1431382850017ac017d194f2a6f6cacb35212888", - "reference": "1431382850017ac017d194f2a6f6cacb35212888", + "url": "https://api.github.com/repos/donatj/PhpUserAgent/zipball/246c1cf0a44f07168c702203bf30d5f48f17bab0", + "reference": "246c1cf0a44f07168c702203bf30d5f48f17bab0", "shasum": "" }, "require": { @@ -278,7 +398,7 @@ "require-dev": { "camspiers/json-pretty": "~1.0", "donatj/drop": "*", - "phpunit/phpunit": "~4.8" + "phpunit/phpunit": "~4.8|~9" }, "type": "library", "autoload": { @@ -302,7 +422,7 @@ } ], "description": "Lightning fast, minimalist PHP UserAgent string parser.", - "homepage": "http://donatstudios.com/PHP-Parser-HTTP_USER_AGENT", + "homepage": "https://donatstudios.com/PHP-Parser-HTTP_USER_AGENT", "keywords": [ "browser", "browser detection", @@ -310,9 +430,13 @@ "user agent", "useragent" ], + "support": { + "issues": "https://github.com/donatj/PhpUserAgent/issues", + "source": "https://github.com/donatj/PhpUserAgent/tree/v1.4.0" + }, "funding": [ { - "url": "https://www.paypal.me/donatj/15", + "url": "https://www.paypal.me/donatj/5", "type": "custom" }, { @@ -320,7 +444,7 @@ "type": "github" } ], - "time": "2020-04-24T18:07:07+00:00" + "time": "2021-03-16T16:25:14+00:00" }, { "name": "dragonmantank/cron-expression", @@ -364,29 +488,178 @@ "cron", "schedule" ], + "support": { + "source": "https://github.com/dragonmantank/cron-expression/tree/v1.2.0" + }, "time": "2017-01-23T04:29:33+00:00" }, { - "name": "filp/whoops", - "version": "2.7.1", + "name": "enshrined/svg-sanitize", + "version": "0.14.0", "source": { "type": "git", - "url": "https://github.com/filp/whoops.git", - "reference": "fff6f1e4f36be0e0d0b84d66b413d9dcb0c49130" + "url": "https://github.com/darylldoyle/svg-sanitizer.git", + "reference": "beff89576a72540ee99476aeb9cfe98222e76fb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/fff6f1e4f36be0e0d0b84d66b413d9dcb0c49130", - "reference": "fff6f1e4f36be0e0d0b84d66b413d9dcb0c49130", + "url": "https://api.github.com/repos/darylldoyle/svg-sanitizer/zipball/beff89576a72540ee99476aeb9cfe98222e76fb8", + "reference": "beff89576a72540ee99476aeb9cfe98222e76fb8", "shasum": "" }, "require": { - "php": "^5.5.9 || ^7.0", + "ext-dom": "*", + "ext-libxml": "*" + }, + "require-dev": { + "codeclimate/php-test-reporter": "^0.1.2", + "phpunit/phpunit": "^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "enshrined\\svgSanitize\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Daryll Doyle", + "email": "daryll@enshrined.co.uk" + } + ], + "description": "An SVG sanitizer for PHP", + "support": { + "issues": "https://github.com/darylldoyle/svg-sanitizer/issues", + "source": "https://github.com/darylldoyle/svg-sanitizer/tree/0.14.0" + }, + "time": "2021-01-21T10:13:20+00:00" + }, + { + "name": "erusev/parsedown", + "version": "1.7.4", + "source": { + "type": "git", + "url": "https://github.com/erusev/parsedown.git", + "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/erusev/parsedown/zipball/cb17b6477dfff935958ba01325f2e8a2bfa6dab3", + "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35" + }, + "type": "library", + "autoload": { + "psr-0": { + "Parsedown": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Emanuil Rusev", + "email": "hello@erusev.com", + "homepage": "http://erusev.com" + } + ], + "description": "Parser for Markdown.", + "homepage": "http://parsedown.org", + "keywords": [ + "markdown", + "parser" + ], + "support": { + "issues": "https://github.com/erusev/parsedown/issues", + "source": "https://github.com/erusev/parsedown/tree/1.7.x" + }, + "time": "2019-12-30T22:54:17+00:00" + }, + { + "name": "erusev/parsedown-extra", + "version": "0.8.1", + "source": { + "type": "git", + "url": "https://github.com/erusev/parsedown-extra.git", + "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/erusev/parsedown-extra/zipball/91ac3ff98f0cea243bdccc688df43810f044dcef", + "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef", + "shasum": "" + }, + "require": { + "erusev/parsedown": "^1.7.4" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35" + }, + "type": "library", + "autoload": { + "psr-0": { + "ParsedownExtra": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Emanuil Rusev", + "email": "hello@erusev.com", + "homepage": "http://erusev.com" + } + ], + "description": "An extension of Parsedown that adds support for Markdown Extra.", + "homepage": "https://github.com/erusev/parsedown-extra", + "keywords": [ + "markdown", + "markdown extra", + "parsedown", + "parser" + ], + "support": { + "issues": "https://github.com/erusev/parsedown-extra/issues", + "source": "https://github.com/erusev/parsedown-extra/tree/0.8.x" + }, + "time": "2019-12-30T23:20:37+00:00" + }, + { + "name": "filp/whoops", + "version": "2.12.1", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "c13c0be93cff50f88bbd70827d993026821914dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/c13c0be93cff50f88bbd70827d993026821914dd", + "reference": "c13c0be93cff50f88bbd70827d993026821914dd", + "shasum": "" + }, + "require": { + "php": "^5.5.9 || ^7.0 || ^8.0", "psr/log": "^1.0.1" }, "require-dev": { "mockery/mockery": "^0.9 || ^1.0", - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0", + "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.3", "symfony/var-dumper": "^2.6 || ^3.0 || ^4.0 || ^5.0" }, "suggest": { @@ -396,7 +669,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.6-dev" + "dev-master": "2.7-dev" } }, "autoload": { @@ -425,21 +698,31 @@ "throwable", "whoops" ], - "time": "2020-01-15T10:00:00+00:00" + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.12.1" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2021-04-25T12:00:00+00:00" }, { - "name": "gregwar/cache", - "version": "v1.0.12", + "name": "getgrav/cache", + "version": "v2.0.0", "target-dir": "Gregwar/Cache", "source": { "type": "git", - "url": "https://github.com/Gregwar/Cache.git", - "reference": "305d0f5a12c0beecbbd7e1de236f59f39e0c0ac3" + "url": "https://github.com/getgrav/Cache.git", + "reference": "56fd63f752779928fcd1074ab7d12f406dde8861" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Gregwar/Cache/zipball/305d0f5a12c0beecbbd7e1de236f59f39e0c0ac3", - "reference": "305d0f5a12c0beecbbd7e1de236f59f39e0c0ac3", + "url": "https://api.github.com/repos/getgrav/Cache/zipball/56fd63f752779928fcd1074ab7d12f406dde8861", + "reference": "56fd63f752779928fcd1074ab7d12f406dde8861", "shasum": "" }, "require": { @@ -459,6 +742,11 @@ { "name": "Gregwar", "email": "g.passault@gmail.com" + }, + { + "name": "Grav CMS", + "email": "hello@getgrav.org", + "homepage": "https://getgrav.org" } ], "description": "A lightweight file-system cache system", @@ -468,27 +756,30 @@ "file-system", "system" ], - "time": "2016-09-23T08:16:04+00:00" + "support": { + "source": "https://github.com/getgrav/Cache/tree/v2.0.0" + }, + "time": "2021-04-20T05:48:00+00:00" }, { - "name": "gregwar/image", - "version": "v2.0.25", + "name": "getgrav/image", + "version": "v3.0.0", "target-dir": "Gregwar/Image", "source": { "type": "git", - "url": "https://github.com/Gregwar/Image.git", - "reference": "03534d5760cbea5c96e6292041ff81a3bb205c36" + "url": "https://github.com/getgrav/Image.git", + "reference": "02c1bb2c179dd894c4f6610c9c49da364ee7d264" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Gregwar/Image/zipball/03534d5760cbea5c96e6292041ff81a3bb205c36", - "reference": "03534d5760cbea5c96e6292041ff81a3bb205c36", + "url": "https://api.github.com/repos/getgrav/Image/zipball/02c1bb2c179dd894c4f6610c9c49da364ee7d264", + "reference": "02c1bb2c179dd894c4f6610c9c49da364ee7d264", "shasum": "" }, "require": { "ext-gd": "*", - "gregwar/cache": "^1.0.6", - "php": "^5.3 || ^7.0" + "getgrav/cache": "^2.0", + "php": "^5.6 || ^7.0 || ^8.0" }, "require-dev": { "sllh/php-cs-fixer-styleci-bridge": "~1.0", @@ -512,6 +803,11 @@ "name": "Grégoire Passault", "email": "g.passault@gmail.com", "homepage": "http://www.gregwar.com/" + }, + { + "name": "Grav CMS", + "email": "hello@getgrav.org", + "homepage": "https://getgrav.org" } ], "description": "Image handling", @@ -520,20 +816,23 @@ "gd", "image" ], - "time": "2019-03-01T15:55:29+00:00" + "support": { + "source": "https://github.com/getgrav/Image/tree/v3.0.0" + }, + "time": "2021-04-20T05:50:18+00:00" }, { "name": "guzzlehttp/psr7", - "version": "1.6.1", + "version": "1.8.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "239400de7a173fe9901b9ac7c06497751f00727a" + "reference": "35ea11d335fd638b5882ff1725228b3d35496ab1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a", - "reference": "239400de7a173fe9901b9ac7c06497751f00727a", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/35ea11d335fd638b5882ff1725228b3d35496ab1", + "reference": "35ea11d335fd638b5882ff1725228b3d35496ab1", "shasum": "" }, "require": { @@ -546,15 +845,15 @@ }, "require-dev": { "ext-zlib": "*", - "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8" + "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.10" }, "suggest": { - "zendframework/zend-httphandlerrunner": "Emit PSR-7 responses" + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.6-dev" + "dev-master": "1.7-dev" } }, "autoload": { @@ -591,39 +890,45 @@ "uri", "url" ], - "time": "2019-07-01T23:21:34+00:00" + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/1.8.1" + }, + "time": "2021-03-21T16:25:00+00:00" }, { - "name": "kodus/psr7-server", - "version": "1.0.1", + "name": "itsgoingd/clockwork", + "version": "v5.0.7", "source": { "type": "git", - "url": "https://github.com/kodus/psr7-server.git", - "reference": "dcfd0116451b0f0e7c6b23b831757ed288347278" + "url": "https://github.com/itsgoingd/clockwork.git", + "reference": "e41ee368ff4dcc30d3f4563fe8bd80ed72b293b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kodus/psr7-server/zipball/dcfd0116451b0f0e7c6b23b831757ed288347278", - "reference": "dcfd0116451b0f0e7c6b23b831757ed288347278", + "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/e41ee368ff4dcc30d3f4563fe8bd80ed72b293b4", + "reference": "e41ee368ff4dcc30d3f4563fe8bd80ed72b293b4", "shasum": "" }, "require": { - "php": "^7.1", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.0" - }, - "replace": { - "nyholm/psr7-server": "^0.3" - }, - "require-dev": { - "nyholm/nsa": "^1.1", - "nyholm/psr7": "^1.0", - "phpunit/phpunit": "^7.0" + "ext-json": "*", + "php": ">=5.6", + "psr/log": "1.*" }, "type": "library", + "extra": { + "laravel": { + "providers": [ + "Clockwork\\Support\\Laravel\\ClockworkServiceProvider" + ], + "aliases": { + "Clockwork": "Clockwork\\Support\\Laravel\\Facade" + } + } + }, "autoload": { "psr-4": { - "Nyholm\\Psr7Server\\": "src/" + "Clockwork\\": "Clockwork/" } }, "notification-url": "https://packagist.org/downloads/", @@ -632,45 +937,57 @@ ], "authors": [ { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" - }, - { - "name": "Martijn van der Ven", - "email": "martijn@vanderven.se" + "name": "itsgoingd", + "email": "itsgoingd@luzer.sk", + "homepage": "https://twitter.com/itsgoingd" } ], - "description": "Helper classes to handle PSR-7 server requests", - "homepage": "http://tnyholm.se", + "description": "php dev tools in your browser", + "homepage": "https://underground.works/clockwork", "keywords": [ - "psr-17", - "psr-7" + "Devtools", + "debugging", + "laravel", + "logging", + "lumen", + "profiling", + "slim" ], - "time": "2019-06-17T10:48:13+00:00" + "support": { + "issues": "https://github.com/itsgoingd/clockwork/issues", + "source": "https://github.com/itsgoingd/clockwork/tree/v5.0.7" + }, + "funding": [ + { + "url": "https://github.com/itsgoingd", + "type": "github" + } + ], + "time": "2021-03-14T16:29:40+00:00" }, { "name": "league/climate", - "version": "3.5.2", + "version": "3.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/climate.git", - "reference": "6b53a28a58ad9f5f63042e291eb870cf0d02a9c9" + "reference": "5c717c3032c5118be7ad2395dbe0813d9894e8c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/climate/zipball/6b53a28a58ad9f5f63042e291eb870cf0d02a9c9", - "reference": "6b53a28a58ad9f5f63042e291eb870cf0d02a9c9", + "url": "https://api.github.com/repos/thephpleague/climate/zipball/5c717c3032c5118be7ad2395dbe0813d9894e8c7", + "reference": "5c717c3032c5118be7ad2395dbe0813d9894e8c7", "shasum": "" }, "require": { - "php": "^7.1", + "php": "^7.3 || ^8.0", "psr/log": "^1.0", "seld/cli-prompt": "^1.0" }, "require-dev": { "mikey179/vfsstream": "^1.4", - "mockery/mockery": "^1.0", - "phpunit/phpunit": "^5.7.16" + "mockery/mockery": "^1.4.2", + "phpunit/phpunit": "^9.5.0" }, "suggest": { "ext-mbstring": "If ext-mbstring is not available you MUST install symfony/polyfill-mbstring" @@ -707,20 +1024,24 @@ "php", "terminal" ], - "time": "2019-12-01T15:25:43+00:00" + "support": { + "issues": "https://github.com/thephpleague/climate/issues", + "source": "https://github.com/thephpleague/climate/tree/3.7.0" + }, + "time": "2021-01-10T20:18:52+00:00" }, { "name": "matthiasmullie/minify", - "version": "1.3.63", + "version": "1.3.66", "source": { "type": "git", "url": "https://github.com/matthiasmullie/minify.git", - "reference": "9ba1b459828adc13430f4dd6c49dae4950dc4117" + "reference": "45fd3b0f1dfa2c965857c6d4a470bea52adc31a6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/matthiasmullie/minify/zipball/9ba1b459828adc13430f4dd6c49dae4950dc4117", - "reference": "9ba1b459828adc13430f4dd6c49dae4950dc4117", + "url": "https://api.github.com/repos/matthiasmullie/minify/zipball/45fd3b0f1dfa2c965857c6d4a470bea52adc31a6", + "reference": "45fd3b0f1dfa2c965857c6d4a470bea52adc31a6", "shasum": "" }, "require": { @@ -730,8 +1051,8 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "~2.0", - "matthiasmullie/scrapbook": "~1.0", - "phpunit/phpunit": "~4.8" + "matthiasmullie/scrapbook": "dev-master", + "phpunit/phpunit": ">=4.8" }, "suggest": { "psr/cache-implementation": "Cache implementation to use with Minify::cache" @@ -767,7 +1088,25 @@ "minifier", "minify" ], - "time": "2020-01-21T20:21:08+00:00" + "support": { + "issues": "https://github.com/matthiasmullie/minify/issues", + "source": "https://github.com/matthiasmullie/minify/tree/1.3.66" + }, + "funding": [ + { + "url": "https://github.com/[user1", + "type": "github" + }, + { + "url": "https://github.com/matthiasmullie] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g.", + "type": "github" + }, + { + "url": "https://github.com/user2", + "type": "github" + } + ], + "time": "2021-01-06T15:18:10+00:00" }, { "name": "matthiasmullie/path-converter", @@ -816,29 +1155,33 @@ "paths", "relative" ], + "support": { + "issues": "https://github.com/matthiasmullie/path-converter/issues", + "source": "https://github.com/matthiasmullie/path-converter/tree/1.1.3" + }, "time": "2019-02-05T23:41:09+00:00" }, { "name": "maximebf/debugbar", - "version": "v1.16.1", + "version": "v1.16.5", "source": { "type": "git", "url": "https://github.com/maximebf/php-debugbar.git", - "reference": "58998b818c6567fac01e35b8a4b70c1a64530556" + "reference": "6d51ee9e94cff14412783785e79a4e7ef97b9d62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/58998b818c6567fac01e35b8a4b70c1a64530556", - "reference": "58998b818c6567fac01e35b8a4b70c1a64530556", + "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/6d51ee9e94cff14412783785e79a4e7ef97b9d62", + "reference": "6d51ee9e94cff14412783785e79a4e7ef97b9d62", "shasum": "" }, "require": { - "php": "^7.1", + "php": "^7.1|^8", "psr/log": "^1.0", "symfony/var-dumper": "^2.6|^3|^4|^5" }, "require-dev": { - "phpunit/phpunit": "^5" + "phpunit/phpunit": "^7.5.20 || ^9.4.2" }, "suggest": { "kriswallsmith/assetic": "The best way to manage assets", @@ -877,7 +1220,11 @@ "debug", "debugbar" ], - "time": "2019-11-24T09:46:11+00:00" + "support": { + "issues": "https://github.com/maximebf/php-debugbar/issues", + "source": "https://github.com/maximebf/php-debugbar/tree/v1.16.5" + }, + "time": "2020-12-07T11:07:24+00:00" }, { "name": "miljar/php-exif", @@ -933,20 +1280,24 @@ "jpeg", "tiff" ], + "support": { + "issues": "https://github.com/PHPExif/php-exif/issues", + "source": "https://github.com/PHPExif/php-exif/tree/v0.6.5" + }, "time": "2019-02-11T13:47:52+00:00" }, { "name": "monolog/monolog", - "version": "1.25.3", + "version": "1.26.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "fa82921994db851a8becaf3787a9e73c5976b6f1" + "reference": "2209ddd84e7ef1256b7af205d0717fb62cfc9c33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/fa82921994db851a8becaf3787a9e73c5976b6f1", - "reference": "fa82921994db851a8becaf3787a9e73c5976b6f1", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/2209ddd84e7ef1256b7af205d0717fb62cfc9c33", + "reference": "2209ddd84e7ef1256b7af205d0717fb62cfc9c33", "shasum": "" }, "require": { @@ -960,11 +1311,10 @@ "aws/aws-sdk-php": "^2.4.9 || ^3.0", "doctrine/couchdb": "~1.0@dev", "graylog2/gelf-php": "~1.0", - "jakub-onderka/php-parallel-lint": "0.9", "php-amqplib/php-amqplib": "~2.4", "php-console/php-console": "^3.1.3", + "phpstan/phpstan": "^0.12.59", "phpunit/phpunit": "~4.5", - "phpunit/phpunit-mock-objects": "2.3.0", "ruflin/elastica": ">=0.90 <3.0", "sentry/sentry": "^0.13", "swiftmailer/swiftmailer": "^5.3|^6.0" @@ -983,11 +1333,6 @@ "sentry/sentry": "Allow sending log messages to a Sentry server" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, "autoload": { "psr-4": { "Monolog\\": "src/Monolog" @@ -1011,24 +1356,38 @@ "logging", "psr-3" ], - "time": "2019-12-20T14:15:16+00:00" + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/1.26.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2020-12-14T12:56:38+00:00" }, { "name": "nyholm/psr7", - "version": "1.2.1", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/Nyholm/psr7.git", - "reference": "55ff6b76573f5b242554c9775792bd59fb52e11c" + "reference": "23ae1f00fbc6a886cbe3062ca682391b9cc7c37b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Nyholm/psr7/zipball/55ff6b76573f5b242554c9775792bd59fb52e11c", - "reference": "55ff6b76573f5b242554c9775792bd59fb52e11c", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/23ae1f00fbc6a886cbe3062ca682391b9cc7c37b", + "reference": "23ae1f00fbc6a886cbe3062ca682391b9cc7c37b", "shasum": "" }, "require": { - "php": "^7.1", + "php": ">=7.1", "php-http/message-factory": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.0" @@ -1038,14 +1397,15 @@ "psr/http-message-implementation": "1.0" }, "require-dev": { - "http-interop/http-factory-tests": "dev-master", - "php-http/psr7-integration-tests": "dev-master", - "phpunit/phpunit": "^7.5" + "http-interop/http-factory-tests": "^0.8", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || 8.5 || 9.4", + "symfony/error-handler": "^4.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "1.4-dev" } }, "autoload": { @@ -1068,24 +1428,104 @@ } ], "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.4.0" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2021-02-18T15:41:32+00:00" + }, + { + "name": "nyholm/psr7-server", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7-server.git", + "reference": "5c134aeb5dd6521c7978798663470dabf0528c96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/5c134aeb5dd6521c7978798663470dabf0528c96", + "reference": "5c134aeb5dd6521c7978798663470dabf0528c96", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0" + }, + "require-dev": { + "nyholm/nsa": "^1.1", + "nyholm/psr7": "^1.3", + "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Nyholm\\Psr7Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "Helper classes to handle PSR-7 server requests", "homepage": "http://tnyholm.se", "keywords": [ "psr-17", "psr-7" ], - "time": "2019-09-05T13:24:16+00:00" + "support": { + "issues": "https://github.com/Nyholm/psr7-server/issues", + "source": "https://github.com/Nyholm/psr7-server/tree/1.0.1" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2020-11-15T15:26:20+00:00" }, { "name": "phive/twig-extensions-deferred", "version": "v1.0.2", "source": { "type": "git", - "url": "https://github.com/rybakit/twig-deferred-extension.git", + "url": "https://github.com/rybakit/twig-extensions-deferred-legacy.git", "reference": "5a2426d622afa74034e754ca5ea1d1ff7887627f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rybakit/twig-deferred-extension/zipball/5a2426d622afa74034e754ca5ea1d1ff7887627f", + "url": "https://api.github.com/repos/rybakit/twig-extensions-deferred-legacy/zipball/5a2426d622afa74034e754ca5ea1d1ff7887627f", "reference": "5a2426d622afa74034e754ca5ea1d1ff7887627f", "shasum": "" }, @@ -1116,6 +1556,16 @@ "lazy", "twig" ], + "support": { + "issues": "https://github.com/rybakit/twig-extensions-deferred-legacy/issues", + "source": "https://github.com/rybakit/twig-extensions-deferred-legacy/tree/v1.0.2" + }, + "funding": [ + { + "url": "https://github.com/rybakit", + "type": "github" + } + ], "time": "2017-03-17T21:39:21+00:00" }, { @@ -1166,33 +1616,37 @@ "stream", "uri" ], + "support": { + "issues": "https://github.com/php-http/message-factory/issues", + "source": "https://github.com/php-http/message-factory/tree/master" + }, "time": "2015-12-19T14:08:53+00:00" }, { "name": "pimple/pimple", - "version": "v3.2.3", + "version": "v3.3.1", "source": { "type": "git", "url": "https://github.com/silexphp/Pimple.git", - "reference": "9e403941ef9d65d20cba7d54e29fe906db42cf32" + "reference": "21e45061c3429b1e06233475cc0e1f6fc774d5b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/silexphp/Pimple/zipball/9e403941ef9d65d20cba7d54e29fe906db42cf32", - "reference": "9e403941ef9d65d20cba7d54e29fe906db42cf32", + "url": "https://api.github.com/repos/silexphp/Pimple/zipball/21e45061c3429b1e06233475cc0e1f6fc774d5b0", + "reference": "21e45061c3429b1e06233475cc0e1f6fc774d5b0", "shasum": "" }, "require": { - "php": ">=5.3.0", + "php": ">=7.2.5", "psr/container": "^1.0" }, "require-dev": { - "symfony/phpunit-bridge": "^3.2" + "symfony/phpunit-bridge": "^5.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2.x-dev" + "dev-master": "3.3.x-dev" } }, "autoload": { @@ -1211,12 +1665,15 @@ } ], "description": "Pimple, a simple Dependency Injection Container", - "homepage": "http://pimple.sensiolabs.org", + "homepage": "https://pimple.symfony.com", "keywords": [ "container", "dependency injection" ], - "time": "2018-01-21T07:42:36+00:00" + "support": { + "source": "https://github.com/silexphp/Pimple/tree/v3.3.1" + }, + "time": "2020-11-24T20:35:42+00:00" }, { "name": "psr/cache", @@ -1262,6 +1719,9 @@ "psr", "psr-6" ], + "support": { + "source": "https://github.com/php-fig/cache/tree/master" + }, "time": "2016-08-06T20:24:11+00:00" }, { @@ -1311,6 +1771,10 @@ "container-interop", "psr" ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/master" + }, "time": "2017-02-14T16:28:37+00:00" }, { @@ -1363,6 +1827,9 @@ "request", "response" ], + "support": { + "source": "https://github.com/php-fig/http-factory/tree/master" + }, "time": "2019-04-30T12:38:16+00:00" }, { @@ -1413,6 +1880,9 @@ "request", "response" ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/master" + }, "time": "2016-08-06T14:39:51+00:00" }, { @@ -1466,6 +1936,10 @@ "response", "server" ], + "support": { + "issues": "https://github.com/php-fig/http-server-handler/issues", + "source": "https://github.com/php-fig/http-server-handler/tree/master" + }, "time": "2018-10-30T16:46:14+00:00" }, { @@ -1519,20 +1993,24 @@ "request", "response" ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/master" + }, "time": "2018-10-30T17:12:04+00:00" }, { "name": "psr/log", - "version": "1.1.2", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801" + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/446d54b4cb6bf489fc9d75f55843658e6f25d801", - "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801", + "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc", "shasum": "" }, "require": { @@ -1566,7 +2044,10 @@ "psr", "psr-3" ], - "time": "2019-11-01T11:05:21+00:00" + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.3" + }, + "time": "2020-03-23T09:12:05+00:00" }, { "name": "psr/simple-cache", @@ -1614,6 +2095,9 @@ "psr-16", "simple-cache" ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/master" + }, "time": "2017-10-23T01:57:42+00:00" }, { @@ -1654,31 +2138,37 @@ } ], "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, "time": "2019-03-08T08:55:37+00:00" }, { "name": "rockettheme/toolbox", - "version": "1.4.7", + "version": "1.5.9", "source": { "type": "git", "url": "https://github.com/rockettheme/toolbox.git", - "reference": "6a86bc0607884d2194260b6b72d67333e0141585" + "reference": "2d6693235aaca2efaadb61c84dac927aaf4eabfa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rockettheme/toolbox/zipball/6a86bc0607884d2194260b6b72d67333e0141585", - "reference": "6a86bc0607884d2194260b6b72d67333e0141585", + "url": "https://api.github.com/repos/rockettheme/toolbox/zipball/2d6693235aaca2efaadb61c84dac927aaf4eabfa", + "reference": "2d6693235aaca2efaadb61c84dac927aaf4eabfa", "shasum": "" }, "require": { "ext-json": "*", - "php": ">=5.4.0", + "php": ">=5.6.0", "pimple/pimple": "~3.0", - "symfony/event-dispatcher": ">2.5", - "symfony/yaml": ">2.5" + "symfony/event-dispatcher": "^3.4|^4.0", + "symfony/yaml": "^3.4|^4.0" }, "require-dev": { - "phpunit/phpunit": "~6" + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpunit/phpunit": "~7.0" }, "type": "library", "autoload": { @@ -1704,25 +2194,32 @@ "php", "rockettheme" ], - "time": "2020-03-19T18:24:40+00:00" + "support": { + "issues": "https://github.com/rockettheme/toolbox/issues", + "source": "https://github.com/rockettheme/toolbox/tree/1.5.9" + }, + "time": "2021-04-14T19:52:40+00:00" }, { "name": "seld/cli-prompt", - "version": "1.0.3", + "version": "1.0.4", "source": { "type": "git", "url": "https://github.com/Seldaek/cli-prompt.git", - "reference": "a19a7376a4689d4d94cab66ab4f3c816019ba8dd" + "reference": "b8dfcf02094b8c03b40322c229493bb2884423c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/cli-prompt/zipball/a19a7376a4689d4d94cab66ab4f3c816019ba8dd", - "reference": "a19a7376a4689d4d94cab66ab4f3c816019ba8dd", + "url": "https://api.github.com/repos/Seldaek/cli-prompt/zipball/b8dfcf02094b8c03b40322c229493bb2884423c5", + "reference": "b8dfcf02094b8c03b40322c229493bb2884423c5", "shasum": "" }, "require": { "php": ">=5.3" }, + "require-dev": { + "phpstan/phpstan": "^0.12.63" + }, "type": "library", "extra": { "branch-alias": { @@ -1752,29 +2249,37 @@ "input", "prompt" ], - "time": "2017-03-18T11:32:45+00:00" + "support": { + "issues": "https://github.com/Seldaek/cli-prompt/issues", + "source": "https://github.com/Seldaek/cli-prompt/tree/1.0.4" + }, + "time": "2020-12-15T21:32:01+00:00" }, { "name": "symfony/console", - "version": "v4.2.12", + "version": "v4.4.21", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "fc2e274aade6567a750551942094b2145ade9b6c" + "reference": "1ba4560dbbb9fcf5ae28b61f71f49c678086cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/fc2e274aade6567a750551942094b2145ade9b6c", - "reference": "fc2e274aade6567a750551942094b2145ade9b6c", + "url": "https://api.github.com/repos/symfony/console/zipball/1ba4560dbbb9fcf5ae28b61f71f49c678086cf23", + "reference": "1ba4560dbbb9fcf5ae28b61f71f49c678086cf23", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=7.1.3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/polyfill-php80": "^1.15", + "symfony/service-contracts": "^1.1|^2" }, "conflict": { "symfony/dependency-injection": "<3.4", + "symfony/event-dispatcher": "<4.3|>=5", + "symfony/lock": "<4.4", "symfony/process": "<3.3" }, "provide": { @@ -1782,11 +2287,12 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", - "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~3.4|~4.0", - "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.4|~4.0" + "symfony/config": "^3.4|^4.0|^5.0", + "symfony/dependency-injection": "^3.4|^4.0|^5.0", + "symfony/event-dispatcher": "^4.3", + "symfony/lock": "^4.4|^5.0", + "symfony/process": "^3.4|^4.0|^5.0", + "symfony/var-dumper": "^4.3|^5.0" }, "suggest": { "psr/log": "For using the console logger", @@ -1795,11 +2301,6 @@ "symfony/process": "" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.2-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Console\\": "" @@ -1822,26 +2323,43 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Console Component", + "description": "Eases the creation of beautiful and testable command line interfaces", "homepage": "https://symfony.com", - "time": "2019-07-24T17:13:20+00:00" + "support": { + "source": "https://github.com/symfony/console/tree/v4.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-03-26T09:23:24+00:00" }, { "name": "symfony/contracts", - "version": "v1.1.8", + "version": "v1.1.10", "source": { "type": "git", "url": "https://github.com/symfony/contracts.git", - "reference": "f51bca9de06b7a25b19a4155da7bebad099a5def" + "reference": "011c20407c4b99d454f44021d023fb39ce23b73d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/contracts/zipball/f51bca9de06b7a25b19a4155da7bebad099a5def", - "reference": "f51bca9de06b7a25b19a4155da7bebad099a5def", + "url": "https://api.github.com/repos/symfony/contracts/zipball/011c20407c4b99d454f44021d023fb39ce23b73d", + "reference": "011c20407c4b99d454f44021d023fb39ce23b73d", "shasum": "" }, "require": { - "php": "^7.1.3", + "php": ">=7.1.3", "psr/cache": "^1.0", "psr/container": "^1.0" }, @@ -1901,46 +2419,65 @@ "interoperability", "standards" ], - "time": "2019-11-07T12:44:51+00:00" + "support": { + "source": "https://github.com/symfony/contracts/tree/v1.1.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-09-02T16:08:58+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v4.2.12", + "version": "v4.4.20", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "852548c7c704f14d2f6700c8d872a05bd2028732" + "reference": "c352647244bd376bf7d31efbd5401f13f50dad0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/852548c7c704f14d2f6700c8d872a05bd2028732", - "reference": "852548c7c704f14d2f6700c8d872a05bd2028732", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c352647244bd376bf7d31efbd5401f13f50dad0c", + "reference": "c352647244bd376bf7d31efbd5401f13f50dad0c", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/contracts": "^1.0" + "php": ">=7.1.3", + "symfony/event-dispatcher-contracts": "^1.1" }, "conflict": { "symfony/dependency-injection": "<3.4" }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "1.1" + }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", - "symfony/dependency-injection": "~3.4|~4.0", - "symfony/expression-language": "~3.4|~4.0", - "symfony/stopwatch": "~3.4|~4.0" + "symfony/config": "^3.4|^4.0|^5.0", + "symfony/dependency-injection": "^3.4|^4.0|^5.0", + "symfony/error-handler": "~3.4|~4.4", + "symfony/expression-language": "^3.4|^4.0|^5.0", + "symfony/http-foundation": "^3.4|^4.0|^5.0", + "symfony/service-contracts": "^1.1|^2", + "symfony/stopwatch": "^3.4|^4.0|^5.0" }, "suggest": { "symfony/dependency-injection": "", "symfony/http-kernel": "" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.2-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\EventDispatcher\\": "" @@ -1963,26 +2500,123 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony EventDispatcher Component", + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", - "time": "2019-06-26T06:46:55+00:00" + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v4.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-27T09:09:26+00:00" }, { - "name": "symfony/polyfill-ctype", - "version": "v1.14.0", + "name": "symfony/http-client", + "version": "v4.4.21", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "fbdeaec0df06cf3d51c93de80c7eb76e271f5a38" + "url": "https://github.com/symfony/http-client.git", + "reference": "911177e186b82e5b9a9f41c13af53699b6745657" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/fbdeaec0df06cf3d51c93de80c7eb76e271f5a38", - "reference": "fbdeaec0df06cf3d51c93de80c7eb76e271f5a38", + "url": "https://api.github.com/repos/symfony/http-client/zipball/911177e186b82e5b9a9f41c13af53699b6745657", + "reference": "911177e186b82e5b9a9f41c13af53699b6745657", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1.3", + "psr/log": "^1.0", + "symfony/http-client-contracts": "^1.1.10|^2", + "symfony/polyfill-php73": "^1.11", + "symfony/service-contracts": "^1.0|^2" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "1.1|2.0" + }, + "require-dev": { + "guzzlehttp/promises": "^1.4", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/dependency-injection": "^4.3|^5.0", + "symfony/http-kernel": "^4.4.13", + "symfony/process": "^4.2|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-client/tree/v4.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-03-25T17:52:07+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.22.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "c6c942b1ac76c82448322025e084cadc56048b4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/c6c942b1ac76c82448322025e084cadc56048b4e", + "reference": "c6c942b1ac76c82448322025e084cadc56048b4e", + "shasum": "" + }, + "require": { + "php": ">=7.1" }, "suggest": { "ext-ctype": "For best performance" @@ -1990,7 +2624,11 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.14-dev" + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" } }, "autoload": { @@ -2023,24 +2661,41 @@ "polyfill", "portable" ], - "time": "2020-01-13T11:15:53+00:00" + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-07T16:49:33+00:00" }, { "name": "symfony/polyfill-iconv", - "version": "v1.14.0", + "version": "v1.22.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-iconv.git", - "reference": "926832ce51059bb58211b7b2080a88e0c3b5328e" + "reference": "06fb361659649bcfd6a208a0f1fcaf4e827ad342" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/926832ce51059bb58211b7b2080a88e0c3b5328e", - "reference": "926832ce51059bb58211b7b2080a88e0c3b5328e", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/06fb361659649bcfd6a208a0f1fcaf4e827ad342", + "reference": "06fb361659649bcfd6a208a0f1fcaf4e827ad342", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1" }, "suggest": { "ext-iconv": "For best performance" @@ -2048,7 +2703,11 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.14-dev" + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" } }, "autoload": { @@ -2082,24 +2741,41 @@ "portable", "shim" ], - "time": "2020-01-13T11:15:53+00:00" + "support": { + "source": "https://github.com/symfony/polyfill-iconv/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-22T09:19:47+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.14.0", + "version": "v1.22.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "34094cfa9abe1f0f14f48f490772db7a775559f2" + "reference": "5232de97ee3b75b0360528dae24e73db49566ab1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/34094cfa9abe1f0f14f48f490772db7a775559f2", - "reference": "34094cfa9abe1f0f14f48f490772db7a775559f2", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/5232de97ee3b75b0360528dae24e73db49566ab1", + "reference": "5232de97ee3b75b0360528dae24e73db49566ab1", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1" }, "suggest": { "ext-mbstring": "For best performance" @@ -2107,7 +2783,11 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.14-dev" + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" } }, "autoload": { @@ -2141,34 +2821,55 @@ "portable", "shim" ], - "time": "2020-01-13T11:15:53+00:00" + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-22T09:19:47+00:00" }, { - "name": "symfony/polyfill-php72", - "version": "v1.14.0", + "name": "symfony/polyfill-php74", + "version": "v1.22.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "46ecacf4751dd0dc81e4f6bf01dbf9da1dc1dadf" + "url": "https://github.com/symfony/polyfill-php74.git", + "reference": "577e147350331efeb816897e004d85e6e765daaf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/46ecacf4751dd0dc81e4f6bf01dbf9da1dc1dadf", - "reference": "46ecacf4751dd0dc81e4f6bf01dbf9da1dc1dadf", + "url": "https://api.github.com/repos/symfony/polyfill-php74/zipball/577e147350331efeb816897e004d85e6e765daaf", + "reference": "577e147350331efeb816897e004d85e6e765daaf", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.14-dev" + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" } }, "autoload": { "psr-4": { - "Symfony\\Polyfill\\Php72\\": "" + "Symfony\\Polyfill\\Php74\\": "" }, "files": [ "bootstrap.php" @@ -2179,6 +2880,10 @@ "MIT" ], "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -2188,7 +2893,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 7.4+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -2196,34 +2901,55 @@ "portable", "shim" ], - "time": "2020-01-13T11:15:53+00:00" + "support": { + "source": "https://github.com/symfony/polyfill-php74/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-07T16:49:33+00:00" }, { - "name": "symfony/polyfill-php73", - "version": "v1.14.0", + "name": "symfony/polyfill-php80", + "version": "v1.22.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "5e66a0fa1070bf46bec4bea7962d285108edd675" + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/5e66a0fa1070bf46bec4bea7962d285108edd675", - "reference": "5e66a0fa1070bf46bec4bea7962d285108edd675", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dc3063ba22c2a1fd2f45ed856374d79114998f91", + "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.14-dev" + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" } }, "autoload": { "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" + "Symfony\\Polyfill\\Php80\\": "" }, "files": [ "bootstrap.php" @@ -2237,6 +2963,10 @@ "MIT" ], "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -2246,7 +2976,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -2254,31 +2984,43 @@ "portable", "shim" ], - "time": "2020-01-13T11:15:53+00:00" + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-07T16:49:33+00:00" }, { "name": "symfony/process", - "version": "v4.2.12", + "version": "v4.4.20", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "808a4be7e0dd7fcb6a2b1ed2ba22dd581402c5e2" + "reference": "7e950b6366d4da90292c2e7fa820b3c1842b965a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/808a4be7e0dd7fcb6a2b1ed2ba22dd581402c5e2", - "reference": "808a4be7e0dd7fcb6a2b1ed2ba22dd581402c5e2", + "url": "https://api.github.com/repos/symfony/process/zipball/7e950b6366d4da90292c2e7fa820b3c1842b965a", + "reference": "7e950b6366d4da90292c2e7fa820b3c1842b965a", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": ">=7.1.3" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.2-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Process\\": "" @@ -2301,28 +3043,46 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Process Component", + "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", - "time": "2019-05-30T16:06:08+00:00" + "support": { + "source": "https://github.com/symfony/process/tree/v4.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-27T09:09:26+00:00" }, { "name": "symfony/var-dumper", - "version": "v4.2.12", + "version": "v4.4.21", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "4e18e041a477edbb8c54e053f179672f9413816c" + "reference": "0da0e174f728996f5d5072d6a9f0a42259dbc806" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/4e18e041a477edbb8c54e053f179672f9413816c", - "reference": "4e18e041a477edbb8c54e053f179672f9413816c", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0da0e174f728996f5d5072d6a9f0a42259dbc806", + "reference": "0da0e174f728996f5d5072d6a9f0a42259dbc806", "shasum": "" }, "require": { - "php": "^7.1.3", + "php": ">=7.1.3", "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php72": "~1.5" + "symfony/polyfill-php72": "~1.5", + "symfony/polyfill-php80": "^1.15" }, "conflict": { "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0", @@ -2330,9 +3090,9 @@ }, "require-dev": { "ext-iconv": "*", - "symfony/console": "~3.4|~4.0", - "symfony/process": "~3.4|~4.0", - "twig/twig": "~1.34|~2.4" + "symfony/console": "^3.4|^4.0|^5.0", + "symfony/process": "^4.4|^5.0", + "twig/twig": "^1.43|^2.13|^3.0.4" }, "suggest": { "ext-iconv": "To convert non-UTF-8 strings to UTF-8 (or symfony/polyfill-iconv in case ext-iconv cannot be used).", @@ -2343,11 +3103,6 @@ "Resources/bin/var-dump-server" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.2-dev" - } - }, "autoload": { "files": [ "Resources/functions/dump.php" @@ -2373,47 +3128,59 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony mechanism for exploring and dumping PHP variables", + "description": "Provides mechanisms for walking through any arbitrary PHP variable", "homepage": "https://symfony.com", "keywords": [ "debug", "dump" ], - "time": "2019-07-27T06:42:33+00:00" + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v4.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-03-27T19:49:03+00:00" }, { "name": "symfony/yaml", - "version": "v4.2.12", + "version": "v4.4.21", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "9468fef6f1c740b96935e9578560a9e9189ca154" + "reference": "3871c720871029f008928244e56cf43497da7e9d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/9468fef6f1c740b96935e9578560a9e9189ca154", - "reference": "9468fef6f1c740b96935e9578560a9e9189ca154", + "url": "https://api.github.com/repos/symfony/yaml/zipball/3871c720871029f008928244e56cf43497da7e9d", + "reference": "3871c720871029f008928244e56cf43497da7e9d", "shasum": "" }, "require": { - "php": "^7.1.3", + "php": ">=7.1.3", "symfony/polyfill-ctype": "~1.8" }, "conflict": { "symfony/console": "<3.4" }, "require-dev": { - "symfony/console": "~3.4|~4.0" + "symfony/console": "^3.4|^4.0|^5.0" }, "suggest": { "symfony/console": "For validating YAML files using the lint command" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.2-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Yaml\\": "" @@ -2436,36 +3203,53 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Yaml Component", + "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", - "time": "2019-07-24T14:47:26+00:00" + "support": { + "source": "https://github.com/symfony/yaml/tree/v4.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-03-05T17:58:50+00:00" }, { "name": "twig/twig", - "version": "v1.42.5", + "version": "v1.44.2", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "87b2ea9d8f6fd014d0621ca089bb1b3769ea3f8e" + "reference": "138c493c5b8ee7cff3821f80b8896d371366b5fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/87b2ea9d8f6fd014d0621ca089bb1b3769ea3f8e", - "reference": "87b2ea9d8f6fd014d0621ca089bb1b3769ea3f8e", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/138c493c5b8ee7cff3821f80b8896d371366b5fe", + "reference": "138c493c5b8ee7cff3821f80b8896d371366b5fe", "shasum": "" }, "require": { - "php": ">=5.5.0", + "php": ">=7.2.5", "symfony/polyfill-ctype": "^1.8" }, "require-dev": { "psr/container": "^1.0", - "symfony/phpunit-bridge": "^4.4|^5.0" + "symfony/phpunit-bridge": "^4.4.9|^5.0.9" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.42-dev" + "dev-master": "1.44-dev" } }, "autoload": { @@ -2502,32 +3286,46 @@ "keywords": [ "templating" ], - "time": "2020-02-11T05:59:23+00:00" + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v1.44.2" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2021-01-05T10:10:05+00:00" }, { "name": "willdurand/negotiation", - "version": "2.x-dev", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/willdurand/Negotiation.git", - "reference": "cf78c9ac47e8e1e141bf609c71e9e2c3a7780dcf" + "reference": "04e14f38d4edfcc974114a07d2777d90c98f3d9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/willdurand/Negotiation/zipball/cf78c9ac47e8e1e141bf609c71e9e2c3a7780dcf", - "reference": "cf78c9ac47e8e1e141bf609c71e9e2c3a7780dcf", + "url": "https://api.github.com/repos/willdurand/Negotiation/zipball/04e14f38d4edfcc974114a07d2777d90c98f3d9c", + "reference": "04e14f38d4edfcc974114a07d2777d90c98f3d9c", "shasum": "" }, "require": { - "php": ">=5.4.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "~4.5" + "symfony/phpunit-bridge": "^5.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.3-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -2554,31 +3352,36 @@ "header", "negotiation" ], - "time": "2017-08-04T15:54:30+00:00" + "support": { + "issues": "https://github.com/willdurand/Negotiation/issues", + "source": "https://github.com/willdurand/Negotiation/tree/3.0.0" + }, + "time": "2020-09-25T08:01:41+00:00" } ], "packages-dev": [ { "name": "behat/gherkin", - "version": "v4.6.2", + "version": "v4.8.0", "source": { "type": "git", "url": "https://github.com/Behat/Gherkin.git", - "reference": "51ac4500c4dc30cbaaabcd2f25694299df666a31" + "reference": "2391482cd003dfdc36b679b27e9f5326bd656acd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Behat/Gherkin/zipball/51ac4500c4dc30cbaaabcd2f25694299df666a31", - "reference": "51ac4500c4dc30cbaaabcd2f25694299df666a31", + "url": "https://api.github.com/repos/Behat/Gherkin/zipball/2391482cd003dfdc36b679b27e9f5326bd656acd", + "reference": "2391482cd003dfdc36b679b27e9f5326bd656acd", "shasum": "" }, "require": { - "php": ">=5.3.1" + "php": "~7.2|~8.0" }, "require-dev": { - "phpunit/phpunit": "~4.5|~5", - "symfony/phpunit-bridge": "~2.7|~3|~4", - "symfony/yaml": "~2.3|~3|~4" + "cucumber/cucumber": "dev-gherkin-16.0.0", + "phpunit/phpunit": "~8|~9", + "symfony/phpunit-bridge": "~3|~4|~5", + "symfony/yaml": "~3|~4|~5" }, "suggest": { "symfony/yaml": "If you want to parse features, represented in YAML files" @@ -2605,7 +3408,7 @@ "homepage": "http://everzet.com" } ], - "description": "Gherkin DSL parser for PHP 5.3", + "description": "Gherkin DSL parser for PHP", "homepage": "http://behat.org/", "keywords": [ "BDD", @@ -2615,62 +3418,59 @@ "gherkin", "parser" ], - "time": "2020-03-17T14:03:26+00:00" + "support": { + "issues": "https://github.com/Behat/Gherkin/issues", + "source": "https://github.com/Behat/Gherkin/tree/v4.8.0" + }, + "time": "2021-02-04T12:44:21+00:00" }, { "name": "codeception/codeception", - "version": "2.5.6", + "version": "4.1.20", "source": { "type": "git", "url": "https://github.com/Codeception/Codeception.git", - "reference": "b83a9338296e706fab2ceb49de8a352fbca3dc98" + "reference": "d8b16e13e1781dbc3a7ae8292117d520c89a9c5a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/Codeception/zipball/b83a9338296e706fab2ceb49de8a352fbca3dc98", - "reference": "b83a9338296e706fab2ceb49de8a352fbca3dc98", + "url": "https://api.github.com/repos/Codeception/Codeception/zipball/d8b16e13e1781dbc3a7ae8292117d520c89a9c5a", + "reference": "d8b16e13e1781dbc3a7ae8292117d520c89a9c5a", "shasum": "" }, "require": { "behat/gherkin": "^4.4.0", - "codeception/phpunit-wrapper": "^6.0.9|^7.0.6", - "codeception/stub": "^2.0", + "codeception/lib-asserts": "^1.0", + "codeception/phpunit-wrapper": ">6.0.15 <6.1.0 | ^6.6.1 | ^7.7.1 | ^8.1.1 | ^9.0", + "codeception/stub": "^2.0 | ^3.0", "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", - "facebook/webdriver": ">=1.1.3 <2.0", - "guzzlehttp/guzzle": ">=4.1.4 <7.0", - "guzzlehttp/psr7": "~1.0", - "php": ">=5.6.0 <8.0", - "symfony/browser-kit": ">=2.7 <5.0", - "symfony/console": ">=2.7 <5.0", - "symfony/css-selector": ">=2.7 <5.0", - "symfony/dom-crawler": ">=2.7 <5.0", - "symfony/event-dispatcher": ">=2.7 <5.0", - "symfony/finder": ">=2.7 <5.0", - "symfony/yaml": ">=2.7 <5.0" + "guzzlehttp/psr7": "~1.4", + "php": ">=5.6.0 <9.0", + "symfony/console": ">=2.7 <6.0", + "symfony/css-selector": ">=2.7 <6.0", + "symfony/event-dispatcher": ">=2.7 <6.0", + "symfony/finder": ">=2.7 <6.0", + "symfony/yaml": ">=2.7 <6.0" }, "require-dev": { + "codeception/module-asserts": "*@dev", + "codeception/module-cli": "*@dev", + "codeception/module-db": "*@dev", + "codeception/module-filesystem": "*@dev", + "codeception/module-phpbrowser": "*@dev", "codeception/specify": "~0.3", - "facebook/graph-sdk": "~5.3", - "flow/jsonpath": "~0.2", + "codeception/util-universalframework": "*@dev", "monolog/monolog": "~1.8", - "pda/pheanstalk": "~3.0", - "php-amqplib/php-amqplib": "~2.4", - "predis/predis": "^1.0", "squizlabs/php_codesniffer": "~2.0", - "symfony/process": ">=2.7 <5.0", - "vlucas/phpdotenv": "^3.0" + "symfony/process": ">=2.7 <6.0", + "vlucas/phpdotenv": "^2.0 | ^3.0 | ^4.0 | ^5.0" }, "suggest": { - "aws/aws-sdk-php": "For using AWS Auth in REST module and Queue module", - "codeception/phpbuiltinserver": "Start and stop PHP built-in web server for your tests", "codeception/specify": "BDD-style code blocks", "codeception/verify": "BDD-style assertions", - "flow/jsonpath": "For using JSONPath in REST module", - "league/factory-muffin": "For DataFactory module", - "league/factory-muffin-faker": "For Faker support in DataFactory module", - "phpseclib/phpseclib": "for SFTP option in FTP Module", + "hoa/console": "For interactive console functionality", "stecman/symfony-console-completion": "For BASH autocompletion", "symfony/phpunit-bridge": "For phpunit-bridge support" }, @@ -2707,36 +3507,276 @@ "functional testing", "unit testing" ], - "time": "2019-04-24T11:28:19+00:00" + "support": { + "issues": "https://github.com/Codeception/Codeception/issues", + "source": "https://github.com/Codeception/Codeception/tree/4.1.20" + }, + "funding": [ + { + "url": "https://opencollective.com/codeception", + "type": "open_collective" + } + ], + "time": "2021-04-02T16:41:51+00:00" }, { - "name": "codeception/phpunit-wrapper", - "version": "7.8.0", + "name": "codeception/lib-asserts", + "version": "1.13.2", "source": { "type": "git", - "url": "https://github.com/Codeception/phpunit-wrapper.git", - "reference": "bc847bd4f8f6d09012543e2a856f19fe4ecdcf3a" + "url": "https://github.com/Codeception/lib-asserts.git", + "reference": "184231d5eab66bc69afd6b9429344d80c67a33b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/phpunit-wrapper/zipball/bc847bd4f8f6d09012543e2a856f19fe4ecdcf3a", - "reference": "bc847bd4f8f6d09012543e2a856f19fe4ecdcf3a", + "url": "https://api.github.com/repos/Codeception/lib-asserts/zipball/184231d5eab66bc69afd6b9429344d80c67a33b6", + "reference": "184231d5eab66bc69afd6b9429344d80c67a33b6", "shasum": "" }, "require": { - "phpunit/php-code-coverage": "^6.0", - "phpunit/phpunit": "7.5.*", - "sebastian/comparator": "^3.0", - "sebastian/diff": "^3.0" + "codeception/phpunit-wrapper": ">6.0.15 <6.1.0 | ^6.6.1 | ^7.7.1 | ^8.0.3 | ^9.0", + "ext-dom": "*", + "php": ">=5.6.0 <9.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Bodnarchuk", + "email": "davert@mail.ua", + "homepage": "http://codegyre.com" + }, + { + "name": "Gintautas Miselis" + }, + { + "name": "Gustavo Nieves", + "homepage": "https://medium.com/@ganieves" + } + ], + "description": "Assertion methods used by Codeception core and Asserts module", + "homepage": "https://codeception.com/", + "keywords": [ + "codeception" + ], + "support": { + "issues": "https://github.com/Codeception/lib-asserts/issues", + "source": "https://github.com/Codeception/lib-asserts/tree/1.13.2" + }, + "time": "2020-10-21T16:26:20+00:00" + }, + { + "name": "codeception/lib-innerbrowser", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/Codeception/lib-innerbrowser.git", + "reference": "4b0d89b37fe454e060a610a85280a87ab4f534f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Codeception/lib-innerbrowser/zipball/4b0d89b37fe454e060a610a85280a87ab4f534f1", + "reference": "4b0d89b37fe454e060a610a85280a87ab4f534f1", + "shasum": "" + }, + "require": { + "codeception/codeception": "*@dev", + "ext-dom": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0 <9.0", + "symfony/browser-kit": ">=2.7 <6.0", + "symfony/dom-crawler": ">=2.7 <6.0" + }, + "conflict": { + "codeception/codeception": "<4.0" + }, + "require-dev": { + "codeception/util-universalframework": "dev-master" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Bodnarchuk", + "email": "davert@mail.ua", + "homepage": "http://codegyre.com" + }, + { + "name": "Gintautas Miselis" + } + ], + "description": "Parent library for all Codeception framework modules and PhpBrowser", + "homepage": "https://codeception.com/", + "keywords": [ + "codeception" + ], + "support": { + "issues": "https://github.com/Codeception/lib-innerbrowser/issues", + "source": "https://github.com/Codeception/lib-innerbrowser/tree/1.5.0" + }, + "time": "2021-04-23T06:18:29+00:00" + }, + { + "name": "codeception/module-asserts", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/Codeception/module-asserts.git", + "reference": "59374f2fef0cabb9e8ddb53277e85cdca74328de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Codeception/module-asserts/zipball/59374f2fef0cabb9e8ddb53277e85cdca74328de", + "reference": "59374f2fef0cabb9e8ddb53277e85cdca74328de", + "shasum": "" + }, + "require": { + "codeception/codeception": "*@dev", + "codeception/lib-asserts": "^1.13.1", + "php": ">=5.6.0 <9.0" + }, + "conflict": { + "codeception/codeception": "<4.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Bodnarchuk" + }, + { + "name": "Gintautas Miselis" + }, + { + "name": "Gustavo Nieves", + "homepage": "https://medium.com/@ganieves" + } + ], + "description": "Codeception module containing various assertions", + "homepage": "https://codeception.com/", + "keywords": [ + "assertions", + "asserts", + "codeception" + ], + "support": { + "issues": "https://github.com/Codeception/module-asserts/issues", + "source": "https://github.com/Codeception/module-asserts/tree/1.3.1" + }, + "time": "2020-10-21T16:48:15+00:00" + }, + { + "name": "codeception/module-phpbrowser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/Codeception/module-phpbrowser.git", + "reference": "770a6be4160a5c0c08d100dd51bff35f6056bbf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Codeception/module-phpbrowser/zipball/770a6be4160a5c0c08d100dd51bff35f6056bbf1", + "reference": "770a6be4160a5c0c08d100dd51bff35f6056bbf1", + "shasum": "" + }, + "require": { + "codeception/codeception": "^4.0", + "codeception/lib-innerbrowser": "^1.3", + "guzzlehttp/guzzle": "^6.3|^7.0", + "php": ">=5.6.0 <9.0" + }, + "conflict": { + "codeception/codeception": "<4.0" + }, + "require-dev": { + "codeception/module-rest": "^1.0" + }, + "suggest": { + "codeception/phpbuiltinserver": "Start and stop PHP built-in web server for your tests" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Bodnarchuk" + }, + { + "name": "Gintautas Miselis" + } + ], + "description": "Codeception module for testing web application over HTTP", + "homepage": "http://codeception.com/", + "keywords": [ + "codeception", + "functional-testing", + "http" + ], + "support": { + "issues": "https://github.com/Codeception/module-phpbrowser/issues", + "source": "https://github.com/Codeception/module-phpbrowser/tree/1.0.2" + }, + "time": "2020-10-24T15:29:28+00:00" + }, + { + "name": "codeception/phpunit-wrapper", + "version": "9.0.6", + "source": { + "type": "git", + "url": "https://github.com/Codeception/phpunit-wrapper.git", + "reference": "b0c06abb3181eedca690170f7ed0fd26a70bfacc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Codeception/phpunit-wrapper/zipball/b0c06abb3181eedca690170f7ed0fd26a70bfacc", + "reference": "b0c06abb3181eedca690170f7ed0fd26a70bfacc", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "phpunit/phpunit": "^9.0" }, "require-dev": { "codeception/specify": "*", + "consolidation/robo": "^3.0.0-alpha3", "vlucas/phpdotenv": "^3.0" }, "type": "library", "autoload": { "psr-4": { - "Codeception\\PHPUnit\\": "src\\" + "Codeception\\PHPUnit\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2747,27 +3787,34 @@ { "name": "Davert", "email": "davert.php@resend.cc" + }, + { + "name": "Naktibalda" } ], "description": "PHPUnit classes used by Codeception", - "time": "2019-12-23T06:55:58+00:00" + "support": { + "issues": "https://github.com/Codeception/phpunit-wrapper/issues", + "source": "https://github.com/Codeception/phpunit-wrapper/tree/9.0.6" + }, + "time": "2020-12-28T13:59:47+00:00" }, { "name": "codeception/stub", - "version": "2.1.0", + "version": "3.7.0", "source": { "type": "git", "url": "https://github.com/Codeception/Stub.git", - "reference": "853657f988942f7afb69becf3fd0059f192c705a" + "reference": "468dd5fe659f131fc997f5196aad87512f9b1304" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/Stub/zipball/853657f988942f7afb69becf3fd0059f192c705a", - "reference": "853657f988942f7afb69becf3fd0059f192c705a", + "url": "https://api.github.com/repos/Codeception/Stub/zipball/468dd5fe659f131fc997f5196aad87512f9b1304", + "reference": "468dd5fe659f131fc997f5196aad87512f9b1304", "shasum": "" }, "require": { - "codeception/phpunit-wrapper": ">6.0.15 <6.1.0 | ^6.6.1 | ^7.7.1 | ^8.0.3" + "phpunit/phpunit": "^8.4 | ^9.0" }, "type": "library", "autoload": { @@ -2780,90 +3827,39 @@ "MIT" ], "description": "Flexible Stub wrapper for PHPUnit's Mock Builder", - "time": "2019-03-02T15:35:10+00:00" - }, - { - "name": "composer/xdebug-handler", - "version": "1.4.1", - "source": { - "type": "git", - "url": "https://github.com/composer/xdebug-handler.git", - "reference": "1ab9842d69e64fb3a01be6b656501032d1b78cb7" + "support": { + "issues": "https://github.com/Codeception/Stub/issues", + "source": "https://github.com/Codeception/Stub/tree/3.7.0" }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/1ab9842d69e64fb3a01be6b656501032d1b78cb7", - "reference": "1ab9842d69e64fb3a01be6b656501032d1b78cb7", - "shasum": "" - }, - "require": { - "php": "^5.3.2 || ^7.0 || ^8.0", - "psr/log": "^1.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 8" - }, - "type": "library", - "autoload": { - "psr-4": { - "Composer\\XdebugHandler\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "John Stevenson", - "email": "john-stevenson@blueyonder.co.uk" - } - ], - "description": "Restarts a process without Xdebug.", - "keywords": [ - "Xdebug", - "performance" - ], - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - } - ], - "time": "2020-03-01T12:26:26+00:00" + "time": "2020-07-03T15:54:43+00:00" }, { "name": "doctrine/instantiator", - "version": "1.3.0", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "ae466f726242e637cebdd526a7d991b9433bacf1" + "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/ae466f726242e637cebdd526a7d991b9433bacf1", - "reference": "ae466f726242e637cebdd526a7d991b9433bacf1", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/d56bf6102915de5702778fe20f2de3b2fe570b5b", + "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b", "shasum": "" }, "require": { - "php": "^7.1" + "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^6.0", + "doctrine/coding-standard": "^8.0", "ext-pdo": "*", "ext-phar": "*", - "phpbench/phpbench": "^0.13", - "phpstan/phpstan-phpunit": "^0.11", - "phpstan/phpstan-shim": "^0.11", - "phpunit/phpunit": "^7.0" + "phpbench/phpbench": "^0.13 || 1.0.0-alpha2", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.2.x-dev" - } - }, "autoload": { "psr-4": { "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" @@ -2877,7 +3873,7 @@ { "name": "Marco Pivetta", "email": "ocramius@gmail.com", - "homepage": "http://ocramius.github.com/" + "homepage": "https://ocramius.github.io/" } ], "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", @@ -2886,100 +3882,54 @@ "constructor", "instantiate" ], - "time": "2019-10-21T16:45:58+00:00" + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.4.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2020-11-10T18:47:58+00:00" }, { - "name": "facebook/webdriver", - "version": "1.7.1", + "name": "getgrav/markdowndocs", + "version": "2.0.1", "source": { "type": "git", - "url": "https://github.com/php-webdriver/php-webdriver-archive.git", - "reference": "e43de70f3c7166169d0f14a374505392734160e5" + "url": "https://github.com/getgrav/PHP-Markdown-Documentation-Generator.git", + "reference": "4a24d1b64a88da17e8f1696dc64969f5ca769064" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-webdriver/php-webdriver-archive/zipball/e43de70f3c7166169d0f14a374505392734160e5", - "reference": "e43de70f3c7166169d0f14a374505392734160e5", + "url": "https://api.github.com/repos/getgrav/PHP-Markdown-Documentation-Generator/zipball/4a24d1b64a88da17e8f1696dc64969f5ca769064", + "reference": "4a24d1b64a88da17e8f1696dc64969f5ca769064", "shasum": "" }, "require": { - "ext-curl": "*", - "ext-json": "*", - "ext-mbstring": "*", - "ext-zip": "*", - "php": "^5.6 || ~7.0", - "symfony/process": "^2.8 || ^3.1 || ^4.0" + "php": ">=5.5.0", + "symfony/console": ">=2.6" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.0", - "jakub-onderka/php-parallel-lint": "^0.9.2", - "php-coveralls/php-coveralls": "^2.0", - "php-mock/php-mock-phpunit": "^1.1", - "phpunit/phpunit": "^5.7", - "sebastian/environment": "^1.3.4 || ^2.0 || ^3.0", - "squizlabs/php_codesniffer": "^2.6", - "symfony/var-dumper": "^3.3 || ^4.0" + "phpunit/phpunit": "3.7.23" }, - "suggest": { - "ext-SimpleXML": "For Firefox profile creation" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-community": "1.5-dev" - } - }, - "autoload": { - "psr-4": { - "Facebook\\WebDriver\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" + "bin": [ + "bin/phpdoc-md" ], - "description": "A PHP client for Selenium WebDriver", - "homepage": "https://github.com/facebook/php-webdriver", - "keywords": [ - "facebook", - "php", - "selenium", - "webdriver" - ], - "abandoned": "php-webdriver/webdriver", - "time": "2019-06-13T08:02:18+00:00" - }, - { - "name": "fzaninotto/faker", - "version": "v1.9.1", - "source": { - "type": "git", - "url": "https://github.com/fzaninotto/Faker.git", - "reference": "fc10d778e4b84d5bd315dad194661e091d307c6f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/fzaninotto/Faker/zipball/fc10d778e4b84d5bd315dad194661e091d307c6f", - "reference": "fc10d778e4b84d5bd315dad194661e091d307c6f", - "shasum": "" - }, - "require": { - "php": "^5.3.3 || ^7.0" - }, - "require-dev": { - "ext-intl": "*", - "phpunit/phpunit": "^4.8.35 || ^5.7", - "squizlabs/php_codesniffer": "^2.9.2" - }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.9-dev" - } - }, "autoload": { - "psr-4": { - "Faker\\": "src/Faker/" + "psr-0": { + "PHPDocsMD": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2988,50 +3938,62 @@ ], "authors": [ { - "name": "François Zaninotto" + "name": "Victor Jonsson", + "email": "kontakt@victorjonsson.se" + }, + { + "name": "Grav CMS", + "email": "hello@getgrav.org", + "homepage": "https://getgrav.org" } ], - "description": "Faker is a PHP library that generates fake data for you.", - "keywords": [ - "data", - "faker", - "fixtures" - ], - "time": "2019-12-12T13:22:17+00:00" + "description": "Command line tool for generating markdown-formatted class documentation", + "homepage": "https://github.com/victorjonsson/PHP-Markdown-Documentation-Generator", + "support": { + "source": "https://github.com/getgrav/PHP-Markdown-Documentation-Generator/tree/2.0.1" + }, + "time": "2021-04-20T06:04:42+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "6.5.2", + "version": "7.3.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "43ece0e75098b7ecd8d13918293029e555a50f82" + "reference": "7008573787b430c1c1f650e3722d9bba59967628" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/43ece0e75098b7ecd8d13918293029e555a50f82", - "reference": "43ece0e75098b7ecd8d13918293029e555a50f82", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7008573787b430c1c1f650e3722d9bba59967628", + "reference": "7008573787b430c1c1f650e3722d9bba59967628", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.0", - "guzzlehttp/psr7": "^1.6.1", - "php": ">=5.5" + "guzzlehttp/promises": "^1.4", + "guzzlehttp/psr7": "^1.7 || ^2.0", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" }, "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", "ext-curl": "*", - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0", + "php-http/client-integration-tests": "^3.0", + "phpunit/phpunit": "^8.5.5 || ^9.3.5", "psr/log": "^1.1" }, "suggest": { + "ext-curl": "Required for CURL handler support", "ext-intl": "Required for Internationalized Domain Name (IDN) support", "psr/log": "Required for using the Log middleware" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "6.5-dev" + "dev-master": "7.3-dev" } }, "autoload": { @@ -3051,6 +4013,11 @@ "name": "Michael Dowling", "email": "mtdowling@gmail.com", "homepage": "https://github.com/mtdowling" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" } ], "description": "Guzzle is a PHP HTTP client library", @@ -3061,30 +4028,54 @@ "framework", "http", "http client", + "psr-18", + "psr-7", "rest", "web service" ], - "time": "2019-12-23T11:57:10+00:00" + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://github.com/alexeyshockov", + "type": "github" + }, + { + "url": "https://github.com/gmponos", + "type": "github" + } + ], + "time": "2021-03-23T11:33:13+00:00" }, { "name": "guzzlehttp/promises", - "version": "v1.3.1", + "version": "1.4.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646" + "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646", - "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646", + "url": "https://api.github.com/repos/guzzle/promises/zipball/8e7d04f1f6450fef59366c399cfad4b9383aa30d", + "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d", "shasum": "" }, "require": { - "php": ">=5.5.0" + "php": ">=5.5" }, "require-dev": { - "phpunit/phpunit": "^4.0" + "symfony/phpunit-bridge": "^4.4 || ^5.1" }, "type": "library", "extra": { @@ -3115,75 +4106,28 @@ "keywords": [ "promise" ], - "time": "2016-12-20T10:07:11+00:00" - }, - { - "name": "jean85/pretty-package-versions", - "version": "1.2", - "source": { - "type": "git", - "url": "https://github.com/Jean85/pretty-package-versions.git", - "reference": "75c7effcf3f77501d0e0caa75111aff4daa0dd48" + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/1.4.1" }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/75c7effcf3f77501d0e0caa75111aff4daa0dd48", - "reference": "75c7effcf3f77501d0e0caa75111aff4daa0dd48", - "shasum": "" - }, - "require": { - "ocramius/package-versions": "^1.2.0", - "php": "^7.0" - }, - "require-dev": { - "phpunit/phpunit": "^6.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Jean85\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Alessandro Lai", - "email": "alessandro.lai85@gmail.com" - } - ], - "description": "A wrapper for ocramius/package-versions to get pretty versions strings", - "keywords": [ - "composer", - "package", - "release", - "versions" - ], - "time": "2018-06-13T13:22:40+00:00" + "time": "2021-03-07T09:25:29+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.9.5", + "version": "1.10.2", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "b2c28789e80a97badd14145fda39b545d83ca3ef" + "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/b2c28789e80a97badd14145fda39b545d83ca3ef", - "reference": "b2c28789e80a97badd14145fda39b545d83ca3ef", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/776f831124e9c62e1a2c601ecc52e776d8bb7220", + "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220", "shasum": "" }, "require": { - "php": "^7.1" + "php": "^7.1 || ^8.0" }, "replace": { "myclabs/deep-copy": "self.version" @@ -3214,549 +4158,30 @@ "object", "object graph" ], - "time": "2020-01-17T21:11:47+00:00" - }, - { - "name": "nette/bootstrap", - "version": "v3.0.1", - "source": { - "type": "git", - "url": "https://github.com/nette/bootstrap.git", - "reference": "b45a1e33b6a44beb307756522396551e5a9ff249" + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.10.2" }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/bootstrap/zipball/b45a1e33b6a44beb307756522396551e5a9ff249", - "reference": "b45a1e33b6a44beb307756522396551e5a9ff249", - "shasum": "" - }, - "require": { - "nette/di": "^3.0", - "nette/utils": "^3.0", - "php": ">=7.1" - }, - "conflict": { - "tracy/tracy": "<2.6" - }, - "require-dev": { - "latte/latte": "^2.2", - "nette/application": "^3.0", - "nette/caching": "^3.0", - "nette/database": "^3.0", - "nette/forms": "^3.0", - "nette/http": "^3.0", - "nette/mail": "^3.0", - "nette/robot-loader": "^3.0", - "nette/safe-stream": "^2.2", - "nette/security": "^3.0", - "nette/tester": "^2.0", - "tracy/tracy": "^2.6" - }, - "suggest": { - "nette/robot-loader": "to use Configurator::createRobotLoader()", - "tracy/tracy": "to use Configurator::enableTracy()" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0", - "GPL-3.0" - ], - "authors": [ + "funding": [ { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" } ], - "description": "🅱 Nette Bootstrap: the simple way to configure and bootstrap your Nette application.", - "homepage": "https://nette.org", - "keywords": [ - "bootstrapping", - "configurator", - "nette" - ], - "time": "2019-09-30T08:19:38+00:00" - }, - { - "name": "nette/di", - "version": "v3.0.3", - "source": { - "type": "git", - "url": "https://github.com/nette/di.git", - "reference": "77d69061cbf8f9cfb7363dd983136f51213d3e41" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/di/zipball/77d69061cbf8f9cfb7363dd983136f51213d3e41", - "reference": "77d69061cbf8f9cfb7363dd983136f51213d3e41", - "shasum": "" - }, - "require": { - "ext-tokenizer": "*", - "nette/neon": "^3.0", - "nette/php-generator": "^3.3.3", - "nette/robot-loader": "^3.2", - "nette/schema": "^1.0", - "nette/utils": "^3.1", - "php": ">=7.1" - }, - "conflict": { - "nette/bootstrap": "<3.0" - }, - "require-dev": { - "nette/tester": "^2.2", - "phpstan/phpstan": "^0.12", - "tracy/tracy": "^2.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0-only", - "GPL-3.0-only" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "💎 Nette Dependency Injection Container: Flexible, compiled and full-featured DIC with perfectly usable autowiring and support for all new PHP 7.1 features.", - "homepage": "https://nette.org", - "keywords": [ - "compiled", - "di", - "dic", - "factory", - "ioc", - "nette", - "static" - ], - "time": "2020-01-20T12:14:54+00:00" - }, - { - "name": "nette/finder", - "version": "v2.5.2", - "source": { - "type": "git", - "url": "https://github.com/nette/finder.git", - "reference": "4ad2c298eb8c687dd0e74ae84206a4186eeaed50" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/finder/zipball/4ad2c298eb8c687dd0e74ae84206a4186eeaed50", - "reference": "4ad2c298eb8c687dd0e74ae84206a4186eeaed50", - "shasum": "" - }, - "require": { - "nette/utils": "^2.4 || ^3.0", - "php": ">=7.1" - }, - "conflict": { - "nette/nette": "<2.2" - }, - "require-dev": { - "nette/tester": "^2.0", - "phpstan/phpstan": "^0.12", - "tracy/tracy": "^2.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.5-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0", - "GPL-3.0" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "🔍 Nette Finder: find files and directories with an intuitive API.", - "homepage": "https://nette.org", - "keywords": [ - "filesystem", - "glob", - "iterator", - "nette" - ], - "time": "2020-01-03T20:35:40+00:00" - }, - { - "name": "nette/neon", - "version": "v3.1.2", - "source": { - "type": "git", - "url": "https://github.com/nette/neon.git", - "reference": "3c3dcbc6bf6c80dc97b1fc4ba9a22ae67930fc0e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/neon/zipball/3c3dcbc6bf6c80dc97b1fc4ba9a22ae67930fc0e", - "reference": "3c3dcbc6bf6c80dc97b1fc4ba9a22ae67930fc0e", - "shasum": "" - }, - "require": { - "ext-iconv": "*", - "ext-json": "*", - "php": ">=7.1" - }, - "require-dev": { - "nette/tester": "^2.0", - "phpstan/phpstan": "^0.12", - "tracy/tracy": "^2.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.1-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0-only", - "GPL-3.0-only" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "🍸 Nette NEON: encodes and decodes NEON file format.", - "homepage": "https://ne-on.org", - "keywords": [ - "export", - "import", - "neon", - "nette", - "yaml" - ], - "time": "2020-03-04T11:47:04+00:00" - }, - { - "name": "nette/php-generator", - "version": "v3.3.4", - "source": { - "type": "git", - "url": "https://github.com/nette/php-generator.git", - "reference": "8fe7e699dca7db186f56d75800cb1ec32e39c856" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/php-generator/zipball/8fe7e699dca7db186f56d75800cb1ec32e39c856", - "reference": "8fe7e699dca7db186f56d75800cb1ec32e39c856", - "shasum": "" - }, - "require": { - "nette/utils": "^2.4.2 || ^3.0", - "php": ">=7.1" - }, - "require-dev": { - "nette/tester": "^2.0", - "phpstan/phpstan": "^0.12", - "tracy/tracy": "^2.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.3-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0-only", - "GPL-3.0-only" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "🐘 Nette PHP Generator: generates neat PHP code for you. Supports new PHP 7.4 features.", - "homepage": "https://nette.org", - "keywords": [ - "code", - "nette", - "php", - "scaffolding" - ], - "time": "2020-02-09T14:39:09+00:00" - }, - { - "name": "nette/robot-loader", - "version": "v3.2.3", - "source": { - "type": "git", - "url": "https://github.com/nette/robot-loader.git", - "reference": "726c462e73e739e965ec654a667407074cfe83c0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/robot-loader/zipball/726c462e73e739e965ec654a667407074cfe83c0", - "reference": "726c462e73e739e965ec654a667407074cfe83c0", - "shasum": "" - }, - "require": { - "ext-tokenizer": "*", - "nette/finder": "^2.5 || ^3.0", - "nette/utils": "^3.0", - "php": ">=7.1" - }, - "require-dev": { - "nette/tester": "^2.0", - "phpstan/phpstan": "^0.12", - "tracy/tracy": "^2.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.2-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0-only", - "GPL-3.0-only" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "🍀 Nette RobotLoader: high performance and comfortable autoloader that will search and autoload classes within your application.", - "homepage": "https://nette.org", - "keywords": [ - "autoload", - "class", - "interface", - "nette", - "trait" - ], - "time": "2020-02-28T13:10:07+00:00" - }, - { - "name": "nette/schema", - "version": "v1.0.2", - "source": { - "type": "git", - "url": "https://github.com/nette/schema.git", - "reference": "febf71fb4052c824046f5a33f4f769a6e7fa0cb4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/febf71fb4052c824046f5a33f4f769a6e7fa0cb4", - "reference": "febf71fb4052c824046f5a33f4f769a6e7fa0cb4", - "shasum": "" - }, - "require": { - "nette/utils": "^3.1", - "php": ">=7.1" - }, - "require-dev": { - "nette/tester": "^2.2", - "phpstan/phpstan-nette": "^0.12", - "tracy/tracy": "^2.3" - }, - "type": "library", - "extra": { - "branch-alias": [] - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0", - "GPL-3.0" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "📐 Nette Schema: validating data structures against a given Schema.", - "homepage": "https://nette.org", - "keywords": [ - "config", - "nette" - ], - "time": "2020-01-06T22:52:48+00:00" - }, - { - "name": "nette/utils", - "version": "v3.1.1", - "source": { - "type": "git", - "url": "https://github.com/nette/utils.git", - "reference": "2c17d16d8887579ae1c0898ff94a3668997fd3eb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/2c17d16d8887579ae1c0898ff94a3668997fd3eb", - "reference": "2c17d16d8887579ae1c0898ff94a3668997fd3eb", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "require-dev": { - "nette/tester": "~2.0", - "phpstan/phpstan": "^0.12", - "tracy/tracy": "^2.3" - }, - "suggest": { - "ext-gd": "to use Image", - "ext-iconv": "to use Strings::webalize() and toAscii()", - "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", - "ext-json": "to use Nette\\Utils\\Json", - "ext-mbstring": "to use Strings::lower() etc...", - "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()", - "ext-xml": "to use Strings::length() etc. when mbstring is not available" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.1-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause", - "GPL-2.0-only", - "GPL-3.0-only" - ], - "authors": [ - { - "name": "David Grudl", - "homepage": "https://davidgrudl.com" - }, - { - "name": "Nette Community", - "homepage": "https://nette.org/contributors" - } - ], - "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", - "homepage": "https://nette.org", - "keywords": [ - "array", - "core", - "datetime", - "images", - "json", - "nette", - "paginator", - "password", - "slugify", - "string", - "unicode", - "utf-8", - "utility", - "validation" - ], - "time": "2020-02-09T14:10:55+00:00" + "time": "2020-11-13T09:40:50+00:00" }, { "name": "nikic/php-parser", - "version": "v4.3.0", + "version": "v4.10.4", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "9a9981c347c5c49d6dfe5cf826bb882b824080dc" + "reference": "c6d052fc58cb876152f89f532b95a8d7907e7f0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9a9981c347c5c49d6dfe5cf826bb882b824080dc", - "reference": "9a9981c347c5c49d6dfe5cf826bb882b824080dc", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/c6d052fc58cb876152f89f532b95a8d7907e7f0e", + "reference": "c6d052fc58cb876152f89f532b95a8d7907e7f0e", "shasum": "" }, "require": { @@ -3764,8 +4189,8 @@ "php": ">=7.0" }, "require-dev": { - "ircmaxell/php-yacc": "0.0.5", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0" + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" }, "bin": [ "bin/php-parse" @@ -3773,7 +4198,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.3-dev" + "dev-master": "4.9-dev" } }, "autoload": { @@ -3795,82 +4220,37 @@ "parser", "php" ], - "time": "2019-11-08T13:50:10+00:00" - }, - { - "name": "ocramius/package-versions", - "version": "1.4.2", - "source": { - "type": "git", - "url": "https://github.com/Ocramius/PackageVersions.git", - "reference": "44af6f3a2e2e04f2af46bcb302ad9600cba41c7d" + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v4.10.4" }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Ocramius/PackageVersions/zipball/44af6f3a2e2e04f2af46bcb302ad9600cba41c7d", - "reference": "44af6f3a2e2e04f2af46bcb302ad9600cba41c7d", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^1.0.0", - "php": "^7.1.0" - }, - "require-dev": { - "composer/composer": "^1.6.3", - "doctrine/coding-standard": "^5.0.1", - "ext-zip": "*", - "infection/infection": "^0.7.1", - "phpunit/phpunit": "^7.5.17" - }, - "type": "composer-plugin", - "extra": { - "class": "PackageVersions\\Installer", - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "PackageVersions\\": "src/PackageVersions" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com" - } - ], - "description": "Composer plugin that provides efficient querying for installed package versions (no runtime IO)", - "time": "2019-11-15T16:17:10+00:00" + "time": "2020-12-20T10:01:03+00:00" }, { "name": "phar-io/manifest", - "version": "1.0.3", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4" + "reference": "85265efd3af7ba3ca4b2a2c34dbfc5788dd29133" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", - "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/85265efd3af7ba3ca4b2a2c34dbfc5788dd29133", + "reference": "85265efd3af7ba3ca4b2a2c34dbfc5788dd29133", "shasum": "" }, "require": { "ext-dom": "*", "ext-phar": "*", - "phar-io/version": "^2.0", - "php": "^5.6 || ^7.0" + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { @@ -3900,24 +4280,28 @@ } ], "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "time": "2018-07-08T19:23:20+00:00" + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/master" + }, + "time": "2020-06-27T14:33:11+00:00" }, { "name": "phar-io/version", - "version": "2.0.1", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/phar-io/version.git", - "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6" + "reference": "bae7c545bef187884426f042434e561ab1ddb182" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6", - "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "url": "https://api.github.com/repos/phar-io/version/zipball/bae7c545bef187884426f042434e561ab1ddb182", + "reference": "bae7c545bef187884426f042434e561ab1ddb182", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0" + "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { @@ -3947,32 +4331,33 @@ } ], "description": "Library for handling version information and constraints", - "time": "2018-07-08T19:19:57+00:00" + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.1.0" + }, + "time": "2021-02-23T14:00:09+00:00" }, { "name": "phpdocumentor/reflection-common", - "version": "2.0.0", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a" + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/63a995caa1ca9e5590304cd845c15ad6d482a62a", - "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", "shasum": "" }, "require": { - "php": ">=7.1" - }, - "require-dev": { - "phpunit/phpunit": "~6" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.x-dev" + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -3999,45 +4384,45 @@ "reflection", "static analysis" ], - "time": "2018-08-07T13:53:10+00:00" + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" }, { "name": "phpdocumentor/reflection-docblock", - "version": "4.3.4", + "version": "5.2.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "da3fd972d6bafd628114f7e7e036f45944b62e9c" + "reference": "069a785b2141f5bcf49f3e353548dc1cce6df556" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/da3fd972d6bafd628114f7e7e036f45944b62e9c", - "reference": "da3fd972d6bafd628114f7e7e036f45944b62e9c", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/069a785b2141f5bcf49f3e353548dc1cce6df556", + "reference": "069a785b2141f5bcf49f3e353548dc1cce6df556", "shasum": "" }, "require": { - "php": "^7.0", - "phpdocumentor/reflection-common": "^1.0.0 || ^2.0.0", - "phpdocumentor/type-resolver": "~0.4 || ^1.0.0", - "webmozart/assert": "^1.0" + "ext-filter": "*", + "php": "^7.2 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.3", + "webmozart/assert": "^1.9.1" }, "require-dev": { - "doctrine/instantiator": "^1.0.5", - "mockery/mockery": "^1.0", - "phpdocumentor/type-resolver": "0.4.*", - "phpunit/phpunit": "^6.4" + "mockery/mockery": "~1.3.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.x-dev" + "dev-master": "5.x-dev" } }, "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": [ - "src/" - ] + "phpDocumentor\\Reflection\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -4048,38 +4433,44 @@ { "name": "Mike van Riel", "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "account@ijaap.nl" } ], "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "time": "2019-12-28T18:55:12+00:00" + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/master" + }, + "time": "2020-09-03T19:13:55+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.0.1", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9" + "reference": "6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/2e32a6d48972b2c1976ed5d8967145b6cec4a4a9", - "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0", + "reference": "6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0", "shasum": "" }, "require": { - "php": "^7.1", + "php": "^7.2 || ^8.0", "phpdocumentor/reflection-common": "^2.0" }, "require-dev": { - "ext-tokenizer": "^7.1", - "mockery/mockery": "~1", - "phpunit/phpunit": "^7.0" + "ext-tokenizer": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.x-dev" + "dev-1.x": "1.x-dev" } }, "autoload": { @@ -4098,37 +4489,41 @@ } ], "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", - "time": "2019-08-22T18:11:29+00:00" + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.4.0" + }, + "time": "2020-09-17T18:55:26+00:00" }, { "name": "phpspec/prophecy", - "version": "v1.10.3", + "version": "1.13.0", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "451c3cd1418cf640de218914901e51b064abb093" + "reference": "be1996ed8adc35c3fd795488a653f4b518be70ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/451c3cd1418cf640de218914901e51b064abb093", - "reference": "451c3cd1418cf640de218914901e51b064abb093", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/be1996ed8adc35c3fd795488a653f4b518be70ea", + "reference": "be1996ed8adc35c3fd795488a653f4b518be70ea", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.0.2", - "php": "^5.3|^7.0", - "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0", - "sebastian/comparator": "^1.2.3|^2.0|^3.0|^4.0", - "sebastian/recursion-context": "^1.0|^2.0|^3.0|^4.0" + "doctrine/instantiator": "^1.2", + "php": "^7.2 || ~8.0, <8.1", + "phpdocumentor/reflection-docblock": "^5.2", + "sebastian/comparator": "^3.0 || ^4.0", + "sebastian/recursion-context": "^3.0 || ^4.0" }, "require-dev": { - "phpspec/phpspec": "^2.5 || ^3.2", - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1" + "phpspec/phpspec": "^6.0", + "phpunit/phpunit": "^8.0 || ^9.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10.x-dev" + "dev-master": "1.11.x-dev" } }, "autoload": { @@ -4161,162 +4556,100 @@ "spy", "stub" ], - "time": "2020-03-05T15:02:03+00:00" - }, - { - "name": "phpstan/phpdoc-parser", - "version": "0.3.5", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "8c4ef2aefd9788238897b678a985e1d5c8df6db4" + "support": { + "issues": "https://github.com/phpspec/prophecy/issues", + "source": "https://github.com/phpspec/prophecy/tree/1.13.0" }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/8c4ef2aefd9788238897b678a985e1d5c8df6db4", - "reference": "8c4ef2aefd9788238897b678a985e1d5c8df6db4", - "shasum": "" - }, - "require": { - "php": "~7.1" - }, - "require-dev": { - "consistence/coding-standard": "^3.5", - "jakub-onderka/php-parallel-lint": "^0.9.2", - "phing/phing": "^2.16.0", - "phpstan/phpstan": "^0.10", - "phpunit/phpunit": "^6.3", - "slevomat/coding-standard": "^4.7.2", - "squizlabs/php_codesniffer": "^3.3.2", - "symfony/process": "^3.4 || ^4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "0.3-dev" - } - }, - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", - "time": "2019-06-07T19:13:52+00:00" + "time": "2021-03-17T13:42:18+00:00" }, { "name": "phpstan/phpstan", - "version": "0.11.19", + "version": "0.12.84", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "63cc502f6957b7f74efbac444b4cf219dcadffd7" + "reference": "9c43f15da8798c8f30a4b099e6a94530a558cfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/63cc502f6957b7f74efbac444b4cf219dcadffd7", - "reference": "63cc502f6957b7f74efbac444b4cf219dcadffd7", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c43f15da8798c8f30a4b099e6a94530a558cfd5", + "reference": "9c43f15da8798c8f30a4b099e6a94530a558cfd5", "shasum": "" }, "require": { - "composer/xdebug-handler": "^1.3.0", - "jean85/pretty-package-versions": "^1.0.3", - "nette/bootstrap": "^2.4 || ^3.0", - "nette/di": "^2.4.7 || ^3.0", - "nette/neon": "^2.4.3 || ^3.0", - "nette/robot-loader": "^3.0.1", - "nette/schema": "^1.0", - "nette/utils": "^2.4.5 || ^3.0", - "nikic/php-parser": "^4.2.3", - "php": "~7.1", - "phpstan/phpdoc-parser": "^0.3.5", - "symfony/console": "~3.2 || ~4.0", - "symfony/finder": "~3.2 || ~4.0" + "php": "^7.1|^8.0" }, "conflict": { - "symfony/console": "3.4.16 || 4.1.5" - }, - "require-dev": { - "brianium/paratest": "^2.0 || ^3.0", - "consistence/coding-standard": "^3.5", - "dealerdirect/phpcodesniffer-composer-installer": "^0.4.4", - "ext-intl": "*", - "ext-mysqli": "*", - "ext-simplexml": "*", - "ext-soap": "*", - "ext-zip": "*", - "jakub-onderka/php-parallel-lint": "^1.0", - "localheinz/composer-normalize": "^1.1.0", - "phing/phing": "^2.16.0", - "phpstan/phpstan-deprecation-rules": "^0.11", - "phpstan/phpstan-php-parser": "^0.11", - "phpstan/phpstan-phpunit": "^0.11", - "phpstan/phpstan-strict-rules": "^0.11", - "phpunit/phpunit": "^7.5.14 || ^8.0", - "slevomat/coding-standard": "^4.7.2", - "squizlabs/php_codesniffer": "^3.3.2" + "phpstan/phpstan-shim": "*" }, "bin": [ - "bin/phpstan" + "phpstan", + "phpstan.phar" ], "type": "library", "extra": { "branch-alias": { - "dev-master": "0.11-dev" + "dev-master": "0.12-dev" } }, "autoload": { - "psr-4": { - "PHPStan\\": [ - "src/" - ] - } + "files": [ + "bootstrap.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "description": "PHPStan - PHP Static Analysis Tool", - "time": "2019-10-22T20:20:22+00:00" + "support": { + "issues": "https://github.com/phpstan/phpstan/issues", + "source": "https://github.com/phpstan/phpstan/tree/0.12.84" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpstan", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan", + "type": "tidelift" + } + ], + "time": "2021-04-19T17:10:54+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", - "version": "0.11.2", + "version": "0.12.6", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", - "reference": "5685fe48873efc5af1f2cc95d9c1b8ae82c728fe" + "reference": "46dbd43c2db973d2876d6653e53f5c2cc3a01fbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/5685fe48873efc5af1f2cc95d9c1b8ae82c728fe", - "reference": "5685fe48873efc5af1f2cc95d9c1b8ae82c728fe", + "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/46dbd43c2db973d2876d6653e53f5c2cc3a01fbb", + "reference": "46dbd43c2db973d2876d6653e53f5c2cc3a01fbb", "shasum": "" }, "require": { - "nikic/php-parser": "^4.0", - "php": "~7.1", - "phpstan/phpstan": "^0.11.8" + "php": "^7.1 || ^8.0", + "phpstan/phpstan": "^0.12.60" }, "require-dev": { - "consistence/coding-standard": "^3.0.1", - "dealerdirect/phpcodesniffer-composer-installer": "^0.4.4", - "jakub-onderka/php-parallel-lint": "^1.0", - "phing/phing": "^2.16.0", - "phpstan/phpstan-phpunit": "^0.11", - "phpunit/phpunit": "^7.0", - "slevomat/coding-standard": "^4.5.2" + "phing/phing": "^2.16.3", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^0.12", + "phpunit/phpunit": "^7.5.20" }, "type": "phpstan-extension", "extra": { "branch-alias": { - "dev-master": "0.11-dev" + "dev-master": "0.12-dev" }, "phpstan": { "includes": [ @@ -4334,44 +4667,52 @@ "MIT" ], "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", - "time": "2019-05-28T19:54:04+00:00" + "support": { + "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", + "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/0.12.6" + }, + "time": "2020-12-13T10:20:54+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "6.1.4", + "version": "9.2.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d" + "reference": "f6293e1b30a2354e8428e004689671b83871edde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", - "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f6293e1b30a2354e8428e004689671b83871edde", + "reference": "f6293e1b30a2354e8428e004689671b83871edde", "shasum": "" }, "require": { "ext-dom": "*", + "ext-libxml": "*", "ext-xmlwriter": "*", - "php": "^7.1", - "phpunit/php-file-iterator": "^2.0", - "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^3.0", - "sebastian/code-unit-reverse-lookup": "^1.0.1", - "sebastian/environment": "^3.1 || ^4.0", - "sebastian/version": "^2.0.1", - "theseer/tokenizer": "^1.1" + "nikic/php-parser": "^4.10.2", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.3", + "phpunit/php-text-template": "^2.0.2", + "sebastian/code-unit-reverse-lookup": "^2.0.2", + "sebastian/complexity": "^2.0", + "sebastian/environment": "^5.1.2", + "sebastian/lines-of-code": "^1.0.3", + "sebastian/version": "^3.0.1", + "theseer/tokenizer": "^1.2.0" }, "require-dev": { - "phpunit/phpunit": "^7.0" + "phpunit/phpunit": "^9.3" }, "suggest": { - "ext-xdebug": "^2.6.0" + "ext-pcov": "*", + "ext-xdebug": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "6.1-dev" + "dev-master": "9.2-dev" } }, "autoload": { @@ -4397,32 +4738,42 @@ "testing", "xunit" ], - "time": "2018-10-31T16:06:48+00:00" + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-03-28T07:26:59+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "2.0.2", + "version": "3.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "050bedf145a257b1ff02746c31894800e5122946" + "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946", - "reference": "050bedf145a257b1ff02746c31894800e5122946", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/aa4be8575f26070b100fccb67faabb28f21f66f8", + "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8", "shasum": "" }, "require": { - "php": "^7.1" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^7.1" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -4447,26 +4798,107 @@ "filesystem", "iterator" ], - "time": "2018-09-13T20:33:42+00:00" + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:57:25+00:00" }, { - "name": "phpunit/php-text-template", - "version": "1.2.1", + "name": "phpunit/php-invoker", + "version": "3.1.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", - "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, "autoload": { "classmap": [ "src/" @@ -4488,32 +4920,42 @@ "keywords": [ "template" ], - "time": "2015-06-21T13:50:34+00:00" + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" }, { "name": "phpunit/php-timer", - "version": "2.1.2", + "version": "5.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "1038454804406b0b5f5f520358e78c1c2f71501e" + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/1038454804406b0b5f5f520358e78c1c2f71501e", - "reference": "1038454804406b0b5f5f520358e78c1c2f71501e", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", "shasum": "" }, "require": { - "php": "^7.1" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^7.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -4537,106 +4979,69 @@ "keywords": [ "timer" ], - "time": "2019-06-07T04:22:29+00:00" - }, - { - "name": "phpunit/php-token-stream", - "version": "3.1.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff" + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/995192df77f63a59e47f025390d2d1fdf8f425ff", - "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff", - "shasum": "" - }, - "require": { - "ext-tokenizer": "*", - "php": "^7.1" - }, - "require-dev": { - "phpunit/phpunit": "^7.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.1-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + "funding": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "description": "Wrapper around PHP's tokenizer extension.", - "homepage": "https://github.com/sebastianbergmann/php-token-stream/", - "keywords": [ - "tokenizer" - ], - "time": "2019-09-17T06:23:10+00:00" + "time": "2020-10-26T13:16:10+00:00" }, { "name": "phpunit/phpunit", - "version": "7.5.20", + "version": "9.5.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "9467db479d1b0487c99733bb1e7944d32deded2c" + "reference": "c73c6737305e779771147af66c96ca6a7ed8a741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9467db479d1b0487c99733bb1e7944d32deded2c", - "reference": "9467db479d1b0487c99733bb1e7944d32deded2c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c73c6737305e779771147af66c96ca6a7ed8a741", + "reference": "c73c6737305e779771147af66c96ca6a7ed8a741", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.1", + "doctrine/instantiator": "^1.3.1", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", - "myclabs/deep-copy": "^1.7", - "phar-io/manifest": "^1.0.2", - "phar-io/version": "^2.0", - "php": "^7.1", - "phpspec/prophecy": "^1.7", - "phpunit/php-code-coverage": "^6.0.7", - "phpunit/php-file-iterator": "^2.0.1", - "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^2.1", - "sebastian/comparator": "^3.0", - "sebastian/diff": "^3.0", - "sebastian/environment": "^4.0", - "sebastian/exporter": "^3.1", - "sebastian/global-state": "^2.0", - "sebastian/object-enumerator": "^3.0.3", - "sebastian/resource-operations": "^2.0", - "sebastian/version": "^2.0.1" - }, - "conflict": { - "phpunit/phpunit-mock-objects": "*" + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.10.1", + "phar-io/manifest": "^2.0.1", + "phar-io/version": "^3.0.2", + "php": ">=7.3", + "phpspec/prophecy": "^1.12.1", + "phpunit/php-code-coverage": "^9.2.3", + "phpunit/php-file-iterator": "^3.0.5", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.3", + "phpunit/php-timer": "^5.0.2", + "sebastian/cli-parser": "^1.0.1", + "sebastian/code-unit": "^1.0.6", + "sebastian/comparator": "^4.0.5", + "sebastian/diff": "^4.0.3", + "sebastian/environment": "^5.1.3", + "sebastian/exporter": "^4.0.3", + "sebastian/global-state": "^5.0.1", + "sebastian/object-enumerator": "^4.0.3", + "sebastian/resource-operations": "^3.0.3", + "sebastian/type": "^2.3", + "sebastian/version": "^3.0.2" }, "require-dev": { - "ext-pdo": "*" + "ext-pdo": "*", + "phpspec/prophecy-phpunit": "^2.0.1" }, "suggest": { "ext-soap": "*", - "ext-xdebug": "*", - "phpunit/php-invoker": "^2.0" + "ext-xdebug": "*" }, "bin": [ "phpunit" @@ -4644,12 +5049,15 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "7.5-dev" + "dev-master": "9.5-dev" } }, "autoload": { "classmap": [ "src/" + ], + "files": [ + "src/Framework/Assert/Functions.php" ] }, "notification-url": "https://packagist.org/downloads/", @@ -4670,27 +5078,39 @@ "testing", "xunit" ], - "time": "2020-01-08T08:45:45+00:00" + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.4" + }, + "funding": [ + { + "url": "https://phpunit.de/donate.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-03-23T07:16:29+00:00" }, { - "name": "sebastian/code-unit-reverse-lookup", + "name": "psr/http-client", "version": "1.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18" + "url": "https://github.com/php-fig/http-client.git", + "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/4419fcdb5eabb9caa61a27c7a1db532a6b55dd18", - "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", + "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0" - }, - "require-dev": { - "phpunit/phpunit": "^5.7 || ^6.0" + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0" }, "type": "library", "extra": { @@ -4698,6 +5118,172 @@ "dev-master": "1.0.x-dev" } }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client/tree/master" + }, + "time": "2020-06-29T06:28:15+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:08:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, "autoload": { "classmap": [ "src/" @@ -4715,34 +5301,44 @@ ], "description": "Looks up which function or method a line of code belongs to", "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "time": "2017-03-04T06:30:41+00:00" + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" }, { "name": "sebastian/comparator", - "version": "3.0.2", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da" + "reference": "55f4261989e546dc112258c7a75935a81a7ce382" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da", - "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55f4261989e546dc112258c7a75935a81a7ce382", + "reference": "55f4261989e546dc112258c7a75935a81a7ce382", "shasum": "" }, "require": { - "php": "^7.1", - "sebastian/diff": "^3.0", - "sebastian/exporter": "^3.1" + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" }, "require-dev": { - "phpunit/phpunit": "^7.1" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -4755,6 +5351,10 @@ "BSD-3-Clause" ], "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, { "name": "Jeff Welch", "email": "whatthejeff@gmail.com" @@ -4766,10 +5366,6 @@ { "name": "Bernhard Schussek", "email": "bschussek@2bepublished.at" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" } ], "description": "Provides the functionality to compare PHP values for equality", @@ -4779,33 +5375,43 @@ "compare", "equality" ], - "time": "2018-07-12T15:12:46+00:00" + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T15:49:45+00:00" }, { - "name": "sebastian/diff", - "version": "3.0.2", + "name": "sebastian/complexity", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29" + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "739b35e53379900cc9ac327b2147867b8b6efd88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29", - "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88", + "reference": "739b35e53379900cc9ac327b2147867b8b6efd88", "shasum": "" }, "require": { - "php": "^7.1" + "nikic/php-parser": "^4.7", + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^7.5 || ^8.0", - "symfony/process": "^2 || ^3.3 || ^4" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -4819,12 +5425,69 @@ ], "authors": [ { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" - }, + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T15:52:27+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" } ], "description": "Diff implementation", @@ -4835,27 +5498,37 @@ "unidiff", "unified diff" ], - "time": "2019-02-04T06:01:07+00:00" + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:10:38+00:00" }, { "name": "sebastian/environment", - "version": "4.2.3", + "version": "5.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368" + "reference": "388b6ced16caa751030f6a69e588299fa09200ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/464c90d7bdf5ad4e8a6aea15c091fec0603d4368", - "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/388b6ced16caa751030f6a69e588299fa09200ac", + "reference": "388b6ced16caa751030f6a69e588299fa09200ac", "shasum": "" }, "require": { - "php": "^7.1" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^7.5" + "phpunit/phpunit": "^9.3" }, "suggest": { "ext-posix": "*" @@ -4863,7 +5536,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "5.1-dev" } }, "autoload": { @@ -4888,34 +5561,44 @@ "environment", "hhvm" ], - "time": "2019-11-20T08:46:58+00:00" + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:52:38+00:00" }, { "name": "sebastian/exporter", - "version": "3.1.2", + "version": "4.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e" + "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/68609e1261d215ea5b21b7987539cbfbe156ec3e", - "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/d89cc98761b8cb5a1a235a6b703ae50d34080e65", + "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65", "shasum": "" }, "require": { - "php": "^7.0", - "sebastian/recursion-context": "^3.0" + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" }, "require-dev": { "ext-mbstring": "*", - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1.x-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -4955,27 +5638,40 @@ "export", "exporter" ], - "time": "2019-09-14T09:02:43+00:00" + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:24:23+00:00" }, { "name": "sebastian/global-state", - "version": "2.0.0", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4" + "reference": "a90ccbddffa067b51f574dea6eb25d5680839455" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4", - "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/a90ccbddffa067b51f574dea6eb25d5680839455", + "reference": "a90ccbddffa067b51f574dea6eb25d5680839455", "shasum": "" }, "require": { - "php": "^7.0" + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "ext-dom": "*", + "phpunit/phpunit": "^9.3" }, "suggest": { "ext-uopz": "*" @@ -4983,7 +5679,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -5006,34 +5702,101 @@ "keywords": [ "global state" ], - "time": "2017-04-27T15:39:26+00:00" + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T15:55:19+00:00" }, { - "name": "sebastian/object-enumerator", - "version": "3.0.3", + "name": "sebastian/lines-of-code", + "version": "1.0.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5" + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/7cfd9e65d11ffb5af41198476395774d4c8a84c5", - "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc", "shasum": "" }, "require": { - "php": "^7.0", - "sebastian/object-reflector": "^1.1.1", - "sebastian/recursion-context": "^3.0" + "nikic/php-parser": "^4.6", + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0.x-dev" + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-11-28T06:42:11+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" } }, "autoload": { @@ -5053,122 +5816,37 @@ ], "description": "Traverses array structures and object graphs to enumerate all referenced objects", "homepage": "https://github.com/sebastianbergmann/object-enumerator/", - "time": "2017-08-03T12:35:26+00:00" + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" }, { "name": "sebastian/object-reflector", - "version": "1.1.1", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "773f97c67f28de00d397be301821b06708fca0be" + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/773f97c67f28de00d397be301821b06708fca0be", - "reference": "773f97c67f28de00d397be301821b06708fca0be", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", "shasum": "" }, "require": { - "php": "^7.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^6.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.1-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", - "time": "2017-03-29T09:07:27+00:00" - }, - { - "name": "sebastian/recursion-context", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8", - "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8", - "shasum": "" - }, - "require": { - "php": "^7.0" - }, - "require-dev": { - "phpunit/phpunit": "^6.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - } - ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "http://www.github.com/sebastianbergmann/recursion-context", - "time": "2017-03-03T06:23:57+00:00" - }, - { - "name": "sebastian/resource-operations", - "version": "2.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9", - "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9", - "shasum": "" - }, - "require": { - "php": "^7.1" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { @@ -5191,31 +5869,215 @@ "email": "sebastian@phpunit.de" } ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "time": "2018-10-04T04:07:39+00:00" + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" }, { - "name": "sebastian/version", - "version": "2.0.1", + "name": "sebastian/recursion-context", + "version": "4.0.4", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/99732be0ddb3361e16ad77b68ba41efc8e979019", - "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172", + "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172", "shasum": "" }, "require": { - "php": ">=5.6" + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:17:30+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "issues": "https://github.com/sebastianbergmann/resource-operations/issues", + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:45:17+00:00" + }, + { + "name": "sebastian/type", + "version": "2.3.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "81cd61ab7bbf2de744aba0ea61fae32f721df3d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/81cd61ab7bbf2de744aba0ea61fae32f721df3d2", + "reference": "81cd61ab7bbf2de744aba0ea61fae32f721df3d2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/2.3.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:18:59+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" } }, "autoload": { @@ -5236,41 +6098,46 @@ ], "description": "Library that helps with managing the version number of Git-hosted PHP projects", "homepage": "https://github.com/sebastianbergmann/version", - "time": "2016-10-03T07:35:21+00:00" + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" }, { "name": "symfony/browser-kit", - "version": "v4.4.5", + "version": "v5.2.4", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "090ce406505149d6852a7c03b0346dec3b8cf612" + "reference": "3ca3a57ce9860318b20a924fec5daf5c6db44d93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/090ce406505149d6852a7c03b0346dec3b8cf612", - "reference": "090ce406505149d6852a7c03b0346dec3b8cf612", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/3ca3a57ce9860318b20a924fec5daf5c6db44d93", + "reference": "3ca3a57ce9860318b20a924fec5daf5c6db44d93", "shasum": "" }, "require": { - "php": "^7.1.3", - "symfony/dom-crawler": "^3.4|^4.0|^5.0" + "php": ">=7.2.5", + "symfony/dom-crawler": "^4.4|^5.0" }, "require-dev": { - "symfony/css-selector": "^3.4|^4.0|^5.0", - "symfony/http-client": "^4.3|^5.0", - "symfony/mime": "^4.3|^5.0", - "symfony/process": "^3.4|^4.0|^5.0" + "symfony/css-selector": "^4.4|^5.0", + "symfony/http-client": "^4.4|^5.0", + "symfony/mime": "^4.4|^5.0", + "symfony/process": "^4.4|^5.0" }, "suggest": { "symfony/process": "" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\BrowserKit\\": "" @@ -5293,33 +6160,45 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony BrowserKit Component", + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", - "time": "2020-02-23T10:00:59+00:00" + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v5.2.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-02-22T06:48:33+00:00" }, { "name": "symfony/css-selector", - "version": "v4.4.5", + "version": "v5.2.4", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "d0a6dd288fa8848dcc3d1f58b94de6a7cc5d2d22" + "reference": "f65f217b3314504a1ec99c2d6ef69016bb13490f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/d0a6dd288fa8848dcc3d1f58b94de6a7cc5d2d22", - "reference": "d0a6dd288fa8848dcc3d1f58b94de6a7cc5d2d22", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/f65f217b3314504a1ec99c2d6ef69016bb13490f", + "reference": "f65f217b3314504a1ec99c2d6ef69016bb13490f", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": ">=7.2.5" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\CssSelector\\": "" @@ -5346,45 +6225,58 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony CssSelector Component", + "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", - "time": "2020-02-04T09:01:01+00:00" + "support": { + "source": "https://github.com/symfony/css-selector/tree/v5.2.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-27T10:01:46+00:00" }, { "name": "symfony/dom-crawler", - "version": "v4.4.5", + "version": "v5.2.4", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "11dcf08f12f29981bf770f097a5d64d65bce5929" + "reference": "400e265163f65aceee7e904ef532e15228de674b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/11dcf08f12f29981bf770f097a5d64d65bce5929", - "reference": "11dcf08f12f29981bf770f097a5d64d65bce5929", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/400e265163f65aceee7e904ef532e15228de674b", + "reference": "400e265163f65aceee7e904ef532e15228de674b", "shasum": "" }, "require": { - "php": "^7.1.3", + "php": ">=7.2.5", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.0" + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "^1.15" }, "conflict": { "masterminds/html5": "<2.6" }, "require-dev": { "masterminds/html5": "^2.6", - "symfony/css-selector": "^3.4|^4.0|^5.0" + "symfony/css-selector": "^4.4|^5.0" }, "suggest": { "symfony/css-selector": "" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\DomCrawler\\": "" @@ -5407,8 +6299,11 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony DomCrawler Component", + "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v5.2.4" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -5423,31 +6318,26 @@ "type": "tidelift" } ], - "time": "2020-02-29T10:05:28+00:00" + "time": "2021-02-15T18:55:04+00:00" }, { "name": "symfony/finder", - "version": "v4.4.5", + "version": "v5.2.4", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ea69c129aed9fdeca781d4b77eb20b62cf5d5357" + "reference": "0d639a0943822626290d169965804f79400e6a04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ea69c129aed9fdeca781d4b77eb20b62cf5d5357", - "reference": "ea69c129aed9fdeca781d4b77eb20b62cf5d5357", + "url": "https://api.github.com/repos/symfony/finder/zipball/0d639a0943822626290d169965804f79400e6a04", + "reference": "0d639a0943822626290d169965804f79400e6a04", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": ">=7.2.5" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Finder\\": "" @@ -5470,8 +6360,11 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Finder Component", + "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v5.2.4" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -5486,27 +6379,27 @@ "type": "tidelift" } ], - "time": "2020-02-14T07:42:58+00:00" + "time": "2021-02-15T18:55:04+00:00" }, { "name": "theseer/tokenizer", - "version": "1.1.3", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9" + "reference": "75a63c33a8577608444246075ea0af0d052e452a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/11336f6f84e16a720dae9d8e6ed5019efa85a0f9", - "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/75a63c33a8577608444246075ea0af0d052e452a", + "reference": "75a63c33a8577608444246075ea0af0d052e452a", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.0" + "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { @@ -5526,79 +6419,49 @@ } ], "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", - "time": "2019-06-13T22:48:21+00:00" - }, - { - "name": "victorjonsson/markdowndocs", - "version": "dev-master", - "source": { - "type": "git", - "url": "https://github.com/trilbymedia/PHP-Markdown-Documentation-Generator.git", - "reference": "c9fa153b28a79f5da89ec32aa501be92db212aed" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/trilbymedia/PHP-Markdown-Documentation-Generator/zipball/c9fa153b28a79f5da89ec32aa501be92db212aed", - "reference": "c9fa153b28a79f5da89ec32aa501be92db212aed", - "shasum": "" - }, - "require": { - "php": ">=5.5.0", - "symfony/console": ">=2.6" - }, - "require-dev": { - "phpunit/phpunit": "3.7.23" - }, - "bin": [ - "bin/phpdoc-md" - ], - "type": "library", - "autoload": { - "psr-0": { - "PHPDocsMD": "src/" - } - }, - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Victor Jonsson", - "email": "kontakt@victorjonsson.se" - } - ], - "description": "Command line tool for generating markdown-formatted class documentation", - "homepage": "https://github.com/victorjonsson/PHP-Markdown-Documentation-Generator", "support": { - "source": "https://github.com/trilbymedia/PHP-Markdown-Documentation-Generator/tree/master" + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/master" }, - "time": "2017-09-20T13:29:22+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2020-07-12T23:59:07+00:00" }, { "name": "webmozart/assert", - "version": "1.7.0", + "version": "1.10.0", "source": { "type": "git", - "url": "https://github.com/webmozart/assert.git", - "reference": "aed98a490f9a8f78468232db345ab9cf606cf598" + "url": "https://github.com/webmozarts/assert.git", + "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozart/assert/zipball/aed98a490f9a8f78468232db345ab9cf606cf598", - "reference": "aed98a490f9a8f78468232db345ab9cf606cf598", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/6964c76c7804814a842473e0c8fd15bab0f18e25", + "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0", + "php": "^7.2 || ^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "vimeo/psalm": "<3.6.0" + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" }, "require-dev": { - "phpunit/phpunit": "^4.8.36 || ^7.5.13" + "phpunit/phpunit": "^8.5.13" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, "autoload": { "psr-4": { "Webmozart\\Assert\\": "src/" @@ -5620,29 +6483,30 @@ "check", "validate" ], - "time": "2020-02-14T12:15:55+00:00" + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.10.0" + }, + "time": "2021-03-09T10:59:23+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "willdurand/negotiation": 20, - "victorjonsson/markdowndocs": 20 - }, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=7.1.3", + "php": "^7.3.6 || ^8.0", "ext-json": "*", - "ext-mbstring": "*", "ext-openssl": "*", "ext-curl": "*", "ext-zip": "*", - "ext-dom": "*" + "ext-dom": "*", + "ext-libxml": "*" }, "platform-dev": [], "platform-overrides": { - "php": "7.1.3" + "php": "7.3.6" }, - "plugin-api-version": "1.1.0" + "plugin-api-version": "2.0.0" } diff --git a/index.php b/index.php index 7be4df1..9842952 100644 --- a/index.php +++ b/index.php @@ -3,21 +3,25 @@ /** * @package Grav.Core * - * @copyright Copyright (C) 2015 - 2018 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav; \define('GRAV_REQUEST_TIME', microtime(true)); -\define('GRAV_PHP_MIN', '7.1.3'); +\define('GRAV_PHP_MIN', '7.3.6'); if (version_compare($ver = PHP_VERSION, $req = GRAV_PHP_MIN, '<')) { die(sprintf('You are running PHP %s, but Grav needs at least PHP %s to run.', $ver, $req)); } -if (PHP_SAPI === 'cli-server' && !isset($_SERVER['PHP_CLI_ROUTER'])) { - die("PHP webserver requires a router to run Grav, please use:
    php -S {$_SERVER['SERVER_NAME']}:{$_SERVER['SERVER_PORT']} system/router.php
    "); +if (PHP_SAPI === 'cli-server') { + $symfony_server = stripos(getenv('_'), 'symfony') !== false || stripos($_SERVER['SERVER_SOFTWARE'], 'symfony +') !== false; + if (!isset($_SERVER['PHP_CLI_ROUTER']) && !$symfony_server) { + die("PHP webserver requires a router to run Grav, please use:
    php -S {$_SERVER['SERVER_NAME']}:{$_SERVER['SERVER_PORT']} system/router.php
    "); + } } // Set timezone to default, falls back to system if php.ini not set diff --git a/now.json b/now.json index fe7b94b..d2e629c 100644 --- a/now.json +++ b/now.json @@ -1,4 +1,4 @@ - { +{ "version": 2, "builds": [{ "src": "*.php", "use": "@now/php" }] } diff --git a/system/aliases.php b/system/aliases.php deleted file mode 100644 index 387f3a4..0000000 --- a/system/aliases.php +++ /dev/null @@ -1,5 +0,0 @@ - div > * { - padding: 5px 15px; -} - -.phpdebugbar div.phpdebugbar-header > div.phpdebugbar-header-right > * { - padding: 5px 8px; -} - -.phpdebugbar div.phpdebugbar-header, .phpdebugbar a.phpdebugbar-restore-btn { - background-image: url(grav.png); -} - -.phpdebugbar a.phpdebugbar-restore-btn { - width: 13px; -} - -.phpdebugbar a.phpdebugbar-tab.phpdebugbar-active { - background: #3DB9EC; - color: #fff; - margin-top: -1px; - padding-top: 6px; -} - -.phpdebugbar .phpdebugbar-widgets-toolbar { - border-top: 1px solid #ddd; - padding-left: 5px; - padding-right: 2px; - padding-top: 2px; - background-color: #fafafa !important; - width: auto !important; - left: 0; - right: 0; -} - -.phpdebugbar .phpdebugbar-widgets-toolbar input { - background: transparent !important; -} - -.phpdebugbar .phpdebugbar-widgets-toolbar .phpdebugbar-widgets-filter { - -} - - -.phpdebugbar input[type=text] { - padding: 0; - display: inline; -} - -.phpdebugbar dl.phpdebugbar-widgets-varlist, ul.phpdebugbar-widgets-timeline li span.phpdebugbar-widgets-label { - font-family: "DejaVu Sans Mono", Menlo, Monaco, Consolas, Courier, monospace; - font-size: 12px; -} - -ul.phpdebugbar-widgets-timeline li span.phpdebugbar-widgets-label { - text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; - top: 0; -} - -.phpdebugbar pre, .phpdebugbar code { - margin: 0; - font-size: 14px; -} diff --git a/system/assets/debugger/clockwork.css b/system/assets/debugger/clockwork.css new file mode 100644 index 0000000..e26dab1 --- /dev/null +++ b/system/assets/debugger/clockwork.css @@ -0,0 +1,2 @@ +/** Clockwork Debugger CSS **/ +.clockwork-badge{position:fixed;z-index:10;bottom:0;left:0;padding:2px 4px;background-color:#eee;border:1px solid #ccc;border-bottom:0;border-left:0;display:flex;align-items:center}.clockwork-badge:hover{width:auto}.clockwork-badge:hover:after{content:'Grav Clockwork debugger enabled. Install Clockwork Browser extension (Chrome or Firefox), open your Developer tools and then select the Clockwork tab.'}.clockwork-badge:after{margin-left:10px;font-family:Monaco,Consolas,"Lucida Console",monospace;font-size:12px;line-height:1.5;color:#666}.clockwork-badge i{display:block;float:left;height:22px;width:22px;min-width:22px;background-size:contain;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAA/1BMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeHh4AAAD///8EBAT7+/sLCwv29vYVFRUvLy/t7e3m5ubCwsKxsbE/Pz+mpqZMTEwcHBzy8vLp6emfn5+AgIA2Njbi4uLf39+rq6tzc3NWVlYhISHa2trW1tbS0tLMzMy7u7uZmZmUlJSMjIxvb29kZGRHR0c7Ozt5eXkqKiq1tbWQkJBqampbW1tSUlLHx8eHh4ckJCRDQ0M3wD42AAAAI3RSTlMA/PibTbQ0x76TVAlw4LhZLOuEYCAN9Hjx0a2ppGZEGYw97djhXHwAAATZSURBVFjDlVcHW+MwDO1eFCjj2McNOzvdpXTTXVbL/P+/5SQ7QSSX5Di1X1onfi/Sk+Q4sTDbKqWK+YuznZ2zi3wxVdqK/Zf92M1nT9gnO8rmd398GX6Z3xaoOFoiAQcx3E5efgmeSuN8F6Xg1x3G06l/wjNpMR1B0uif4EhnIuFb+0diIoFXk3IVfokisR+h52GO4JKgyjmfaMhAFNlSaPR7DpwI+lzn/E4QKIqmKIJirxCMP4izBPPZPXhgXwMBYgULw0nfg/BF5scDbslb7QeJ08yqqTEmGYoB95d4H8ETL8+n9wBqrLu6ao3bBsMwAnxISf/9BHcqxNB8Y7cWl3Zz7TAUfPrvAT6AoNEFFXvsjutL01yOuMrtBxnFXsmT/1wQHmdWAFNnI3uI48Yj0FUcHbKf62GfUfr8eeQt7Uk3mQZpZNoVRPEui5vtEz5zFEpgWnyqVBZMc6oaGNriH2hGVZ0OxEvInPeMaZWJBA7vmPbCr5jjws5HBnAUxvDMH40aCIf4G5BjRQSs8E8HFFYf8bGxgDvD55bzGhwWkoBcuIyHR/AMdaCagxXDhtL6tSqoWpd4BMnlIR+Or+rYTK/a3EAGcc6e4AWHISnWv20iCCojsHoVlQdjrMexFF2C7UMg2A2WEGWbQhXN6l3eXC6XGp4b9qxbuEB2EBGBwtocrK90cVG5mbRXm6vmx/0phq1sIAGKDgLOBiN1MrO5a9aDl+D0W6x0Ar9BCTRuIIANa90Y7LrLVRXzwVtDInCqMRWcf2bUOEAsa4wJqFowQALL9EiAtVRk8QC4OW+1pOM9jIaVASwYagyNXDj+W0NcfuZNzjtXOiL0Zzg30Llj+ptfxQs4+vBPNiL5PawFCBkgXpUaVtqGl+A8dgZHL34BcBUQrwPptToW+o37Ku+UH9eYByJIx3YkAeFnMFuGO7S5gEp7YhXxa5OOAM39RXDPXb0qmpROsswZe+twXdU55oUIZAiEv3bD1UFwIYKkmGqytPCDCwKFQCKK0yL7qtSAPX54UAbtsLuBHkb9zyLmPQSNjsSgmQwKUOIfEY8F8t4B34DvndJY9BA8tNBJq1Nev9axmaStFcQLhgYoCTo0salkIaW8OUDdWjMTR2sHPhrAFZqx6cqcKE4pl2BJJ4K6hfwvqNgAnXfKX/HU6X3Zrhnu0k7tLNZtTBRv1hkwTDBY1NzFU6doDYjJbWdQkQhWwuU7/LvhTh3SDoco4ECL4i5dwURbc8NdDZz2IwKicE8d0KIqWetLE3+lL4hvUuGSeRfVWNLfj/gpOw4smBJBkKQHCzlHGwvAj4woB1gq5NGGLSXtORBPnUQPV5/MPVkDMxbpwG7w4x0xL6Ltxka0A/4NBvV09UVk4DoSn/jl2+JQS9q9KYawisAD4CfhsZ4TH3htylsdEHARIQBusqCKyUpymycgbbkkXEXjT3z7/oKQFTFVuZD2FMJHZIDsO5x2d4aAr2jR+GLwZhtAb028/0yJ9J8dE87jQyKObcjtTXT8dH+fDuKF4/eiPwzH44wTf/yUi6wrpRIOZ9lM1EtXAifFI+CJn9+iX/t2xMQwOMth/UZbASi8btAwR9FHWSpJr75g9Oqbin3VDg+SpwlP6k6TB4ex/7JvmcJx8jydy6XPk8eFTKhyfwCgX71MSvaBHgAAAABJRU5ErkJggg==)} diff --git a/system/assets/debugger/clockwork.js b/system/assets/debugger/clockwork.js new file mode 100644 index 0000000..bb1b69d --- /dev/null +++ b/system/assets/debugger/clockwork.js @@ -0,0 +1,3 @@ +/** Clockwork Debugger JS **/ +document.addEventListener("DOMContentLoaded",function () { + var e=document.createElement("div");e.appendChild(document.createElement("i")),e.className="clockwork-badge",document.body.appendChild(e)}); diff --git a/system/assets/debugger/phpdebugbar.css b/system/assets/debugger/phpdebugbar.css new file mode 100644 index 0000000..93e2d2c --- /dev/null +++ b/system/assets/debugger/phpdebugbar.css @@ -0,0 +1,70 @@ +div.phpdebugbar { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +.phpdebugbar pre { + padding: 1rem; +} + +.phpdebugbar div.phpdebugbar-header > div > * { + padding: 5px 15px; +} + +.phpdebugbar div.phpdebugbar-header > div.phpdebugbar-header-right > * { + padding: 5px 8px; +} + +.phpdebugbar div.phpdebugbar-header, .phpdebugbar a.phpdebugbar-restore-btn { + background-image: url(data:image/png;base64,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); +} + +.phpdebugbar a.phpdebugbar-restore-btn { + width: 13px; +} + +.phpdebugbar a.phpdebugbar-tab.phpdebugbar-active { + background: #3DB9EC; + color: #fff; + margin-top: -1px; + padding-top: 6px; +} + +.phpdebugbar .phpdebugbar-widgets-toolbar { + border-top: 1px solid #ddd; + padding-left: 5px; + padding-right: 2px; + padding-top: 2px; + background-color: #fafafa !important; + width: auto !important; + left: 0; + right: 0; +} + +.phpdebugbar .phpdebugbar-widgets-toolbar input { + background: transparent !important; +} + +.phpdebugbar .phpdebugbar-widgets-toolbar .phpdebugbar-widgets-filter { + +} + + +.phpdebugbar input[type=text] { + padding: 0; + display: inline; +} + +.phpdebugbar dl.phpdebugbar-widgets-varlist, ul.phpdebugbar-widgets-timeline li span.phpdebugbar-widgets-label { + font-family: "DejaVu Sans Mono", Menlo, Monaco, Consolas, Courier, monospace; + font-size: 12px; +} + +ul.phpdebugbar-widgets-timeline li span.phpdebugbar-widgets-label { + text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; + top: 0; +} + +.phpdebugbar pre, .phpdebugbar code { + margin: 0; + font-size: 14px; +} diff --git a/system/assets/grav.png b/system/assets/grav.png index 9ae6d97db20c96402fbd914f7867139cc9364093..67a98b9df1f19546bd7540b58d37f65dccea55af 100644 GIT binary patch literal 1612 zcmchX{XY{30LC|FquE;76k&`z?9}G!BU&<{8EYi(o0qhUIIKvV9ivQU!001zuyQ|M8i9f8Z zxtZnnGd^xY?M?A@+vNWs6BCoo7PVRp0)f`n)}T=6^71kYg~H)*DwRs9REou7kx1m^ z`#TQVZ7wrU^r0RHtgnOZ;B{4@NGrvd1V$jdtxg;c@$Xw#x6fD*oGtj%_RV3zmT5R? z;k3O7{~BA({g!*Ly!>7+%hpfE-QaJGJ&*Jnvn2E?&!@8t(gYLwHTJ<6AjI|!H8f4< zx`%?bqgwvT`04Pibn)KPw#LrVe}r=PNX#T{jTC{&2q-*@O3T5B(0{)_kuSee(d!_L zdPYQ!`$R3J!ppQ*>F?L62rKRltIUW71kdTxJ<#WI$K>!ZO<;r;w39q>VctzC(TwY% zsUSJS{k6Aje>UI(zw(pOPxNpvZhi$s*S9^0&U+2+#A~qJ$?6Tzd2H7p>*RfXeo}%8 zk7TWW0v=*l3~8ZKKjzPQ4%72ndQ}jE?$Dk>H639R=?^#_X`XsX@zi|K&&aq@ICnyL zY;`?ldbf(aUjRPMkfB@`_T-kT^bRE;J;H-owZ8h4gj&O$vMF@b5x;VHm*=yxue{U6 zIUx9_l4Ws2i9$R;0yB|M=ljulhT7RHy9!Raf$HbIAV`1of=I+BbiTj(+0aP;3Y*@- z%O=h1o~yWa0oBQot~O(9g89jyo*6TAe#3_`N`veT5qxIgx?C&U%zCsv7Nd^+B0`2_s$5LRU{?{pl^#Yt{}A$yzK3skeb;h5t%aC`*p?xd8Pzu=o;lVkXbLE; zQ%W|hIk$f|1#;mU17P!Wzus$&8NI-lFVX4~HQJqZ_Qjd9VKHxaetgj9hx+`4=7M3? z+M>$RPmS0UZDM#jHNkkIM&FV~}}@;Jpno1M|Rd35{lG#7fB^5_5| z-~eVR(tiJ7>&ey9ux_aR>b#d7=9^=^mbQUuXtANcjKh~7OahZ|X+S0l(V#iod_aFS zZw>pY2>q%Af^nB5SpX*^@iY#hsg>=K8i87fDa;2v-*98T2zi9PRzz*=H%OghhP-r8 zW@$6DFSaLjzI0}qWmc6$H#W^*fNEqBG2CIyTI~qiF)i5}th(ElVRB^S1xD!z%FFMn z5a@34o(?q;f!b zmAh^Y5cWF;Y3|jZ?TDLK7~A41RZovJX?}-+za)NfW5^F|7p}~_p7QpTJ+kK9<#uSw zEhk0S_0Y+&y)ivR&j1a}ZgY8#aH2A7Evc&uZ{K%f;|L9C^)PM_PZ}ZxVaW}T!U!sJc;MxJ literal 4166 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 I4P?xJ0I0&g$N&HU diff --git a/system/assets/jquery/jquery-3.x.min.js b/system/assets/jquery/jquery-3.x.min.js index a1c07fd..b061403 100644 --- a/system/assets/jquery/jquery-3.x.min.js +++ b/system/assets/jquery/jquery-3.x.min.js @@ -1,2 +1,2 @@ -/*! jQuery v3.4.1 | (c) JS Foundation and other contributors | jquery.org/license */ -!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],E=C.document,r=Object.getPrototypeOf,s=t.slice,g=t.concat,u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.4.1",k=function(e,t){return new k.fn.init(e,t)},p=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;function d(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp($),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+$),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ne=function(e,t,n){var r="0x"+t-65536;return r!=r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(m.childNodes),m.childNodes),t[m.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&((e?e.ownerDocument||e:m)!==C&&T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!A[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&U.test(t)){(s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=k),o=(l=h(t)).length;while(o--)l[o]="#"+s+" "+xe(l[o]);c=l.join(","),f=ee.test(t)&&ye(e.parentNode)||e}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){A(t,!0)}finally{s===k&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[k]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:m;return r!==C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),m!==C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=k,!C.getElementsByName||!C.getElementsByName(k).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+k+"-]").length||v.push("~="),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+k+"+*").length||v.push(".#.+[+~]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",$)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e===C||e.ownerDocument===m&&y(m,e)?-1:t===C||t.ownerDocument===m&&y(m,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===C?-1:t===C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]===m?-1:s[r]===m?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if((e.ownerDocument||e)!==C&&T(e),d.matchesSelector&&E&&!A[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){A(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=p[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&p(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?k.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?k.grep(e,function(e){return e===n!==r}):"string"!=typeof n?k.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(k.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:L.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof k?t[0]:t,k.merge(this,k.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),D.test(r[1])&&k.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(k):k.makeArray(e,this)}).prototype=k.fn,q=k(E);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}k.fn.extend({has:function(e){var t=k(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
    "],col:[2,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?k.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;nx",y.noCloneChecked=!!me.cloneNode(!0).lastChild.defaultValue;var Te=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ee=/^([^.]*)(?:\.(.+)|)/;function ke(){return!0}function Se(){return!1}function Ne(e,t){return e===function(){try{return E.activeElement}catch(e){}}()==("focus"===t)}function Ae(e,t,n,r,i,o){var a,s;if("object"==typeof t){for(s in"string"!=typeof n&&(r=r||n,n=void 0),t)Ae(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=Se;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return k().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=k.guid++)),e.each(function(){k.event.add(this,t,i,r,n)})}function De(e,i,o){o?(Q.set(e,i,!1),k.event.add(e,i,{namespace:!1,handler:function(e){var t,n,r=Q.get(this,i);if(1&e.isTrigger&&this[i]){if(r.length)(k.event.special[i]||{}).delegateType&&e.stopPropagation();else if(r=s.call(arguments),Q.set(this,i,r),t=o(this,i),this[i](),r!==(n=Q.get(this,i))||t?Q.set(this,i,!1):n={},r!==n)return e.stopImmediatePropagation(),e.preventDefault(),n.value}else r.length&&(Q.set(this,i,{value:k.event.trigger(k.extend(r[0],k.Event.prototype),r.slice(1),this)}),e.stopImmediatePropagation())}})):void 0===Q.get(e,i)&&k.event.add(e,i,ke)}k.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.get(t);if(v){n.handler&&(n=(o=n).handler,i=o.selector),i&&k.find.matchesSelector(ie,i),n.guid||(n.guid=k.guid++),(u=v.events)||(u=v.events={}),(a=v.handle)||(a=v.handle=function(e){return"undefined"!=typeof k&&k.event.triggered!==e.type?k.event.dispatch.apply(t,arguments):void 0}),l=(e=(e||"").match(R)||[""]).length;while(l--)d=g=(s=Ee.exec(e[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=k.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=k.event.special[d]||{},c=k.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&k.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(d,a)),f.add&&(f.add.call(t,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),k.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.hasData(e)&&Q.get(e);if(v&&(u=v.events)){l=(t=(t||"").match(R)||[""]).length;while(l--)if(d=g=(s=Ee.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d){f=k.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;while(o--)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||k.removeEvent(e,d,v.handle),delete u[d])}else for(d in u)k.event.remove(e,d+t[l],n,r,!0);k.isEmptyObject(u)&&Q.remove(e,"handle events")}},dispatch:function(e){var t,n,r,i,o,a,s=k.event.fix(e),u=new Array(arguments.length),l=(Q.get(this,"events")||{})[s.type]||[],c=k.event.special[s.type]||{};for(u[0]=s,t=1;t\x20\t\r\n\f]*)[^>]*)\/>/gi,qe=/\s*$/g;function Oe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&k(e).children("tbody")[0]||e}function Pe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Re(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Me(e,t){var n,r,i,o,a,s,u,l;if(1===t.nodeType){if(Q.hasData(e)&&(o=Q.access(e),a=Q.set(t,o),l=o.events))for(i in delete a.handle,a.events={},l)for(n=0,r=l[i].length;n")},clone:function(e,t,n){var r,i,o,a,s,u,l,c=e.cloneNode(!0),f=oe(e);if(!(y.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||k.isXMLDoc(e)))for(a=ve(c),r=0,i=(o=ve(e)).length;r").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Vt,Gt=[],Yt=/(=)\?(?=&|$)|\?\?/;k.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Gt.pop()||k.expando+"_"+kt++;return this[e]=!0,e}}),k.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Yt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Yt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Yt,"$1"+r):!1!==e.jsonp&&(e.url+=(St.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||k.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?k(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Gt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Vt=E.implementation.createHTMLDocument("").body).innerHTML="
    ",2===Vt.childNodes.length),k.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=D.exec(e))?[t.createElement(i[1])]:(i=we([e],t,o),o&&o.length&&k(o).remove(),k.merge([],i.childNodes)));var r,i,o},k.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(k.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},k.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){k.fn[t]=function(e){return this.on(t,e)}}),k.expr.pseudos.animated=function(t){return k.grep(k.timers,function(e){return t===e.elem}).length},k.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=k.css(e,"position"),c=k(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=k.css(e,"top"),u=k.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,k.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},k.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){k.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===k.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===k.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=k(e).offset()).top+=k.css(e,"borderTopWidth",!0),i.left+=k.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-k.css(r,"marginTop",!0),left:t.left-i.left-k.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===k.css(e,"position"))e=e.offsetParent;return e||ie})}}),k.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;k.fn[t]=function(e){return _(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),k.each(["top","left"],function(e,n){k.cssHooks[n]=ze(y.pixelPosition,function(e,t){if(t)return t=_e(e,n),$e.test(t)?k(e).position()[n]+"px":t})}),k.each({Height:"height",Width:"width"},function(a,s){k.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){k.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return _(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?k.css(e,t,i):k.style(e,t,n,i)},s,n?e:void 0,n)}})}),k.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){k.fn[n]=function(e,t){return 0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,j=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
    "],col:[2,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function qe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function Le(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function He(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
    ",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):("number"==typeof f.top&&(f.top+="px"),"number"==typeof f.left&&(f.left+="px"),c.css(f))}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=$e(y.pixelPosition,function(e,t){if(t)return t=Be(e,n),Me.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0
    - INVALID_INPUT: إدخال غير صحيح في + VALIDATION_FAIL: 'فشل التحقق من صحة:' + INVALID_INPUT: 'إدخال غير صحيح في' MISSING_REQUIRED_FIELD: 'حقل مطلوب مفقود:' MONTHS_OF_THE_YEAR: - 'كانون الثاني' @@ -61,3 +72,11 @@ GRAV: - 'الجمعة' - 'السبت' - 'الأحد' + CRON: + EVERY: كل + EVERY_HOUR: كل ساعة + EVERY_MINUTE: كل دقيقة + EVERY_DAY_OF_WEEK: كل يوم في الأسبوع + EVERY_DAY_OF_MONTH: كل يوم في الشهر + EVERY_MONTH: ' كل شهر' + TEXT_PERIOD: كل diff --git a/system/languages/bg.yaml b/system/languages/bg.yaml index 48daceb..174b3a4 100644 --- a/system/languages/bg.yaml +++ b/system/languages/bg.yaml @@ -36,8 +36,8 @@ GRAV: YR_PLURAL: г DEC_PLURAL: дстлт FORM: - VALIDATION_FAIL: Неуспешна проверка: - INVALID_INPUT: Невалидно въвеждане в + VALIDATION_FAIL: 'Неуспешна проверка:' + INVALID_INPUT: 'Невалидно въвеждане в' MISSING_REQUIRED_FIELD: 'Липсва задължително поле:' MONTHS_OF_THE_YEAR: - 'януари' diff --git a/system/languages/ca.yaml b/system/languages/ca.yaml index bd04474..7795aef 100644 --- a/system/languages/ca.yaml +++ b/system/languages/ca.yaml @@ -1,6 +1,15 @@ --- GRAV: FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# S'ha produït un error: Frontmatter invàlid\n\nRuta: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_UNCOUNTABLE: + - 'equipment' + - 'informació' + - 'rice' + - 'money' + - 'species' + - 'series' + - 'fish' + - 'sheep' NICETIME: NO_DATE_PROVIDED: No s'ha proporcionat data BAD_DATE: Data invàlida @@ -36,8 +45,8 @@ GRAV: YR_PLURAL: anys DEC_PLURAL: dèc. FORM: - VALIDATION_FAIL: Ha fallat la validació: - INVALID_INPUT: Entrada no vàlida a + VALIDATION_FAIL: 'Ha fallat la validació:' + INVALID_INPUT: 'Entrada no vàlida a' MISSING_REQUIRED_FIELD: 'Falta camp obligatori:' MONTHS_OF_THE_YEAR: - 'Gener' diff --git a/system/languages/cs.yaml b/system/languages/cs.yaml index 80844c9..bfad423 100644 --- a/system/languages/cs.yaml +++ b/system/languages/cs.yaml @@ -101,9 +101,10 @@ GRAV: YR_PLURAL: r DEC_PLURAL: dek FORM: - VALIDATION_FAIL: Ověření se nezdařilo: - INVALID_INPUT: Neplatný vstup v + VALIDATION_FAIL: 'Ověření se nezdařilo:' + INVALID_INPUT: 'Neplatný vstup v' MISSING_REQUIRED_FIELD: 'Chybí požadované pole:' + XSS_ISSUES: "Byly zjištěny možné problémy XSS v poli '%s'" MONTHS_OF_THE_YEAR: - 'leden' - 'únor' @@ -125,6 +126,8 @@ GRAV: - 'pátek' - 'sobota' - 'neděle' + YES: "Ano" + NO: "Ne" CRON: EVERY: každý EVERY_HOUR: každou hodinu diff --git a/system/languages/da.yaml b/system/languages/da.yaml index 17ce683..f23477c 100644 --- a/system/languages/da.yaml +++ b/system/languages/da.yaml @@ -1,11 +1,27 @@ --- GRAV: FRONTMATTER_ERROR_PAGE: "---\nTitel: %1$s\n---\n\n# Fejl: Ugyldigt frontmatter\n\nSti: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_UNCOUNTABLE: + - 'udstyr' + - 'information' + - 'ris' + - 'penge' + - 'arter' + - 'Serier' + - 'fisk' + - 'får' + INFLECTOR_IRREGULAR: + 'person': 'personer' + 'man': 'mænd' + 'child': 'børn' + 'sex': 'køn' + 'move': 'flyt' NICETIME: NO_DATE_PROVIDED: Ingen dato angivet BAD_DATE: Ugyldig dato AGO: siden FROM_NOW: fra nu + JUST_NOW: lige nu SECOND: sekund MINUTE: minut HOUR: time @@ -15,6 +31,7 @@ GRAV: YEAR: år DECADE: årti SEC: sek + MIN: min. HR: t WK: u MO: md @@ -36,8 +53,8 @@ GRAV: YR_PLURAL: år DEC_PLURAL: årtier FORM: - VALIDATION_FAIL: Validering mislykkedes: - INVALID_INPUT: Ugyldigt input i + VALIDATION_FAIL: 'Validering mislykkedes:' + INVALID_INPUT: 'Ugyldigt input i' MISSING_REQUIRED_FIELD: 'Mangler obligatorisk felt:' MONTHS_OF_THE_YEAR: - 'januar' @@ -60,3 +77,14 @@ GRAV: - 'fredag' - 'lørdag' - 'søndag' + CRON: + EVERY: hver + EVERY_HOUR: hver time + EVERY_MINUTE: hvert minut + EVERY_DAY_OF_WEEK: alle ugens dage + EVERY_DAY_OF_MONTH: alle dage i måneden + EVERY_MONTH: hver måned + TEXT_PERIOD: Hver + TEXT_MINS: ' ved minut(ter) over timen' + ERROR1: Tagget %s understøttes ikke! + ERROR2: Ugyldigt antal elementer diff --git a/system/languages/de.yaml b/system/languages/de.yaml index e9a2d4b..45c24e7 100644 --- a/system/languages/de.yaml +++ b/system/languages/de.yaml @@ -101,9 +101,10 @@ GRAV: YR_PLURAL: Jahre DEC_PLURAL: Jahrzehnten FORM: - VALIDATION_FAIL: Überprüfung fehlgeschlagen: - INVALID_INPUT: Ungültige Eingabe in + VALIDATION_FAIL: 'Überprüfung fehlgeschlagen:' + INVALID_INPUT: 'Ungültige Eingabe in' MISSING_REQUIRED_FIELD: 'Erforderliches Feld fehlt:' + XSS_ISSUES: "Potenzielle XSS-Probleme im Feld '%s' erkannt" MONTHS_OF_THE_YEAR: - 'Januar' - 'Februar' @@ -125,6 +126,8 @@ GRAV: - 'Freitag' - 'Samstag' - 'Sonntag' + YES: "Ja" + NO: "Nein" CRON: EVERY: jede EVERY_HOUR: jede Stunde diff --git a/system/languages/el.yaml b/system/languages/el.yaml index 1faa8dd..28619f8 100644 --- a/system/languages/el.yaml +++ b/system/languages/el.yaml @@ -1,11 +1,75 @@ --- GRAV: FRONTMATTER_ERROR_PAGE: "---\nΤίτλος: %1$s\n---\n\n# Σφάλμα: Μη έγκυρη διαδρομή Frontmatter\n\nΔιαδρομή: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'εξοπλισμός' + - 'πληροφοριες' + - 'rice' + - 'χρήματα' + - 'είδη' + - 'σειρές' + - 'ψάρι' + - 'πρόβατο' + INFLECTOR_IRREGULAR: + 'person': 'άνθρωποι' + 'man': 'άνδρες' + 'child': 'παιδιά' + 'sex': 'φύλο' + 'move': 'κινήσεις' + INFLECTOR_ORDINALS: + 'default': 'th' + 'first': 'st' + 'second': 'nd' + 'third': 'rd' NICETIME: NO_DATE_PROVIDED: Δεν δόθηκε καμία ημερομηνία BAD_DATE: Εσφαλμένη ημερομηνία AGO: πρίν FROM_NOW: από τώρα + JUST_NOW: μόλις τώρα SECOND: δευτερόλεπτο MINUTE: λεπτό HOUR: ώρα @@ -37,8 +101,8 @@ GRAV: YR_PLURAL: έτη DEC_PLURAL: δεκαετίες FORM: - VALIDATION_FAIL: Η επικύρωση απέτυχε: - INVALID_INPUT: Μη έγκυρα δεδομένα σε + VALIDATION_FAIL: 'Η επικύρωση απέτυχε:' + INVALID_INPUT: 'Μη έγκυρα δεδομένα σε' MISSING_REQUIRED_FIELD: 'Λείπει το απαιτούμενο πεδίο:' MONTHS_OF_THE_YEAR: - 'Ιανουάριος' @@ -61,3 +125,20 @@ GRAV: - 'Παρασκευή' - 'Σάββατο' - 'Κυριακή' + CRON: + EVERY: κάθε + EVERY_HOUR: κάθε ώρα + EVERY_MINUTE: κάθε λεπτό + EVERY_DAY_OF_WEEK: κάθε μέρα της εβδομάδος + EVERY_DAY_OF_MONTH: κάθε μέρα του μήνα + EVERY_MONTH: κάθε μήνα + TEXT_PERIOD: Κάθε + TEXT_MINS: ' κατά λεπτό(ά) μετά την ώρα' + TEXT_TIME: ' στο :' + TEXT_DOW: ' στις ' + TEXT_MONTH: ' από ' + TEXT_DOM: ' στις ' + ERROR1: Η ετικέτα %s δεν υποστηρίζεται! + ERROR2: Μη έγκυρος αριθμός στοιχείων + ERROR3: Το jquery_element θα έπρεπε να οριστεί στις ρυθμίσεις του jqCron + ERROR4: Μη αναγνωρισμένη έκφραση diff --git a/system/languages/en.yaml b/system/languages/en.yaml index 7c351e6..7a8a68c 100644 --- a/system/languages/en.yaml +++ b/system/languages/en.yaml @@ -94,9 +94,10 @@ GRAV: YR_PLURAL: yrs DEC_PLURAL: decs FORM: - VALIDATION_FAIL: Validation failed: - INVALID_INPUT: Invalid input in - MISSING_REQUIRED_FIELD: Missing required field: + VALIDATION_FAIL: 'Validation failed:' + INVALID_INPUT: 'Invalid input in' + MISSING_REQUIRED_FIELD: 'Missing required field:' + XSS_ISSUES: "Potential XSS issues detected in '%s' field" MONTHS_OF_THE_YEAR: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] DAYS_OF_THE_WEEK: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] YES: "Yes" diff --git a/system/languages/es.yaml b/system/languages/es.yaml index 7bacfeb..95827ee 100644 --- a/system/languages/es.yaml +++ b/system/languages/es.yaml @@ -1,19 +1,25 @@ --- GRAV: - FRONTMATTER_ERROR_PAGE: "---\ntítulo: %1$s\n---\n\n# Error: Frontmatter no válido\n\nRuta: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + FRONTMATTER_ERROR_PAGE: "---\ntítulo: %1$s\n---\n\n# Error: Prefacio no válido\n\nRuta: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1ios' + '/s$/i': 's' + '/$/': 's' INFLECTOR_UNCOUNTABLE: - - 'equipo' + - 'equipamiento' - 'información' - - 'rice' + - 'arroz' - 'dinero' - - 'species' + - 'especies' - 'series' - 'pescado' - 'oveja' INFLECTOR_IRREGULAR: + 'person': 'personas' 'man': 'hombres' 'child': 'niños' 'sex': 'sexos' + 'move': 'movido' INFLECTOR_ORDINALS: 'first': 'ro' 'second': 'do' @@ -33,10 +39,12 @@ GRAV: YEAR: año DECADE: década SEC: seg + MIN: min HR: h WK: sem MO: mes YR: año + DEC: dic SECOND_PLURAL: segundos MINUTE_PLURAL: minutos HOUR_PLURAL: horas @@ -46,6 +54,7 @@ GRAV: YEAR_PLURAL: años DECADE_PLURAL: décadas SEC_PLURAL: segs + MIN_PLURAL: mins HR_PLURAL: hs WK_PLURAL: sem MO_PLURAL: mes @@ -55,6 +64,7 @@ GRAV: VALIDATION_FAIL: 'Falló la validación: ' INVALID_INPUT: 'Dato inválido en: ' MISSING_REQUIRED_FIELD: 'Falta el campo requerido: ' + XSS_ISSUES: "Se detectaron problemas XSS potenciales en el campo '%s'" MONTHS_OF_THE_YEAR: - 'Enero' - 'Febrero' @@ -76,6 +86,8 @@ GRAV: - 'Viernes' - 'Sábado' - 'Domingo' + YES: "Si" + NO: "No" CRON: EVERY: cada EVERY_HOUR: cada hora diff --git a/system/languages/et.yaml b/system/languages/et.yaml index 619aa1a..e5659d3 100644 --- a/system/languages/et.yaml +++ b/system/languages/et.yaml @@ -1,11 +1,22 @@ --- GRAV: FRONTMATTER_ERROR_PAGE: "---\npealkiri: %1$s\n---\n\n# Viga: vigane Frontmatter'i\n\nasukoht: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(octop|vir)us$/i': '\1i' + INFLECTOR_SINGULAR: + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/(x|ch|ss|sh)es$/i': '\1' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' INFLECTOR_UNCOUNTABLE: - 'equipment' - 'informatsioon' - - 'rice' - - 'money' + - 'riis' + - 'raha' - 'species' - 'series' - 'kala' @@ -34,6 +45,7 @@ GRAV: YEAR: aasta DECADE: 10 aastat SEC: sek + MIN: min HR: t WK: näd MO: k. @@ -55,7 +67,7 @@ GRAV: YR_PLURAL: aastat DEC_PLURAL: dek. FORM: - VALIDATION_FAIL: Kinnitamine nurjus: + VALIDATION_FAIL: 'Kinnitamine nurjus:' INVALID_INPUT: 'Vigane sisend:' MISSING_REQUIRED_FIELD: 'Nõutud väli puudub:' MONTHS_OF_THE_YEAR: @@ -81,5 +93,12 @@ GRAV: - 'pühapäev' CRON: EVERY: iga + EVERY_HOUR: iga tund + EVERY_MINUTE: iga minut + EVERY_DAY_OF_WEEK: iga nädala päev EVERY_MONTH: iga kuu TEXT_PERIOD: Iga + ERROR1: Silt %s pole toetatud! + ERROR2: Vale elementide arv + ERROR3: jqCron seadetes peaks olema määratud jquery_element + ERROR4: Tundmatu väljend diff --git a/system/languages/eu.yaml b/system/languages/eu.yaml index 4a9a58e..91c3c8e 100644 --- a/system/languages/eu.yaml +++ b/system/languages/eu.yaml @@ -36,8 +36,8 @@ GRAV: YR_PLURAL: urt DEC_PLURAL: ham FORM: - VALIDATION_FAIL: Balidazioak huts egin du - INVALID_INPUT: Baliogabeko sarrera + VALIDATION_FAIL: 'Balidazioak huts egin du' + INVALID_INPUT: 'Baliogabeko sarrera' MISSING_REQUIRED_FIELD: 'Derrigorrezko eremua bete gabe:' MONTHS_OF_THE_YEAR: - 'Urtarrila' diff --git a/system/languages/fa.yaml b/system/languages/fa.yaml index 186f79d..96b96dc 100644 --- a/system/languages/fa.yaml +++ b/system/languages/fa.yaml @@ -36,8 +36,8 @@ GRAV: YR_PLURAL: سال DEC_PLURAL: دهه FORM: - VALIDATION_FAIL: سنجش اعتبار ناموفق بود - INVALID_INPUT: ورودی نامعتبر در + VALIDATION_FAIL: 'سنجش اعتبار ناموفق بود' + INVALID_INPUT: 'ورودی نامعتبر در' MISSING_REQUIRED_FIELD: 'قسمت ضروری جا افتاده:' MONTHS_OF_THE_YEAR: - 'ژانویه' diff --git a/system/languages/fi.yaml b/system/languages/fi.yaml index e84c19f..4cb547c 100644 --- a/system/languages/fi.yaml +++ b/system/languages/fi.yaml @@ -100,8 +100,8 @@ GRAV: YR_PLURAL: v DEC_PLURAL: vuosikymmentä FORM: - VALIDATION_FAIL: Vahvistus epäonnistui: - INVALID_INPUT: Syöte ei kelpaa + VALIDATION_FAIL: 'Vahvistus epäonnistui:' + INVALID_INPUT: 'Syöte ei kelpaa' MISSING_REQUIRED_FIELD: 'Puuttuva pakollinen kenttä:' MONTHS_OF_THE_YEAR: - 'Tammikuu' diff --git a/system/languages/fr.yaml b/system/languages/fr.yaml index 9284f7e..d9fa177 100644 --- a/system/languages/fr.yaml +++ b/system/languages/fr.yaml @@ -16,6 +16,7 @@ GRAV: '/(buffal|tomat)o$/i': '\1es' '/(bu)s$/i': 'Bus' '/(alias|status)/i': 'alias|status' + '/(octop|vir)us$/i': 'virus' '/(ax|test)is$/i': '\1s' '/s$/i': 's' '/$/': 's' @@ -80,30 +81,30 @@ GRAV: YR_PLURAL: a DEC_PLURAL: décs FORM: - VALIDATION_FAIL: La validation a échoué : - INVALID_INPUT: Saisie non valide + VALIDATION_FAIL: 'La validation a échoué :' + INVALID_INPUT: 'Saisie non valide' MISSING_REQUIRED_FIELD: 'Champ obligatoire manquant :' MONTHS_OF_THE_YEAR: - - 'Janvier' - - 'Février' - - 'Mars' - - 'Avril' - - 'Mai' - - 'Juin' - - 'Juillet' - - 'Août' - - 'Septembre' - - 'Octobre' - - 'Novembre' - - 'Décembre' + - 'janvier' + - 'février' + - 'mars' + - 'avril' + - 'mai' + - 'juin' + - 'juillet' + - 'août' + - 'septembre' + - 'octobre' + - 'novembre' + - 'décembre' DAYS_OF_THE_WEEK: - - 'Lundi' - - 'Mardi' - - 'Mercredi' - - 'Jeudi' - - 'Vendredi' - - 'Samedi' - - 'Dimanche' + - 'lundi' + - 'mardi' + - 'mercredi' + - 'jeudi' + - 'vendredi' + - 'samedi' + - 'dimanche' CRON: EVERY: chaque EVERY_HOUR: toutes les heures diff --git a/system/languages/gl.yaml b/system/languages/gl.yaml new file mode 100644 index 0000000..b016c59 --- /dev/null +++ b/system/languages/gl.yaml @@ -0,0 +1,144 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntítulo: %1$s\n---\n\n# Erro: Limiar incorrecto\n\nRuta: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1' + '/(octop|vir)us$/i': '\1' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1ces' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1' + '/(cris|ax|test)es$/i': '\1es' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1se' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2se' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'equipo' + - 'información' + - 'arroz' + - 'diñeiro' + - 'especies' + - 'series' + - 'peixe' + - 'ovella' + INFLECTOR_IRREGULAR: + 'person': 'xente' + 'man': 'home' + 'child': 'neno' + 'sex': 'sexos' + 'move': 'move' + INFLECTOR_ORDINALS: + 'default': 'º' + 'first': 'º' + 'second': 'º' + 'third': 'º' + NICETIME: + NO_DATE_PROVIDED: Non fornece unha data + BAD_DATE: Data errada + AGO: hai + FROM_NOW: dende agora + JUST_NOW: xusto agora + SECOND: segundo + MINUTE: minuto + HOUR: hora + DAY: día + WEEK: semana + MONTH: mes + YEAR: ano + DECADE: década + SEC: seg + MIN: min + HR: hr + WK: Sem + MO: m + YR: a + DEC: dec + SECOND_PLURAL: segundos + MINUTE_PLURAL: minutos + HOUR_PLURAL: horas + DAY_PLURAL: días + WEEK_PLURAL: semanas + MONTH_PLURAL: meses + YEAR_PLURAL: anos + DECADE_PLURAL: décadas + SEC_PLURAL: segs + MIN_PLURAL: mins + HR_PLURAL: hrs + WK_PLURAL: sem + MO_PLURAL: mes + YR_PLURAL: a + DEC_PLURAL: deca + FORM: + VALIDATION_FAIL: 'Fallou a validación:' + INVALID_INPUT: 'Entrada incorrecta en' + MISSING_REQUIRED_FIELD: 'Falta un campo requirido:' + MONTHS_OF_THE_YEAR: + - 'xaneiro' + - 'febreiro' + - 'marzo' + - 'abril' + - 'maio' + - 'xuño' + - 'xullo' + - 'agosto' + - 'setembro' + - 'outubro' + - 'novembro' + - 'decembro' + DAYS_OF_THE_WEEK: + - 'luns' + - 'martes' + - 'mércores' + - 'xoves' + - 'venres' + - 'sábado' + - 'domingo' + CRON: + EVERY: cada + EVERY_HOUR: Cada hora + EVERY_MINUTE: Cada minuto + EVERY_DAY_OF_WEEK: cada día da semana + EVERY_DAY_OF_MONTH: cada día do mes + EVERY_MONTH: cada mes + TEXT_PERIOD: Cada + TEXT_MINS: ' dentro de minuto(s) despois da hora' + TEXT_TIME: ' dentro :' + TEXT_DOW: ' o ' + TEXT_MONTH: ' de ' + TEXT_DOM: ' o ' + ERROR1: A etiqueta %s non é compatíbel! + ERROR2: Mal número de elementos + ERROR3: O jquery_element debería estar determinado na configuración de jqCron + ERROR4: Expresión non recoñecida diff --git a/system/languages/he.yaml b/system/languages/he.yaml index afb63d7..d7f4318 100644 --- a/system/languages/he.yaml +++ b/system/languages/he.yaml @@ -37,8 +37,8 @@ GRAV: YR_PLURAL: שני' DEC_PLURAL: עש' FORM: - VALIDATION_FAIL: האימות נכשל: - INVALID_INPUT: קלט לא חוקי + VALIDATION_FAIL: 'האימות נכשל:' + INVALID_INPUT: 'קלט לא חוקי' MISSING_REQUIRED_FIELD: 'שדות חובה חסרים:' MONTHS_OF_THE_YEAR: - 'ינואר' diff --git a/system/languages/hr.yaml b/system/languages/hr.yaml index 1b530b5..4827d3e 100644 --- a/system/languages/hr.yaml +++ b/system/languages/hr.yaml @@ -50,8 +50,8 @@ GRAV: YR_PLURAL: g DEC_PLURAL: des FORM: - VALIDATION_FAIL: Validacija nije uspjela: - INVALID_INPUT: Pogrešan unos u + VALIDATION_FAIL: 'Validacija nije uspjela:' + INVALID_INPUT: 'Pogrešan unos u' MISSING_REQUIRED_FIELD: 'Nedostaje obavezno polje:' MONTHS_OF_THE_YEAR: - 'Siječanj' diff --git a/system/languages/hu.yaml b/system/languages/hu.yaml index a834c45..2624cf9 100644 --- a/system/languages/hu.yaml +++ b/system/languages/hu.yaml @@ -58,7 +58,7 @@ GRAV: YR_PLURAL: év DEC_PLURAL: évt FORM: - VALIDATION_FAIL: Érvényesítés nem sikerült: + VALIDATION_FAIL: 'Érvényesítés nem sikerült:' INVALID_INPUT: 'A megadott érték érvénytelen:' MISSING_REQUIRED_FIELD: 'Ez a kötelező mező nincs kitöltve:' MONTHS_OF_THE_YEAR: diff --git a/system/languages/id.yaml b/system/languages/id.yaml index 41431f1..690959d 100644 --- a/system/languages/id.yaml +++ b/system/languages/id.yaml @@ -1,6 +1,8 @@ --- GRAV: FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Error: Frontmatter tidak valid\n\nLokasi: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' INFLECTOR_UNCOUNTABLE: - 'peralatan' - 'informasi' @@ -53,8 +55,8 @@ GRAV: YR_PLURAL: thn DEC_PLURAL: dekade FORM: - VALIDATION_FAIL: Validasi gagal: - INVALID_INPUT: Input tidak valid di + VALIDATION_FAIL: 'Validasi gagal:' + INVALID_INPUT: 'Input tidak valid di' MISSING_REQUIRED_FIELD: 'Data yang diperlukan belum terisi:' MONTHS_OF_THE_YEAR: - 'Januari' diff --git a/system/languages/is.yaml b/system/languages/is.yaml index 6f9761c..00d42ee 100644 --- a/system/languages/is.yaml +++ b/system/languages/is.yaml @@ -46,8 +46,8 @@ GRAV: YR_PLURAL: árum DEC_PLURAL: árat FORM: - VALIDATION_FAIL: Sannvottun mistókst: - INVALID_INPUT: Ógilt inntak í + VALIDATION_FAIL: 'Sannvottun mistókst:' + INVALID_INPUT: 'Ógilt inntak í' MISSING_REQUIRED_FIELD: 'Vantar nauðsynlegan reit:' MONTHS_OF_THE_YEAR: - 'janúar' diff --git a/system/languages/it.yaml b/system/languages/it.yaml index 741f721..f366eb6 100644 --- a/system/languages/it.yaml +++ b/system/languages/it.yaml @@ -1,6 +1,49 @@ --- GRAV: FRONTMATTER_ERROR_PAGE: "---Titolo: %1$s---# Errore: Frontmatter non valido: '%2$s' * *%3$s * * ' '%4$s ' '" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' INFLECTOR_UNCOUNTABLE: - 'dotazione' - 'informazione' @@ -58,9 +101,10 @@ GRAV: YR_PLURAL: anni DEC_PLURAL: decenni FORM: - VALIDATION_FAIL: Validazione fallita: - INVALID_INPUT: Input non valido in + VALIDATION_FAIL: 'Validazione fallita:' + INVALID_INPUT: 'Input non valido in' MISSING_REQUIRED_FIELD: 'Campo richiesto mancante:' + XSS_ISSUES: "Rilevati potenziali problemi di XSS nel campo '%s'" MONTHS_OF_THE_YEAR: - 'Gennaio' - 'Febbraio' @@ -82,6 +126,8 @@ GRAV: - 'Venerdì' - 'Sabato' - 'Domenica' + YES: "Sì" + NO: "No" CRON: EVERY: ogni EVERY_HOUR: ogni ora diff --git a/system/languages/ja.yaml b/system/languages/ja.yaml index 0e3fa16..701d8e7 100644 --- a/system/languages/ja.yaml +++ b/system/languages/ja.yaml @@ -1,11 +1,22 @@ --- GRAV: + INFLECTOR_UNCOUNTABLE: + - 'equipment' + - '情報' + - 'rice' + - 'お金' + - 'species' + - 'series' + - '魚' + - 'ヒツジ' INFLECTOR_IRREGULAR: 'person': 'みんな' 'man': '人' 'child': '子供' 'sex': '性別' 'move': '移動' + INFLECTOR_ORDINALS: + 'first': '番目' NICETIME: NO_DATE_PROVIDED: 日付が設定されていません BAD_DATE: 不正な日付 @@ -40,8 +51,8 @@ GRAV: YR_PLURAL: 年 DEC_PLURAL: 10年 FORM: - VALIDATION_FAIL: バリデーション失敗 : - INVALID_INPUT: 不正な入力: + VALIDATION_FAIL: 'バリデーション失敗 :' + INVALID_INPUT: '不正な入力:' MISSING_REQUIRED_FIELD: '必須項目が入力されていません:' MONTHS_OF_THE_YEAR: - '1月' @@ -64,3 +75,7 @@ GRAV: - '金' - '土' - '日' + CRON: + EVERY: 毎 + EVERY_MONTH: 毎月 + ERROR1: 共有タイプ %s はサポートされていません diff --git a/system/languages/ko.yaml b/system/languages/ko.yaml index 6676a6d..15aee8e 100644 --- a/system/languages/ko.yaml +++ b/system/languages/ko.yaml @@ -37,8 +37,8 @@ GRAV: YR_PLURAL: 년 DEC_PLURAL: 년간 FORM: - VALIDATION_FAIL: 유효성 검사 실패: - INVALID_INPUT: 잘못된 입력 + VALIDATION_FAIL: '유효성 검사 실패:' + INVALID_INPUT: '잘못된 입력' MISSING_REQUIRED_FIELD: '누락 된 필수 필드:' MONTHS_OF_THE_YEAR: - '일월' diff --git a/system/languages/lt.yaml b/system/languages/lt.yaml index aca0fc3..d894b96 100644 --- a/system/languages/lt.yaml +++ b/system/languages/lt.yaml @@ -52,8 +52,8 @@ GRAV: YR_PLURAL: m. DEC_PLURAL: dešimtmečiai FORM: - VALIDATION_FAIL: Patvirtinimas nepavyko: - INVALID_INPUT: Neteisingai įvesta į + VALIDATION_FAIL: 'Patvirtinimas nepavyko:' + INVALID_INPUT: 'Neteisingai įvesta į' MISSING_REQUIRED_FIELD: 'Būtina užpildyti laukelį:' MONTHS_OF_THE_YEAR: - 'Sausis' diff --git a/system/languages/nl.yaml b/system/languages/nl.yaml index 9706d03..bd0aed9 100644 --- a/system/languages/nl.yaml +++ b/system/languages/nl.yaml @@ -101,8 +101,8 @@ GRAV: YR_PLURAL: jaren DEC_PLURAL: decennia FORM: - VALIDATION_FAIL: Validatie mislukt: - INVALID_INPUT: Ongeldige invoer in + VALIDATION_FAIL: 'Validatie mislukt:' + INVALID_INPUT: 'Ongeldige invoer in' MISSING_REQUIRED_FIELD: 'Ontbrekend verplicht veld:' MONTHS_OF_THE_YEAR: - 'Januari' diff --git a/system/languages/no.yaml b/system/languages/no.yaml index 5b57938..2a93e6e 100644 --- a/system/languages/no.yaml +++ b/system/languages/no.yaml @@ -21,6 +21,7 @@ GRAV: BAD_DATE: Ugyldig dato AGO: siden FROM_NOW: fra nå + JUST_NOW: akkurat nå SECOND: sekund MINUTE: minutt HOUR: time @@ -51,8 +52,8 @@ GRAV: YR_PLURAL: år DEC_PLURAL: årtier FORM: - VALIDATION_FAIL: Godkjenning mislyktes: - INVALID_INPUT: Ugyldig innhold i + VALIDATION_FAIL: 'Godkjenning mislyktes:' + INVALID_INPUT: 'Ugyldig innhold i' MISSING_REQUIRED_FIELD: 'Mangler påkrevd felt:' MONTHS_OF_THE_YEAR: - 'januar' @@ -75,3 +76,7 @@ GRAV: - 'fredag' - 'lørdag' - 'søndag' + CRON: + EVERY: hver + EVERY_HOUR: hver time + EVERY_MINUTE: hvert minutt diff --git a/system/languages/pl.yaml b/system/languages/pl.yaml index bf6fe37..6da24e5 100644 --- a/system/languages/pl.yaml +++ b/system/languages/pl.yaml @@ -1,11 +1,32 @@ --- GRAV: FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Error: Nieprawidłowy Frontmatter\n\nPath: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_SINGULAR: + '/(alias|status)es$/i': '\1' + INFLECTOR_UNCOUNTABLE: + - 'wyposażenie' + - 'informacja' + - 'rice' + - 'pieniądze' + - 'species' + - 'series' + - 'ryba' + - 'owca' + INFLECTOR_IRREGULAR: + 'person': 'człowiek' + 'man': 'mężczyźni' + 'child': 'dzieci' + 'sex': 'płci' + INFLECTOR_ORDINALS: + 'first': 'pierwszy' + 'second': 'drugi' + 'third': 'trzeci' NICETIME: NO_DATE_PROVIDED: Nie podano daty BAD_DATE: Zła data AGO: temu FROM_NOW: od teraz + JUST_NOW: właśnie teraz SECOND: sekunda MINUTE: minuta HOUR: godzina @@ -15,6 +36,7 @@ GRAV: YEAR: rok DECADE: dekada SEC: sek + MIN: minuta HR: godz WK: tydz MO: m-c @@ -36,9 +58,10 @@ GRAV: YR_PLURAL: lat DEC_PLURAL: dekad FORM: - VALIDATION_FAIL: Weryfikacja nie powiodła się: - INVALID_INPUT: Nieprawidłowe dane wejściowe + VALIDATION_FAIL: 'Weryfikacja nie powiodła się:' + INVALID_INPUT: 'Nieprawidłowe dane wejściowe' MISSING_REQUIRED_FIELD: 'Opuszczono wymagane pole:' + XSS_ISSUES: "Potencjalne problemy XSS wykryte w polu '%s'" MONTHS_OF_THE_YEAR: - 'Styczeń' - 'Luty' @@ -60,3 +83,18 @@ GRAV: - 'Piątek' - 'Sobota' - 'Niedziela' + YES: "Tak" + NO: "Nie" + CRON: + EVERY: każdy + EVERY_HOUR: każdą godzinę + EVERY_MINUTE: każdą minutę + EVERY_DAY_OF_WEEK: każdego dnia tygodnia + EVERY_DAY_OF_MONTH: każdego dnia miesiące + EVERY_MONTH: każdego miesiąca + TEXT_PERIOD: Każdego + TEXT_MINS: 'o minut po godzinie' + TEXT_TIME: 'o :' + ERROR1: Znacznik %s nie jest wspierany! + ERROR2: Nieprawidłowa liczba elementów + ERROR4: Wyrażenie nierozpoznane diff --git a/system/languages/pt.yaml b/system/languages/pt.yaml index 590b5e5..2da6944 100644 --- a/system/languages/pt.yaml +++ b/system/languages/pt.yaml @@ -1,8 +1,75 @@ --- GRAV: + FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Erro: Frontmatter Inválido\n\nLocalização: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'equipamento' + - 'informação' + - 'arroz' + - 'dinheiro' + - 'espécie' + - 'série' + - 'peixe' + - 'ovelha' + INFLECTOR_IRREGULAR: + 'person': 'pessoas' + 'man': 'homens' + 'child': 'crianças' + 'sex': 'sexos' + 'move': 'movimentos' + INFLECTOR_ORDINALS: + 'default': 'º' + 'first': 'º' + 'second': 'º' + 'third': 'º' NICETIME: NO_DATE_PROVIDED: Nenhuma data fornecida + BAD_DATE: Data inválida AGO: atrás + FROM_NOW: a partir de agora + JUST_NOW: mesmo agora SECOND: segundo MINUTE: minuto HOUR: hora @@ -11,17 +78,32 @@ GRAV: MONTH: mês YEAR: ano DECADE: década - SEC: segundos - MIN: minutos + SEC: seg + MIN: min + HR: hora + WK: semana + MO: mês + YR: ano + DEC: década + SECOND_PLURAL: segundos MINUTE_PLURAL: minutos + HOUR_PLURAL: horas DAY_PLURAL: dias WEEK_PLURAL: semanas MONTH_PLURAL: meses YEAR_PLURAL: anos - DECADE_PLURAL: decadas + DECADE_PLURAL: décadas + SEC_PLURAL: segs + MIN_PLURAL: mins + HR_PLURAL: hrs + WK_PLURAL: sems + MO_PLURAL: meses + YR_PLURAL: anos + DEC_PLURAL: décadas FORM: - VALIDATION_FAIL: Falha na validação! - MISSING_REQUIRED_FIELD: 'Campo obrigatório requerido:' + VALIDATION_FAIL: 'Falha na validação:' + INVALID_INPUT: 'Dados inseridos são inválidos em' + MISSING_REQUIRED_FIELD: 'Campo obrigatório em falta:' MONTHS_OF_THE_YEAR: - 'Janeiro' - 'Fevereiro' @@ -35,12 +117,28 @@ GRAV: - 'Outubro' - 'Novembro' - 'Dezembro' - INFLECTOR_UNCOUNTABLE: - - 'equipment' - - 'information' - - 'arroz' - - 'money' - - 'species' - - 'series' - - 'fish' - - 'sheep' + DAYS_OF_THE_WEEK: + - 'Segunda-feira' + - 'Terça-feira' + - 'Quarta-feira' + - 'Quinta-feira' + - 'Sexta-feira' + - 'Sábado' + - 'Domingo' + CRON: + EVERY: cada + EVERY_HOUR: cada hora + EVERY_MINUTE: cada minuto + EVERY_DAY_OF_WEEK: todos os dias da semana + EVERY_DAY_OF_MONTH: todos os dias do mês + EVERY_MONTH: todos os meses + TEXT_PERIOD: Cada + TEXT_MINS: ' em minuto(s) após a hora' + TEXT_TIME: ' em :' + TEXT_DOW: ' em ' + TEXT_MONTH: ' de ' + TEXT_DOM: ' em ' + ERROR1: A tag %s não é suportada! + ERROR2: Número de elementos inválido + ERROR3: O jquery_element deve ser definido nas configurações do jqCron + ERROR4: Expressão não reconhecida diff --git a/system/languages/ro.yaml b/system/languages/ro.yaml index 5bee578..dc22b20 100644 --- a/system/languages/ro.yaml +++ b/system/languages/ro.yaml @@ -53,8 +53,8 @@ GRAV: YR_PLURAL: ani DEC_PLURAL: decenii FORM: - VALIDATION_FAIL: Validare nereușită - INVALID_INPUT: Date incorecte în + VALIDATION_FAIL: 'Validare nereușită' + INVALID_INPUT: 'Date incorecte în' MISSING_REQUIRED_FIELD: 'Câmp obligatoriu lipsă:' MONTHS_OF_THE_YEAR: - 'Ianuarie' diff --git a/system/languages/ru.yaml b/system/languages/ru.yaml index 78c5963..2097ada 100644 --- a/system/languages/ru.yaml +++ b/system/languages/ru.yaml @@ -13,7 +13,7 @@ GRAV: INFLECTOR_IRREGULAR: 'person': 'люди' 'man': 'человек' - 'child': 'ребенок' + 'child': 'дети' 'sex': 'пол' 'move': 'движется' INFLECTOR_ORDINALS: @@ -58,9 +58,10 @@ GRAV: YR_PLURAL: г DEC_PLURAL: дстлт FORM: - VALIDATION_FAIL: Проверка не удалась: - INVALID_INPUT: Неверный ввод в + VALIDATION_FAIL: 'Проверка не удалась:' + INVALID_INPUT: 'Неверный ввод в' MISSING_REQUIRED_FIELD: 'Отсутствует необходимое поле:' + XSS_ISSUES: "Обнаружены потенциальные XSS проблемы в поле '%s'" MONTHS_OF_THE_YEAR: - 'январь' - 'февраль' @@ -68,12 +69,12 @@ GRAV: - 'апрель' - 'май' - 'июнь' - - 'Июль' - - 'Август' - - 'Сентябрь' - - 'Октябрь' - - 'Ноябрь' - - 'Декабрь' + - 'июль' + - 'август' + - 'сентябрь' + - 'октябрь' + - 'ноябрь' + - 'декабрь' DAYS_OF_THE_WEEK: - 'понедельник' - 'вторник' @@ -82,6 +83,8 @@ GRAV: - 'пятница' - 'суббота' - 'воскресенье' + YES: "Да" + NO: "Нет" CRON: EVERY: раз в EVERY_HOUR: раз в час diff --git a/system/languages/sk.yaml b/system/languages/sk.yaml index 89a7f56..9543239 100644 --- a/system/languages/sk.yaml +++ b/system/languages/sk.yaml @@ -101,8 +101,8 @@ GRAV: YR_PLURAL: rokov DEC_PLURAL: dekád FORM: - VALIDATION_FAIL: Overenie zlyhalo: - INVALID_INPUT: Neplatný vstup v + VALIDATION_FAIL: 'Overenie zlyhalo:' + INVALID_INPUT: 'Neplatný vstup v' MISSING_REQUIRED_FIELD: 'Chýba vyžadované pole:' MONTHS_OF_THE_YEAR: - 'Január' diff --git a/system/languages/sl.yaml b/system/languages/sl.yaml index ae6032c..879fe1b 100644 --- a/system/languages/sl.yaml +++ b/system/languages/sl.yaml @@ -36,8 +36,8 @@ GRAV: YR_PLURAL: l DEC_PLURAL: des FORM: - VALIDATION_FAIL: Preverjanje veljavnosti ni uspelo: - INVALID_INPUT: Neveljaven vnos v + VALIDATION_FAIL: 'Preverjanje veljavnosti ni uspelo:' + INVALID_INPUT: 'Neveljaven vnos v' MISSING_REQUIRED_FIELD: 'Manjka obvezno polje:' MONTHS_OF_THE_YEAR: - 'Januar' diff --git a/system/languages/sr.yaml b/system/languages/sr.yaml new file mode 100644 index 0000000..3368bdf --- /dev/null +++ b/system/languages/sr.yaml @@ -0,0 +1,144 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\nнаслов: %1$s\n---\n\n# Грешка: неисправан Frontmatter\n\nПутања: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - 'опрема' + - 'информација' + - 'пиринач' + - 'новац' + - 'врсте' + - 'серије' + - 'риба' + - 'овца' + INFLECTOR_IRREGULAR: + 'person': 'особе' + 'man': 'људи' + 'child': 'деца' + 'sex': 'полови' + 'move': 'помери' + INFLECTOR_ORDINALS: + 'default': 'ти' + 'first': 'први' + 'second': 'други' + 'third': 'трећи' + NICETIME: + NO_DATE_PROVIDED: Нема датума + BAD_DATE: Погрешан датум + AGO: од пре + FROM_NOW: од сада + JUST_NOW: управо сада + SECOND: секунда + MINUTE: минута + HOUR: сат + DAY: дан + WEEK: недеља + MONTH: месец + YEAR: година + DECADE: декада + SEC: сек + MIN: мин + HR: сат + WK: нед + MO: мес + YR: год + DEC: дек + SECOND_PLURAL: секунди + MINUTE_PLURAL: минута + HOUR_PLURAL: сати + DAY_PLURAL: дана + WEEK_PLURAL: недеља + MONTH_PLURAL: месеци + YEAR_PLURAL: године(а) + DECADE_PLURAL: декаде(а) + SEC_PLURAL: сек + MIN_PLURAL: мин + HR_PLURAL: сати + WK_PLURAL: недеља + MO_PLURAL: месеци + YR_PLURAL: година + DEC_PLURAL: декада + FORM: + VALIDATION_FAIL: 'Провера неуспела:' + INVALID_INPUT: 'Неисправан унос у' + MISSING_REQUIRED_FIELD: 'Недостаје обавезн поље:' + MONTHS_OF_THE_YEAR: + - 'Јануар' + - 'Фебруар' + - 'Март' + - 'Април' + - 'Мај' + - 'Јуни' + - 'Јули' + - 'Август' + - 'Септембар' + - 'Октобар' + - 'Новембар' + - 'Децембар' + DAYS_OF_THE_WEEK: + - 'Понедељак' + - 'Уторак' + - 'Среда' + - 'Четвртак' + - 'Петак' + - 'Субота' + - 'Недеља' + CRON: + EVERY: сваки + EVERY_HOUR: сваки сат + EVERY_MINUTE: сваки минут + EVERY_DAY_OF_WEEK: сваки дан у недељи + EVERY_DAY_OF_MONTH: сваки дан у месецу + EVERY_MONTH: сваки месец + TEXT_PERIOD: Сваки + TEXT_MINS: ' у минути(а) прошлог сата' + TEXT_TIME: ' у :' + TEXT_DOW: ' на ' + TEXT_MONTH: ' од ' + TEXT_DOM: ' на ' + ERROR1: Таг %s није подржан! + ERROR2: Погрешан број елемената + ERROR3: јquery_element би требао да буде постављен у jqCron подешавању + ERROR4: Непрепознат израз diff --git a/system/languages/sv.yaml b/system/languages/sv.yaml index 16afa16..bf76bef 100644 --- a/system/languages/sv.yaml +++ b/system/languages/sv.yaml @@ -12,11 +12,21 @@ GRAV: - 'får' INFLECTOR_IRREGULAR: 'person': 'personer' + 'man': 'män' + 'child': 'barn' + 'sex': 'kön' + 'move': 'flytta' + INFLECTOR_ORDINALS: + 'default': ':e' + 'first': ':a' + 'second': ':a' + 'third': ':e' NICETIME: NO_DATE_PROVIDED: Inget datum har angivits BAD_DATE: Ogiltigt datum AGO: sedan FROM_NOW: fr.o.m nu + JUST_NOW: just nu SECOND: sekund MINUTE: minut HOUR: timme @@ -26,10 +36,12 @@ GRAV: YEAR: år DECADE: årtionde SEC: sek + MIN: min HR: t WK: v MO: m YR: år + DEC: dec SECOND_PLURAL: sekunder MINUTE_PLURAL: minuter HOUR_PLURAL: timmar @@ -46,8 +58,8 @@ GRAV: YR_PLURAL: år DEC_PLURAL: dec FORM: - VALIDATION_FAIL: Kontrollen misslyckades: - INVALID_INPUT: Ogiltig indata i + VALIDATION_FAIL: 'Kontrollen misslyckades:' + INVALID_INPUT: 'Ogiltig indata i' MISSING_REQUIRED_FIELD: 'Obligatoriskt fält måste fyllas i:' MONTHS_OF_THE_YEAR: - 'Januari' @@ -70,3 +82,19 @@ GRAV: - 'Fredag' - 'Lördag' - 'Söndag' + CRON: + EVERY: varje + EVERY_HOUR: varje timme + EVERY_MINUTE: varje minut + EVERY_DAY_OF_WEEK: varje veckodag + EVERY_DAY_OF_MONTH: alla månadens dagar + EVERY_MONTH: varje månad + TEXT_PERIOD: Varje + TEXT_MINS: ' timmens :e minut' + TEXT_TIME: ' kl :' + TEXT_DOW: ' ' + TEXT_MONTH: ' ' + TEXT_DOM: ' ' + ERROR1: Taggen %s stöds inte! + ERROR2: Ogiltigt antal element + ERROR4: Uttrycket känns inte igen diff --git a/system/languages/th.yaml b/system/languages/th.yaml index e6ccfab..f71b037 100644 --- a/system/languages/th.yaml +++ b/system/languages/th.yaml @@ -31,7 +31,7 @@ GRAV: YR_PLURAL: ปี FORM: VALIDATION_FAIL: 'ตรวจสอบล้มเหลว: ' - INVALID_INPUT: ป้อนข้อมูลไม่ถูกต้องใน + INVALID_INPUT: 'ป้อนข้อมูลไม่ถูกต้องใน' MISSING_REQUIRED_FIELD: 'ขาดข้อมูลที่จำเป็น:' MONTHS_OF_THE_YEAR: - 'มกราคม' diff --git a/system/languages/tr.yaml b/system/languages/tr.yaml index 2143f31..783674d 100644 --- a/system/languages/tr.yaml +++ b/system/languages/tr.yaml @@ -1,11 +1,32 @@ --- GRAV: FRONTMATTER_ERROR_PAGE: "---\nBaşlık: %1$s\n---\n\n# Hata: Geçersiz Önbölüm\n\nYol: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_UNCOUNTABLE: + - 'ekipman' + - 'bilgi' + - 'pirinç' + - 'para' + - 'türler' + - 'seriler' + - 'balık' + - 'koyun' + INFLECTOR_IRREGULAR: + 'person': 'kişi' + 'man': 'erkek' + 'child': 'çocuklar' + 'sex': 'cinsiyet' + 'move': 'taşınmış' + INFLECTOR_ORDINALS: + 'default': '#F' + 'first': ' 1.' + 'second': ' 2.' + 'third': ' 3.' NICETIME: NO_DATE_PROVIDED: Sağlanan tarih yok BAD_DATE: Yanlış tarih AGO: önce FROM_NOW: şu andan itibaren + JUST_NOW: şimdi SECOND: saniye MINUTE: dakika HOUR: saat @@ -37,8 +58,8 @@ GRAV: YR_PLURAL: yıl DEC_PLURAL: onyl FORM: - VALIDATION_FAIL: Doğrulama başarısız: - INVALID_INPUT: Geçersiz bilgi girişi + VALIDATION_FAIL: 'Doğrulama başarısız:' + INVALID_INPUT: 'Geçersiz bilgi girişi' MISSING_REQUIRED_FIELD: 'Gerekli alan eksik:' MONTHS_OF_THE_YEAR: - 'Ocak' @@ -61,3 +82,17 @@ GRAV: - 'Cuma' - 'Cumartesi' - 'Pazar' + CRON: + EVERY: her + EVERY_HOUR: saatte bir + EVERY_MINUTE: dakikada bir + EVERY_DAY_OF_WEEK: haftanın her günü + EVERY_DAY_OF_MONTH: ayın her günü + EVERY_MONTH: her ay + TEXT_PERIOD: Her + TEXT_MINS: ' saatin dakikasında' + TEXT_TIME: ' da' + ERROR1: Etiket %s desteklenmiyor! + ERROR2: Kötü eleman sayısı + ERROR3: jquery_element jqCron ayarları içinde tanımlanmalı + ERROR4: Tanınmayan ifade diff --git a/system/languages/uk.yaml b/system/languages/uk.yaml index d264fba..8a138a4 100644 --- a/system/languages/uk.yaml +++ b/system/languages/uk.yaml @@ -37,8 +37,8 @@ GRAV: YR_PLURAL: рр. DEC_PLURAL: рр. FORM: - VALIDATION_FAIL: Перевірка не вдалася: - INVALID_INPUT: Невірне введення в + VALIDATION_FAIL: 'Перевірка не вдалася:' + INVALID_INPUT: 'Невірне введення в' MISSING_REQUIRED_FIELD: 'Відсутнє обов''язкове поле:' MONTHS_OF_THE_YEAR: - 'Січень' diff --git a/system/languages/vi.yaml b/system/languages/vi.yaml index 0dc7e92..9e3a0f4 100644 --- a/system/languages/vi.yaml +++ b/system/languages/vi.yaml @@ -37,8 +37,8 @@ GRAV: YR_PLURAL: năm DEC_PLURAL: thập kỷ FORM: - VALIDATION_FAIL: Xác nhận thất bại: - INVALID_INPUT: Dữ liệu nhập không hợp lệ cho + VALIDATION_FAIL: 'Xác nhận thất bại:' + INVALID_INPUT: 'Dữ liệu nhập không hợp lệ cho' MISSING_REQUIRED_FIELD: 'Thiếu trường bắt buộc:' MONTHS_OF_THE_YEAR: - 'Tháng 1' diff --git a/system/languages/zh-cn.yaml b/system/languages/zh-cn.yaml new file mode 100644 index 0000000..334b0b4 --- /dev/null +++ b/system/languages/zh-cn.yaml @@ -0,0 +1,144 @@ +--- +GRAV: + FRONTMATTER_ERROR_PAGE: "---\n标题: %1$s\n---\n\n# 错误:无效参数\n\n位置: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```" + INFLECTOR_PLURALS: + '/(quiz)$/i': '\1zes' + '/^(ox)$/i': '\1en' + '/([m|l])ouse$/i': '\1ice' + '/(matr|vert|ind)ix|ex$/i': '\1ices' + '/(x|ch|ss|sh)$/i': '\1es' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([^aeiouy]|qu)y$/i': '\1ies' + '/(hive)$/i': '\1s' + '/(?:([^f])fe|([lr])f)$/i': '\1\2ves' + '/sis$/i': 'ses' + '/([ti])um$/i': '\1a' + '/(buffal|tomat)o$/i': '\1oes' + '/(bu)s$/i': '\1ses' + '/(alias|status)/i': '\1es' + '/(octop|vir)us$/i': '\1i' + '/(ax|test)is$/i': '\1es' + '/s$/i': 's' + '/$/': 's' + INFLECTOR_SINGULAR: + '/(quiz)zes$/i': '\1' + '/(matr)ices$/i': '\1ix' + '/(vert|ind)ices$/i': '\1ex' + '/^(ox)en/i': '\1' + '/(alias|status)es$/i': '\1' + '/([octop|vir])i$/i': '\1us' + '/(cris|ax|test)es$/i': '\1is' + '/(shoe)s$/i': '\1' + '/(o)es$/i': '\1' + '/(bus)es$/i': '\1' + '/([m|l])ice$/i': '\1ouse' + '/(x|ch|ss|sh)es$/i': '\1' + '/(m)ovies$/i': '\1ovie' + '/(s)eries$/i': '\1eries' + '/([^aeiouy]|qu)ies$/i': '\1y' + '/([lr])ves$/i': '\1f' + '/(tive)s$/i': '\1' + '/(hive)s$/i': '\1' + '/([^f])ves$/i': '\1fe' + '/(^analy)ses$/i': '\1sis' + '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis' + '/([ti])a$/i': '\1um' + '/(n)ews$/i': '\1ews' + INFLECTOR_UNCOUNTABLE: + - '装备' + - '信息' + - '大米' + - '钱' + - '物种' + - '系列' + - '鱼' + - '羊' + INFLECTOR_IRREGULAR: + 'person': '人员' + 'man': '男人' + 'child': '儿童' + 'sex': '性别' + 'move': '移动' + INFLECTOR_ORDINALS: + 'default': 'th' + 'first': 'st' + 'second': 'md' + 'third': 'rd' + NICETIME: + NO_DATE_PROVIDED: 无日期信息 + BAD_DATE: 无效日期 + AGO: 前 + FROM_NOW: 距今 + JUST_NOW: 刚刚 + SECOND: 秒 + MINUTE: 分钟 + HOUR: 小时 + DAY: 天 + WEEK: 周 + MONTH: 月 + YEAR: 年 + DECADE: 十年 + SEC: 秒 + MIN: 分钟 + HR: 小时 + WK: 周 + MO: 月 + YR: 年 + DEC: 年代 + SECOND_PLURAL: 秒 + MINUTE_PLURAL: 分 + HOUR_PLURAL: 小时 + DAY_PLURAL: 天 + WEEK_PLURAL: 周 + MONTH_PLURAL: 月 + YEAR_PLURAL: 年 + DECADE_PLURAL: 十年 + SEC_PLURAL: 秒 + MIN_PLURAL: 分 + HR_PLURAL: 时 + WK_PLURAL: 周 + MO_PLURAL: 月 + YR_PLURAL: 年 + DEC_PLURAL: 年代 + FORM: + VALIDATION_FAIL: '验证失败:' + INVALID_INPUT: '无效输入' + MISSING_REQUIRED_FIELD: '必填字段缺失:' + MONTHS_OF_THE_YEAR: + - '1月' + - '2月' + - '3月' + - '4月' + - '5月' + - '6月' + - '7月' + - '8月' + - '9月' + - '10月' + - '11月' + - '12月' + DAYS_OF_THE_WEEK: + - '星期一' + - '星期二' + - '星期三' + - '星期四' + - '星期五' + - '星期六' + - '星期日' + CRON: + EVERY: 每隔 + EVERY_HOUR: 每小时 + EVERY_MINUTE: 每分钟 + EVERY_DAY_OF_WEEK: 一周中的每一天 + EVERY_DAY_OF_MONTH: 月份中的每一天 + EVERY_MONTH: 每月 + TEXT_PERIOD: 所有 + TEXT_MINS: ' 在 小时过后的分钟' + TEXT_TIME: ' 在 :' + TEXT_DOW: ' on ' + TEXT_MONTH: ' of ' + TEXT_DOM: ' on ' + ERROR1: 不支持分享类型 %s + ERROR2: 无效数字 + ERROR3: 请在 jqCron 设置中设定 jquery_element + ERROR4: 无法识别表达式 diff --git a/system/languages/zh.yaml b/system/languages/zh.yaml index 4912aa9..334b0b4 100644 --- a/system/languages/zh.yaml +++ b/system/languages/zh.yaml @@ -101,9 +101,9 @@ GRAV: YR_PLURAL: 年 DEC_PLURAL: 年代 FORM: - VALIDATION_FAIL: 验证失败: - INVALID_INPUT: 无效输入 - MISSING_REQUIRED_FIELD: 必填字段缺失: + VALIDATION_FAIL: '验证失败:' + INVALID_INPUT: '无效输入' + MISSING_REQUIRED_FIELD: '必填字段缺失:' MONTHS_OF_THE_YEAR: - '1月' - '2月' diff --git a/system/pages/notfound.md b/system/pages/notfound.md index ca7d3de..9228664 100644 --- a/system/pages/notfound.md +++ b/system/pages/notfound.md @@ -2,4 +2,5 @@ title: Not Found routable: false notfound: true +expires: 0 --- diff --git a/system/router.php b/system/router.php index 5e3c477..187d4d8 100644 --- a/system/router.php +++ b/system/router.php @@ -3,7 +3,7 @@ /** * @package Grav\Core * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -20,6 +20,7 @@ if (is_file($_SERVER['DOCUMENT_ROOT'] . DIRECTORY_SEPARATOR . $_SERVER['SCRIPT_N $grav_index = 'index.php'; /* Check the GRAV_BASEDIR environment variable and use if set */ + $grav_basedir = getenv('GRAV_BASEDIR') ?: ''; if ($grav_basedir) { $grav_index = ltrim($grav_basedir, '/') . DIRECTORY_SEPARATOR . $grav_index; diff --git a/system/src/Grav/Common/Assets.php b/system/src/Grav/Common/Assets.php index 74ba296..51a2531 100644 --- a/system/src/Grav/Common/Assets.php +++ b/system/src/Grav/Common/Assets.php @@ -3,30 +3,41 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; +use Closure; use Grav\Common\Assets\Pipeline; use Grav\Common\Assets\Traits\LegacyAssetsTrait; use Grav\Common\Assets\Traits\TestingAssetsTrait; use Grav\Common\Config\Config; use Grav\Framework\Object\PropertyObject; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use function call_user_func_array; +use function count; +use function func_get_args; +use function is_array; +/** + * Class Assets + * @package Grav\Common + */ class Assets extends PropertyObject { use TestingAssetsTrait; use LegacyAssetsTrait; + const CSS = 'css'; + const JS = 'js'; const CSS_COLLECTION = 'assets_css'; const JS_COLLECTION = 'assets_js'; - const CSS_TYPE = 'Css'; - const JS_TYPE = 'Js'; - const INLINE_CSS_TYPE = 'InlineCss'; - const INLINE_JS_TYPE = 'InlineJs'; + const CSS_TYPE = Assets\Css::class; + const JS_TYPE = Assets\Js::class; + const INLINE_CSS_TYPE = Assets\InlineCss::class; + const INLINE_JS_TYPE = Assets\InlineJs::class; /** @const Regex to match CSS and JavaScript files */ const DEFAULT_REGEX = '/.\.(css|js)$/i'; @@ -37,31 +48,57 @@ class Assets extends PropertyObject /** @const Regex to match JavaScript files */ const JS_REGEX = '/.\.js$/i'; + /** @var string */ protected $assets_dir; + /** @var string */ protected $assets_url; + /** @var array */ protected $assets_css = []; + /** @var array */ protected $assets_js = []; - // Config Options + // Following variables come from the configuration: + /** @var bool */ protected $css_pipeline; + /** @var bool */ protected $css_pipeline_include_externals; + /** @var bool */ protected $css_pipeline_before_excludes; + /** @var bool */ + protected $inlinecss_pipeline_include_externals; + /** @var bool */ + protected $inlinecss_pipeline_before_excludes; + /** @var bool */ protected $js_pipeline; + /** @var bool */ protected $js_pipeline_include_externals; + /** @var bool */ protected $js_pipeline_before_excludes; + /** @var bool */ + protected $inlinejs_pipeline_include_externals; + /** @var bool */ + protected $inlinejs_pipeline_before_excludes; + /** @var array */ protected $pipeline_options = []; - + /** @var Closure|string */ protected $fetch_command; + /** @var string */ protected $autoload; + /** @var bool */ protected $enable_asset_timestamp; + /** @var array|null */ protected $collections; + /** @var string */ protected $timestamp; - + /** @var array Keeping track for order counts (for sorting) */ + protected $order = []; /** * Initialization called in the Grav lifecycle to initialize the Assets with appropriate configuration + * + * @return void */ public function init() { @@ -92,7 +129,6 @@ class Assets extends PropertyObject * assets and/or collections that will be automatically added on startup. * * @param array $config Configurable options. - * * @return $this */ public function config(array $config) @@ -119,35 +155,35 @@ class Assets extends PropertyObject * It automatically detects the asset type (JavaScript, CSS or collection). * You may add more than one asset passing an array as argument. * - * @param array|string $asset + * @param string|string[] $asset * @return $this */ public function add($asset) { - $args = \func_get_args(); + $args = func_get_args(); // More than one asset - if (\is_array($asset)) { + if (is_array($asset)) { foreach ($asset as $a) { array_shift($args); $args = array_merge([$a], $args); - \call_user_func_array([$this, 'add'], $args); + call_user_func_array([$this, 'add'], $args); } } elseif (isset($this->collections[$asset])) { array_shift($args); $args = array_merge([$this->collections[$asset]], $args); - \call_user_func_array([$this, 'add'], $args); + call_user_func_array([$this, 'add'], $args); } else { // Get extension $extension = pathinfo(parse_url($asset, PHP_URL_PATH), PATHINFO_EXTENSION); // JavaScript or CSS - if (\strlen($extension) > 0) { + if ($extension !== '') { $extension = strtolower($extension); if ($extension === 'css') { - \call_user_func_array([$this, 'addCss'], $args); + call_user_func_array([$this, 'addCss'], $args); } elseif ($extension === 'js') { - \call_user_func_array([$this, 'addJs'], $args); + call_user_func_array([$this, 'addJs'], $args); } } } @@ -155,12 +191,20 @@ class Assets extends PropertyObject return $this; } + /** + * @param string $collection + * @param string $type + * @param string|string[] $asset + * @param array $options + * @return $this + */ protected function addType($collection, $type, $asset, $options) { - if (\is_array($asset)) { + if (is_array($asset)) { foreach ($asset as $a) { $this->addType($collection, $type, $a, $options); } + return $this; } @@ -172,7 +216,7 @@ class Assets extends PropertyObject // If pipeline disabled, set to position if provided, else after if (isset($options['pipeline'])) { if ($options['pipeline'] === false) { - $exclude_type = ($type === $this::JS_TYPE || $type === $this::INLINE_JS_TYPE) ? $this::JS_TYPE : $this::CSS_TYPE; + $exclude_type = ($type === $this::JS_TYPE || $type === $this::INLINE_JS_TYPE) ? $this::JS : $this::CSS; $excludes = strtolower($exclude_type . '_pipeline_before_excludes'); if ($this->{$excludes}) { $default = 'after'; @@ -190,11 +234,18 @@ class Assets extends PropertyObject $options['timestamp'] = $this->timestamp; // Set order - $options['order'] = \count($this->$collection); + $group = $options['group'] ?? 'head'; + $position = $options['position'] ?? 'pipeline'; + + $orderKey = "{$type}|{$group}|{$position}"; + if (!isset($this->order[$orderKey])) { + $this->order[$orderKey] = 0; + } + + $options['order'] = $this->order[$orderKey]++; // Create asset of correct type - $asset_class = "\\Grav\\Common\\Assets\\{$type}"; - $asset_object = new $asset_class(); + $asset_object = new $type(); // If exists if ($asset_object->init($asset, $options)) { @@ -202,7 +253,6 @@ class Assets extends PropertyObject } return $this; - } /** @@ -212,7 +262,7 @@ class Assets extends PropertyObject */ public function addCss($asset) { - return $this->addType(Assets::CSS_COLLECTION,Assets::CSS_TYPE, $asset, $this->unifyLegacyArguments(\func_get_args(), Assets::CSS_TYPE)); + return $this->addType($this::CSS_COLLECTION, $this::CSS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::CSS_TYPE)); } /** @@ -222,7 +272,7 @@ class Assets extends PropertyObject */ public function addInlineCss($asset) { - return $this->addType(Assets::CSS_COLLECTION, Assets::INLINE_CSS_TYPE, $asset, $this->unifyLegacyArguments(\func_get_args(), Assets::INLINE_CSS_TYPE)); + return $this->addType($this::CSS_COLLECTION, $this::INLINE_CSS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::INLINE_CSS_TYPE)); } /** @@ -232,7 +282,7 @@ class Assets extends PropertyObject */ public function addJs($asset) { - return $this->addType(Assets::JS_COLLECTION, Assets::JS_TYPE, $asset, $this->unifyLegacyArguments(\func_get_args(), Assets::JS_TYPE)); + return $this->addType($this::JS_COLLECTION, $this::JS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::JS_TYPE)); } /** @@ -242,20 +292,19 @@ class Assets extends PropertyObject */ public function addInlineJs($asset) { - return $this->addType(Assets::JS_COLLECTION, Assets::INLINE_JS_TYPE, $asset, $this->unifyLegacyArguments(\func_get_args(), Assets::INLINE_JS_TYPE)); + return $this->addType($this::JS_COLLECTION, $this::INLINE_JS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::INLINE_JS_TYPE)); } /** * Add/replace collection. * - * @param string $collectionName - * @param array $assets + * @param string $collectionName + * @param array $assets * @param bool $overwrite - * * @return $this */ - public function registerCollection($collectionName, Array $assets, $overwrite = false) + public function registerCollection($collectionName, array $assets, $overwrite = false) { if ($overwrite || !isset($this->collections[$collectionName])) { $this->collections[$collectionName] = $assets; @@ -264,26 +313,33 @@ class Assets extends PropertyObject return $this; } + /** + * @param array $assets + * @param string $key + * @param string $value + * @param bool $sort + * @return array|false + */ protected function filterAssets($assets, $key, $value, $sort = false) { - $results = array_filter($assets, function($asset) use ($key, $value) { + $results = array_filter($assets, function ($asset) use ($key, $value) { if ($key === 'position' && $value === 'pipeline') { - $type = $asset->getType(); - if ($asset->getRemote() && $this->{$type . '_pipeline_include_externals'} === false && $asset['position'] === 'pipeline' ) { - if ($this->{$type . '_pipeline_before_excludes'}) { + if ($asset->getRemote() && $this->{strtolower($type) . '_pipeline_include_externals'} === false && $asset['position'] === 'pipeline') { + if ($this->{strtolower($type) . '_pipeline_before_excludes'}) { $asset->setPosition('after'); } else { $asset->setPosition('before'); } return false; } - } - if ($asset[$key] === $value) return true; + if ($asset[$key] === $value) { + return true; + } return false; }); @@ -295,17 +351,25 @@ class Assets extends PropertyObject return $results; } + /** + * @param array $assets + * @return array + */ protected function sortAssets($assets) { - uasort ($assets, function($a, $b) { - if ($a['priority'] == $b['priority']) { - return $a['order'] - $b['order']; - } - return $b['priority'] - $a['priority']; + uasort($assets, static function ($a, $b) { + return $b['priority'] <=> $a['priority'] ?: $a['order'] <=> $b['order']; }); + return $assets; } + /** + * @param string $type + * @param string $group + * @param array $attributes + * @return string + */ public function render($type, $group = 'head', $attributes = []) { $before_output = ''; @@ -352,7 +416,6 @@ class Assets extends PropertyObject * * @param string $group name of the group * @param array $attributes - * * @return string */ public function css($group = 'head', $attributes = []) @@ -365,7 +428,6 @@ class Assets extends PropertyObject * * @param string $group name of the group * @param array $attributes - * * @return string */ public function js($group = 'head', $attributes = []) diff --git a/system/src/Grav/Common/Assets/BaseAsset.php b/system/src/Grav/Common/Assets/BaseAsset.php index 49e3864..3e07974 100644 --- a/system/src/Grav/Common/Assets/BaseAsset.php +++ b/system/src/Grav/Common/Assets/BaseAsset.php @@ -3,18 +3,24 @@ /** * @package Grav\Common\Assets * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Assets; use Grav\Common\Assets\Traits\AssetUtilsTrait; +use Grav\Common\Config\Config; use Grav\Common\Grav; use Grav\Common\Uri; use Grav\Common\Utils; use Grav\Framework\Object\PropertyObject; +use SplFileInfo; +/** + * Class BaseAsset + * @package Grav\Common\Assets + */ abstract class BaseAsset extends PropertyObject { use AssetUtilsTrait; @@ -22,32 +28,46 @@ abstract class BaseAsset extends PropertyObject protected const CSS_ASSET = true; protected const JS_ASSET = false; - /** @const Regex to match CSS import content */ - protected const CSS_IMPORT_REGEX = '{@import(.*?);}'; - + /** @var string|false */ protected $asset; - + /** @var string */ protected $asset_type; + /** @var int */ protected $order; + /** @var string */ protected $group; + /** @var string */ protected $position; + /** @var int */ protected $priority; + /** @var array */ protected $attributes = []; - + /** @var string */ protected $timestamp; + /** @var int|false */ protected $modified; + /** @var bool */ protected $remote; + /** @var string */ protected $query = ''; // Private Bits - private $base_url; - private $fetch_command; + /** @var bool */ private $css_rewrite = false; + /** @var bool */ private $css_minify = false; + /** + * @return string + */ abstract function render(); + /** + * BaseAsset constructor. + * @param array $elements + * @param string|null $key + */ public function __construct(array $elements = [], $key = null) { $base_config = [ @@ -64,6 +84,11 @@ abstract class BaseAsset extends PropertyObject parent::__construct($elements, $key); } + /** + * @param string|false $asset + * @param array $options + * @return $this|false + */ public function init($asset, $options) { $config = Grav::instance()['config']; @@ -88,7 +113,6 @@ abstract class BaseAsset extends PropertyObject // Move this to render? if (!$this->remote) { - $asset_parts = parse_url($asset); if (isset($asset_parts['query'])) { $this->query = $asset_parts['query']; @@ -101,7 +125,7 @@ abstract class BaseAsset extends PropertyObject if ($locator->isStream($asset)) { $path = $locator->findResource($asset, true); } else { - $path = GRAV_ROOT . $asset; + $path = GRAV_WEBROOT . $asset; } // If local file is missing return @@ -109,7 +133,7 @@ abstract class BaseAsset extends PropertyObject return false; } - $file = new \SplFileInfo($path); + $file = new SplFileInfo($path); $asset = $this->buildLocalLink($file->getPathname()); @@ -122,22 +146,57 @@ abstract class BaseAsset extends PropertyObject return $this; } + /** + * @return string|false + */ public function getAsset() { return $this->asset; } + /** + * @return bool + */ public function getRemote() { return $this->remote; } + /** + * @param string $position + * @return $this + */ public function setPosition($position) { $this->position = $position; + return $this; } + /** + * Receive asset location and return the SRI integrity hash + * + * @param string $input + * @return string + */ + public static function integrityHash($input) + { + $grav = Grav::instance(); + + $assetsConfig = $grav['config']->get('system.assets'); + + if ( !empty($assetsConfig['enable_asset_sri']) && $assetsConfig['enable_asset_sri'] ) + { + $dataToHash = file_get_contents( GRAV_WEBROOT . $input); + + $hash = hash('sha256', $dataToHash, true); + $hash_base64 = base64_encode($hash); + return ' integrity="sha256-' . $hash_base64 . '"'; + } + + return ''; + } + /** * @@ -149,7 +208,7 @@ abstract class BaseAsset extends PropertyObject */ // protected function getLastModificationTime($asset) // { -// $file = GRAV_ROOT . $asset; +// $file = GRAV_WEBROOT . $asset; // if (Grav::instance()['locator']->isStream($asset)) { // $file = $this->buildLocalLink($asset, true); // } @@ -163,12 +222,12 @@ abstract class BaseAsset extends PropertyObject * * @param string $asset the asset string reference * - * @return string the final link url to the asset + * @return string|false the final link url to the asset */ protected function buildLocalLink($asset) { if ($asset) { - return $this->base_url . ltrim(Utils::replaceFirstOccurrence(GRAV_ROOT, '', $asset), '/'); + return $this->base_url . ltrim(Utils::replaceFirstOccurrence(GRAV_WEBROOT, '', $asset), '/'); } return false; } @@ -190,6 +249,7 @@ abstract class BaseAsset extends PropertyObject * @param string $file * @param string $dir * @param bool $local + * @return string */ protected function cssRewrite($file, $dir, $local) { diff --git a/system/src/Grav/Common/Assets/Css.php b/system/src/Grav/Common/Assets/Css.php index 405d8a3..4c6a9c9 100644 --- a/system/src/Grav/Common/Assets/Css.php +++ b/system/src/Grav/Common/Assets/Css.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Assets * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,8 +11,17 @@ namespace Grav\Common\Assets; use Grav\Common\Utils; +/** + * Class Css + * @package Grav\Common\Assets + */ class Css extends BaseAsset { + /** + * Css constructor. + * @param array $elements + * @param string|null $key + */ public function __construct(array $elements = [], $key = null) { $base_options = [ @@ -28,13 +37,16 @@ class Css extends BaseAsset parent::__construct($merged_attributes, $key); } + /** + * @return string + */ public function render() { if (isset($this->attributes['loading']) && $this->attributes['loading'] === 'inline') { - $buffer = $this->gatherLinks( [$this], self::CSS_ASSET); + $buffer = $this->gatherLinks([$this], self::CSS_ASSET); return "\n"; } - return 'renderAttributes() . ">\n"; + return 'renderAttributes() . $this->integrityHash($this->asset) . ">\n"; } } diff --git a/system/src/Grav/Common/Assets/InlineCss.php b/system/src/Grav/Common/Assets/InlineCss.php index c48dc48..f024a95 100644 --- a/system/src/Grav/Common/Assets/InlineCss.php +++ b/system/src/Grav/Common/Assets/InlineCss.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Assets * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,8 +11,17 @@ namespace Grav\Common\Assets; use Grav\Common\Utils; +/** + * Class InlineCss + * @package Grav\Common\Assets + */ class InlineCss extends BaseAsset { + /** + * InlineCss constructor. + * @param array $elements + * @param string|null $key + */ public function __construct(array $elements = [], $key = null) { $base_options = [ @@ -25,6 +34,9 @@ class InlineCss extends BaseAsset parent::__construct($merged_attributes, $key); } + /** + * @return string + */ public function render() { return 'renderAttributes(). ">\n" . trim($this->asset) . "\n\n"; diff --git a/system/src/Grav/Common/Assets/InlineJs.php b/system/src/Grav/Common/Assets/InlineJs.php index 11e4442..6787608 100644 --- a/system/src/Grav/Common/Assets/InlineJs.php +++ b/system/src/Grav/Common/Assets/InlineJs.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Assets * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,8 +11,17 @@ namespace Grav\Common\Assets; use Grav\Common\Utils; +/** + * Class InlineJs + * @package Grav\Common\Assets + */ class InlineJs extends BaseAsset { + /** + * InlineJs constructor. + * @param array $elements + * @param string|null $key + */ public function __construct(array $elements = [], $key = null) { $base_options = [ @@ -25,6 +34,9 @@ class InlineJs extends BaseAsset parent::__construct($merged_attributes, $key); } + /** + * @return string + */ public function render() { return 'renderAttributes(). ">\n" . trim($this->asset) . "\n\n"; diff --git a/system/src/Grav/Common/Assets/Js.php b/system/src/Grav/Common/Assets/Js.php index cce86d9..fc2a472 100644 --- a/system/src/Grav/Common/Assets/Js.php +++ b/system/src/Grav/Common/Assets/Js.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Assets * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,8 +11,17 @@ namespace Grav\Common\Assets; use Grav\Common\Utils; +/** + * Class Js + * @package Grav\Common\Assets + */ class Js extends BaseAsset { + /** + * Js constructor. + * @param array $elements + * @param string|null $key + */ public function __construct(array $elements = [], $key = null) { $base_options = [ @@ -24,13 +33,16 @@ class Js extends BaseAsset parent::__construct($merged_attributes, $key); } + /** + * @return string + */ public function render() { if (isset($this->attributes['loading']) && $this->attributes['loading'] === 'inline') { - $buffer = $this->gatherLinks( [$this], self::JS_ASSET); + $buffer = $this->gatherLinks([$this], self::JS_ASSET); return 'renderAttributes() . ">\n" . trim($buffer) . "\n\n"; } - return '\n"; + return '\n"; } } diff --git a/system/src/Grav/Common/Assets/Pipeline.php b/system/src/Grav/Common/Assets/Pipeline.php index b009585..7aef0e1 100644 --- a/system/src/Grav/Common/Assets/Pipeline.php +++ b/system/src/Grav/Common/Assets/Pipeline.php @@ -3,20 +3,28 @@ /** * @package Grav\Common\Assets * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Assets; +use Grav\Common\Assets\BaseAsset; use Grav\Common\Assets\Traits\AssetUtilsTrait; use Grav\Common\Config\Config; use Grav\Common\Grav; use Grav\Common\Uri; use Grav\Common\Utils; use Grav\Framework\Object\PropertyObject; +use MatthiasMullie\Minify\CSS; +use MatthiasMullie\Minify\JS; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use function array_key_exists; +/** + * Class Pipeline + * @package Grav\Common\Assets + */ class Pipeline extends PropertyObject { use AssetUtilsTrait; @@ -30,40 +38,42 @@ class Pipeline extends PropertyObject /** @const Regex to match CSS sourcemap comments */ protected const CSS_SOURCEMAP_REGEX = '{\/\*# (.*?) \*\/}'; - /** @const Regex to match CSS import content */ - protected const CSS_IMPORT_REGEX = '{@import(.*?);}'; - protected const FIRST_FORWARDSLASH_REGEX = '{^\/{1}\w}'; - protected $css_minify; - protected $css_minify_windows; - protected $css_rewrite; + // Following variables come from the configuration: + /** @var bool */ + protected $css_minify = false; + /** @var bool */ + protected $css_minify_windows = false; + /** @var bool */ + protected $css_rewrite = false; + /** @var bool */ + protected $css_pipeline_include_externals = true; + /** @var bool */ + protected $js_minify = false; + /** @var bool */ + protected $js_minify_windows = false; + /** @var bool */ + protected $js_pipeline_include_externals = true; - protected $js_minify; - protected $js_minify_windows; - - protected $base_url; + /** @var string */ protected $assets_dir; + /** @var string */ protected $assets_url; + /** @var string */ protected $timestamp; + /** @var array */ protected $attributes; - protected $query; + /** @var string */ + protected $query = ''; + /** @var string */ protected $asset; /** - * Closure used by the pipeline to fetch assets. - * - * Useful when file_get_contents() function is not available in your PHP - * installation or when you want to apply any kind of preprocessing to - * your assets before they get pipelined. - * - * The closure will receive as the only parameter a string with the path/URL of the asset and - * it should return the content of the asset file as a string. - * - * @var \Closure + * Pipeline constructor. + * @param array $elements + * @param string|null $key */ - protected $fetch_command; - public function __construct(array $elements = [], ?string $key = null) { parent::__construct($elements, $key); @@ -88,7 +98,6 @@ class Pipeline extends PropertyObject * @param array $assets * @param string $group * @param array $attributes - * * @return bool|string URL or generated content if available, else false */ public function renderCss($assets, $group, $attributes = []) @@ -125,7 +134,7 @@ class Pipeline extends PropertyObject // Minify if required if ($this->shouldMinify('css')) { - $minifier = new \MatthiasMullie\Minify\CSS(); + $minifier = new CSS(); $minifier->add($buffer); $buffer = $minifier->minify(); } @@ -140,7 +149,7 @@ class Pipeline extends PropertyObject $output = "\n"; } else { $this->asset = $relative_path; - $output = 'renderAttributes() . ">\n"; + $output = 'renderAttributes() . BaseAsset::integrityHash($this->asset) . ">\n"; } return $output; @@ -152,7 +161,6 @@ class Pipeline extends PropertyObject * @param array $assets * @param string $group * @param array $attributes - * * @return bool|string URL or generated content if available, else false */ public function renderJs($assets, $group, $attributes = []) @@ -189,7 +197,7 @@ class Pipeline extends PropertyObject // Minify if required if ($this->shouldMinify('js')) { - $minifier = new \MatthiasMullie\Minify\JS(); + $minifier = new JS(); $minifier->add($buffer); $buffer = $minifier->minify(); } @@ -204,7 +212,7 @@ class Pipeline extends PropertyObject $output = 'renderAttributes(). ">\n" . $buffer . "\n\n"; } else { $this->asset = $relative_path; - $output = '\n"; + $output = '\n"; } return $output; @@ -217,8 +225,7 @@ class Pipeline extends PropertyObject * @param string $file the css source file * @param string $dir , $local relative path to the css file * @param bool $local is this a local or remote asset - * - * @return mixed + * @return string */ protected function cssRewrite($file, $dir, $local) { @@ -244,16 +251,16 @@ class Pipeline extends PropertyObject $new_url = ($local ? $this->base_url: '') . $old_url; - $fixed = str_replace($matches[2], $new_url, $matches[0]); - - return $fixed; + return str_replace($matches[2], $new_url, $matches[0]); }, $file); return $file; } - - + /** + * @param string $type + * @return bool + */ private function shouldMinify($type = 'css') { $check = $type . '_minify'; diff --git a/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php b/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php index 0a1d149..902a7c5 100644 --- a/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php +++ b/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php @@ -3,17 +3,42 @@ /** * @package Grav\Common\Assets\Traits * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Assets\Traits; +use Closure; use Grav\Common\Grav; use Grav\Common\Utils; +use function dirname; +use function in_array; +use function is_array; +/** + * Trait AssetUtilsTrait + * @package Grav\Common\Assets\Traits + */ trait AssetUtilsTrait { + /** + * @var Closure|null + * + * Closure used by the pipeline to fetch assets. + * + * Useful when file_get_contents() function is not available in your PHP + * installation or when you want to apply any kind of preprocessing to + * your assets before they get pipelined. + * + * The closure will receive as the only parameter a string with the path/URL of the asset and + * it should return the content of the asset file as a string. + */ + protected $fetch_command; + + /** @var string */ + protected $base_url; + /** * Determine whether a link is local or remote. * Understands both "http://" and "https://" as well as protocol agnostic links "//" @@ -38,7 +63,6 @@ trait AssetUtilsTrait * * @param array $assets * @param bool $css - * * @return string */ protected function gatherLinks(array $assets, $css = true) @@ -57,7 +81,7 @@ trait AssetUtilsTrait if (0 === strpos($link, '//')) { $link = 'http:' . $link; } - $relative_dir = \dirname($relative_path); + $relative_dir = dirname($relative_path); } else { // Fix to remove relative dir if grav is in one if (($this->base_url !== '/') && Utils::startsWith($relative_path, $this->base_url)) { @@ -65,11 +89,12 @@ trait AssetUtilsTrait $relative_path = ltrim(preg_replace($base_url, '/', $link, 1), '/'); } - $relative_dir = \dirname($relative_path); - $link = ROOT_DIR . $relative_path; + $relative_dir = dirname($relative_path); + $link = GRAV_ROOT . '/' . $relative_path; } - $file = ($this->fetch_command instanceof \Closure) ? @$this->fetch_command->__invoke($link) : @file_get_contents($link); + // TODO: looks like this is not being used. + $file = $this->fetch_command instanceof Closure ? @$this->fetch_command->__invoke($link) : @file_get_contents($link); // No file found, skip it... if ($file === false) { @@ -102,14 +127,15 @@ trait AssetUtilsTrait * Moves @import statements to the top of the file per the CSS specification * * @param string $file the file containing the combined CSS files - * * @return string the modified file with any @imports at the top of the file */ protected function moveImports($file) { + $regex = '{@import.*?["\']([^"\']+)["\'].*?;}'; + $imports = []; - $file = (string)preg_replace_callback(self::CSS_IMPORT_REGEX, function ($matches) use (&$imports) { + $file = (string)preg_replace_callback($regex, function ($matches) use (&$imports) { $imports[] = $matches[0]; return ''; @@ -133,11 +159,11 @@ trait AssetUtilsTrait if (is_numeric($key)) { $key = $value; } - if (\is_array($value)) { + if (is_array($value)) { $value = implode(' ', $value); } - if (\in_array($key, $no_key, true)) { + if (in_array($key, $no_key, true)) { $element = htmlentities($value, ENT_QUOTES, 'UTF-8', false); } else { $element = $key . '="' . htmlentities($value, ENT_QUOTES, 'UTF-8', false) . '"'; @@ -152,7 +178,7 @@ trait AssetUtilsTrait /** * Render Querystring * - * @param string $asset + * @param string|null $asset * @return string */ protected function renderQueryString($asset = null) diff --git a/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php b/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php index 7f91c6d..36b2152 100644 --- a/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php +++ b/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php @@ -3,17 +3,23 @@ /** * @package Grav\Common\Assets\Traits * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Assets\Traits; use Grav\Common\Assets; +use function count; +use function is_array; +use function is_int; +/** + * Trait LegacyAssetsTrait + * @package Grav\Common\Assets\Traits + */ trait LegacyAssetsTrait { - /** * @param array $args * @param string $type @@ -39,12 +45,12 @@ trait LegacyAssetsTrait } switch ($type) { - case(Assets::JS_TYPE): + case (Assets::JS_TYPE): $defaults = ['priority' => null, 'pipeline' => true, 'loading' => null, 'group' => null]; $arguments = $this->createArgumentsFromLegacy($args, $defaults); break; - case(Assets::INLINE_JS_TYPE): + case (Assets::INLINE_JS_TYPE): $defaults = ['priority' => null, 'group' => null, 'attributes' => null]; $arguments = $this->createArgumentsFromLegacy($args, $defaults); @@ -61,13 +67,13 @@ trait LegacyAssetsTrait break; - case(Assets::INLINE_CSS_TYPE): + case (Assets::INLINE_CSS_TYPE): $defaults = ['priority' => null, 'group' => null]; $arguments = $this->createArgumentsFromLegacy($args, $defaults); break; default: - case(Assets::CSS_TYPE): + case (Assets::CSS_TYPE): $defaults = ['priority' => null, 'pipeline' => true, 'group' => null, 'loading' => null]; $arguments = $this->createArgumentsFromLegacy($args, $defaults); } @@ -75,6 +81,11 @@ trait LegacyAssetsTrait return $arguments; } + /** + * @param array $args + * @param array $defaults + * @return array + */ protected function createArgumentsFromLegacy(array $args, array $defaults) { // Remove arguments with old default values. @@ -97,8 +108,7 @@ trait LegacyAssetsTrait * @param int $priority * @param bool $pipeline * @param string $group name of the group - * - * @return \Grav\Common\Assets + * @return Assets * @deprecated Please use dynamic method with ['loading' => 'async']. */ public function addAsyncJs($asset, $priority = 10, $pipeline = true, $group = 'head') @@ -115,8 +125,7 @@ trait LegacyAssetsTrait * @param int $priority * @param bool $pipeline * @param string $group name of the group - * - * @return \Grav\Common\Assets + * @return Assets * @deprecated Please use dynamic method with ['loading' => 'defer']. */ public function addDeferJs($asset, $priority = 10, $pipeline = true, $group = 'head') @@ -125,5 +134,4 @@ trait LegacyAssetsTrait return $this->addJs($asset, $priority, $pipeline, 'defer', $group); } - } diff --git a/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php b/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php index 84b83e3..15dc00e 100644 --- a/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php +++ b/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php @@ -3,21 +3,29 @@ /** * @package Grav\Common\Assets\Traits * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Assets\Traits; +use FilesystemIterator; use Grav\Common\Grav; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use RegexIterator; +use function strlen; +/** + * Trait TestingAssetsTrait + * @package Grav\Common\Assets\Traits + */ trait TestingAssetsTrait { /** * Determines if an asset exists as a collection, CSS or JS reference * * @param string $asset - * * @return bool */ public function exists($asset) @@ -39,7 +47,6 @@ trait TestingAssetsTrait * Set the array of collections explicitly * * @param array $collections - * * @return $this */ public function setCollection($collections) @@ -54,7 +61,7 @@ trait TestingAssetsTrait * If a $key is provided, it will try to return only that asset * else it will return null * - * @param null|string $key the asset key + * @param string|null $key the asset key * @return array */ public function getCss($key = null) @@ -73,7 +80,7 @@ trait TestingAssetsTrait * If a $key is provided, it will try to return only that asset * else it will return null * - * @param null|string $key the asset key + * @param string|null $key the asset key * @return array */ public function getJs($key = null) @@ -91,7 +98,6 @@ trait TestingAssetsTrait * Set the whole array of CSS assets * * @param array $css - * * @return $this */ public function setCss($css) @@ -105,7 +111,6 @@ trait TestingAssetsTrait * Set the whole array of JS assets * * @param array $js - * * @return $this */ public function setJs($js) @@ -119,7 +124,6 @@ trait TestingAssetsTrait * Removes an item from the CSS array if set * * @param string $key The asset key - * * @return $this */ public function removeCss($key) @@ -136,7 +140,6 @@ trait TestingAssetsTrait * Removes an item from the JS array if set * * @param string $key The asset key - * * @return $this */ public function removeJs($key) @@ -153,7 +156,6 @@ trait TestingAssetsTrait * Sets the state of CSS Pipeline * * @param bool $value - * * @return $this */ public function setCssPipeline($value) @@ -167,7 +169,6 @@ trait TestingAssetsTrait * Sets the state of JS Pipeline * * @param bool $value - * * @return $this */ public function setJsPipeline($value) @@ -188,6 +189,7 @@ trait TestingAssetsTrait $this->resetJs(); $this->setCssPipeline(false); $this->setJsPipeline(false); + $this->order = []; return $this; } @@ -230,7 +232,7 @@ trait TestingAssetsTrait * Get the timestamp for assets * * @param bool $include_join - * @return string + * @return string|null */ public function getTimestamp($include_join = true) { @@ -246,12 +248,11 @@ trait TestingAssetsTrait * * @param string $directory Relative to the Grav root path, or a stream identifier * @param string $pattern (regex) - * * @return $this */ public function addDir($directory, $pattern = self::DEFAULT_REGEX) { - $root_dir = rtrim(ROOT_DIR, '/'); + $root_dir = GRAV_ROOT; // Check if $directory is a stream. if (strpos($directory, '://')) { @@ -296,7 +297,6 @@ trait TestingAssetsTrait * Add all JavaScript assets within $directory * * @param string $directory Relative to the Grav root path, or a stream identifier - * * @return $this */ public function addDirJs($directory) @@ -308,7 +308,6 @@ trait TestingAssetsTrait * Add all CSS assets within $directory * * @param string $directory Relative to the Grav root path, or a stream identifier - * * @return $this */ public function addDirCss($directory) @@ -321,15 +320,16 @@ trait TestingAssetsTrait * * @param string $directory * @param string $pattern (regex) - * @param string $ltrim Will be trimmed from the left of the file path - * + * @param string|null $ltrim Will be trimmed from the left of the file path * @return array */ protected function rglob($directory, $pattern, $ltrim = null) { - $iterator = new \RegexIterator(new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, - \FilesystemIterator::SKIP_DOTS)), $pattern); - $offset = \strlen($ltrim); + $iterator = new RegexIterator(new RecursiveIteratorIterator(new RecursiveDirectoryIterator( + $directory, + FilesystemIterator::SKIP_DOTS + )), $pattern); + $offset = strlen($ltrim); $files = []; foreach ($iterator as $file) { @@ -338,6 +338,4 @@ trait TestingAssetsTrait return $files; } - - } diff --git a/system/src/Grav/Common/Backup/Backups.php b/system/src/Grav/Common/Backup/Backups.php index 9582b9e..9483b15 100644 --- a/system/src/Grav/Common/Backup/Backups.php +++ b/system/src/Grav/Common/Backup/Backups.php @@ -3,12 +3,16 @@ /** * @package Grav\Common\Backup * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Backup; +use DateTime; +use Exception; +use FilesystemIterator; +use GlobIterator; use Grav\Common\Filesystem\Archiver; use Grav\Common\Filesystem\Folder; use Grav\Common\Inflector; @@ -17,81 +21,126 @@ use Grav\Common\Scheduler\Scheduler; use Grav\Common\Utils; use Grav\Common\Grav; use RocketTheme\Toolbox\Event\Event; -use RocketTheme\Toolbox\Event\EventDispatcher; use RocketTheme\Toolbox\File\JsonFile; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use SplFileInfo; +use stdClass; +use Symfony\Component\EventDispatcher\EventDispatcher; +use function count; +/** + * Class Backups + * @package Grav\Common\Backup + */ class Backups { protected const BACKUP_FILENAME_REGEXZ = "#(.*)--(\d*).zip#"; protected const BACKUP_DATE_FORMAT = 'YmdHis'; + /** @var string */ protected static $backup_dir; - protected static $backups = null; + /** @var array|null */ + protected static $backups; + /** + * @return void + */ public function init() { + $grav = Grav::instance(); + /** @var EventDispatcher $dispatcher */ - $dispatcher = Grav::instance()['events']; + $dispatcher = $grav['events']; $dispatcher->addListener('onSchedulerInitialized', [$this, 'onSchedulerInitialized']); - Grav::instance()->fireEvent('onBackupsInitialized', new Event(['backups' => $this])); + + $grav->fireEvent('onBackupsInitialized', new Event(['backups' => $this])); } + /** + * @return void + */ public function setup() { if (null === static::$backup_dir) { - static::$backup_dir = Grav::instance()['locator']->findResource('backup://', true, true); + $grav = Grav::instance(); + static::$backup_dir = $grav['locator']->findResource('backup://', true, true); Folder::create(static::$backup_dir); } } + /** + * @param Event $event + * @return void + */ public function onSchedulerInitialized(Event $event) { + $grav = Grav::instance(); + /** @var Scheduler $scheduler */ $scheduler = $event['scheduler']; /** @var Inflector $inflector */ - $inflector = Grav::instance()['inflector']; + $inflector = $grav['inflector']; foreach (static::getBackupProfiles() as $id => $profile) { $at = $profile['schedule_at']; $name = $inflector::hyphenize($profile['name']); $logs = 'logs/backup-' . $name . '.out'; /** @var Job $job */ - $job = $scheduler->addFunction('Grav\Common\Backup\Backups::backup', [$id], $name ); + $job = $scheduler->addFunction('Grav\Common\Backup\Backups::backup', [$id], $name); $job->at($at); $job->output($logs); $job->backlink('/tools/backups'); } } + /** + * @param string $backup + * @param string $base_url + * @return string + */ public function getBackupDownloadUrl($backup, $base_url) { $param_sep = $param_sep = Grav::instance()['config']->get('system.param_sep', ':'); - $download = urlencode(base64_encode($backup)); - $url = rtrim(Grav::instance()['uri']->rootUrl(true), '/') . '/' . trim($base_url, - '/') . '/task' . $param_sep . 'backup/download' . $param_sep . $download . '/admin-nonce' . $param_sep . Utils::getNonce('admin-form'); + $download = urlencode(base64_encode(basename($backup))); + $url = rtrim(Grav::instance()['uri']->rootUrl(true), '/') . '/' . trim( + $base_url, + '/' + ) . '/task' . $param_sep . 'backup/download' . $param_sep . $download . '/admin-nonce' . $param_sep . Utils::getNonce('admin-form'); return $url; } + /** + * @return array + */ public static function getBackupProfiles() { return Grav::instance()['config']->get('backups.profiles'); } + /** + * @return array + */ public static function getPurgeConfig() { return Grav::instance()['config']->get('backups.purge'); } + /** + * @return array + */ public function getBackupNames() { return array_column(static::getBackupProfiles(), 'name'); } + /** + * @return float|int + */ public static function getTotalBackupsSize() { $backups = static::getAvailableBackups(); @@ -100,24 +149,29 @@ class Backups return $size ?? 0; } + /** + * @param bool $force + * @return array + */ public static function getAvailableBackups($force = false) { if ($force || null === static::$backups) { static::$backups = []; - $backups_itr = new \GlobIterator(static::$backup_dir . '/*.zip', \FilesystemIterator::KEY_AS_FILENAME); - $inflector = Grav::instance()['inflector']; + + $grav = Grav::instance(); + $backups_itr = new GlobIterator(static::$backup_dir . '/*.zip', FilesystemIterator::KEY_AS_FILENAME); + $inflector = $grav['inflector']; $long_date_format = DATE_RFC2822; /** * @var string $name - * @var \SplFileInfo $file + * @var SplFileInfo $file */ foreach ($backups_itr as $name => $file) { - if (preg_match(static::BACKUP_FILENAME_REGEXZ, $name, $matches)) { - $date = \DateTime::createFromFormat(static::BACKUP_DATE_FORMAT, $matches[2]); + $date = DateTime::createFromFormat(static::BACKUP_DATE_FORMAT, $matches[2]); $timestamp = $date->getTimestamp(); - $backup = new \stdClass(); + $backup = new stdClass(); $backup->title = $inflector->titleize($matches[1]); $backup->time = $date; $backup->date = $date->format($long_date_format); @@ -137,28 +191,29 @@ class Backups /** * Backup * - * @param int $id + * @param int $id * @param callable|null $status - * - * @return null|string + * @return string|null */ public static function backup($id = 0, callable $status = null) { + $grav = Grav::instance(); + $profiles = static::getBackupProfiles(); /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; + $locator = $grav['locator']; if (isset($profiles[$id])) { $backup = (object) $profiles[$id]; } else { - throw new \RuntimeException('No backups defined...'); + throw new RuntimeException('No backups defined...'); } - $name = Grav::instance()['inflector']->underscorize($backup->name); + $name = $grav['inflector']->underscorize($backup->name); $date = date(static::BACKUP_DATE_FORMAT, time()); $filename = trim($name, '_') . '--' . $date . '.zip'; $destination = static::$backup_dir . DS . $filename; - $max_execution_time = ini_set('max_execution_time', 600); + $max_execution_time = ini_set('max_execution_time', '600'); $backup_root = $backup->root; if ($locator->isStream($backup_root)) { @@ -168,7 +223,7 @@ class Backups } if (!file_exists($backup_root)) { - throw new \RuntimeException("Backup location: {$backup_root} does not exist..."); + throw new RuntimeException("Backup location: {$backup_root} does not exist..."); } $options = [ @@ -176,7 +231,6 @@ class Backups 'exclude_paths' => static::convertExclude($backup->exclude_paths ?? ''), ]; - /** @var Archiver $archiver */ $archiver = Archiver::create('zip'); $archiver->setArchive($destination)->setOptions($options)->compress($backup_root, $status)->addEmptyFolders($options['exclude_paths'], $status); @@ -195,16 +249,16 @@ class Backups } // Log the backup - Grav::instance()['log']->notice('Backup Created: ' . $destination); + $grav['log']->notice('Backup Created: ' . $destination); // Fire Finished event - Grav::instance()->fireEvent('onBackupFinished', new Event(['backup' => $destination])); + $grav->fireEvent('onBackupFinished', new Event(['backup' => $destination])); // Purge anything required static::purge(); // Log - $log = JsonFile::instance(Grav::instance()['locator']->findResource("log://backup.log", true, true)); + $log = JsonFile::instance($locator->findResource("log://backup.log", true, true)); $log->content([ 'time' => time(), 'location' => $destination @@ -214,26 +268,29 @@ class Backups return $destination; } + /** + * @return void + * @throws Exception + */ public static function purge() { $purge_config = static::getPurgeConfig(); $trigger = $purge_config['trigger']; $backups = static::getAvailableBackups(true); - switch ($trigger) - { + switch ($trigger) { case 'number': $backups_count = count($backups); if ($backups_count > $purge_config['max_backups_count']) { $last = end($backups); - unlink ($last->path); + unlink($last->path); static::purge(); } break; case 'time': $last = end($backups); - $now = new \DateTime(); + $now = new DateTime(); $interval = $now->diff($last->time); if ($interval->days > $purge_config['max_backups_time']) { unlink($last->path); @@ -253,9 +310,14 @@ class Backups } } + /** + * @param string $exclude + * @return array + */ protected static function convertExclude($exclude) { $lines = preg_split("/[\s,]+/", $exclude); - return array_map('trim', $lines, array_fill(0, \count($lines), '/')); + + return array_map('trim', $lines, array_fill(0, count($lines), '/')); } } diff --git a/system/src/Grav/Common/Browser.php b/system/src/Grav/Common/Browser.php index 8d0d687..e4a6513 100644 --- a/system/src/Grav/Common/Browser.php +++ b/system/src/Grav/Common/Browser.php @@ -3,12 +3,13 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; +use InvalidArgumentException; use function donatj\UserAgent\parse_user_agent; /** @@ -16,6 +17,7 @@ use function donatj\UserAgent\parse_user_agent; */ class Browser { + /** @var string[] */ protected $useragent = []; /** @@ -25,7 +27,7 @@ class Browser { try { $this->useragent = parse_user_agent(); - } catch (\InvalidArgumentException $e) { + } catch (InvalidArgumentException $e) { $this->useragent = parse_user_agent("Mozilla/5.0 (compatible; Unknown;)"); } } @@ -110,7 +112,7 @@ class Browser /** * Get the current major version identifier * - * @return string the browser major version identifier + * @return int the browser major version identifier */ public function getVersion() { @@ -137,7 +139,7 @@ class Browser return true; } - + /** * Determine if “Do Not Track” is set by browser * @see https://www.w3.org/TR/tracking-dnt/ diff --git a/system/src/Grav/Common/Cache.php b/system/src/Grav/Common/Cache.php index 94eae59..a961904 100644 --- a/system/src/Grav/Common/Cache.php +++ b/system/src/Grav/Common/Cache.php @@ -3,19 +3,27 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; +use DirectoryIterator; use \Doctrine\Common\Cache as DoctrineCache; +use Exception; use Grav\Common\Config\Config; use Grav\Common\Filesystem\Folder; use Grav\Common\Scheduler\Scheduler; +use LogicException; use Psr\SimpleCache\CacheInterface; use RocketTheme\Toolbox\Event\Event; -use RocketTheme\Toolbox\Event\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcher; +use function dirname; +use function extension_loaded; +use function function_exists; +use function in_array; +use function is_array; /** * The GravCache object is used throughout Grav to store and retrieve cached data. @@ -29,42 +37,41 @@ use RocketTheme\Toolbox\Event\EventDispatcher; */ class Cache extends Getters { - /** - * @var string Cache key. - */ + /** @var string Cache key. */ protected $key; + /** @var int */ protected $lifetime; + + /** @var int */ protected $now; /** @var Config $config */ protected $config; - /** - * @var DoctrineCache\CacheProvider - */ + /** @var DoctrineCache\CacheProvider */ protected $driver; - /** - * @var CacheInterface - */ + /** @var CacheInterface */ protected $simpleCache; + /** @var string */ protected $driver_name; + /** @var string */ protected $driver_setting; - /** - * @var bool - */ + /** @var bool */ protected $enabled; + /** @var string */ protected $cache_dir; protected static $standard_remove = [ 'cache://twig/', 'cache://doctrine/', 'cache://compiled/', + 'cache://clockwork/', 'cache://validated-', 'cache://images', 'asset://', @@ -74,6 +81,7 @@ class Cache extends Getters 'cache://twig/', 'cache://doctrine/', 'cache://compiled/', + 'cache://clockwork/', 'cache://validated-', 'asset://', ]; @@ -115,12 +123,10 @@ class Cache extends Getters * Initialization that sets a base key and the driver based on configuration settings * * @param Grav $grav - * * @return void */ public function init(Grav $grav) { - /** @var Config $config */ $this->config = $grav['config']; $this->now = time(); @@ -174,7 +180,7 @@ class Cache extends Getters $current = basename($this->cache_dir); $count = 0; - foreach (new \DirectoryIterator($cache_dir) as $file) { + foreach (new DirectoryIterator($cache_dir) as $file) { $dir = $file->getBasename(); if ($dir === $current || $file->isDot() || $file->isFile()) { continue; @@ -191,6 +197,7 @@ class Cache extends Getters * Public accessor to set the enabled state of the cache * * @param bool|int $enabled + * @return void */ public function setEnabled($enabled) { @@ -260,24 +267,28 @@ class Cache extends Getters case 'memcache': if (extension_loaded('memcache')) { $memcache = new \Memcache(); - $memcache->connect($this->config->get('system.cache.memcache.server', 'localhost'), - $this->config->get('system.cache.memcache.port', 11211)); + $memcache->connect( + $this->config->get('system.cache.memcache.server', 'localhost'), + $this->config->get('system.cache.memcache.port', 11211) + ); $driver = new DoctrineCache\MemcacheCache(); $driver->setMemcache($memcache); } else { - throw new \LogicException('Memcache PHP extension has not been installed'); + throw new LogicException('Memcache PHP extension has not been installed'); } break; case 'memcached': if (extension_loaded('memcached')) { $memcached = new \Memcached(); - $memcached->addServer($this->config->get('system.cache.memcached.server', 'localhost'), - $this->config->get('system.cache.memcached.port', 11211)); + $memcached->addServer( + $this->config->get('system.cache.memcached.server', 'localhost'), + $this->config->get('system.cache.memcached.port', 11211) + ); $driver = new DoctrineCache\MemcachedCache(); $driver->setMemcached($memcached); } else { - throw new \LogicException('Memcached PHP extension has not been installed'); + throw new LogicException('Memcached PHP extension has not been installed'); } break; @@ -286,12 +297,15 @@ class Cache extends Getters $redis = new \Redis(); $socket = $this->config->get('system.cache.redis.socket', false); $password = $this->config->get('system.cache.redis.password', false); + $databaseId = $this->config->get('system.cache.redis.database', 0); if ($socket) { $redis->connect($socket); } else { - $redis->connect($this->config->get('system.cache.redis.server', 'localhost'), - $this->config->get('system.cache.redis.port', 6379)); + $redis->connect( + $this->config->get('system.cache.redis.server', 'localhost'), + $this->config->get('system.cache.redis.port', 6379) + ); } // Authenticate with password if set @@ -299,10 +313,15 @@ class Cache extends Getters throw new \RedisException('Redis authentication failed'); } + // Select alternate ( !=0 ) database ID if set + if ($databaseId && !$redis->select($databaseId)) { + throw new \RedisException('Could not select alternate Redis database ID'); + } + $driver = new DoctrineCache\RedisCache(); $driver->setRedis($redis); } else { - throw new \LogicException('Redis PHP extension has not been installed'); + throw new LogicException('Redis PHP extension has not been installed'); } break; @@ -318,8 +337,7 @@ class Cache extends Getters * Gets a cached entry if it exists based on an id. If it does not exist, it returns false * * @param string $id the id of the cached entry - * - * @return object|bool returns the cached entry, can be any type, or false if doesn't exist + * @return mixed|bool returns the cached entry, can be any type, or false if doesn't exist */ public function fetch($id) { @@ -334,8 +352,8 @@ class Cache extends Getters * Stores a new cached entry. * * @param string $id the id of the cached entry - * @param array|object $data the data for the cached entry to store - * @param int $lifetime the lifetime to store the entry in seconds + * @param array|object|int $data the data for the cached entry to store + * @param int|null $lifetime the lifetime to store the entry in seconds */ public function save($id, $data, $lifetime = null) { @@ -393,6 +411,8 @@ class Cache extends Getters /** * Getter method to get the cache key + * + * @return string */ public function getKey() { @@ -401,6 +421,9 @@ class Cache extends Getters /** * Setter method to set key (Advanced) + * + * @param string $key + * @return void */ public function setKey($key) { @@ -412,7 +435,6 @@ class Cache extends Getters * Helper method to clear all Grav caches * * @param string $remove standard|all|assets-only|images-only|cache-only - * * @return array */ public static function clearCache($remove = 'standard') @@ -446,7 +468,6 @@ class Cache extends Getters } else { $remove_paths = self::$standard_remove_no_images; } - } // Delete entries in the doctrine cache if required @@ -459,11 +480,12 @@ class Cache extends Getters Grav::instance()->fireEvent('onBeforeCacheClear', new Event(['remove' => $remove, 'paths' => &$remove_paths])); foreach ($remove_paths as $stream) { - // Convert stream to a real path try { $path = $locator->findResource($stream, true, true); - if($path === false) continue; + if ($path === false) { + continue; + } $anything = false; $files = glob($path . '/*'); @@ -477,7 +499,7 @@ class Cache extends Getters $anything = true; } } elseif (is_dir($file)) { - if (Folder::delete($file)) { + if (Folder::delete($file, false)) { $anything = true; } } @@ -487,7 +509,7 @@ class Cache extends Getters if ($anything) { $output[] = 'Cleared: ' . $path . '/*'; } - } catch (\Exception $e) { + } catch (Exception $e) { // stream not found or another error while deleting files. $output[] = 'ERROR: ' . $e->getMessage(); } @@ -510,9 +532,14 @@ class Cache extends Getters @opcache_reset(); } + Grav::instance()->fireEvent('onAfterCacheClear', new Event(['remove' => $remove, 'output' => &$output])); + return $output; } + /** + * @return void + */ public static function invalidateCache() { $user_config = USER_DIR . 'config/system.yaml'; @@ -528,13 +555,13 @@ class Cache extends Getters if (function_exists('opcache_reset')) { @opcache_reset(); } - } /** * Set the cache lifetime programmatically * * @param int $future timestamp + * @return void */ public function setLifetime($future) { @@ -552,7 +579,7 @@ class Cache extends Getters /** * Retrieve the cache lifetime (in seconds) * - * @return mixed + * @return int */ public function getLifetime() { @@ -566,7 +593,7 @@ class Cache extends Getters /** * Returns the current driver name * - * @return mixed + * @return string */ public function getDriverName() { @@ -576,7 +603,7 @@ class Cache extends Getters /** * Returns the current driver setting * - * @return mixed + * @return string */ public function getDriverSetting() { @@ -600,20 +627,30 @@ class Cache extends Getters /** * Static function to call as a scheduled Job to purge old Doctrine files + * + * @param bool $echo + * + * @return string|void */ - public static function purgeJob() + public static function purgeJob($echo = false) { /** @var Cache $cache */ $cache = Grav::instance()['cache']; $deleted_folders = $cache->purgeOldCache(); + $msg = 'Purged ' . $deleted_folders . ' old cache folders...'; - echo 'Purged ' . $deleted_folders . ' old cache folders...'; + if ($echo) { + echo $msg; + } else { + return $msg; + } } /** * Static function to call as a scheduled Job to clear Grav cache * * @param string $type + * @return void */ public static function clearJob($type) { @@ -623,6 +660,10 @@ class Cache extends Getters echo strip_tags(implode("\n", $result)); } + /** + * @param Event $event + * @return void + */ public function onSchedulerInitialized(Event $event) { /** @var Scheduler $scheduler */ @@ -634,7 +675,7 @@ class Cache extends Getters $name = 'cache-purge'; $logs = 'logs/' . $name . '.out'; - $job = $scheduler->addFunction('Grav\Common\Cache::purgeJob', [], $name ); + $job = $scheduler->addFunction('Grav\Common\Cache::purgeJob', [true], $name); $job->at($at); $job->output($logs); $job->backlink('/config/system#caching'); @@ -645,12 +686,9 @@ class Cache extends Getters $name = 'cache-clear'; $logs = 'logs/' . $name . '.out'; - $job = $scheduler->addFunction('Grav\Common\Cache::clearJob', [$clear_type], $name ); + $job = $scheduler->addFunction('Grav\Common\Cache::clearJob', [$clear_type], $name); $job->at($at); $job->output($logs); $job->backlink('/config/system#caching'); - } - - } diff --git a/system/src/Grav/Common/Composer.php b/system/src/Grav/Common/Composer.php index c6abb2c..f1f6e5d 100644 --- a/system/src/Grav/Common/Composer.php +++ b/system/src/Grav/Common/Composer.php @@ -3,12 +3,18 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; +use function function_exists; + +/** + * Class Composer + * @package Grav\Common + */ class Composer { /** @const Default composer location */ @@ -21,12 +27,12 @@ class Composer */ public static function getComposerLocation() { - if (!\function_exists('shell_exec') || stripos(PHP_OS, 'win') === 0) { + if (!function_exists('shell_exec') || stripos(PHP_OS, 'win') === 0) { return self::DEFAULT_PATH; } // check for global composer install - $path = trim(shell_exec('command -v composer')); + $path = trim((string)shell_exec('command -v composer')); // fall back to grav bundled composer if (!$path || !preg_match('/(composer|composer\.phar)$/', $path)) { diff --git a/system/src/Grav/Common/Config/CompiledBase.php b/system/src/Grav/Common/Config/CompiledBase.php index 3e2022f..0764189 100644 --- a/system/src/Grav/Common/Config/CompiledBase.php +++ b/system/src/Grav/Common/Config/CompiledBase.php @@ -3,78 +3,70 @@ /** * @package Grav\Common\Config * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Config; +use BadMethodCallException; +use Exception; use RocketTheme\Toolbox\File\PhpFile; +use RuntimeException; +use function get_class; +use function is_array; +/** + * Class CompiledBase + * @package Grav\Common\Config + */ abstract class CompiledBase { - /** - * @var int Version number for the compiled file. - */ + /** @var int Version number for the compiled file. */ public $version = 1; - /** - * @var string Filename (base name) of the compiled configuration. - */ + /** @var string Filename (base name) of the compiled configuration. */ public $name; - /** - * @var string|bool Configuration checksum. - */ + /** @var string|bool Configuration checksum. */ public $checksum; - /** - * @var string Timestamp of compiled configuration - */ - public $timestamp; + /** @var int Timestamp of compiled configuration */ + public $timestamp = 0; - /** - * @var string Cache folder to be used. - */ + /** @var string Cache folder to be used. */ protected $cacheFolder; - /** - * @var array List of files to load. - */ + /** @var array List of files to load. */ protected $files; - /** - * @var string - */ + /** @var string */ protected $path; - /** - * @var mixed Configuration object. - */ + /** @var mixed Configuration object. */ protected $object; /** * @param string $cacheFolder Cache folder to be used. * @param array $files List of files as returned from ConfigFileFinder class. * @param string $path Base path for the file list. - * @throws \BadMethodCallException + * @throws BadMethodCallException */ public function __construct($cacheFolder, array $files, $path) { if (!$cacheFolder) { - throw new \BadMethodCallException('Cache folder not defined.'); + throw new BadMethodCallException('Cache folder not defined.'); } $this->path = $path ? rtrim($path, '\\/') . '/' : ''; $this->cacheFolder = $cacheFolder; $this->files = $files; - $this->timestamp = 0; } /** * Get filename for the compiled PHP file. * - * @param string $name + * @param string|null $name * @return $this */ public function name($name = null) @@ -88,8 +80,12 @@ abstract class CompiledBase /** * Function gets called when cached configuration is saved. + * + * @return void */ - public function modified() {} + public function modified() + { + } /** * Get timestamp of compiled configuration @@ -136,6 +132,9 @@ abstract class CompiledBase return $this->checksum; } + /** + * @return string + */ protected function createFilename() { return "{$this->cacheFolder}/{$this->name()->name}.php"; @@ -145,11 +144,14 @@ abstract class CompiledBase * Create configuration object. * * @param array $data + * @return void */ abstract protected function createObject(array $data = []); /** * Finalize configuration object. + * + * @return void */ abstract protected function finalizeObject(); @@ -157,7 +159,8 @@ abstract class CompiledBase * Load single configuration file and append it to the correct position. * * @param string $name Name of the position. - * @param string $filename File to be loaded. + * @param string|string[] $filename File(s) to be loaded. + * @return void */ abstract protected function loadFile($name, $filename); @@ -197,10 +200,9 @@ abstract class CompiledBase } $cache = include $filename; - if ( - !\is_array($cache) + if (!is_array($cache) || !isset($cache['checksum'], $cache['data'], $cache['@class']) - || $cache['@class'] !== \get_class($this) + || $cache['@class'] !== get_class($this) ) { return false; } @@ -222,7 +224,8 @@ abstract class CompiledBase * Save compiled file. * * @param string $filename - * @throws \RuntimeException + * @return void + * @throws RuntimeException * @internal */ protected function saveCompiledFile($filename) @@ -232,7 +235,7 @@ abstract class CompiledBase // Attempt to lock the file for writing. try { $file->lock(false); - } catch (\Exception $e) { + } catch (Exception $e) { // Another process has locked the file; we will check this in a bit. } @@ -242,7 +245,7 @@ abstract class CompiledBase } $cache = [ - '@class' => \get_class($this), + '@class' => get_class($this), 'timestamp' => time(), 'checksum' => $this->checksum(), 'files' => $this->files, @@ -256,6 +259,9 @@ abstract class CompiledBase $this->modified(); } + /** + * @return array + */ protected function getState() { return $this->object->toArray(); diff --git a/system/src/Grav/Common/Config/CompiledBlueprints.php b/system/src/Grav/Common/Config/CompiledBlueprints.php index 9ad7be6..7743054 100644 --- a/system/src/Grav/Common/Config/CompiledBlueprints.php +++ b/system/src/Grav/Common/Config/CompiledBlueprints.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Config * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -19,6 +19,12 @@ use Grav\Common\Grav; */ class CompiledBlueprints extends CompiledBase { + /** + * CompiledBlueprints constructor. + * @param string $cacheFolder + * @param array $files + * @param string $path + */ public function __construct($cacheFolder, array $files, $path) { parent::__construct($cacheFolder, $files, $path); @@ -45,7 +51,7 @@ class CompiledBlueprints extends CompiledBase /** * Create configuration object. * - * @param array $data + * @param array $data */ protected function createObject(array $data = []) { @@ -64,6 +70,8 @@ class CompiledBlueprints extends CompiledBase /** * Finalize configuration object. + * + * @return void */ protected function finalizeObject() { @@ -74,6 +82,7 @@ class CompiledBlueprints extends CompiledBase * * @param string $name Name of the position. * @param array $files Files to be loaded. + * @return void */ protected function loadFile($name, $files) { @@ -112,6 +121,9 @@ class CompiledBlueprints extends CompiledBase return true; } + /** + * @return array + */ protected function getState() { return $this->object->getState(); diff --git a/system/src/Grav/Common/Config/CompiledConfig.php b/system/src/Grav/Common/Config/CompiledConfig.php index 1c92edd..22225bc 100644 --- a/system/src/Grav/Common/Config/CompiledConfig.php +++ b/system/src/Grav/Common/Config/CompiledConfig.php @@ -3,26 +3,33 @@ /** * @package Grav\Common\Config * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Config; use Grav\Common\File\CompiledYamlFile; +use function is_callable; +/** + * Class CompiledConfig + * @package Grav\Common\Config + */ class CompiledConfig extends CompiledBase { - /** - * @var callable Blueprints loader. - */ + /** @var callable Blueprints loader. */ protected $callable; - /** - * @var bool - */ - protected $withDefaults; + /** @var bool */ + protected $withDefaults = false; + /** + * CompiledConfig constructor. + * @param string $cacheFolder + * @param array $files + * @param string $path + */ public function __construct($cacheFolder, array $files, $path) { parent::__construct($cacheFolder, $files, $path); @@ -58,10 +65,11 @@ class CompiledConfig extends CompiledBase * Create configuration object. * * @param array $data + * @return void */ protected function createObject(array $data = []) { - if ($this->withDefaults && empty($data) && \is_callable($this->callable)) { + if ($this->withDefaults && empty($data) && is_callable($this->callable)) { $blueprints = $this->callable; $data = $blueprints()->getDefaults(); } @@ -71,6 +79,8 @@ class CompiledConfig extends CompiledBase /** * Finalize configuration object. + * + * @return void */ protected function finalizeObject() { @@ -80,6 +90,8 @@ class CompiledConfig extends CompiledBase /** * Function gets called when cached configuration is saved. + * + * @return void */ public function modified() { @@ -91,6 +103,7 @@ class CompiledConfig extends CompiledBase * * @param string $name Name of the position. * @param string $filename File to be loaded. + * @return void */ protected function loadFile($name, $filename) { diff --git a/system/src/Grav/Common/Config/CompiledLanguages.php b/system/src/Grav/Common/Config/CompiledLanguages.php index ef18145..6674389 100644 --- a/system/src/Grav/Common/Config/CompiledLanguages.php +++ b/system/src/Grav/Common/Config/CompiledLanguages.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Config * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,8 +11,18 @@ namespace Grav\Common\Config; use Grav\Common\File\CompiledYamlFile; +/** + * Class CompiledLanguages + * @package Grav\Common\Config + */ class CompiledLanguages extends CompiledBase { + /** + * CompiledLanguages constructor. + * @param string $cacheFolder + * @param array $files + * @param string $path + */ public function __construct($cacheFolder, array $files, $path) { parent::__construct($cacheFolder, $files, $path); @@ -24,6 +34,7 @@ class CompiledLanguages extends CompiledBase * Create configuration object. * * @param array $data + * @return void */ protected function createObject(array $data = []) { @@ -32,6 +43,8 @@ class CompiledLanguages extends CompiledBase /** * Finalize configuration object. + * + * @return void */ protected function finalizeObject() { @@ -42,6 +55,8 @@ class CompiledLanguages extends CompiledBase /** * Function gets called when cached configuration is saved. + * + * @return void */ public function modified() { @@ -53,6 +68,7 @@ class CompiledLanguages extends CompiledBase * * @param string $name Name of the position. * @param string $filename File to be loaded. + * @return void */ protected function loadFile($name, $filename) { diff --git a/system/src/Grav/Common/Config/Config.php b/system/src/Grav/Common/Config/Config.php index 7b80846..7adb523 100644 --- a/system/src/Grav/Common/Config/Config.php +++ b/system/src/Grav/Common/Config/Config.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Config * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,9 +14,15 @@ use Grav\Common\Grav; use Grav\Common\Data\Data; use Grav\Common\Service\ConfigServiceProvider; use Grav\Common\Utils; +use function is_array; +/** + * Class Config + * @package Grav\Common\Config + */ class Config extends Data { + /** @var string */ public $environment; /** @var string */ @@ -28,6 +34,9 @@ class Config extends Data /** @var bool */ protected $modified = false; + /** + * @return string + */ public function key() { if (null === $this->key) { @@ -37,6 +46,10 @@ class Config extends Data return $this->key; } + /** + * @param string|null $checksum + * @return string|null + */ public function checksum($checksum = null) { if ($checksum !== null) { @@ -46,6 +59,10 @@ class Config extends Data return $this->checksum; } + /** + * @param bool|null $modified + * @return bool + */ public function modified($modified = null) { if ($modified !== null) { @@ -55,6 +72,10 @@ class Config extends Data return $this->modified; } + /** + * @param int|null $timestamp + * @return int + */ public function timestamp($timestamp = null) { if ($timestamp !== null) { @@ -64,6 +85,9 @@ class Config extends Data return $this->timestamp; } + /** + * @return $this + */ public function reload() { $grav = Grav::instance(); @@ -86,6 +110,9 @@ class Config extends Data return $this; } + /** + * @return void + */ public function debug() { /** @var Debugger $debugger */ @@ -97,11 +124,14 @@ class Config extends Data } } + /** + * @return void + */ public function init() { $setup = Grav::instance()['setup']->toArray(); foreach ($setup as $key => $value) { - if ($key === 'streams' || !\is_array($value)) { + if ($key === 'streams' || !is_array($value)) { // Optimized as streams and simple values are fully defined in setup. $this->items[$key] = $value; } else { diff --git a/system/src/Grav/Common/Config/ConfigFileFinder.php b/system/src/Grav/Common/Config/ConfigFileFinder.php index 1e7adca..e6b1afe 100644 --- a/system/src/Grav/Common/Config/ConfigFileFinder.php +++ b/system/src/Grav/Common/Config/ConfigFileFinder.php @@ -3,16 +3,23 @@ /** * @package Grav\Common\Config * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Config; +use DirectoryIterator; use Grav\Common\Filesystem\Folder; +use RecursiveDirectoryIterator; +/** + * Class ConfigFileFinder + * @package Grav\Common\Config + */ class ConfigFileFinder { + /** @var string */ protected $base = ''; /** @@ -40,6 +47,7 @@ class ConfigFileFinder foreach ($paths as $folder) { $list += $this->detectRecursive($folder, $pattern, $levels); } + return $list; } @@ -61,6 +69,7 @@ class ConfigFileFinder $list += $files[trim($path, '/')]; } + return $list; } @@ -78,6 +87,7 @@ class ConfigFileFinder foreach ($paths as $folder) { $list = array_merge_recursive($list, $this->detectAll($folder, $pattern, $levels)); } + return $list; } @@ -96,6 +106,7 @@ class ConfigFileFinder foreach ($folders as $folder) { $list += $this->detectInFolder($folder, $filename); } + return $list; } @@ -103,7 +114,7 @@ class ConfigFileFinder * Find filename from a list of folders. * * @param array $folders - * @param string $filename + * @param string|null $filename * @return array */ public function locateInFolders(array $folders, $filename = null) @@ -113,6 +124,7 @@ class ConfigFileFinder $path = trim(Folder::getRelativePath($folder), '/'); $list[$path] = $this->detectInFolder($folder, $filename); } + return $list; } @@ -166,7 +178,7 @@ class ConfigFileFinder 'filters' => [ 'pre-key' => $this->base, 'key' => $pattern, - 'value' => function (\RecursiveDirectoryIterator $file) use ($path) { + 'value' => function (RecursiveDirectoryIterator $file) use ($path) { return ['file' => "{$path}/{$file->getSubPathname()}", 'modified' => $file->getMTime()]; } ], @@ -187,7 +199,7 @@ class ConfigFileFinder * Detects all directories with the lookup file and returns them with last modification time. * * @param string $folder Location to look up from. - * @param string $lookup Filename to be located (defaults to directory name). + * @param string|null $lookup Filename to be located (defaults to directory name). * @return array * @internal */ @@ -200,9 +212,7 @@ class ConfigFileFinder $list = []; if (is_dir($folder)) { - $iterator = new \DirectoryIterator($folder); - - /** @var \DirectoryIterator $directory */ + $iterator = new DirectoryIterator($folder); foreach ($iterator as $directory) { if (!$directory->isDir() || $directory->isDot()) { continue; @@ -244,7 +254,7 @@ class ConfigFileFinder 'filters' => [ 'pre-key' => $this->base, 'key' => $pattern, - 'value' => function (\RecursiveDirectoryIterator $file) use ($path) { + 'value' => function (RecursiveDirectoryIterator $file) use ($path) { return ["{$path}/{$file->getSubPathname()}" => $file->getMTime()]; } ], diff --git a/system/src/Grav/Common/Config/Languages.php b/system/src/Grav/Common/Config/Languages.php index 9c76dc5..4a863a1 100644 --- a/system/src/Grav/Common/Config/Languages.php +++ b/system/src/Grav/Common/Config/Languages.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Config * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -12,24 +12,25 @@ namespace Grav\Common\Config; use Grav\Common\Data\Data; use Grav\Common\Utils; +/** + * Class Languages + * @package Grav\Common\Config + */ class Languages extends Data { - /** - * @var string|null - */ + /** @var string|null */ protected $checksum; - /** - * @var string|null - */ - protected $modified; + /** @var bool */ + protected $modified = false; + + /** @var int */ + protected $timestamp = 0; /** - * @var string|null + * @param string|null $checksum + * @return string|null */ - protected $timestamp; - - public function checksum($checksum = null) { if ($checksum !== null) { @@ -39,6 +40,10 @@ class Languages extends Data return $this->checksum; } + /** + * @param bool|null $modified + * @return bool + */ public function modified($modified = null) { if ($modified !== null) { @@ -48,6 +53,10 @@ class Languages extends Data return $this->modified; } + /** + * @param int|null $timestamp + * @return int + */ public function timestamp($timestamp = null) { if ($timestamp !== null) { @@ -57,6 +66,9 @@ class Languages extends Data return $this->timestamp; } + /** + * @return void + */ public function reformat() { if (isset($this->items['plugins'])) { @@ -65,17 +77,29 @@ class Languages extends Data } } + /** + * @param array $data + * @return void + */ public function mergeRecursive(array $data) { $this->items = Utils::arrayMergeRecursiveUnique($this->items, $data); } + /** + * @param string $lang + * @return array + */ public function flattenByLang($lang) { $language = $this->items[$lang]; return Utils::arrayFlattenDotNotation($language); } + /** + * @param array $array + * @return array + */ public function unflatten($array) { return Utils::arrayUnflattenDotNotation($array); diff --git a/system/src/Grav/Common/Config/Setup.php b/system/src/Grav/Common/Config/Setup.php index 6a4d336..5693a92 100644 --- a/system/src/Grav/Common/Config/Setup.php +++ b/system/src/Grav/Common/Config/Setup.php @@ -3,19 +3,28 @@ /** * @package Grav\Common\Config * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Config; +use BadMethodCallException; use Grav\Common\File\CompiledYamlFile; use Grav\Common\Data\Data; use Grav\Common\Utils; +use InvalidArgumentException; use Pimple\Container; use Psr\Http\Message\ServerRequestInterface; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use function defined; +use function is_array; +/** + * Class Setup + * @package Grav\Common\Config + */ class Setup extends Data { /** @@ -28,28 +37,58 @@ class Setup extends Data ]; /** - * @var string Current environment normalized to lower case. + * @var string|null Current environment normalized to lower case. */ public static $environment; + /** @var array */ protected $streams = [ - 'system' => [ - 'type' => 'ReadOnlyStream', - 'prefixes' => [ - '' => ['system'], - ] - ], 'user' => [ 'type' => 'ReadOnlyStream', 'force' => true, 'prefixes' => [ - '' => ['user'], + '' => [] // Set in constructor + ] + ], + 'cache' => [ + 'type' => 'Stream', + 'force' => true, + 'prefixes' => [ + '' => [], // Set in constructor + 'images' => ['images'] + ] + ], + 'log' => [ + 'type' => 'Stream', + 'force' => true, + 'prefixes' => [ + '' => [] // Set in constructor + ] + ], + 'tmp' => [ + 'type' => 'Stream', + 'force' => true, + 'prefixes' => [ + '' => [] // Set in constructor + ] + ], + 'backup' => [ + 'type' => 'Stream', + 'force' => true, + 'prefixes' => [ + '' => [] // Set in constructor ] ], 'environment' => [ 'type' => 'ReadOnlyStream' // If not defined, environment will be set up in the constructor. ], + 'system' => [ + 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['system'], + ] + ], 'asset' => [ 'type' => 'Stream', 'prefixes' => [ @@ -59,13 +98,13 @@ class Setup extends Data 'blueprints' => [ 'type' => 'ReadOnlyStream', 'prefixes' => [ - '' => ['environment://blueprints', 'user://blueprints', 'system/blueprints'], + '' => ['environment://blueprints', 'user://blueprints', 'system://blueprints'], ] ], 'config' => [ 'type' => 'ReadOnlyStream', 'prefixes' => [ - '' => ['environment://config', 'user://config', 'system/config'], + '' => ['environment://config', 'user://config', 'system://config'], ] ], 'plugins' => [ @@ -89,36 +128,7 @@ class Setup extends Data 'languages' => [ 'type' => 'ReadOnlyStream', 'prefixes' => [ - '' => ['environment://languages', 'user://languages', 'system/languages'], - ] - ], - 'cache' => [ - 'type' => 'Stream', - 'force' => true, - 'prefixes' => [ - '' => ['cache'], - 'images' => ['images'] - ] - ], - 'log' => [ - 'type' => 'Stream', - 'force' => true, - 'prefixes' => [ - '' => ['logs'] - ] - ], - 'backup' => [ - 'type' => 'Stream', - 'force' => true, - 'prefixes' => [ - '' => ['backup'] - ] - ], - 'tmp' => [ - 'type' => 'Stream', - 'force' => true, - 'prefixes' => [ - '' => ['tmp'] + '' => ['environment://languages', 'user://languages', 'system://languages'], ] ], 'image' => [ @@ -153,27 +163,58 @@ class Setup extends Data */ public function __construct($container) { + // Configure main streams. + $abs = str_starts_with(GRAV_SYSTEM_PATH, '/'); + $this->streams['system']['prefixes'][''] = $abs ? ['system', GRAV_SYSTEM_PATH] : ['system']; + $this->streams['user']['prefixes'][''] = [GRAV_USER_PATH]; + $this->streams['cache']['prefixes'][''] = [GRAV_CACHE_PATH]; + $this->streams['log']['prefixes'][''] = [GRAV_LOG_PATH]; + $this->streams['tmp']['prefixes'][''] = [GRAV_TMP_PATH]; + $this->streams['backup']['prefixes'][''] = [GRAV_BACKUP_PATH]; + + // If environment is not set, look for the environment variable and then the constant. + $environment = static::$environment ?? + (defined('GRAV_ENVIRONMENT') ? GRAV_ENVIRONMENT : (getenv('GRAV_ENVIRONMENT') ?: null)); + // If no environment is set, make sure we get one (CLI or hostname). - if (!static::$environment) { - if (\defined('GRAV_CLI')) { - static::$environment = 'cli'; + if (null === $environment) { + if (defined('GRAV_CLI')) { + $environment = 'cli'; } else { /** @var ServerRequestInterface $request */ $request = $container['request']; $host = $request->getUri()->getHost(); - static::$environment = Utils::substrToString($host, ':'); + $environment = Utils::substrToString($host, ':'); } } // Resolve server aliases to the proper environment. - $environment = $this->environments[static::$environment] ?? static::$environment; + static::$environment = static::$environments[$environment] ?? $environment; // Pre-load setup.php which contains our initial configuration. // Configuration may contain dynamic parts, which is why we need to always load it. - // If "GRAV_SETUP_PATH" has been defined, use it, otherwise use defaults. - $file = \defined('GRAV_SETUP_PATH') ? GRAV_SETUP_PATH : GRAV_ROOT . '/setup.php'; - $setup = is_file($file) ? (array) include $file : []; + // If GRAV_SETUP_PATH has been defined, use it, otherwise use defaults. + $setupFile = defined('GRAV_SETUP_PATH') ? GRAV_SETUP_PATH : (getenv('GRAV_SETUP_PATH') ?: null); + if (null !== $setupFile) { + // Make sure that the custom setup file exists. Terminates the script if not. + if (!str_starts_with($setupFile, '/')) { + $setupFile = GRAV_WEBROOT . '/' . $setupFile; + } + if (!is_file($setupFile)) { + echo 'GRAV_SETUP_PATH is defined but does not point to existing setup file.'; + exit(1); + } + } else { + $setupFile = GRAV_WEBROOT . '/setup.php'; + if (!is_file($setupFile)) { + $setupFile = GRAV_WEBROOT . '/' . GRAV_USER_PATH . '/setup.php'; + } + if (!is_file($setupFile)) { + $setupFile = null; + } + } + $setup = $setupFile ? (array) include $setupFile : []; // Add default streams defined in beginning of the class. if (!isset($setup['streams']['schemes'])) { @@ -184,19 +225,41 @@ class Setup extends Data // Initialize class. parent::__construct($setup); + $this->def('environment', static::$environment); + + // Figure out path for the current environment. + $envPath = defined('GRAV_ENVIRONMENT_PATH') ? GRAV_ENVIRONMENT_PATH : (getenv('GRAV_ENVIRONMENT_PATH') ?: null); + if (null === $envPath) { + // Find common path for all environments and append current environment into it. + $envPath = defined('GRAV_ENVIRONMENTS_PATH') ? GRAV_ENVIRONMENTS_PATH : (getenv('GRAV_ENVIRONMENTS_PATH') ?: null); + if (null !== $envPath) { + $envPath .= '/'; + } else { + // Use default location. Start with Grav 1.7 default. + $envPath = GRAV_WEBROOT. '/' . GRAV_USER_PATH . '/env'; + if (is_dir($envPath)) { + $envPath = 'user://env/'; + } else { + // Fallback to Grav 1.6 default. + $envPath = 'user://'; + } + } + $envPath .= $this->get('environment'); + } + // Set up environment. - $this->def('environment', $environment); - $this->def('streams.schemes.environment.prefixes', ['' => ["user://{$this->get('environment')}"]]); + $this->def('environment', static::$environment); + $this->def('streams.schemes.environment.prefixes', ['' => [$envPath]]); } /** * @return $this - * @throws \RuntimeException - * @throws \InvalidArgumentException + * @throws RuntimeException + * @throws InvalidArgumentException */ public function init() { - $locator = new UniformResourceLocator(GRAV_ROOT); + $locator = new UniformResourceLocator(GRAV_WEBROOT); $files = []; $guard = 5; @@ -220,7 +283,7 @@ class Setup extends Data } while (--$guard); if (!$guard) { - throw new \RuntimeException('Setup: Configuration reload loop detected!'); + throw new RuntimeException('Setup: Configuration reload loop detected!'); } // Make sure we have valid setup. @@ -233,7 +296,8 @@ class Setup extends Data * Initialize resource locator by using the configuration. * * @param UniformResourceLocator $locator - * @throws \BadMethodCallException + * @return void + * @throws BadMethodCallException */ public function initializeLocator(UniformResourceLocator $locator) { @@ -279,24 +343,51 @@ class Setup extends Data /** * @param UniformResourceLocator $locator - * @throws \InvalidArgumentException - * @throws \BadMethodCallException - * @throws \RuntimeException + * @return void + * @throws InvalidArgumentException + * @throws BadMethodCallException + * @throws RuntimeException */ protected function check(UniformResourceLocator $locator) { $streams = $this->items['streams']['schemes'] ?? null; - if (!\is_array($streams)) { - throw new \InvalidArgumentException('Configuration is missing streams.schemes!'); + if (!is_array($streams)) { + throw new InvalidArgumentException('Configuration is missing streams.schemes!'); } $diff = array_keys(array_diff_key($this->streams, $streams)); if ($diff) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( sprintf('Configuration is missing keys %s from streams.schemes!', implode(', ', $diff)) ); } try { + // If environment is found, remove all missing override locations (B/C compatibility). + if ($locator->findResource('environment://', true)) { + $force = $this->get('streams.schemes.environment.force', false); + if (!$force) { + $prefixes = $this->get('streams.schemes.environment.prefixes.'); + $update = false; + foreach ($prefixes as $i => $prefix) { + if ($locator->isStream($prefix)) { + if ($locator->findResource($prefix, true)) { + break; + } + } elseif (file_exists($prefix)) { + break; + } + + unset($prefixes[$i]); + $update = true; + } + + if ($update) { + $this->set('streams.schemes.environment.prefixes', ['' => array_values($prefixes)]); + $this->initializeLocator($locator); + } + } + } + if (!$locator->findResource('environment://config', true)) { // If environment does not have its own directory, remove it from the lookup. $this->set('streams.schemes.environment.prefixes', ['config' => []]); @@ -314,8 +405,8 @@ class Setup extends Data $security_file->save(); $security_file->free(); } - } catch (\RuntimeException $e) { - throw new \RuntimeException(sprintf('Grav failed to initialize: %s', $e->getMessage()), 500, $e); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Grav failed to initialize: %s', $e->getMessage()), 500, $e); } } } diff --git a/system/src/Grav/Common/Data/Blueprint.php b/system/src/Grav/Common/Data/Blueprint.php index e703777..29a2636 100644 --- a/system/src/Grav/Common/Data/Blueprint.php +++ b/system/src/Grav/Common/Data/Blueprint.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Data * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,22 +14,44 @@ use Grav\Common\Grav; use Grav\Common\User\Interfaces\UserInterface; use RocketTheme\Toolbox\Blueprints\BlueprintForm; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use function call_user_func_array; +use function count; +use function function_exists; +use function in_array; +use function is_array; +use function is_int; +use function is_object; +use function is_string; +use function strlen; +/** + * Class Blueprint + * @package Grav\Common\Data + */ class Blueprint extends BlueprintForm { /** @var string */ protected $context = 'blueprints://'; + /** @var string|null */ protected $scope; /** @var BlueprintSchema */ protected $blueprintSchema; - /** @var array */ + /** @var object|null */ + protected $object; + + /** @var array|null */ protected $defaults; + /** @var array */ protected $handlers = []; + /** + * Clone blueprint. + */ public function __clone() { if ($this->blueprintSchema) { @@ -37,11 +59,24 @@ class Blueprint extends BlueprintForm } } + /** + * @param string $scope + * @return void + */ public function setScope($scope) { $this->scope = $scope; } + /** + * @param object $object + * @return void + */ + public function setObject($object) + { + $this->object = $object; + } + /** * Set default values for field types. * @@ -57,6 +92,29 @@ class Blueprint extends BlueprintForm return $this; } + /** + * @param string $name + * @return array|mixed|null + * @since 1.7 + */ + public function getDefaultValue(string $name) + { + $path = explode('.', $name) ?: []; + $current = $this->getDefaults(); + + foreach ($path as $field) { + if (is_object($current) && isset($current->{$field})) { + $current = $current->{$field}; + } elseif (is_array($current) && isset($current[$field])) { + $current = $current[$field]; + } else { + return null; + } + } + + return $current; + } + /** * Get nested structure containing default values defined in the blueprints. * @@ -88,7 +146,7 @@ class Blueprint extends BlueprintForm $current = &$this->items; foreach ($path as $field) { - if (\is_object($current)) { + if (is_object($current)) { // Handle objects. if (!isset($current->{$field})) { $current->{$field} = []; @@ -97,7 +155,7 @@ class Blueprint extends BlueprintForm $current = &$current->{$field}; } else { // Handle arrays and scalars. - if (!\is_array($current)) { + if (!is_array($current)) { $current = [$field => []]; } elseif (!isset($current[$field])) { $current[$field] = []; @@ -111,6 +169,7 @@ class Blueprint extends BlueprintForm foreach ($data as $property => $call) { $action = $call['action']; $method = 'dynamic' . ucfirst($action); + $call['object'] = $this->object; if (isset($this->handlers[$action])) { $callable = $this->handlers[$action]; @@ -124,12 +183,44 @@ class Blueprint extends BlueprintForm return $this; } + /** + * Extend blueprint with another blueprint. + * + * @param BlueprintForm|array $extends + * @param bool $append + * @return $this + */ + public function extend($extends, $append = false) + { + parent::extend($extends, $append); + + $this->deepInit($this->items); + + return $this; + } + + /** + * @param string $name + * @param mixed $value + * @param string $separator + * @param bool $append + * @return $this + */ + public function embed($name, $value, $separator = '/', $append = false) + { + parent::embed($name, $value, $separator, $append); + + $this->deepInit($this->items); + + return $this; + } + /** * Merge two arrays by using blueprints. * * @param array $data1 * @param array $data2 - * @param string $name Optional + * @param string|null $name Optional * @param string $separator Optional * @return array */ @@ -172,13 +263,15 @@ class Blueprint extends BlueprintForm * Validate data against blueprints. * * @param array $data - * @throws \RuntimeException + * @param array $options + * @return void + * @throws RuntimeException */ - public function validate(array $data) + public function validate(array $data, array $options = []) { $this->initInternals(); - $this->blueprintSchema->validate($data); + $this->blueprintSchema->validate($data, $options); } /** @@ -193,7 +286,7 @@ class Blueprint extends BlueprintForm { $this->initInternals(); - return $this->blueprintSchema->filter($data, $missingValuesAsNull, $keepEmptyValues); + return $this->blueprintSchema->filter($data, $missingValuesAsNull, $keepEmptyValues) ?? []; } @@ -201,13 +294,14 @@ class Blueprint extends BlueprintForm * Flatten data by using blueprints. * * @param array $data + * @param bool $includeAll * @return array */ - public function flattenData(array $data) + public function flattenData(array $data, bool $includeAll = false) { $this->initInternals(); - return $this->blueprintSchema->flattenData($data); + return $this->blueprintSchema->flattenData($data, $includeAll); } @@ -223,6 +317,11 @@ class Blueprint extends BlueprintForm return $this->blueprintSchema; } + /** + * @param string $name + * @param callable $callable + * @return void + */ public function addDynamicHandler(string $name, callable $callable): void { $this->handlers[$name] = $callable; @@ -230,6 +329,8 @@ class Blueprint extends BlueprintForm /** * Initialize validator. + * + * @return void */ protected function initInternals() { @@ -250,12 +351,12 @@ class Blueprint extends BlueprintForm /** * @param string $filename - * @return string + * @return array */ protected function loadFile($filename) { $file = CompiledYamlFile::instance($filename); - $content = $file->content(); + $content = (array)$file->content(); $file->free(); return $content; @@ -263,7 +364,7 @@ class Blueprint extends BlueprintForm /** * @param string|array $path - * @param string $context + * @param string|null $context * @return array */ protected function getFiles($path, $context = null) @@ -271,16 +372,24 @@ class Blueprint extends BlueprintForm /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; - if (\is_string($path) && !$locator->isStream($path)) { + if (is_string($path) && !$locator->isStream($path)) { + if (is_file($path)) { + return [$path]; + } + // Find path overrides. - $paths = (array) ($this->overrides[$path] ?? null); + if (null === $context) { + $paths = (array) ($this->overrides[$path] ?? null); + } else { + $paths = []; + } // Add path pointing to default context. if ($context === null) { $context = $this->context; } - if ($context && $context[\strlen($context)-1] !== '/') { + if ($context && $context[strlen($context)-1] !== '/') { $context .= '/'; } @@ -297,7 +406,7 @@ class Blueprint extends BlueprintForm $files = []; foreach ($paths as $lookup) { - if (\is_string($lookup) && strpos($lookup, '://')) { + if (is_string($lookup) && strpos($lookup, '://')) { $files = array_merge($files, $locator->findResources($lookup)); } else { $files[] = $lookup; @@ -311,12 +420,13 @@ class Blueprint extends BlueprintForm * @param array $field * @param string $property * @param array $call + * @return void */ protected function dynamicData(array &$field, $property, array &$call) { $params = $call['params']; - if (\is_array($params)) { + if (is_array($params)) { $function = array_shift($params); } else { $function = $params; @@ -327,18 +437,18 @@ class Blueprint extends BlueprintForm $data = null; if (!$f) { - if (\function_exists($o)) { - $data = \call_user_func_array($o, $params); + if (function_exists($o)) { + $data = call_user_func_array($o, $params); } } else { if (method_exists($o, $f)) { - $data = \call_user_func_array([$o, $f], $params); + $data = call_user_func_array([$o, $f], $params); } } // If function returns a value, if (null !== $data) { - if (\is_array($data) && isset($field[$property]) && \is_array($field[$property])) { + if (is_array($data) && isset($field[$property]) && is_array($field[$property])) { // Combine field and @data-field together. $field[$property] += $data; } else { @@ -352,15 +462,33 @@ class Blueprint extends BlueprintForm * @param array $field * @param string $property * @param array $call + * @return void */ protected function dynamicConfig(array &$field, $property, array &$call) { - $value = $call['params']; + $params = $call['params']; + if (is_array($params)) { + $value = array_shift($params); + $params = array_shift($params); + } else { + $value = $params; + $params = []; + } + $default = $field[$property] ?? null; $config = Grav::instance()['config']->get($value, $default); + if (!empty($field['value_only'])) { + $config = array_combine($config, $config); + } if (null !== $config) { - $field[$property] = $config; + if (!empty($params['append']) && is_array($config) && isset($field[$property]) && is_array($field[$property])) { + // Combine field and @config-field together. + $field[$property] += $config; + } else { + // Or create/replace field with @config-field. + $field[$property] = $config; + } } } @@ -368,6 +496,7 @@ class Blueprint extends BlueprintForm * @param array $field * @param string $property * @param array $call + * @return void */ protected function dynamicSecurity(array &$field, $property, array &$call) { @@ -380,18 +509,48 @@ class Blueprint extends BlueprintForm /** @var UserInterface|null $user */ $user = $grav['user'] ?? null; - foreach ($actions as $action) { - if (!$user || !$user->authorize($action)) { - $this->addPropertyRecursive($field, 'validate', ['ignore' => true]); - return; + $success = null !== $user; + if ($success) { + $success = $this->resolveActions($user, $actions); + } + if (!$success) { + $this->addPropertyRecursive($field, 'validate', ['ignore' => true]); + } + } + + /** + * @param UserInterface|null $user + * @param array $actions + * @param string $op + * @return bool + */ + protected function resolveActions(?UserInterface $user, array $actions, string $op = 'and') + { + if (null === $user) { + return false; + } + + $c = $i = count($actions); + foreach ($actions as $key => $action) { + if (!is_int($key) && is_array($actions)) { + $i -= $this->resolveActions($user, $action, $key); + } elseif ($user->authorize($action)) { + $i--; } } + + if ($op === 'and') { + return $i === 0; + } + + return $c !== $i; } /** * @param array $field * @param string $property * @param array $call + * @return void */ protected function dynamicScope(array &$field, $property, array &$call) { @@ -400,7 +559,7 @@ class Blueprint extends BlueprintForm } $scopes = (array)$call['params']; - $matches = \in_array($this->scope, $scopes, true); + $matches = in_array($this->scope, $scopes, true); if ($this->scope && $property !== 'ignore') { $matches = !$matches; } @@ -411,9 +570,15 @@ class Blueprint extends BlueprintForm } } + /** + * @param array $field + * @param string $property + * @param mixed $value + * @return void + */ protected function addPropertyRecursive(array &$field, $property, $value) { - if (\is_array($value) && isset($field[$property]) && \is_array($field[$property])) { + if (is_array($value) && isset($field[$property]) && is_array($field[$property])) { $field[$property] = array_merge_recursive($field[$property], $value); } else { $field[$property] = $value; diff --git a/system/src/Grav/Common/Data/BlueprintSchema.php b/system/src/Grav/Common/Data/BlueprintSchema.php index c79b3fd..a50bf23 100644 --- a/system/src/Grav/Common/Data/BlueprintSchema.php +++ b/system/src/Grav/Common/Data/BlueprintSchema.php @@ -3,21 +3,33 @@ /** * @package Grav\Common\Data * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Data; +use Grav\Common\Config\Config; use Grav\Common\Grav; use RocketTheme\Toolbox\ArrayTraits\Export; use RocketTheme\Toolbox\ArrayTraits\ExportInterface; use RocketTheme\Toolbox\Blueprints\BlueprintSchema as BlueprintSchemaBase; +use RuntimeException; +use function is_array; +use function is_string; +/** + * Class BlueprintSchema + * @package Grav\Common\Data + */ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface { use Export; + /** @var array */ + protected $filter = ['validation' => true, 'xss_check' => true]; + + /** @var array */ protected $ignoreFormKeys = [ 'title' => true, 'help' => true, @@ -48,14 +60,16 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface * Validate data against blueprints. * * @param array $data - * @throws \RuntimeException + * @param array $options + * @return void + * @throws RuntimeException */ - public function validate(array $data) + public function validate(array $data, array $options = []) { try { - $messages = $this->validateArray($data, $this->nested); - - } catch (\RuntimeException $e) { + $validation = $this->items['']['form']['validation'] ?? 'loose'; + $messages = $this->validateArray($data, $this->nested, $validation === 'strict', $options['xss_check'] ?? true); + } catch (RuntimeException $e) { throw (new ValidationException($e->getMessage(), $e->getCode(), $e))->setMessages(); } @@ -71,7 +85,7 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface */ public function processForm(array $data, array $toggles = []) { - return $this->processFormRecursive($data, $toggles, $this->nested); + return $this->processFormRecursive($data, $toggles, $this->nested) ?? []; } /** @@ -84,18 +98,31 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface */ public function filter(array $data, $missingValuesAsNull = false, $keepEmptyValues = false) { - return $this->filterArray($data, $this->nested, $missingValuesAsNull, $keepEmptyValues); + $this->buildIgnoreNested($this->nested); + + return $this->filterArray($data, $this->nested, '', $missingValuesAsNull, $keepEmptyValues) ?? []; } /** * Flatten data by using blueprints. * * @param array $data Data to be flattened. + * @param bool $includeAll * @return array */ - public function flattenData(array $data) + public function flattenData(array $data, bool $includeAll = false) { - return $this->flattenArray($data, $this->nested, ''); + $list = []; + if ($includeAll) { + foreach ($this->items as $key => $rules) { + $type = $rules['type'] ?? ''; + if (!str_starts_with($type, '_') && !str_contains($key, '*')) { + $list[$key] = null; + } + } + } + + return array_replace($list, $this->flattenArray($data, $this->nested, '')); } /** @@ -123,22 +150,26 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface $array[$prefix.$key] = $field; } } + return $array; } /** * @param array $data * @param array $rules + * @param bool $strict + * @param bool $xss * @return array - * @throws \RuntimeException + * @throws RuntimeException */ - protected function validateArray(array $data, array $rules) + protected function validateArray(array $data, array $rules, bool $strict, bool $xss = true) { $messages = $this->checkRequired($data, $rules); foreach ($data as $key => $child) { $val = $rules[$key] ?? $rules['*'] ?? null; - $rule = \is_string($val) ? $this->items[$val] : null; + $rule = is_string($val) ? $this->items[$val] : null; + $checkXss = $xss; if ($rule) { // Item has been defined in blueprints. @@ -148,12 +179,25 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface } $messages += Validation::validate($child, $rule); - } elseif (\is_array($child) && \is_array($val)) { + + } elseif (is_array($child) && is_array($val)) { // Array has been defined in blueprints. - $messages += $this->validateArray($child, $val); - } elseif (isset($rules['validation']) && $rules['validation'] === 'strict') { - // Undefined/extra item. - throw new \RuntimeException(sprintf('%s is not defined in blueprints', $key)); + $messages += $this->validateArray($child, $val, $strict); + $checkXss = false; + + } elseif ($strict) { + // Undefined/extra item in strict mode. + /** @var Config $config */ + $config = Grav::instance()['config']; + if (!$config->get('system.strict_mode.blueprint_strict_compat', true)) { + throw new RuntimeException(sprintf('%s is not defined in blueprints', $key)); + } + + user_error(sprintf('Having extra key %s in your data is deprecated with blueprint having \'validation: strict\'', $key), E_USER_DEPRECATED); + } + + if ($checkXss) { + $messages += Validation::checkSafety($child, $rule ?: ['name' => $key]); } } @@ -163,51 +207,55 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface /** * @param array $data * @param array $rules + * @param string $parent * @param bool $missingValuesAsNull * @param bool $keepEmptyValues - * @return array + * @return array|null */ - protected function filterArray(array $data, array $rules, $missingValuesAsNull, $keepEmptyValues) + protected function filterArray(array $data, array $rules, string $parent, bool $missingValuesAsNull, bool $keepEmptyValues) { $results = []; - if ($missingValuesAsNull) { - // First pass is to fill up all the fields with null. This is done to lock the ordering of the fields. - foreach ($rules as $key => $rule) { - if ($key && !isset($results[$key])) { - $val = $rules[$key] ?? $rules['*'] ?? null; - $rule = \is_string($val) ? $this->items[$val] : null; - - if (empty($rule['disabled']) && empty($rule['validate']['ignore'])) { - continue; - } - } - } - } - foreach ($data as $key => $field) { $val = $rules[$key] ?? $rules['*'] ?? null; - $rule = \is_string($val) ? $this->items[$val] : null; + $rule = is_string($val) ? $this->items[$val] : $this->items[$parent . $key] ?? null; - if ($rule) { - // Item has been defined in blueprints. - if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) { - // Skip any data in the ignored field. + if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) { + // Skip any data in the ignored field. + unset($results[$key]); + continue; + } + + if (null === $field) { + if ($missingValuesAsNull) { + $results[$key] = null; + } else { + unset($results[$key]); + } + continue; + } + + $isParent = isset($val['*']); + $type = $rule['type'] ?? null; + + if (!$isParent && $type && $type !== '_parent') { + $field = Validation::filter($field, $rule); + } elseif (is_array($field) && is_array($val)) { + // Array has been defined in blueprints. + $k = $isParent ? '*' : $key; + $field = $this->filterArray($field, $val, $parent . $k . '.', $missingValuesAsNull, $keepEmptyValues); + + if (null === $field) { + // Nested parent has no values. unset($results[$key]); continue; } - - $field = Validation::filter($field, $rule); - } elseif (\is_array($field) && \is_array($val)) { - // Array has been defined in blueprints. - $field = $this->filterArray($field, $val, $missingValuesAsNull, $keepEmptyValues); - } elseif (isset($rules['validation']) && $rules['validation'] === 'strict') { // Skip any extra data. continue; } - if ($keepEmptyValues || (null !== $field && (!\is_array($field) || !empty($field)))) { + if ($keepEmptyValues || (null !== $field && (!is_array($field) || !empty($field)))) { $results[$key] = $field; } } @@ -215,6 +263,31 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface return $results ?: null; } + /** + * @param array $nested + * @param string $parent + * @return bool + */ + protected function buildIgnoreNested(array $nested, $parent = '') + { + $ignore = true; + foreach ($nested as $key => $val) { + $key = $parent . $key; + if (is_array($val)) { + $ignore = $this->buildIgnoreNested($val, $key . '.') && $ignore; // Keep the order! + } else { + $child = $this->items[$key] ?? null; + $ignore = $ignore && (!$child || !empty($child['disabled']) || !empty($child['validate']['ignore'])); + } + } + if ($ignore) { + $key = trim($parent, '.'); + $this->items[$key]['validate']['ignore'] = true; + } + + return $ignore; + } + /** * @param array|null $data * @param array $toggles @@ -232,8 +305,19 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface continue; } if (is_array($value)) { + // Special toggle handling for all the nested data. + $toggle = $toggles[$key] ?? []; + if (!is_array($toggle)) { + if (!$toggle) { + $data[$key] = null; + + continue; + } + + $toggle = []; + } // Recursively fetch the items. - $data[$key] = $this->processFormRecursive($data[$key] ?? null, $toggles[$key] ?? [], $value); + $data[$key] = $this->processFormRecursive($data[$key] ?? null, $toggle, $value); } else { $field = $this->get($value); // Do not add the field if: @@ -244,8 +328,8 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface || !empty($field['disabled']) // Field validation is set to be ignored || !empty($field['validate']['ignore']) - // Field is toggleable and the toggle is turned off - || (!empty($field['toggleable']) && empty($toggles[$key])) + // Field is overridable and the toggle is turned off + || (!empty($field['overridable']) && empty($toggles[$key])) ) { continue; } @@ -268,7 +352,7 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface $messages = []; foreach ($fields as $name => $field) { - if (!\is_string($field)) { + if (!is_string($field)) { continue; } @@ -279,10 +363,15 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface continue; } + // Skip overridable fields without value. + // TODO: We need better overridable support, which is not just ignoring required values but also looking if defaults are good. + if (!empty($field['overridable']) && !isset($data[$name])) { + continue; + } + // Check if required. if (isset($field['validate']['required']) && $field['validate']['required'] === true) { - if (isset($data[$name])) { continue; } @@ -304,6 +393,7 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface * @param array $field * @param string $property * @param array $call + * @return void */ protected function dynamicConfig(array &$field, $property, array &$call) { diff --git a/system/src/Grav/Common/Data/Blueprints.php b/system/src/Grav/Common/Data/Blueprints.php index 1d0fcc6..abe153f 100644 --- a/system/src/Grav/Common/Data/Blueprints.php +++ b/system/src/Grav/Common/Data/Blueprints.php @@ -3,15 +3,23 @@ /** * @package Grav\Common\Data * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Data; +use DirectoryIterator; use Grav\Common\Grav; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use function is_array; +use function is_object; +/** + * Class Blueprints + * @package Grav\Common\Data + */ class Blueprints { /** @var array|string */ @@ -34,7 +42,7 @@ class Blueprints * * @param string $type Blueprint type. * @return Blueprint - * @throws \RuntimeException + * @throws RuntimeException */ public function get($type) { @@ -65,10 +73,9 @@ class Blueprints if ($locator->isStream($this->search)) { $iterator = $locator->getIterator($this->search); } else { - $iterator = new \DirectoryIterator($this->search); + $iterator = new DirectoryIterator($this->search); } - /** @var \DirectoryIterator $file */ foreach ($iterator as $file) { if (!$file->isFile() || '.' . $file->getExtension() !== YAML_EXT) { continue; @@ -92,7 +99,7 @@ class Blueprints { $blueprint = new Blueprint($name); - if (\is_array($this->search) || \is_object($this->search)) { + if (is_array($this->search) || is_object($this->search)) { // Page types. $blueprint->setOverrides($this->search); $blueprint->setContext('blueprints://pages'); @@ -102,7 +109,7 @@ class Blueprints try { $blueprint->load()->init(); - } catch (\RuntimeException $e) { + } catch (RuntimeException $e) { $log = Grav::instance()['log']; $log->error(sprintf('Blueprint %s cannot be loaded: %s', $name, $e->getMessage())); diff --git a/system/src/Grav/Common/Data/Data.php b/system/src/Grav/Common/Data/Data.php index 97b5530..67fb0a8 100644 --- a/system/src/Grav/Common/Data/Data.php +++ b/system/src/Grav/Common/Data/Data.php @@ -3,49 +3,58 @@ /** * @package Grav\Common\Data * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Data; +use ArrayAccess; +use Exception; +use JsonSerializable; use RocketTheme\Toolbox\ArrayTraits\Countable; use RocketTheme\Toolbox\ArrayTraits\Export; use RocketTheme\Toolbox\ArrayTraits\ExportInterface; use RocketTheme\Toolbox\ArrayTraits\NestedArrayAccessWithGetters; -use RocketTheme\Toolbox\File\File; use RocketTheme\Toolbox\File\FileInterface; +use RuntimeException; +use function func_get_args; +use function is_array; +use function is_callable; +use function is_object; -class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable, ExportInterface +/** + * Class Data + * @package Grav\Common\Data + */ +class Data implements DataInterface, ArrayAccess, \Countable, JsonSerializable, ExportInterface { use NestedArrayAccessWithGetters, Countable, Export; /** @var string */ protected $gettersVariable = 'items'; - /** @var array */ protected $items; - - /** @var Blueprint */ + /** @var Blueprint|callable|null */ protected $blueprints; - - /** @var File */ + /** @var FileInterface|null */ protected $storage; /** @var bool */ private $missingValuesAsNull = false; - /** @var bool */ private $keepEmptyValues = true; /** * @param array $items - * @param Blueprint|callable $blueprints + * @param Blueprint|callable|null $blueprints */ public function __construct(array $items = [], $blueprints = null) { $this->items = $items; - $this->blueprints = $blueprints; + if (null !== $blueprints) { + $this->blueprints = $blueprints; + } } /** @@ -92,20 +101,20 @@ class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable * @param mixed $value Value to be joined. * @param string $separator Separator, defaults to '.' * @return $this - * @throws \RuntimeException + * @throws RuntimeException */ public function join($name, $value, $separator = '.') { $old = $this->get($name, null, $separator); if ($old !== null) { - if (!\is_array($old)) { - throw new \RuntimeException('Value ' . $old); + if (!is_array($old)) { + throw new RuntimeException('Value ' . $old); } - if (\is_object($value)) { + if (is_object($value)) { $value = (array) $value; - } elseif (!\is_array($value)) { - throw new \RuntimeException('Value ' . $value); + } elseif (!is_array($value)) { + throw new RuntimeException('Value ' . $value); } $value = $this->blueprints()->mergeData($old, $value, $name, $separator); @@ -138,7 +147,7 @@ class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable */ public function joinDefaults($name, $value, $separator = '.') { - if (\is_object($value)) { + if (is_object($value)) { $value = (array) $value; } @@ -159,14 +168,14 @@ class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable * @param array|object $value Value to be joined. * @param string $separator Separator, defaults to '.' * @return array - * @throws \RuntimeException + * @throws RuntimeException */ public function getJoined($name, $value, $separator = '.') { - if (\is_object($value)) { + if (is_object($value)) { $value = (array) $value; - } elseif (!\is_array($value)) { - throw new \RuntimeException('Value ' . $value); + } elseif (!is_array($value)) { + throw new RuntimeException('Value ' . $value); } $old = $this->get($name, null, $separator); @@ -176,8 +185,8 @@ class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable return $value; } - if (!\is_array($old)) { - throw new \RuntimeException('Value ' . $old); + if (!is_array($old)) { + throw new RuntimeException('Value ' . $old); } // Return joined data. @@ -215,7 +224,7 @@ class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable * Validate by blueprints. * * @return $this - * @throws \Exception + * @throws Exception */ public function validate() { @@ -255,19 +264,22 @@ class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable */ public function blueprints() { - if (!$this->blueprints){ - $this->blueprints = new Blueprint; - } elseif (\is_callable($this->blueprints)) { + if (!$this->blueprints) { + $this->blueprints = new Blueprint(); + } elseif (is_callable($this->blueprints)) { // Lazy load blueprints. $blueprints = $this->blueprints; $this->blueprints = $blueprints(); } + return $this->blueprints; } /** * Save data if storage has been defined. - * @throws \RuntimeException + * + * @return void + * @throws RuntimeException */ public function save() { @@ -308,8 +320,8 @@ class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable /** * Set or get the data storage. * - * @param FileInterface $storage Optionally enter a new storage. - * @return FileInterface + * @param FileInterface|null $storage Optionally enter a new storage. + * @return FileInterface|null */ public function file(FileInterface $storage = null) { @@ -320,6 +332,9 @@ class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable return $this->storage; } + /** + * @return array + */ public function jsonSerialize() { return $this->items; diff --git a/system/src/Grav/Common/Data/DataInterface.php b/system/src/Grav/Common/Data/DataInterface.php index 91279b2..b4452bb 100644 --- a/system/src/Grav/Common/Data/DataInterface.php +++ b/system/src/Grav/Common/Data/DataInterface.php @@ -3,14 +3,19 @@ /** * @package Grav\Common\Data * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Data; +use Exception; use RocketTheme\Toolbox\File\FileInterface; +/** + * Interface DataInterface + * @package Grav\Common\Data + */ interface DataInterface { /** @@ -35,35 +40,44 @@ interface DataInterface /** * Return blueprints. + * + * @return Blueprint */ public function blueprints(); /** * Validate by blueprints. * - * @throws \Exception + * @return $this + * @throws Exception */ public function validate(); /** * Filter all items by using blueprints. + * + * @return $this */ public function filter(); /** * Get extra items which haven't been defined in blueprints. + * + * @return array */ public function extra(); /** * Save data into the file. + * + * @return void */ public function save(); /** * Set or get the data storage. * - * @param FileInterface $storage Optionally enter a new storage. + * @param FileInterface|null $storage Optionally enter a new storage. * @return FileInterface */ public function file(FileInterface $storage = null); diff --git a/system/src/Grav/Common/Data/Validation.php b/system/src/Grav/Common/Data/Validation.php index a2bf0c5..bb3fdd1 100644 --- a/system/src/Grav/Common/Data/Validation.php +++ b/system/src/Grav/Common/Data/Validation.php @@ -3,16 +3,35 @@ /** * @package Grav\Common\Data * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Data; +use ArrayAccess; +use Countable; +use DateTime; +use Grav\Common\Config\Config; use Grav\Common\Grav; +use Grav\Common\Language\Language; +use Grav\Common\Security; +use Grav\Common\User\Interfaces\UserInterface; use Grav\Common\Utils; use Grav\Common\Yaml; +use Grav\Framework\Flex\Interfaces\FlexObjectInterface; +use Traversable; +use function count; +use function is_array; +use function is_bool; +use function is_float; +use function is_int; +use function is_string; +/** + * Class Validation + * @package Grav\Common\Data + */ class Validation { /** @@ -44,7 +63,7 @@ class Validation $name = ucfirst($field['label'] ?? $field['name']); $message = (string) isset($field['validate']['message']) ? $language->translate($field['validate']['message']) - : $language->translate('GRAV.FORM.INVALID_INPUT', null, true) . ' "' . $language->translate($name) . '"'; + : $language->translate('GRAV.FORM.INVALID_INPUT') . ' "' . $language->translate($name) . '"'; // Validate type with fallback type text. @@ -78,6 +97,92 @@ class Validation return $messages; } + /** + * @param mixed $value + * @param array $field + * @return array + */ + public static function checkSafety($value, array $field) + { + $messages = []; + + $type = $field['validate']['type'] ?? $field['type'] ?? 'text'; + $options = $field['xss_check'] ?? []; + if ($options === false || $type === 'unset') { + return $messages; + } + if (!is_array($options)) { + $options = []; + } + + $name = ucfirst($field['label'] ?? $field['name'] ?? 'UNKNOWN'); + + /** @var UserInterface $user */ + $user = Grav::instance()['user'] ?? null; + /** @var Config $config */ + $config = Grav::instance()['config']; + + $xss_whitelist = $config->get('security.xss_whitelist', 'admin.super'); + + // Get language class. + /** @var Language $language */ + $language = Grav::instance()['language']; + + if (!static::authorize($xss_whitelist, $user)) { + $defaults = Security::getXssDefaults(); + $options += $defaults; + $options['enabled_rules'] += $defaults['enabled_rules']; + if (!empty($options['safe_protocols'])) { + $options['invalid_protocols'] = array_diff($options['invalid_protocols'], $options['safe_protocols']); + } + if (!empty($options['safe_tags'])) { + $options['dangerous_tags'] = array_diff($options['dangerous_tags'], $options['safe_tags']); + } + + if (is_string($value)) { + $violation = Security::detectXss($value, $options); + if ($violation) { + $messages[$name][] = $language->translate(['GRAV.FORM.XSS_ISSUES', $language->translate($name)], null, true); + } + } elseif (is_array($value)) { + $violations = Security::detectXssFromArray($value, "{$name}.", $options); + if ($violations) { + $messages[$name][] = $language->translate(['GRAV.FORM.XSS_ISSUES', $language->translate($name)], null, true); + } + } + } + + return $messages; + } + + /** + * Checks user authorisation to the action. + * + * @param string|string[] $action + * @param UserInterface|null $user + * @return bool + */ + public static function authorize($action, UserInterface $user = null) + { + if (!$user) { + return false; + } + + $action = (array)$action; + foreach ($action as $a) { + // Ignore 'admin.super' if it's not the only value to be checked. + if ($a === 'admin.super' && count($action) > 1 && $user instanceof FlexObjectInterface) { + continue; + } + + if ($user->authorize($a)) { + return true; + } + } + + return false; + } + /** * Filter value against a blueprint field definition. * @@ -123,7 +228,7 @@ class Validation */ public static function typeText($value, array $params, array $field) { - if (!\is_string($value) && !is_numeric($value)) { + if (!is_string($value) && !is_numeric($value)) { return false; } @@ -133,16 +238,21 @@ class Validation $value = trim($value); } - if (isset($params['min']) && \strlen($value) < $params['min']) { + $value = preg_replace("/\r\n|\r/um", "\n", $value); + $len = mb_strlen($value); + + $min = (int)($params['min'] ?? 0); + if ($min && $len < $min) { return false; } - if (isset($params['max']) && \strlen($value) > $params['max']) { + $max = (int)($params['max'] ?? 0); + if ($max && $len > $max) { return false; } - $min = $params['min'] ?? 0; - if (isset($params['step']) && (\strlen($value) - $min) % $params['step'] === 0) { + $step = (int)($params['step'] ?? 0); + if ($step && ($len - $min) % $step === 0) { return false; } @@ -153,17 +263,25 @@ class Validation return true; } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return string + */ protected static function filterText($value, array $params, array $field) { - if (!\is_string($value) && !is_numeric($value)) { + if (!is_string($value) && !is_numeric($value)) { return ''; } + $value = (string)$value; + if (!empty($params['trim'])) { $value = trim($value); } - return (string) $value; + return preg_replace("/\r\n|\r/um", "\n", $value); } /** @@ -180,24 +298,57 @@ class Validation return $value === $field_value ? $value : null; } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array|array[]|false|string[] + */ protected static function filterCommaList($value, array $params, array $field) { - return \is_array($value) ? $value : preg_split('/\s*,\s*/', $value, -1, PREG_SPLIT_NO_EMPTY); + return is_array($value) ? $value : preg_split('/\s*,\s*/', $value, -1, PREG_SPLIT_NO_EMPTY); } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return bool + */ public static function typeCommaList($value, array $params, array $field) { - return \is_array($value) ? true : self::typeText($value, $params, $field); + return is_array($value) ? true : self::typeText($value, $params, $field); } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array|array[]|false|string[] + */ + protected static function filterLines($value, array $params, array $field) + { + return is_array($value) ? $value : preg_split('/\s*[\r\n]+\s*/', $value, -1, PREG_SPLIT_NO_EMPTY); + } + + /** + * @param mixed $value + * @param array $params + * @return string + */ protected static function filterLower($value, array $params) { - return strtolower($value); + return mb_strtolower($value); } + /** + * @param mixed $value + * @param array $params + * @return string + */ protected static function filterUpper($value, array $params) { - return strtoupper($value); + return mb_strtoupper($value); } @@ -260,6 +411,12 @@ class Validation return self::typeArray((array) $value, $params, $field); } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array|null + */ protected static function filterCheckboxes($value, array $params, array $field) { return self::filterArray($value, $params, $field); @@ -304,7 +461,7 @@ class Validation */ public static function typeToggle($value, array $params, array $field) { - if (\is_bool($value)) { + if (is_bool($value)) { $value = (int)$value; } @@ -324,6 +481,12 @@ class Validation return self::typeArray((array)$value, $params, $field); } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array + */ protected static function filterFile($value, array $params, array $field) { return (array)$value; @@ -369,22 +532,33 @@ class Validation return !(isset($params['step']) && fmod($value - $min, $params['step']) === 0); } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return float|int + */ protected static function filterNumber($value, array $params, array $field) { - return (string)(int)$value !== (string)(float)$value ? (float) $value : (int) $value; + return (string)(int)$value !== (string)(float)$value ? (float)$value : (int)$value; } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return string + */ protected static function filterDateTime($value, array $params, array $field) { $format = Grav::instance()['config']->get('system.pages.dateformat.default'); if ($format) { - $converted = new \DateTime($value); + $converted = new DateTime($value); return $converted->format($format); } return $value; } - /** * HTML5 input: range * @@ -398,6 +572,12 @@ class Validation return self::typeNumber($value, $params, $field); } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return float|int + */ protected static function filterRange($value, array $params, array $field) { return self::filterNumber($value, $params, $field); @@ -426,7 +606,7 @@ class Validation */ public static function typeEmail($value, array $params, array $field) { - $values = !\is_array($value) ? explode(',', preg_replace('/\s+/', '', $value)) : $value; + $values = !is_array($value) ? explode(',', preg_replace('/\s+/', '', $value)) : $value; foreach ($values as $val) { if (!(self::typeText($val, $params, $field) && filter_var($val, FILTER_VALIDATE_EMAIL))) { @@ -445,7 +625,6 @@ class Validation * @param array $field Blueprint for the field. * @return bool True if validation succeeded. */ - public static function typeUrl($value, array $params, array $field) { return self::typeText($value, $params, $field) && filter_var($value, FILTER_VALIDATE_URL); @@ -461,17 +640,17 @@ class Validation */ public static function typeDatetime($value, array $params, array $field) { - if ($value instanceof \DateTime) { + if ($value instanceof DateTime) { return true; } - if (!\is_string($value)) { + if (!is_string($value)) { return false; } if (!isset($params['format'])) { return false !== strtotime($value); } - $dateFromFormat = \DateTime::createFromFormat($params['format'], $value); + $dateFromFormat = DateTime::createFromFormat($params['format'], $value); return $dateFromFormat && $value === date($params['format'], $dateFromFormat->getTimestamp()); } @@ -567,21 +746,21 @@ class Validation */ public static function typeArray($value, array $params, array $field) { - if (!\is_array($value)) { + if (!is_array($value)) { return false; } if (isset($field['multiple'])) { - if (isset($params['min']) && \count($value) < $params['min']) { + if (isset($params['min']) && count($value) < $params['min']) { return false; } - if (isset($params['max']) && \count($value) > $params['max']) { + if (isset($params['max']) && count($value) > $params['max']) { return false; } $min = $params['min'] ?? 0; - if (isset($params['step']) && (\count($value) - $min) % $params['step'] === 0) { + if (isset($params['step']) && (count($value) - $min) % $params['step'] === 0) { return false; } } @@ -604,13 +783,32 @@ class Validation return !($options && array_diff($value, $options)); } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array|null + */ + protected static function filterFlatten_array($value, $params, $field) + { + $value = static::filterArray($value, $params, $field); + + return Utils::arrayUnflattenDotNotation($value); + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array|null + */ protected static function filterArray($value, $params, $field) { $values = (array) $value; $options = isset($field['options']) ? array_keys($field['options']) : []; $multi = $field['multiple'] ?? false; - if (\count($values) === 1 && isset($values[0]) && $values[0] === '') { + if (count($values) === 1 && isset($values[0]) && $values[0] === '') { return null; } @@ -624,7 +822,7 @@ class Validation if ($multi) { foreach ($values as $key => $val) { - if (\is_array($val)) { + if (is_array($val)) { $val = implode(',', $val); $values[$key] = array_map('trim', explode(',', $val)); } else { @@ -633,28 +831,90 @@ class Validation } } - if (isset($field['ignore_empty']) && Utils::isPositive($field['ignore_empty'])) { - foreach ($values as $key => $val) { - if ($val === '') { + $ignoreEmpty = isset($field['ignore_empty']) && Utils::isPositive($field['ignore_empty']); + $valueType = $params['value_type'] ?? null; + $keyType = $params['key_type'] ?? null; + if ($ignoreEmpty || $valueType || $keyType) { + $values = static::arrayFilterRecurse($values, ['value_type' => $valueType, 'key_type' => $keyType, 'ignore_empty' => $ignoreEmpty]); + } + + return $values; + } + + /** + * @param array $values + * @param array $params + * @return array + */ + protected static function arrayFilterRecurse(array $values, array $params): array + { + foreach ($values as $key => &$val) { + if ($params['key_type']) { + switch ($params['key_type']) { + case 'int': + $result = is_int($key); + break; + case 'string': + $result = is_string($key); + break; + default: + $result = false; + } + if (!$result) { unset($values[$key]); - } elseif (\is_array($val)) { - foreach ($val as $inner_key => $inner_value) { - if ($inner_value === '') { - unset($val[$inner_key]); - } + } + } + if (is_array($val)) { + $val = static::arrayFilterRecurse($val, $params); + if ($params['ignore_empty'] && empty($val)) { + unset($values[$key]); + } + } else { + if ($params['value_type'] && $val !== '' && $val !== null) { + switch ($params['value_type']) { + case 'bool': + if (Utils::isPositive($val)) { + $val = true; + } elseif (Utils::isNegative($val)) { + $val = false; + } else { + // Ignore invalid bool values. + $val = null; + } + break; + case 'int': + $val = (int)$val; + break; + case 'float': + $val = (float)$val; + break; + case 'string': + $val = (string)$val; + break; + case 'trim': + $val = trim($val); + break; } } - $values[$key] = $val; + if ($params['ignore_empty'] && ($val === '' || $val === null)) { + unset($values[$key]); + } } } return $values; } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return bool + */ public static function typeList($value, array $params, array $field) { - if (!\is_array($value)) { + if (!is_array($value)) { return false; } @@ -671,19 +931,29 @@ class Validation return true; } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array + */ protected static function filterList($value, array $params, array $field) { return (array) $value; } + /** + * @param mixed $value + * @param array $params + * @return array + */ public static function filterYaml($value, $params) { - if (!\is_string($value)) { + if (!is_string($value)) { return $value; } return (array) Yaml::parse($value); - } /** @@ -699,6 +969,12 @@ class Validation return true; } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return mixed + */ public static function filterIgnore($value, array $params, array $field) { return $value; @@ -717,6 +993,12 @@ class Validation return true; } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return null + */ public static function filterUnset($value, array $params, array $field) { return null; @@ -724,6 +1006,11 @@ class Validation // HTML5 attributes (min, max and range are handled inside the types) + /** + * @param mixed $value + * @param bool $params + * @return bool + */ public static function validateRequired($value, $params) { if (is_scalar($value)) { @@ -733,79 +1020,170 @@ class Validation return (bool) $params !== true || !empty($value); } + /** + * @param mixed $value + * @param string $params + * @return bool + */ public static function validatePattern($value, $params) { return (bool) preg_match("`^{$params}$`u", $value); } - // Internal types + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ public static function validateAlpha($value, $params) { return ctype_alpha($value); } + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ public static function validateAlnum($value, $params) { return ctype_alnum($value); } + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ public static function typeBool($value, $params) { - return \is_bool($value) || $value == 1 || $value == 0; + return is_bool($value) || $value == 1 || $value == 0; } + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ public static function validateBool($value, $params) { - return \is_bool($value) || $value == 1 || $value == 0; + return is_bool($value) || $value == 1 || $value == 0; } + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ protected static function filterBool($value, $params) { return (bool) $value; } + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ public static function validateDigit($value, $params) { return ctype_digit($value); } + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ public static function validateFloat($value, $params) { - return \is_float(filter_var($value, FILTER_VALIDATE_FLOAT)); + return is_float(filter_var($value, FILTER_VALIDATE_FLOAT)); } + /** + * @param mixed $value + * @param mixed $params + * @return float + */ protected static function filterFloat($value, $params) { return (float) $value; } + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ public static function validateHex($value, $params) { return ctype_xdigit($value); } + /** + * Custom input: int + * + * @param mixed $value Value to be validated. + * @param array $params Validation parameters. + * @param array $field Blueprint for the field. + * @return bool True if validation succeeded. + */ + public static function typeInt($value, array $params, array $field) + { + $params['step'] = max(1, (int)($params['step'] ?? 0)); + + return self::typeNumber($value, $params, $field); + } + + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ public static function validateInt($value, $params) { return is_numeric($value) && (int)$value == $value; } + /** + * @param mixed $value + * @param mixed $params + * @return int + */ protected static function filterInt($value, $params) { return (int)$value; } + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ public static function validateArray($value, $params) { - return \is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable && $value instanceof \Countable); + return is_array($value) || ($value instanceof ArrayAccess && $value instanceof Traversable && $value instanceof Countable); } + /** + * @param mixed $value + * @param mixed $params + * @return array + */ public static function filterItem_List($value, $params) { - return array_values(array_filter($value, function($v) { return !empty($v); } )); + return array_values(array_filter($value, function ($v) { + return !empty($v); + })); } + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ public static function validateJson($value, $params) { return (bool) (@json_decode($value)); diff --git a/system/src/Grav/Common/Data/ValidationException.php b/system/src/Grav/Common/Data/ValidationException.php index 8bdb057..2d94ab8 100644 --- a/system/src/Grav/Common/Data/ValidationException.php +++ b/system/src/Grav/Common/Data/ValidationException.php @@ -3,19 +3,30 @@ /** * @package Grav\Common\Data * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Data; use Grav\Common\Grav; +use RuntimeException; -class ValidationException extends \RuntimeException +/** + * Class ValidationException + * @package Grav\Common\Data + */ +class ValidationException extends RuntimeException { + /** @var array */ protected $messages = []; - public function setMessages(array $messages = []) { + /** + * @param array $messages + * @return $this + */ + public function setMessages(array $messages = []) + { $this->messages = $messages; $language = Grav::instance()['language']; @@ -31,6 +42,9 @@ class ValidationException extends \RuntimeException return $this; } + /** + * @return array + */ public function getMessages() { return $this->messages; diff --git a/system/src/Grav/Common/Debugger.php b/system/src/Grav/Common/Debugger.php index 0737209..6a1e256 100644 --- a/system/src/Grav/Common/Debugger.php +++ b/system/src/Grav/Common/Debugger.php @@ -3,12 +3,19 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; +use Clockwork\Clockwork; +use Clockwork\DataSource\MonologDataSource; +use Clockwork\DataSource\PsrMessageDataSource; +use Clockwork\DataSource\XdebugDataSource; +use Clockwork\Helpers\ServerTiming; +use Clockwork\Request\UserData; +use Clockwork\Storage\FileStorage; use DebugBar\DataCollector\ConfigCollector; use DebugBar\DataCollector\DataCollectorInterface; use DebugBar\DataCollector\ExceptionsCollector; @@ -18,76 +25,106 @@ use DebugBar\DataCollector\PhpInfoCollector; use DebugBar\DataCollector\RequestDataCollector; use DebugBar\DataCollector\TimeDataCollector; use DebugBar\DebugBar; +use DebugBar\DebugBarException; use DebugBar\JavascriptRenderer; -use DebugBar\StandardDebugBar; use Grav\Common\Config\Config; use Grav\Common\Processors\ProcessorInterface; +use Grav\Common\Twig\TwigClockworkDataSource; +use Grav\Framework\Psr7\Response; +use Monolog\Logger; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use ReflectionObject; +use SplFileInfo; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Throwable; +use Twig\Environment; use Twig\Template; use Twig\TemplateWrapper; +use function array_slice; +use function call_user_func; +use function count; +use function define; +use function defined; +use function extension_loaded; +use function get_class; +use function gettype; +use function is_array; +use function is_bool; +use function is_object; +use function is_scalar; +use function is_string; +/** + * Class Debugger + * @package Grav\Common + */ class Debugger { - /** @var Grav $grav */ + /** @var static */ + protected static $instance; + /** @var Grav|null */ protected $grav; - - /** @var Config $config */ + /** @var Config|null */ protected $config; - - /** @var JavascriptRenderer $renderer */ + /** @var JavascriptRenderer|null */ protected $renderer; - - /** @var StandardDebugBar $debugbar */ + /** @var DebugBar|null */ protected $debugbar; - + /** @var Clockwork|null */ + protected $clockwork; + /** @var bool */ + protected $enabled = false; /** @var bool */ - protected $enabled; - protected $initialized = false; - /** @var array */ protected $timers = []; - - /** @var array $deprecations */ + /** @var array */ protected $deprecations = []; - - /** @var callable */ + /** @var callable|null */ protected $errorHandler; + /** @var float */ + protected $requestTime; + /** @var float */ + protected $currentTime; + /** @var int */ + protected $profiling = 0; + /** @var bool */ + protected $censored = false; /** * Debugger constructor. */ public function __construct() { - $currentTime = microtime(true); + static::$instance = $this; - if (!\defined('GRAV_REQUEST_TIME')) { - \define('GRAV_REQUEST_TIME', $currentTime); + $this->currentTime = microtime(true); + + if (!defined('GRAV_REQUEST_TIME')) { + define('GRAV_REQUEST_TIME', $this->currentTime); } - // Enable debugger until $this->init() gets called. - $this->enabled = true; - - $debugbar = new DebugBar(); - $debugbar->addCollector(new PhpInfoCollector()); - $debugbar->addCollector(new MessagesCollector()); - $debugbar->addCollector(new RequestDataCollector()); - $debugbar->addCollector(new TimeDataCollector($_SERVER['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME)); - - $debugbar['time']->addMeasure('Server', $debugbar['time']->getRequestStartTime(), GRAV_REQUEST_TIME); - $debugbar['time']->addMeasure('Loading', GRAV_REQUEST_TIME, $currentTime); - $debugbar['time']->addMeasure('Debugger', $currentTime, microtime(true)); - - $this->debugbar = $debugbar; + $this->requestTime = $_SERVER['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME; // Set deprecation collector. $this->setErrorHandler(); } + /** + * @return Clockwork|null + */ + public function getClockwork(): ?Clockwork + { + return $this->enabled ? $this->clockwork : null; + } + /** * Initialize the debugger * * @return $this - * @throws \DebugBar\DebugBarException + * @throws DebugBarException */ public function init() { @@ -100,30 +137,242 @@ class Debugger // Enable/disable debugger based on configuration. $this->enabled = (bool)$this->config->get('system.debugger.enabled'); + $this->censored = (bool)$this->config->get('system.debugger.censored', false); - if ($this->enabled()) { + if ($this->enabled) { $this->initialized = true; - $plugins_config = (array)$this->config->get('plugins'); + $clockwork = $debugbar = null; + switch ($this->config->get('system.debugger.provider', 'debugbar')) { + case 'clockwork': + $this->clockwork = $clockwork = new Clockwork(); + break; + default: + $this->debugbar = $debugbar = new DebugBar(); + } + + $plugins_config = (array)$this->config->get('plugins'); ksort($plugins_config); - $debugbar = $this->debugbar; - $debugbar->addCollector(new MemoryCollector()); - $debugbar->addCollector(new ExceptionsCollector()); - $debugbar->addCollector(new ConfigCollector((array)$this->config->get('system'), 'Config')); - $debugbar->addCollector(new ConfigCollector($plugins_config, 'Plugins')); - $this->addMessage('Grav v' . GRAV_VERSION); + if ($clockwork) { + $log = $this->grav['log']; + $clockwork->setStorage(new FileStorage('cache://clockwork')); + if (extension_loaded('xdebug')) { + $clockwork->addDataSource(new XdebugDataSource()); + } + if ($log instanceof Logger) { + $clockwork->addDataSource(new MonologDataSource($log)); + } + + $timeline = $clockwork->timeline(); + if ($this->requestTime !== GRAV_REQUEST_TIME) { + $event = $timeline->event('Server'); + $event->finalize($this->requestTime, GRAV_REQUEST_TIME); + } + if ($this->currentTime !== GRAV_REQUEST_TIME) { + $event = $timeline->event('Loading'); + $event->finalize(GRAV_REQUEST_TIME, $this->currentTime); + } + $event = $timeline->event('Site Setup'); + $event->finalize($this->currentTime, microtime(true)); + } + + if ($this->censored) { + $censored = ['CENSORED' => true]; + } + + if ($debugbar) { + $debugbar->addCollector(new PhpInfoCollector()); + $debugbar->addCollector(new MessagesCollector()); + if (!$this->censored) { + $debugbar->addCollector(new RequestDataCollector()); + } + $debugbar->addCollector(new TimeDataCollector($this->requestTime)); + $debugbar->addCollector(new MemoryCollector()); + $debugbar->addCollector(new ExceptionsCollector()); + $debugbar->addCollector(new ConfigCollector($censored ?? (array)$this->config->get('system'), 'Config')); + $debugbar->addCollector(new ConfigCollector($censored ?? $plugins_config, 'Plugins')); + $debugbar->addCollector(new ConfigCollector($this->config->get('streams.schemes'), 'Streams')); + + if ($this->requestTime !== GRAV_REQUEST_TIME) { + $debugbar['time']->addMeasure('Server', $debugbar['time']->getRequestStartTime(), GRAV_REQUEST_TIME); + } + if ($this->currentTime !== GRAV_REQUEST_TIME) { + $debugbar['time']->addMeasure('Loading', GRAV_REQUEST_TIME, $this->currentTime); + } + $debugbar['time']->addMeasure('Site Setup', $this->currentTime, microtime(true)); + } + + $this->addMessage('Grav v' . GRAV_VERSION . ' - PHP ' . PHP_VERSION); + $this->config->debug(); + + if ($clockwork) { + $clockwork->info('System Configuration', $censored ?? $this->config->get('system')); + $clockwork->info('Plugins Configuration', $censored ?? $plugins_config); + $clockwork->info('Streams', $this->config->get('streams.schemes')); + } } return $this; } + public function finalize(): void + { + if ($this->clockwork && $this->enabled) { + $this->stopProfiling('Profiler Analysis'); + $this->addMeasures(); + + $deprecations = $this->getDeprecations(); + $count = count($deprecations); + if (!$count) { + return; + } + + /** @var UserData $userData */ + $userData = $this->clockwork->userData('Deprecated'); + $userData->counters([ + 'Deprecated' => count($deprecations) + ]); + /* + foreach ($deprecations as &$deprecation) { + $d = $deprecation; + unset($d['message']); + $this->clockwork->log('deprecated', $deprecation['message'], $d); + } + unset($deprecation); + */ + + $userData->table('Your site is using following deprecated features', $deprecations); + } + } + + public function logRequest(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + if (!$this->enabled || !$this->clockwork) { + return $response; + } + + $clockwork = $this->clockwork; + + $this->finalize(); + + $clockwork->timeline()->finalize($request->getAttribute('request_time')); + + if ($this->censored) { + $censored = 'CENSORED'; + $request = $request + ->withCookieParams([$censored => '']) + ->withUploadedFiles([]) + ->withHeader('cookie', $censored); + $request = $request->withParsedBody([$censored => '']); + } + + $clockwork->addDataSource(new PsrMessageDataSource($request, $response)); + + $clockwork->resolveRequest(); + $clockwork->storeRequest(); + + $clockworkRequest = $clockwork->getRequest(); + + $response = $response + ->withHeader('X-Clockwork-Id', $clockworkRequest->id) + ->withHeader('X-Clockwork-Version', $clockwork::VERSION); + + $grav = Grav::instance(); + $basePath = $this->grav['base_url_relative'] . $grav['pages']->base(); + if ($basePath) { + $response = $response->withHeader('X-Clockwork-Path', $basePath . '/__clockwork/'); + } + + return $response->withHeader('Server-Timing', ServerTiming::fromRequest($clockworkRequest)->value()); + } + + + public function debuggerRequest(RequestInterface $request): Response + { + $clockwork = $this->clockwork; + + $headers = [ + 'Content-Type' => 'application/json', + 'Grav-Internal-SkipShutdown' => 1 + ]; + + $path = $request->getUri()->getPath(); + $clockworkDataUri = '#/__clockwork(?:/(?[0-9-]+))?(?:/(?(?:previous|next)))?(?:/(?\d+))?#'; + if (preg_match($clockworkDataUri, $path, $matches) === false) { + $response = ['message' => 'Bad Input']; + + return new Response(400, $headers, json_encode($response)); + } + + $id = $matches['id'] ?? null; + $direction = $matches['direction'] ?? null; + $count = $matches['count'] ?? null; + + $storage = $clockwork->getStorage(); + + if ($direction === 'previous') { + $data = $storage->previous($id, $count); + } elseif ($direction === 'next') { + $data = $storage->next($id, $count); + } elseif ($id === 'latest') { + $data = $storage->latest(); + } else { + $data = $storage->find($id); + } + + if (preg_match('#(?[0-9-]+|latest)/extended#', $path)) { + $clockwork->extendRequest($data); + } + + if (!$data) { + $response = ['message' => 'Not Found']; + + return new Response(404, $headers, json_encode($response)); + } + + $data = is_array($data) ? array_map(function ($item) { + return $item->toArray(); + }, $data) : $data->toArray(); + + return new Response(200, $headers, json_encode($data)); + } + + /** + * @return void + */ + protected function addMeasures(): void + { + if (!$this->enabled) { + return; + } + + $nowTime = microtime(true); + $clkTimeLine = $this->clockwork ? $this->clockwork->timeline() : null; + $debTimeLine = $this->debugbar ? $this->debugbar['time'] : null; + foreach ($this->timers as $name => $data) { + $description = $data[0]; + $startTime = $data[1] ?? null; + $endTime = $data[2] ?? $nowTime; + if ($clkTimeLine) { + $event = $clkTimeLine->event($description); + $event->finalize($startTime, $endTime); + } elseif ($debTimeLine) { + if ($endTime - $startTime < 0.001) { + continue; + } + + $debTimeLine->addMeasure($description ?? $name, $startTime, $endTime); + } + } + $this->timers = []; + } + /** * Set/get the enabled state of the debugger * - * @param bool $state If null, the method returns the enabled value. If set, the method sets the enabled state - * + * @param bool|null $state If null, the method returns the enabled value. If set, the method sets the enabled state * @return bool */ public function enabled($state = null) @@ -142,8 +391,7 @@ class Debugger */ public function addAssets() { - if ($this->enabled()) { - + if ($this->enabled) { // Only add assets if Page is HTML $page = $this->grav['page']; if ($page->templateFormat() !== 'html') { @@ -153,28 +401,42 @@ class Debugger /** @var Assets $assets */ $assets = $this->grav['assets']; - // Add jquery library - $assets->add('jquery', 101); - - $this->renderer = $this->debugbar->getJavascriptRenderer(); - $this->renderer->setIncludeVendors(false); - - // Get the required CSS files - list($css_files, $js_files) = $this->renderer->getAssets(null, JavascriptRenderer::RELATIVE_URL); - foreach ((array)$css_files as $css) { - $assets->addCss($css); + // Clockwork specific assets + if ($this->clockwork) { + $assets->addCss('/system/assets/debugger/clockwork.css', ['loading' => 'inline']); + $assets->addJs('/system/assets/debugger/clockwork.js', ['loading' => 'inline']); } - $assets->addCss('/system/assets/debugger.css'); - foreach ((array)$js_files as $js) { - $assets->addJs($js); + // Debugbar specific assets + if ($this->debugbar) { + // Add jquery library + $assets->add('jquery', 101); + + $this->renderer = $this->debugbar->getJavascriptRenderer(); + $this->renderer->setIncludeVendors(false); + + [$css_files, $js_files] = $this->renderer->getAssets(null, JavascriptRenderer::RELATIVE_URL); + + foreach ((array)$css_files as $css) { + $assets->addCss($css); + } + + $assets->addCss('/system/assets/debugger/phpdebugbar.css', ['loading' => 'inline']); + + foreach ((array)$js_files as $js) { + $assets->addJs($js); + } } } return $this; } + /** + * @param int $limit + * @return array + */ public function getCaller($limit = 2) { $trace = debug_backtrace(false, $limit); @@ -186,13 +448,14 @@ class Debugger * Adds a data collector * * @param DataCollectorInterface $collector - * * @return $this - * @throws \DebugBar\DebugBarException + * @throws DebugBarException */ public function addCollector($collector) { - $this->debugbar->addCollector($collector); + if ($this->debugbar && !$this->debugbar->hasCollector($collector->getName())) { + $this->debugbar->addCollector($collector); + } return $this; } @@ -200,14 +463,17 @@ class Debugger /** * Returns a data collector * - * @param DataCollectorInterface $collector - * - * @return DataCollectorInterface - * @throws \DebugBar\DebugBarException + * @param string $name + * @return DataCollectorInterface|null + * @throws DebugBarException */ - public function getCollector($collector) + public function getCollector($name) { - return $this->debugbar->getCollector($collector); + if ($this->debugbar && $this->debugbar->hasCollector($name)) { + return $this->debugbar->getCollector($name); + } + + return null; } /** @@ -217,13 +483,14 @@ class Debugger */ public function render() { - if ($this->enabled()) { + if ($this->enabled && $this->debugbar) { // Only add assets if Page is HTML $page = $this->grav['page']; if (!$this->renderer || $page->templateFormat() !== 'html') { return $this; } + $this->addMeasures(); $this->addDeprecations(); echo $this->renderer->render(); @@ -239,7 +506,8 @@ class Debugger */ public function sendDataInHeaders() { - if ($this->enabled()) { + if ($this->enabled && $this->debugbar) { + $this->addMeasures(); $this->addDeprecations(); $this->debugbar->sendDataInHeaders(); } @@ -250,34 +518,182 @@ class Debugger /** * Returns collected debugger data. * - * @return array + * @return array|null */ public function getData() { - if (!$this->enabled()) { + if (!$this->enabled || !$this->debugbar) { return null; } + $this->addMeasures(); $this->addDeprecations(); $this->timers = []; return $this->debugbar->getData(); } + /** + * Hierarchical Profiler support. + * + * @param callable $callable + * @param string|null $message + * @return mixed + */ + public function profile(callable $callable, string $message = null) + { + $this->startProfiling(); + $response = $callable(); + $this->stopProfiling($message); + + return $response; + } + + public function addTwigProfiler(Environment $twig): void + { + $clockwork = $this->getClockwork(); + if ($clockwork) { + $source = new TwigClockworkDataSource($twig); + $source->listenToEvents(); + $clockwork->addDataSource($source); + } + } + + /** + * Start profiling code. + * + * @return void + */ + public function startProfiling(): void + { + if ($this->enabled && extension_loaded('tideways_xhprof')) { + $this->profiling++; + if ($this->profiling === 1) { + // @phpstan-ignore-next-line + \tideways_xhprof_enable(TIDEWAYS_XHPROF_FLAGS_NO_BUILTINS); + } + } + } + + /** + * Stop profiling code. Returns profiling array or null if profiling couldn't be done. + * + * @param string|null $message + * @return array|null + */ + public function stopProfiling(string $message = null): ?array + { + $timings = null; + if ($this->enabled && extension_loaded('tideways_xhprof')) { + $profiling = $this->profiling - 1; + if ($profiling === 0) { + // @phpstan-ignore-next-line + $timings = \tideways_xhprof_disable(); + $timings = $this->buildProfilerTimings($timings); + + if ($this->clockwork) { + /** @var UserData $userData */ + $userData = $this->clockwork->userData('Profiler'); + $userData->counters([ + 'Calls' => count($timings) + ]); + $userData->table('Profiler', $timings); + } else { + $this->addMessage($message ?? 'Profiler Analysis', 'debug', $timings); + } + } + $this->profiling = max(0, $profiling); + } + + return $timings; + } + + /** + * @param array $timings + * @return array + */ + protected function buildProfilerTimings(array $timings): array + { + // Filter method calls which take almost no time. + $timings = array_filter($timings, function ($value) { + return $value['wt'] > 50; + }); + + uasort($timings, function (array $a, array $b) { + return $b['wt'] <=> $a['wt']; + }); + + $table = []; + foreach ($timings as $key => $timing) { + $parts = explode('==>', $key); + $method = $this->parseProfilerCall(array_pop($parts)); + $context = $this->parseProfilerCall(array_pop($parts)); + + // Skip redundant method calls. + if ($context === 'Grav\Framework\RequestHandler\RequestHandler::handle()') { + continue; + } + + // Do not profile library calls. + if (strpos($context, 'Grav\\') !== 0) { + continue; + } + + $table[] = [ + 'Context' => $context, + 'Method' => $method, + 'Calls' => $timing['ct'], + 'Time (ms)' => $timing['wt'] / 1000, + ]; + } + + return $table; + } + + /** + * @param string|null $call + * @return mixed|string|null + */ + protected function parseProfilerCall(?string $call) + { + if (null === $call) { + return ''; + } + if (strpos($call, '@')) { + [$call,] = explode('@', $call); + } + if (strpos($call, '::')) { + [$class, $call] = explode('::', $call); + } + + if (!isset($class)) { + return $call; + } + + // It is also possible to display twig files, but they are being logged in views. + /* + if (strpos($class, '__TwigTemplate_') === 0 && class_exists($class)) { + $env = new Environment(); + / ** @var Template $template * / + $template = new $class($env); + + return $template->getTemplateName(); + } + */ + + return "{$class}::{$call}()"; + } + /** * Start a timer with an associated name and description * * @param string $name * @param string|null $description - * * @return $this */ public function startTimer($name, $description = null) { - if (strpos($name, '_') === 0 || $this->enabled()) { - $this->debugbar['time']->startMeasure($name, $description); - $this->timers[] = $name; - } + $this->timers[$name] = [$description, microtime(true)]; return $this; } @@ -286,13 +702,13 @@ class Debugger * Stop the named timer * * @param string $name - * * @return $this */ public function stopTimer($name) { - if (\in_array($name, $this->timers, true) && (strpos($name, '_') === 0 || $this->enabled())) { - $this->debugbar['time']->stopMeasure($name); + if (isset($this->timers[$name])) { + $endTime = microtime(true); + $this->timers[$name][] = $endTime; } return $this; @@ -303,14 +719,76 @@ class Debugger * * @param mixed $message * @param string $label - * @param bool $isString - * + * @param mixed|bool $isString * @return $this */ public function addMessage($message, $label = 'info', $isString = true) { - if ($this->enabled()) { - $this->debugbar['messages']->addMessage($message, $label, $isString); + if ($this->enabled) { + if ($this->censored) { + if (!is_scalar($message)) { + $message = 'CENSORED'; + } + if (!is_scalar($isString)) { + $isString = ['CENSORED']; + } + } + + if ($this->debugbar) { + if (is_array($isString)) { + $message = $isString; + $isString = false; + } elseif (is_string($isString)) { + $message = $isString; + $isString = true; + } + $this->debugbar['messages']->addMessage($message, $label, $isString); + } + + if ($this->clockwork) { + $context = $isString; + if (!is_scalar($message)) { + $context = $message; + $message = gettype($context); + } + if (is_bool($context)) { + $context = []; + } elseif (!is_array($context)) { + $type = gettype($context); + $context = [$type => $context]; + } + + $this->clockwork->log($label, $message, $context); + } + } + + return $this; + } + + /** + * @param string $name + * @param object $event + * @param EventDispatcherInterface $dispatcher + * @param float|null $time + * @return $this + */ + public function addEvent(string $name, $event, EventDispatcherInterface $dispatcher, float $time = null) + { + if ($this->enabled && $this->clockwork) { + $time = $time ?? microtime(true); + $duration = (microtime(true) - $time) * 1000; + + $data = null; + if ($event && method_exists($event, '__debugInfo')) { + $data = $event; + } + + $listeners = []; + foreach ($dispatcher->getListeners($name) as $listener) { + $listeners[] = $this->resolveCallable($listener); + } + + $this->clockwork->addEvent($name, $data, $time, ['listeners' => $listeners, 'duration' => $duration]); } return $this; @@ -319,18 +797,31 @@ class Debugger /** * Dump exception into the Messages tab of the Debug Bar * - * @param \Exception $e + * @param Throwable $e * @return Debugger */ - public function addException(\Exception $e) + public function addException(Throwable $e) { - if ($this->initialized && $this->enabled()) { - $this->debugbar['exceptions']->addException($e); + if ($this->initialized && $this->enabled) { + if ($this->debugbar) { + $this->debugbar['exceptions']->addThrowable($e); + } + + if ($this->clockwork) { + /** @var UserData $exceptions */ + $exceptions = $this->clockwork->userData('Exceptions'); + $exceptions->data(['message' => $e->getMessage()]); + + $this->clockwork->alert($e->getMessage(), ['exception' => $e]); + } } return $this; } + /** + * @return void + */ public function setErrorHandler() { $this->errorHandler = set_error_handler( @@ -349,13 +840,13 @@ class Debugger { if ($errno !== E_USER_DEPRECATED && $errno !== E_DEPRECATED) { if ($this->errorHandler) { - return \call_user_func($this->errorHandler, $errno, $errstr, $errfile, $errline); + return call_user_func($this->errorHandler, $errno, $errstr, $errfile, $errline); } return true; } - if (!$this->enabled()) { + if (!$this->enabled) { return true; } @@ -382,10 +873,10 @@ class Debugger foreach ($backtrace as $current) { if (isset($current['args'])) { foreach ($current['args'] as $arg) { - if ($arg instanceof \SplFileInfo) { + if ($arg instanceof SplFileInfo) { $arg = $arg->getPathname(); } - if (\is_string($arg) && preg_match('/.+\.(yaml|md)$/i', $arg)) { + if (is_string($arg) && preg_match('/.+\.(yaml|md)$/i', $arg)) { $errfile = $arg; $errline = 0; @@ -403,18 +894,18 @@ class Debugger if (isset($current['args'])) { $args = []; foreach ($current['args'] as $arg) { - if (\is_string($arg)) { + if (is_string($arg)) { $arg = "'" . $arg . "'"; if (mb_strlen($arg) > 100) { $arg = 'string'; } - } elseif (\is_bool($arg)) { + } elseif (is_bool($arg)) { $arg = $arg ? 'true' : 'false'; - } elseif (\is_scalar($arg)) { + } elseif (is_scalar($arg)) { $arg = $arg; - } elseif (\is_object($arg)) { + } elseif (is_object($arg)) { $arg = get_class($arg) . ' $object'; - } elseif (\is_array($arg)) { + } elseif (is_array($arg)) { $arg = '$array'; } else { $arg = '$object'; @@ -430,7 +921,7 @@ class Debugger $reflection = null; if ($object instanceof TemplateWrapper) { - $reflection = new \ReflectionObject($object); + $reflection = new ReflectionObject($object); $property = $reflection->getProperty('template'); $property->setAccessible(true); $object = $property->getValue($object); @@ -540,6 +1031,28 @@ class Debugger return true; } + /** + * @return array + */ + protected function getDeprecations(): array + { + if (!$this->deprecations) { + return []; + } + + $list = []; + /** @var array $deprecated */ + foreach ($this->deprecations as $deprecated) { + $list[] = $this->getDepracatedMessage($deprecated)[0]; + } + + return $list; + } + + /** + * @return void + * @throws DebugBarException + */ protected function addDeprecations() { if (!$this->deprecations) { @@ -558,6 +1071,10 @@ class Debugger } } + /** + * @param array $deprecated + * @return array + */ protected function getDepracatedMessage($deprecated) { $scope = $deprecated['scope']; @@ -595,6 +1112,10 @@ class Debugger ]; } + /** + * @param array $trace + * @return string + */ protected function getFunction($trace) { if (!isset($trace['function'])) { @@ -603,4 +1124,17 @@ class Debugger return $trace['function'] . '(' . implode(', ', $trace['args'] ?? []) . ')'; } + + /** + * @param callable $callable + * @return string + */ + protected function resolveCallable(callable $callable) + { + if (is_array($callable)) { + return get_class($callable[0]) . '->' . $callable[1] . '()'; + } + + return 'unknown'; + } } diff --git a/system/src/Grav/Common/Errors/BareHandler.php b/system/src/Grav/Common/Errors/BareHandler.php index 52effc6..206b57e 100644 --- a/system/src/Grav/Common/Errors/BareHandler.php +++ b/system/src/Grav/Common/Errors/BareHandler.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Errors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,22 +11,23 @@ namespace Grav\Common\Errors; use Whoops\Handler\Handler; +/** + * Class BareHandler + * @package Grav\Common\Errors + */ class BareHandler extends Handler { - /** - * @return int|null + * @return int */ public function handle() { $inspector = $this->getInspector(); $code = $inspector->getException()->getCode(); - if ( ($code >= 400) && ($code < 600) ) - { - $this->getRun()->sendHttpCode($code); + if (($code >= 400) && ($code < 600)) { + $this->getRun()->sendHttpCode($code); } return Handler::QUIT; } - } diff --git a/system/src/Grav/Common/Errors/Errors.php b/system/src/Grav/Common/Errors/Errors.php index dbc955e..3dc99c6 100644 --- a/system/src/Grav/Common/Errors/Errors.php +++ b/system/src/Grav/Common/Errors/Errors.php @@ -3,17 +3,29 @@ /** * @package Grav\Common\Errors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Errors; +use Exception; use Grav\Common\Grav; -use Whoops; +use Whoops\Handler\JsonResponseHandler; +use Whoops\Handler\PrettyPageHandler; +use Whoops\Run; +use Whoops\Util\Misc; +use function is_int; +/** + * Class Errors + * @package Grav\Common\Errors + */ class Errors { + /** + * @return void + */ public function resetHandlers() { $grav = Grav::instance(); @@ -22,7 +34,7 @@ class Errors // Setup Whoops-based error handler $system = new SystemFacade; - $whoops = new Whoops\Run($system); + $whoops = new Run($system); $verbosity = 1; @@ -36,30 +48,30 @@ class Errors switch ($verbosity) { case 1: - $error_page = new Whoops\Handler\PrettyPageHandler; + $error_page = new PrettyPageHandler(); $error_page->setPageTitle('Crikey! There was an error...'); $error_page->addResourcePath(GRAV_ROOT . '/system/assets'); $error_page->addCustomCss('whoops.css'); - $whoops->pushHandler($error_page); + $whoops->prependHandler($error_page); break; case -1: - $whoops->pushHandler(new BareHandler); + $whoops->prependHandler(new BareHandler); break; default: - $whoops->pushHandler(new SimplePageHandler); + $whoops->prependHandler(new SimplePageHandler); break; } - if (Whoops\Util\Misc::isAjaxRequest() || $jsonRequest) { - $whoops->pushHandler(new Whoops\Handler\JsonResponseHandler); + if ($jsonRequest || Misc::isAjaxRequest()) { + $whoops->prependHandler(new JsonResponseHandler()); } if (isset($config['log']) && $config['log']) { $logger = $grav['log']; - $whoops->pushHandler(function($exception, $inspector, $run) use ($logger) { + $whoops->pushHandler(function ($exception, $inspector, $run) use ($logger) { try { $logger->addCritical($exception->getMessage() . ' - Trace: ' . $exception->getTraceAsString()); - } catch (\Exception $e) { + } catch (Exception $e) { echo $e; } }); diff --git a/system/src/Grav/Common/Errors/SimplePageHandler.php b/system/src/Grav/Common/Errors/SimplePageHandler.php index 311d722..0118929 100644 --- a/system/src/Grav/Common/Errors/SimplePageHandler.php +++ b/system/src/Grav/Common/Errors/SimplePageHandler.php @@ -3,20 +3,29 @@ /** * @package Grav\Common\Errors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Errors; +use ErrorException; +use InvalidArgumentException; +use RuntimeException; use Whoops\Handler\Handler; use Whoops\Util\Misc; use Whoops\Util\TemplateHelper; +/** + * Class SimplePageHandler + * @package Grav\Common\Errors + */ class SimplePageHandler extends Handler { - private $searchPaths = array(); - private $resourceCache = array(); + /** @var array */ + private $searchPaths = []; + /** @var array */ + private $resourceCache = []; public function __construct() { @@ -25,7 +34,7 @@ class SimplePageHandler extends Handler } /** - * @return int|null + * @return int */ public function handle() { @@ -36,13 +45,12 @@ class SimplePageHandler extends Handler $cssFile = $this->getResource('error.css'); $code = $inspector->getException()->getCode(); - if ( ($code >= 400) && ($code < 600) ) - { - $this->getRun()->sendHttpCode($code); + if (($code >= 400) && ($code < 600)) { + $this->getRun()->sendHttpCode($code); } $message = $inspector->getException()->getMessage(); - if ($inspector->getException() instanceof \ErrorException) { + if ($inspector->getException() instanceof ErrorException) { $code = Misc::translateErrorCode($code); } @@ -60,9 +68,8 @@ class SimplePageHandler extends Handler /** * @param string $resource - * * @return string - * @throws \RuntimeException + * @throws RuntimeException */ protected function getResource($resource) { @@ -85,15 +92,19 @@ class SimplePageHandler extends Handler } // If we got this far, nothing was found. - throw new \RuntimeException( + throw new RuntimeException( "Could not find resource '{$resource}' in any resource paths (searched: " . implode(', ', $this->searchPaths). ')' ); } + /** + * @param string $path + * @return void + */ public function addResourcePath($path) { if (!is_dir($path)) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( "'{$path}' is not a valid directory" ); } @@ -101,6 +112,9 @@ class SimplePageHandler extends Handler array_unshift($this->searchPaths, $path); } + /** + * @return array + */ public function getResourcePaths() { return $this->searchPaths; diff --git a/system/src/Grav/Common/Errors/SystemFacade.php b/system/src/Grav/Common/Errors/SystemFacade.php index 02ef0cf..8a7f1ce 100644 --- a/system/src/Grav/Common/Errors/SystemFacade.php +++ b/system/src/Grav/Common/Errors/SystemFacade.php @@ -3,19 +3,23 @@ /** * @package Grav\Common\Errors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Errors; +/** + * Class SystemFacade + * @package Grav\Common\Errors + */ class SystemFacade extends \Whoops\Util\SystemFacade { + /** @var callable */ protected $whoopsShutdownHandler; /** * @param callable $function - * * @return void */ public function registerShutdownFunction(callable $function) @@ -26,6 +30,8 @@ class SystemFacade extends \Whoops\Util\SystemFacade /** * Special case to deal with Fatal errors and the like. + * + * @return void */ public function handleShutdown() { diff --git a/system/src/Grav/Common/File/CompiledFile.php b/system/src/Grav/Common/File/CompiledFile.php index f4b1e8d..66fa620 100644 --- a/system/src/Grav/Common/File/CompiledFile.php +++ b/system/src/Grav/Common/File/CompiledFile.php @@ -3,21 +3,30 @@ /** * @package Grav\Common\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\File; +use Exception; use RocketTheme\Toolbox\File\PhpFile; +use RuntimeException; +use Throwable; +use function function_exists; +use function get_class; +/** + * Trait CompiledFile + * @package Grav\Common\File + */ trait CompiledFile { /** * Get/set parsed file contents. * * @param mixed $var - * @return string + * @return array */ public function content($var = null) { @@ -28,9 +37,12 @@ trait CompiledFile $file = PhpFile::instance(CACHE_DIR . "compiled/files/{$key}{$this->extension}.php"); $modified = $this->modified(); - if (!$modified) { - return $this->decode($this->raw()); + try { + return $this->decode($this->raw()); + } catch (Throwable $e) { + // If the compiled file is broken, we can safely ignore the error and continue. + } } $class = get_class($this); @@ -38,8 +50,7 @@ trait CompiledFile $cache = $file->exists() ? $file->content() : null; // Load real file if cache isn't up to date (or is invalid). - if ( - !isset($cache['@class']) + if (!isset($cache['@class']) || $cache['@class'] !== $class || $cache['modified'] !== $modified || $cache['filename'] !== $this->filename @@ -47,7 +58,7 @@ trait CompiledFile // Attempt to lock the file for writing. try { $file->lock(false); - } catch (\Exception $e) { + } catch (Exception $e) { // Another process has locked the file; we will check this in a bit. } @@ -76,9 +87,8 @@ trait CompiledFile $this->content = $cache['data']; } - - } catch (\Exception $e) { - throw new \RuntimeException(sprintf('Failed to read %s: %s', basename($this->filename), $e->getMessage()), 500, $e); + } catch (Exception $e) { + throw new RuntimeException(sprintf('Failed to read %s: %s', basename($this->filename), $e->getMessage()), 500, $e); } return parent::content($var); @@ -86,6 +96,8 @@ trait CompiledFile /** * Serialize file. + * + * @return array */ public function __sleep() { diff --git a/system/src/Grav/Common/File/CompiledJsonFile.php b/system/src/Grav/Common/File/CompiledJsonFile.php index d73e817..dae95ba 100644 --- a/system/src/Grav/Common/File/CompiledJsonFile.php +++ b/system/src/Grav/Common/File/CompiledJsonFile.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,6 +11,10 @@ namespace Grav\Common\File; use RocketTheme\Toolbox\File\JsonFile; +/** + * Class CompiledJsonFile + * @package Grav\Common\File + */ class CompiledJsonFile extends JsonFile { use CompiledFile; @@ -20,7 +24,7 @@ class CompiledJsonFile extends JsonFile * * @param string $var * @param bool $assoc - * @return array mixed + * @return array */ protected function decode($var, $assoc = true) { diff --git a/system/src/Grav/Common/File/CompiledMarkdownFile.php b/system/src/Grav/Common/File/CompiledMarkdownFile.php index 74b2820..1107417 100644 --- a/system/src/Grav/Common/File/CompiledMarkdownFile.php +++ b/system/src/Grav/Common/File/CompiledMarkdownFile.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,6 +11,10 @@ namespace Grav\Common\File; use RocketTheme\Toolbox\File\MarkdownFile; +/** + * Class CompiledMarkdownFile + * @package Grav\Common\File + */ class CompiledMarkdownFile extends MarkdownFile { use CompiledFile; diff --git a/system/src/Grav/Common/File/CompiledYamlFile.php b/system/src/Grav/Common/File/CompiledYamlFile.php index 2eb1e98..0fedeb7 100644 --- a/system/src/Grav/Common/File/CompiledYamlFile.php +++ b/system/src/Grav/Common/File/CompiledYamlFile.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,6 +11,10 @@ namespace Grav\Common\File; use RocketTheme\Toolbox\File\YamlFile; +/** + * Class CompiledYamlFile + * @package Grav\Common\File + */ class CompiledYamlFile extends YamlFile { use CompiledFile; diff --git a/system/src/Grav/Common/Filesystem/Archiver.php b/system/src/Grav/Common/Filesystem/Archiver.php index 50e40fb..090bc13 100644 --- a/system/src/Grav/Common/Filesystem/Archiver.php +++ b/system/src/Grav/Common/Filesystem/Archiver.php @@ -3,23 +3,37 @@ /** * @package Grav\Common\Filesystem * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Filesystem; +use FilesystemIterator; use Grav\Common\Utils; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use function function_exists; +/** + * Class Archiver + * @package Grav\Common\Filesystem + */ abstract class Archiver { + /** @var array */ protected $options = [ 'exclude_files' => ['.DS_Store'], 'exclude_paths' => [] ]; + /** @var string */ protected $archive_file; + /** + * @param string $compression + * @return ZipArchiver + */ public static function create($compression) { if ($compression === 'zip') { @@ -29,38 +43,66 @@ abstract class Archiver return new ZipArchiver(); } + /** + * @param string $archive_file + * @return $this + */ public function setArchive($archive_file) { $this->archive_file = $archive_file; + return $this; } + /** + * @param array $options + * @return $this + */ public function setOptions($options) { // Set infinite PHP execution time if possible. - if (function_exists('set_time_limit') && !Utils::isFunctionDisabled('set_time_limit')) { - set_time_limit(0); + if (Utils::functionExists('set_time_limit')) { + @set_time_limit(0); } $this->options = $options + $this->options; + return $this; } - public abstract function compress($folder, callable $status = null); + /** + * @param string $folder + * @param callable|null $status + * @return $this + */ + abstract public function compress($folder, callable $status = null); - public abstract function extract($destination, callable $status = null); + /** + * @param string $destination + * @param callable|null $status + * @return $this + */ + abstract public function extract($destination, callable $status = null); - public abstract function addEmptyFolders($folders, callable $status = null); + /** + * @param array $folders + * @param callable|null $status + * @return $this + */ + abstract public function addEmptyFolders($folders, callable $status = null); + /** + * @param string $rootPath + * @return RecursiveIteratorIterator + */ protected function getArchiveFiles($rootPath) { $exclude_paths = $this->options['exclude_paths']; $exclude_files = $this->options['exclude_files']; - $dirItr = new \RecursiveDirectoryIterator($rootPath, \RecursiveDirectoryIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS | \FilesystemIterator::UNIX_PATHS); + $dirItr = new RecursiveDirectoryIterator($rootPath, RecursiveDirectoryIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS | FilesystemIterator::UNIX_PATHS); $filterItr = new RecursiveDirectoryFilterIterator($dirItr, $rootPath, $exclude_paths, $exclude_files); - $files = new \RecursiveIteratorIterator($filterItr, \RecursiveIteratorIterator::SELF_FIRST); + $files = new RecursiveIteratorIterator($filterItr, RecursiveIteratorIterator::SELF_FIRST); return $files; } - } diff --git a/system/src/Grav/Common/Filesystem/Folder.php b/system/src/Grav/Common/Filesystem/Folder.php index 3196b65..6a3783b 100644 --- a/system/src/Grav/Common/Filesystem/Folder.php +++ b/system/src/Grav/Common/Filesystem/Folder.php @@ -3,15 +3,29 @@ /** * @package Grav\Common\Filesystem * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Filesystem; +use DirectoryIterator; +use Exception; +use FilesystemIterator; use Grav\Common\Grav; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use RegexIterator; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use function count; +use function dirname; +use function is_callable; +/** + * Class Folder + * @package Grav\Common\Filesystem + */ abstract class Folder { /** @@ -30,16 +44,15 @@ abstract class Folder /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; - $flags = \RecursiveDirectoryIterator::SKIP_DOTS; + $flags = RecursiveDirectoryIterator::SKIP_DOTS; if ($locator->isStream($path)) { $directory = $locator->getRecursiveIterator($path, $flags); } else { - $directory = new \RecursiveDirectoryIterator($path, $flags); + $directory = new RecursiveDirectoryIterator($path, $flags); } $filter = new RecursiveFolderFilterIterator($directory); - $iterator = new \RecursiveIteratorIterator($filter, \RecursiveIteratorIterator::SELF_FIRST); + $iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST); - /** @var \RecursiveDirectoryIterator $file */ foreach ($iterator as $dir) { $dir_modified = $dir->getMTime(); if ($dir_modified > $last_modified) { @@ -55,7 +68,6 @@ abstract class Folder * * @param string $path * @param string $extensions which files to search for specifically - * * @return int */ public static function lastModifiedFile($path, $extensions = 'md|yaml') @@ -68,23 +80,23 @@ abstract class Folder /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; - $flags = \RecursiveDirectoryIterator::SKIP_DOTS; + $flags = RecursiveDirectoryIterator::SKIP_DOTS; if ($locator->isStream($path)) { $directory = $locator->getRecursiveIterator($path, $flags); } else { - $directory = new \RecursiveDirectoryIterator($path, $flags); + $directory = new RecursiveDirectoryIterator($path, $flags); } - $recursive = new \RecursiveIteratorIterator($directory, \RecursiveIteratorIterator::SELF_FIRST); - $iterator = new \RegexIterator($recursive, '/^.+\.'.$extensions.'$/i'); + $recursive = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); + $iterator = new RegexIterator($recursive, '/^.+\.'.$extensions.'$/i'); - /** @var \RecursiveDirectoryIterator $file */ + /** @var RecursiveDirectoryIterator $file */ foreach ($iterator as $filepath => $file) { try { $file_modified = $file->getMTime(); if ($file_modified > $last_modified) { $last_modified = $file_modified; } - } catch (\Exception $e) { + } catch (Exception $e) { Grav::instance()['log']->error('Could not process file: ' . $e->getMessage()); } } @@ -103,17 +115,17 @@ abstract class Folder $files = []; if (file_exists($path)) { - $flags = \RecursiveDirectoryIterator::SKIP_DOTS; + $flags = RecursiveDirectoryIterator::SKIP_DOTS; /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; if ($locator->isStream($path)) { $directory = $locator->getRecursiveIterator($path, $flags); } else { - $directory = new \RecursiveDirectoryIterator($path, $flags); + $directory = new RecursiveDirectoryIterator($path, $flags); } - $iterator = new \RecursiveIteratorIterator($directory, \RecursiveIteratorIterator::SELF_FIRST); + $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); foreach ($iterator as $file) { $files[] = $file->getPathname() . '?'. $file->getMTime(); @@ -126,9 +138,8 @@ abstract class Folder /** * Get relative path between target and base path. If path isn't relative, return full path. * - * @param string $path - * @param mixed|string $base - * + * @param string $path + * @param string $base * @return string */ public static function getRelativePath($path, $base = GRAV_ROOT) @@ -203,12 +214,12 @@ abstract class Folder * @param string $path * @param array $params * @return array - * @throws \RuntimeException + * @throws RuntimeException */ public static function all($path, array $params = []) { - if ($path === false) { - throw new \RuntimeException("Path doesn't exist."); + if (!$path) { + throw new RuntimeException("Path doesn't exist."); } if (!file_exists($path)) { return []; @@ -227,26 +238,26 @@ abstract class Folder /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; if ($recursive) { - $flags = \RecursiveDirectoryIterator::SKIP_DOTS + \FilesystemIterator::UNIX_PATHS - + \FilesystemIterator::CURRENT_AS_SELF + \FilesystemIterator::FOLLOW_SYMLINKS; + $flags = RecursiveDirectoryIterator::SKIP_DOTS + FilesystemIterator::UNIX_PATHS + + FilesystemIterator::CURRENT_AS_SELF + FilesystemIterator::FOLLOW_SYMLINKS; if ($locator->isStream($path)) { $directory = $locator->getRecursiveIterator($path, $flags); } else { - $directory = new \RecursiveDirectoryIterator($path, $flags); + $directory = new RecursiveDirectoryIterator($path, $flags); } - $iterator = new \RecursiveIteratorIterator($directory, \RecursiveIteratorIterator::SELF_FIRST); + $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); $iterator->setMaxDepth(max($levels, -1)); } else { if ($locator->isStream($path)) { $iterator = $locator->getIterator($path); } else { - $iterator = new \FilesystemIterator($path); + $iterator = new FilesystemIterator($path); } } $results = []; - /** @var \RecursiveDirectoryIterator $file */ + /** @var RecursiveDirectoryIterator $file */ foreach ($iterator as $file) { // Ignore hidden files. if (strpos($file->getFilename(), '.') === 0 && $file->isFile()) { @@ -293,8 +304,9 @@ abstract class Folder * * @param string $source * @param string $target - * @param string $ignore Ignore files matching pattern (regular expression). - * @throws \RuntimeException + * @param string|null $ignore Ignore files matching pattern (regular expression). + * @return void + * @throws RuntimeException */ public static function copy($source, $target, $ignore = null) { @@ -302,7 +314,7 @@ abstract class Folder $target = rtrim($target, '\\/'); if (!is_dir($source)) { - throw new \RuntimeException('Cannot copy non-existing folder.'); + throw new RuntimeException('Cannot copy non-existing folder.'); } // Make sure that path to the target exists before copying. @@ -332,7 +344,7 @@ abstract class Folder if (!$success) { $error = error_get_last(); - throw new \RuntimeException($error['message'] ?? 'Unknown error'); + throw new RuntimeException($error['message'] ?? 'Unknown error'); } // Make sure that the change will be detected when caching. @@ -344,13 +356,14 @@ abstract class Folder * * @param string $source * @param string $target - * @throws \RuntimeException + * @return void + * @throws RuntimeException */ public static function move($source, $target) { if (!file_exists($source) || !is_dir($source)) { // Rename fails if source folder does not exist. - throw new \RuntimeException('Cannot move non-existing folder.'); + throw new RuntimeException('Cannot move non-existing folder.'); } // Don't do anything if the source is the same as the new target @@ -358,9 +371,13 @@ abstract class Folder return; } + if (strpos($target, $source) === 0) { + throw new RuntimeException('Cannot move folder to itself'); + } + if (file_exists($target)) { // Rename fails if target folder exists. - throw new \RuntimeException('Cannot move files to existing folder/file.'); + throw new RuntimeException('Cannot move files to existing folder/file.'); } // Make sure that path to the target exists before moving. @@ -370,11 +387,7 @@ abstract class Folder @rename($source, $target); // Rename function can fail while still succeeding, so let's check if the folder exists. - if (!file_exists($target) || !is_dir($target)) { - // In some rare cases rename() creates file, not a folder. Get rid of it. - if (file_exists($target)) { - @unlink($target); - } + if (is_dir($source)) { // Rename doesn't support moving folders across filesystems. Use copy instead. self::copy($source, $target); self::delete($source); @@ -392,7 +405,7 @@ abstract class Folder * @param string $target * @param bool $include_target * @return bool - * @throws \RuntimeException + * @throws RuntimeException */ public static function delete($target, $include_target = true) { @@ -404,7 +417,7 @@ abstract class Folder if (!$success) { $error = error_get_last(); - throw new \RuntimeException($error['message']); + throw new RuntimeException($error['message']); } // Make sure that the change will be detected when caching. @@ -419,7 +432,8 @@ abstract class Folder /** * @param string $folder - * @throws \RuntimeException + * @return void + * @throws RuntimeException */ public static function mkdir($folder) { @@ -428,7 +442,8 @@ abstract class Folder /** * @param string $folder - * @throws \RuntimeException + * @return void + * @throws RuntimeException */ public static function create($folder) { @@ -443,7 +458,7 @@ abstract class Folder // Take yet another look, make sure that the folder doesn't exist. clearstatcache(true, $folder); if (!@is_dir($folder)) { - throw new \RuntimeException(sprintf('Unable to create directory: %s', $folder)); + throw new RuntimeException(sprintf('Unable to create directory: %s', $folder)); } } } @@ -453,9 +468,8 @@ abstract class Folder * * @param string $src * @param string $dest - * * @return bool - * @throws \RuntimeException + * @throws RuntimeException */ public static function rcopy($src, $dest) { @@ -472,8 +486,7 @@ abstract class Folder } // Open the source directory to read in files - $i = new \DirectoryIterator($src); - /** @var \DirectoryIterator $f */ + $i = new DirectoryIterator($src); foreach ($i as $f) { if ($f->isFile()) { copy($f->getRealPath(), "{$dest}/" . $f->getFilename()); @@ -486,6 +499,22 @@ abstract class Folder return true; } + /** + * Does a directory contain children + * + * @param string $directory + * @return int|false + */ + public static function countChildren($directory) + { + if (!is_dir($directory)) { + return false; + } + $directories = glob($directory . '/*', GLOB_ONLYDIR); + + return count($directories); + } + /** * @param string $folder * @param bool $include_target diff --git a/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php b/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php index c662fc4..0dac81c 100644 --- a/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php +++ b/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php @@ -3,27 +3,39 @@ /** * @package Grav\Common\Filesystem * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Filesystem; -class RecursiveDirectoryFilterIterator extends \RecursiveFilterIterator +use RecursiveFilterIterator; +use RecursiveIterator; +use SplFileInfo; +use function in_array; + +/** + * Class RecursiveDirectoryFilterIterator + * @package Grav\Common\Filesystem + */ +class RecursiveDirectoryFilterIterator extends RecursiveFilterIterator { + /** @var string */ protected static $root; + /** @var array */ protected static $ignore_folders; + /** @var array */ protected static $ignore_files; /** * Create a RecursiveFilterIterator from a RecursiveIterator * - * @param \RecursiveIterator $iterator + * @param RecursiveIterator $iterator * @param string $root * @param array $ignore_folders * @param array $ignore_files */ - public function __construct(\RecursiveIterator $iterator, $root, $ignore_folders, $ignore_files) + public function __construct(RecursiveIterator $iterator, $root, $ignore_folders, $ignore_files) { parent::__construct($iterator); @@ -39,7 +51,7 @@ class RecursiveDirectoryFilterIterator extends \RecursiveFilterIterator */ public function accept() { - /** @var \SplFileInfo $file */ + /** @var SplFileInfo $file */ $file = $this->current(); $filename = $file->getFilename(); $relative_filename = str_replace($this::$root . '/', '', $file->getPathname()); @@ -57,6 +69,9 @@ class RecursiveDirectoryFilterIterator extends \RecursiveFilterIterator return false; } + /** + * @return RecursiveDirectoryFilterIterator|RecursiveFilterIterator + */ public function getChildren() { /** @var RecursiveDirectoryFilterIterator $iterator */ diff --git a/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php b/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php index eb493db..ded0259 100644 --- a/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php +++ b/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php @@ -3,25 +3,33 @@ /** * @package Grav\Common\Filesystem * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Filesystem; use Grav\Common\Grav; +use RecursiveIterator; +use SplFileInfo; +use function in_array; +/** + * Class RecursiveFolderFilterIterator + * @package Grav\Common\Filesystem + */ class RecursiveFolderFilterIterator extends \RecursiveFilterIterator { + /** @var array */ protected static $ignore_folders; /** * Create a RecursiveFilterIterator from a RecursiveIterator * - * @param \RecursiveIterator $iterator + * @param RecursiveIterator $iterator * @param array $ignore_folders */ - public function __construct(\RecursiveIterator $iterator, $ignore_folders = []) + public function __construct(RecursiveIterator $iterator, $ignore_folders = []) { parent::__construct($iterator); @@ -39,7 +47,7 @@ class RecursiveFolderFilterIterator extends \RecursiveFilterIterator */ public function accept() { - /** @var \SplFileInfo $current */ + /** @var SplFileInfo $current */ $current = $this->current(); return $current->isDir() && !in_array($current->getFilename(), $this::$ignore_folders, true); diff --git a/system/src/Grav/Common/Filesystem/ZipArchiver.php b/system/src/Grav/Common/Filesystem/ZipArchiver.php index 91212f7..450a581 100644 --- a/system/src/Grav/Common/Filesystem/ZipArchiver.php +++ b/system/src/Grav/Common/Filesystem/ZipArchiver.php @@ -3,47 +3,67 @@ /** * @package Grav\Common\Filesystem * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Filesystem; +use InvalidArgumentException; +use RuntimeException; +use ZipArchive; +use function extension_loaded; +use function strlen; + +/** + * Class ZipArchiver + * @package Grav\Common\Filesystem + */ class ZipArchiver extends Archiver { - + /** + * @param string $destination + * @param callable|null $status + * @return $this + */ public function extract($destination, callable $status = null) { - $zip = new \ZipArchive(); + $zip = new ZipArchive(); $archive = $zip->open($this->archive_file); if ($archive === true) { Folder::create($destination); if (!$zip->extractTo($destination)) { - throw new \RuntimeException('ZipArchiver: ZIP failed to extract ' . $this->archive_file . ' to ' . $destination); + throw new RuntimeException('ZipArchiver: ZIP failed to extract ' . $this->archive_file . ' to ' . $destination); } $zip->close(); + return $this; } - throw new \RuntimeException('ZipArchiver: Failed to open ' . $this->archive_file); + throw new RuntimeException('ZipArchiver: Failed to open ' . $this->archive_file); } + /** + * @param string $source + * @param callable|null $status + * @return $this + */ public function compress($source, callable $status = null) { if (!extension_loaded('zip')) { - throw new \InvalidArgumentException('ZipArchiver: Zip PHP module not installed...'); + throw new InvalidArgumentException('ZipArchiver: Zip PHP module not installed...'); } if (!file_exists($source)) { - throw new \InvalidArgumentException('ZipArchiver: ' . $source . ' cannot be found...'); + throw new InvalidArgumentException('ZipArchiver: ' . $source . ' cannot be found...'); } - $zip = new \ZipArchive(); - if (!$zip->open($this->archive_file, \ZipArchive::CREATE)) { - throw new \InvalidArgumentException('ZipArchiver:' . $this->archive_file . ' cannot be created...'); + $zip = new ZipArchive(); + if (!$zip->open($this->archive_file, ZipArchive::CREATE)) { + throw new InvalidArgumentException('ZipArchiver:' . $this->archive_file . ' cannot be created...'); } // Get real path for our folder @@ -81,15 +101,20 @@ class ZipArchiver extends Archiver return $this; } + /** + * @param array $folders + * @param callable|null $status + * @return $this + */ public function addEmptyFolders($folders, callable $status = null) { if (!extension_loaded('zip')) { - throw new \InvalidArgumentException('ZipArchiver: Zip PHP module not installed...'); + throw new InvalidArgumentException('ZipArchiver: Zip PHP module not installed...'); } - $zip = new \ZipArchive(); + $zip = new ZipArchive(); if (!$zip->open($this->archive_file)) { - throw new \InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be opened...'); + throw new InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be opened...'); } $status && $status([ @@ -97,7 +122,7 @@ class ZipArchiver extends Archiver 'message' => 'Adding empty folders...' ]); - foreach($folders as $folder) { + foreach ($folders as $folder) { $zip->addEmptyDir($folder); $status && $status([ 'type' => 'progress', diff --git a/system/src/Grav/Common/Flex/FlexCollection.php b/system/src/Grav/Common/Flex/FlexCollection.php new file mode 100644 index 0000000..6429c9e --- /dev/null +++ b/system/src/Grav/Common/Flex/FlexCollection.php @@ -0,0 +1,28 @@ + + */ +abstract class FlexCollection extends \Grav\Framework\Flex\FlexCollection +{ + use FlexGravTrait; + use FlexCollectionTrait; +} diff --git a/system/src/Grav/Common/Flex/FlexIndex.php b/system/src/Grav/Common/Flex/FlexIndex.php new file mode 100644 index 0000000..44b6fdd --- /dev/null +++ b/system/src/Grav/Common/Flex/FlexIndex.php @@ -0,0 +1,29 @@ + + */ +abstract class FlexIndex extends \Grav\Framework\Flex\FlexIndex +{ + use FlexGravTrait; + use FlexIndexTrait; +} diff --git a/system/src/Grav/Common/Flex/FlexObject.php b/system/src/Grav/Common/Flex/FlexObject.php new file mode 100644 index 0000000..2a43eaa --- /dev/null +++ b/system/src/Grav/Common/Flex/FlexObject.php @@ -0,0 +1,73 @@ +getNestedProperty($name, null, $separator); + + // Handle media order field. + if (null === $value && $name === 'media_order') { + return implode(',', $this->getMediaOrder()); + } + + // Handle media fields. + $settings = $this->getFieldSettings($name); + if ($settings['media_field'] ?? false === true) { + return $this->parseFileProperty($value, $settings); + } + + return $value ?? $default; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::prepareStorage() + */ + public function prepareStorage(): array + { + // Remove extra content from media fields. + $fields = $this->getMediaFields(); + foreach ($fields as $field) { + $data = $this->getNestedProperty($field); + if (is_array($data)) { + foreach ($data as $name => &$image) { + unset($image['image_url'], $image['thumb_url']); + } + unset($image); + $this->setNestedProperty($field, $data); + } + } + + return parent::prepareStorage(); + } +} diff --git a/system/src/Grav/Common/Flex/Traits/FlexCollectionTrait.php b/system/src/Grav/Common/Flex/Traits/FlexCollectionTrait.php new file mode 100644 index 0000000..29b640e --- /dev/null +++ b/system/src/Grav/Common/Flex/Traits/FlexCollectionTrait.php @@ -0,0 +1,51 @@ + 'flex', + 'directory' => $this->getFlexDirectory(), + 'collection' => $this + ]); + } + if (strpos($name, 'onFlexCollection') !== 0 && strpos($name, 'on') === 0) { + $name = 'onFlexCollection' . substr($name, 2); + } + + $container = $this->getContainer(); + if ($event instanceof Event) { + $container->fireEvent($name, $event); + } else { + $container->dispatchEvent($event); + } + + return $this; + } +} diff --git a/system/src/Grav/Common/Flex/Traits/FlexCommonTrait.php b/system/src/Grav/Common/Flex/Traits/FlexCommonTrait.php new file mode 100644 index 0000000..1077e00 --- /dev/null +++ b/system/src/Grav/Common/Flex/Traits/FlexCommonTrait.php @@ -0,0 +1,54 @@ +getContainer(); + + /** @var Twig $twig */ + $twig = $container['twig']; + + try { + return $twig->twig()->resolveTemplate($this->getTemplatePaths($layout)); + } catch (LoaderError $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + return $twig->twig()->resolveTemplate(['flex/404.html.twig']); + } + } + + abstract protected function getTemplatePaths(string $layout): array; + abstract protected function getContainer(): Grav; +} diff --git a/system/src/Grav/Common/Flex/Traits/FlexGravTrait.php b/system/src/Grav/Common/Flex/Traits/FlexGravTrait.php new file mode 100644 index 0000000..9d5c9e0 --- /dev/null +++ b/system/src/Grav/Common/Flex/Traits/FlexGravTrait.php @@ -0,0 +1,74 @@ +getContainer(); + + /** @var Flex $flex */ + $flex = $container['flex']; + + return $flex; + } + + /** + * @return UserInterface|null + */ + protected function getActiveUser(): ?UserInterface + { + $container = $this->getContainer(); + + /** @var UserInterface|null $user */ + $user = $container['user'] ?? null; + + return $user; + } + + /** + * @return bool + */ + protected function isAdminSite(): bool + { + $container = $this->getContainer(); + + return isset($container['admin']); + } + + /** + * @return string + */ + protected function getAuthorizeScope(): string + { + return $this->isAdminSite() ? 'admin' : 'site'; + } +} diff --git a/system/src/Grav/Common/Flex/Traits/FlexIndexTrait.php b/system/src/Grav/Common/Flex/Traits/FlexIndexTrait.php new file mode 100644 index 0000000..1d0ee5c --- /dev/null +++ b/system/src/Grav/Common/Flex/Traits/FlexIndexTrait.php @@ -0,0 +1,20 @@ + 'onFlexObjectRender', + 'onBeforeSave' => 'onFlexObjectBeforeSave', + 'onAfterSave' => 'onFlexObjectAfterSave', + 'onBeforeDelete' => 'onFlexObjectBeforeDelete', + 'onAfterDelete' => 'onFlexObjectAfterDelete' + ]; + + if (null === $event) { + $event = new Event([ + 'type' => 'flex', + 'directory' => $this->getFlexDirectory(), + 'object' => $this + ]); + } + + if (isset($events['name'])) { + $name = $events['name']; + } elseif (strpos($name, 'onFlexObject') !== 0 && strpos($name, 'on') === 0) { + $name = 'onFlexObject' . substr($name, 2); + } + + $container = $this->getContainer(); + if ($event instanceof Event) { + $container->fireEvent($name, $event); + } else { + $container->dispatchEvent($event); + } + + return $this; + } +} diff --git a/system/src/Grav/Common/Flex/Types/Generic/GenericCollection.php b/system/src/Grav/Common/Flex/Types/Generic/GenericCollection.php new file mode 100644 index 0000000..6b2fbc1 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Generic/GenericCollection.php @@ -0,0 +1,24 @@ + + */ +class GenericCollection extends FlexCollection +{ +} diff --git a/system/src/Grav/Common/Flex/Types/Generic/GenericIndex.php b/system/src/Grav/Common/Flex/Types/Generic/GenericIndex.php new file mode 100644 index 0000000..81dbf95 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Generic/GenericIndex.php @@ -0,0 +1,24 @@ + + */ +class GenericIndex extends FlexIndex +{ +} diff --git a/system/src/Grav/Common/Flex/Types/Generic/GenericObject.php b/system/src/Grav/Common/Flex/Types/Generic/GenericObject.php new file mode 100644 index 0000000..4a56752 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Generic/GenericObject.php @@ -0,0 +1,22 @@ + + * + * Incompatibilities with Grav\Common\Page\Collection: + * $page = $collection->key() will not work at all + * $clone = clone $collection does not clone objects inside the collection, does it matter? + * $string = (string)$collection returns collection id instead of comma separated list + * $collection->add() incompatible method signature + * $collection->remove() incompatible method signature + * $collection->filter() incompatible method signature (takes closure instead of callable) + * $collection->prev() does not rewind the internal pointer + * AND most methods are immutable; they do not update the current collection, but return updated one + * + * @method static shuffle() + * @method static select(array $keys) + * @method static unselect(array $keys) + * @method static createFrom(array $elements, string $keyField = null) + * @method PageIndex getIndex() + */ +class PageCollection extends FlexPageCollection implements PageCollectionInterface +{ + use FlexGravTrait; + use FlexCollectionTrait; + + /** @var array|null */ + protected $_params; + + /** + * @return array + */ + public static function getCachedMethods(): array + { + return [ + // Collection specific methods + 'getRoot' => false, + 'getParams' => false, + 'setParams' => false, + 'params' => false, + 'addPage' => false, + 'merge' => false, + 'intersect' => false, + 'prev' => false, + 'nth' => false, + 'random' => false, + 'append' => false, + 'batch' => false, + 'order' => false, + + // Collection filtering + 'dateRange' => true, + 'visible' => true, + 'nonVisible' => true, + 'pages' => true, + 'modules' => true, + 'modular' => true, + 'nonModular' => true, + 'published' => true, + 'nonPublished' => true, + 'routable' => true, + 'nonRoutable' => true, + 'ofType' => true, + 'ofOneOfTheseTypes' => true, + 'ofOneOfTheseAccessLevels' => true, + 'withOrdered' => true, + 'withModules' => true, + 'withPages' => true, + 'withTranslation' => true, + 'filterBy' => true, + + 'toExtendedArray' => false, + 'getLevelListing' => false, + ] + parent::getCachedMethods(); + } + + /** + * @return PageObject + */ + public function getRoot() + { + $index = $this->getIndex(); + + return $index->getRoot(); + } + + /** + * Get the collection params + * + * @return array + */ + public function getParams(): array + { + return $this->_params ?? []; + } + + /** + * Set parameters to the Collection + * + * @param array $params + * @return $this + */ + public function setParams(array $params) + { + $this->_params = $this->_params ? array_merge($this->_params, $params) : $params; + + return $this; + } + + /** + * Get the collection params + * + * @return array + */ + public function params(): array + { + return $this->getParams(); + } + + /** + * Add a single page to a collection + * + * @param PageInterface $page + * @return static + */ + public function addPage(PageInterface $page) + { + if (!$page instanceof FlexObjectInterface) { + throw new InvalidArgumentException('$page is not a flex page.'); + } + + // FIXME: support other keys. + $this->set($page->getKey(), $page); + + return $this; + } + + /** + * + * Merge another collection with the current collection + * + * @param PageCollectionInterface $collection + * @return static + */ + public function merge(PageCollectionInterface $collection) + { + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Intersect another collection with the current collection + * + * @param PageCollectionInterface $collection + * @return static + */ + public function intersect(PageCollectionInterface $collection) + { + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Return previous item. + * + * @return PageInterface|false + * @phpstan-return PageObject|false + */ + public function prev() + { + // FIXME: this method does not rewind the internal pointer! + $key = (string)$this->key(); + $prev = $this->prevSibling($key); + + return $prev !== $this->current() ? $prev : false; + } + + /** + * Return nth item. + * @param int $key + * @return PageInterface|bool + * @phpstan-return PageObject|false + */ + public function nth($key) + { + return $this->slice($key, 1)[0] ?? false; + } + + /** + * Pick one or more random entries. + * + * @param int $num Specifies how many entries should be picked. + * @return static + */ + public function random($num = 1) + { + return $this->createFrom($this->shuffle()->slice(0, $num)); + } + + /** + * Append new elements to the list. + * + * @param array $items Items to be appended. Existing keys will be overridden with the new values. + * @return static + */ + public function append($items) + { + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Split collection into array of smaller collections. + * + * @param int $size + * @return static[] + */ + public function batch($size): array + { + $chunks = $this->chunk($size); + + $list = []; + foreach ($chunks as $chunk) { + $list[] = $this->createFrom($chunk); + } + + return $list; + } + + /** + * Reorder collection. + * + * @param string $by + * @param string $dir + * @param array|null $manual + * @param int|null $sort_flags + * @return static + */ + public function order($by, $dir = 'asc', $manual = null, $sort_flags = null) + { + if (!$this->count()) { + return $this; + } + + if ($by === 'random') { + return $this->shuffle(); + } + + $keys = $this->buildSort($by, $dir, $manual, $sort_flags); + + return $this->createFrom(array_replace(array_flip($keys), $this->toArray()) ?? []); + } + + /** + * @param string $order_by + * @param string $order_dir + * @param array|null $manual + * @param int|null $sort_flags + * @return array + */ + protected function buildSort($order_by = 'default', $order_dir = 'asc', $manual = null, $sort_flags = null): array + { + // do this header query work only once + $header_query = null; + $header_default = null; + if (strpos($order_by, 'header.') === 0) { + $query = explode('|', str_replace('header.', '', $order_by), 2); + $header_query = array_shift($query) ?? ''; + $header_default = array_shift($query); + } + + $list = []; + foreach ($this as $key => $child) { + switch ($order_by) { + case 'title': + $list[$key] = $child->title(); + break; + case 'date': + $list[$key] = $child->date(); + $sort_flags = SORT_REGULAR; + break; + case 'modified': + $list[$key] = $child->modified(); + $sort_flags = SORT_REGULAR; + break; + case 'publish_date': + $list[$key] = $child->publishDate(); + $sort_flags = SORT_REGULAR; + break; + case 'unpublish_date': + $list[$key] = $child->unpublishDate(); + $sort_flags = SORT_REGULAR; + break; + case 'slug': + $list[$key] = $child->slug(); + break; + case 'basename': + $list[$key] = basename($key); + break; + case 'folder': + $list[$key] = $child->folder(); + break; + case 'manual': + case 'default': + default: + if (is_string($header_query)) { + /** @var Header $child_header */ + $child_header = $child->header(); + $header_value = $child_header->get($header_query); + if (is_array($header_value)) { + $list[$key] = implode(',', $header_value); + } elseif ($header_value) { + $list[$key] = $header_value; + } else { + $list[$key] = $header_default ?: $key; + } + $sort_flags = $sort_flags ?: SORT_REGULAR; + break; + } + $list[$key] = $key; + $sort_flags = $sort_flags ?: SORT_REGULAR; + } + } + + if (null === $sort_flags) { + $sort_flags = SORT_NATURAL | SORT_FLAG_CASE; + } + + // else just sort the list according to specified key + if (extension_loaded('intl') && Grav::instance()['config']->get('system.intl_enabled')) { + $locale = setlocale(LC_COLLATE, '0'); //`setlocale` with a '0' param returns the current locale set + $col = Collator::create($locale); + if ($col) { + $col->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON); + if (($sort_flags & SORT_NATURAL) === SORT_NATURAL) { + $list = preg_replace_callback('~([0-9]+)\.~', static function ($number) { + return sprintf('%032d.', $number[0]); + }, $list); + if (!is_array($list)) { + throw new RuntimeException('Internal Error'); + } + + $list_vals = array_values($list); + if (is_numeric(array_shift($list_vals))) { + $sort_flags = Collator::SORT_REGULAR; + } else { + $sort_flags = Collator::SORT_STRING; + } + } + + $col->asort($list, $sort_flags); + } else { + asort($list, $sort_flags); + } + } else { + asort($list, $sort_flags); + } + + // Move manually ordered items into the beginning of the list. Order of the unlisted items does not change. + if (is_array($manual) && !empty($manual)) { + $i = count($manual); + $new_list = []; + foreach ($list as $key => $dummy) { + $child = $this[$key]; + $order = array_search($child->slug, $manual, true); + if ($order === false) { + $order = $i++; + } + $new_list[$key] = (int)$order; + } + + $list = $new_list; + + // Apply manual ordering to the list. + asort($list, SORT_NUMERIC); + } + + if ($order_dir !== 'asc') { + $list = array_reverse($list); + } + + return array_keys($list); + } + + /** + * Mimicks Pages class. + * + * @return $this + * @deprecated 1.7 Not needed anymore in Flex Pages (does nothing). + */ + public function all() + { + return $this; + } + + /** + * Returns the items between a set of date ranges of either the page date field (default) or + * an arbitrary datetime page field where end date is optional + * Dates can be passed in as text that strtotime() can process + * http://php.net/manual/en/function.strtotime.php + * + * @param string $startDate + * @param string|false $endDate + * @param string|null $field + * @return static + * @throws Exception + */ + public function dateRange($startDate, $endDate = false, $field = null) + { + $start = Utils::date2timestamp($startDate); + $end = $endDate ? Utils::date2timestamp($endDate) : false; + + $entries = []; + foreach ($this as $key => $object) { + if (!$object) { + continue; + } + + $date = $field ? strtotime($object->getNestedProperty($field)) : $object->date(); + + if ($date >= $start && (!$end || $date <= $end)) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only visible pages + * + * @return static The collection with only visible pages + */ + public function visible() + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && $object->visible()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only non-visible pages + * + * @return static The collection with only non-visible pages + */ + public function nonVisible() + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && !$object->visible()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only pages + * + * @return static The collection with only pages + */ + public function pages() + { + $entries = []; + /** + * @var int|string $key + * @var PageInterface|null $object + */ + foreach ($this as $key => $object) { + if ($object && !$object->isModule()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only modules + * + * @return static The collection with only modules + */ + public function modules() + { + $entries = []; + /** + * @var int|string $key + * @var PageInterface|null $object + */ + foreach ($this as $key => $object) { + if ($object && $object->isModule()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Alias of modules() + * + * @return static + */ + public function modular() + { + return $this->modules(); + } + + /** + * Alias of pages() + * + * @return static + */ + public function nonModular() + { + return $this->pages(); + } + + /** + * Creates new collection with only published pages + * + * @return static The collection with only published pages + */ + public function published() + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && $object->published()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only non-published pages + * + * @return static The collection with only non-published pages + */ + public function nonPublished() + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && !$object->published()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only routable pages + * + * @return static The collection with only routable pages + */ + public function routable() + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && $object->routable()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only non-routable pages + * + * @return static The collection with only non-routable pages + */ + public function nonRoutable() + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && !$object->routable()) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only pages of the specified type + * + * @param string $type + * @return static The collection + */ + public function ofType($type) + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && $object->template() === $type) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only pages of one of the specified types + * + * @param string[] $types + * @return static The collection + */ + public function ofOneOfTheseTypes($types) + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && in_array($object->template(), $types, true)) { + $entries[$key] = $object; + } + } + + return $this->createFrom($entries); + } + + /** + * Creates new collection with only pages of one of the specified access levels + * + * @param array $accessLevels + * @return static The collection + */ + public function ofOneOfTheseAccessLevels($accessLevels) + { + $entries = []; + foreach ($this as $key => $object) { + if ($object && isset($object->header()->access)) { + if (is_array($object->header()->access)) { + //Multiple values for access + $valid = false; + + foreach ($object->header()->access as $index => $accessLevel) { + if (is_array($accessLevel)) { + foreach ($accessLevel as $innerIndex => $innerAccessLevel) { + if (in_array($innerAccessLevel, $accessLevels)) { + $valid = true; + } + } + } else { + if (in_array($index, $accessLevels)) { + $valid = true; + } + } + } + if ($valid) { + $entries[$key] = $object; + } + } else { + //Single value for access + if (in_array($object->header()->access, $accessLevels)) { + $entries[$key] = $object; + } + } + } + } + + return $this->createFrom($entries); + } + + /** + * @param bool $bool + * @return static + */ + public function withOrdered(bool $bool = true) + { + $list = array_keys(array_filter($this->call('isOrdered', [$bool]))); + + return $this->select($list); + } + + /** + * @param bool $bool + * @return static + */ + public function withModules(bool $bool = true) + { + $list = array_keys(array_filter($this->call('isModule', [$bool]))); + + return $this->select($list); + } + + /** + * @param bool $bool + * @return static + */ + public function withPages(bool $bool = true) + { + $list = array_keys(array_filter($this->call('isPage', [$bool]))); + + return $this->select($list); + } + + /** + * @param bool $bool + * @param string|null $languageCode + * @param bool|null $fallback + * @return static + */ + public function withTranslation(bool $bool = true, string $languageCode = null, bool $fallback = null) + { + $list = array_keys(array_filter($this->call('hasTranslation', [$languageCode, $fallback]))); + + return $bool ? $this->select($list) : $this->unselect($list); + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return PageIndex + */ + public function withTranslated(string $languageCode = null, bool $fallback = null) + { + return $this->getIndex()->withTranslated($languageCode, $fallback); + } + + /** + * Filter pages by given filters. + * + * - search: string + * - page_type: string|string[] + * - modular: bool + * - visible: bool + * - routable: bool + * - published: bool + * - page: bool + * - translated: bool + * + * @param array $filters + * @param bool $recursive + * @return static + */ + public function filterBy(array $filters, bool $recursive = false) + { + $list = array_keys(array_filter($this->call('filterBy', [$filters, $recursive]))); + + return $this->select($list); + } + + /** + * Get the extended version of this Collection with each page keyed by route + * + * @return array + * @throws Exception + */ + public function toExtendedArray(): array + { + $entries = []; + foreach ($this as $key => $object) { + if ($object) { + $entries[$object->route()] = $object->toArray(); + } + } + + return $entries; + } + + /** + * @param array $options + * @return array + */ + public function getLevelListing(array $options): array + { + /** @var PageIndex $index */ + $index = $this->getIndex(); + + return method_exists($index, 'getLevelListing') ? $index->getLevelListing($options) : []; + } +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/PageIndex.php b/system/src/Grav/Common/Flex/Types/Pages/PageIndex.php new file mode 100644 index 0000000..b536c4c --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/PageIndex.php @@ -0,0 +1,1156 @@ + + * + * @method PageIndex withModules(bool $bool = true) + * @method PageIndex withPages(bool $bool = true) + * @method PageIndex withTranslation(bool $bool = true, string $languageCode = null, bool $fallback = null) + */ +class PageIndex extends FlexPageIndex implements PageCollectionInterface +{ + use FlexGravTrait; + use FlexIndexTrait; + + public const VERSION = parent::VERSION . '.5'; + public const ORDER_LIST_REGEX = '/(\/\d+)\.[^\/]+/u'; + public const PAGE_ROUTE_REGEX = '/\/\d+\./u'; + + /** @var PageObject|array */ + protected $_root; + /** @var array|null */ + protected $_params; + + /** + * @param array $entries + * @param FlexDirectory|null $directory + */ + public function __construct(array $entries = [], FlexDirectory $directory = null) + { + // Remove root if it's taken. + if (isset($entries[''])) { + $this->_root = $entries['']; + unset($entries['']); + } + + parent::__construct($entries, $directory); + } + + /** + * @param FlexStorageInterface $storage + * @return array + */ + public static function loadEntriesFromStorage(FlexStorageInterface $storage): array + { + // Load saved index. + $index = static::loadIndex($storage); + + $version = $index['version'] ?? 0; + $force = static::VERSION !== $version; + + // TODO: Following check flex index to be out of sync after some saves, disabled until better solution is found. + //$timestamp = $index['timestamp'] ?? 0; + //if (!$force && $timestamp && $timestamp > time() - 1) { + // return $index['index']; + //} + + // Load up to date index. + $entries = parent::loadEntriesFromStorage($storage); + + return static::updateIndexFile($storage, $index['index'], $entries, ['include_missing' => true, 'force_update' => $force]); + } + + /** + * @param string $key + * @return PageObject|null + */ + public function get($key) + { + if (mb_strpos($key, '|') !== false) { + [$key, $params] = explode('|', $key, 2); + } + + $element = parent::get($key); + if (isset($params)) { + $element = $element->getTranslation(ltrim($params, '.')); + } + + return $element; + } + + /** + * @return PageObject + */ + public function getRoot() + { + $root = $this->_root; + if (is_array($root)) { + $directory = $this->getFlexDirectory(); + $storage = $directory->getStorage(); + + $defaults = [ + 'header' => [ + 'routable' => false, + 'permissions' => [ + 'inherit' => false + ] + ] + ]; + + $row = $storage->readRows(['' => null])[''] ?? null; + if (null !== $row) { + if (isset($row['__ERROR'])) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $message = sprintf('Flex Pages: root page is broken in storage: %s', $row['__ERROR']); + + $debugger->addException(new RuntimeException($message)); + $debugger->addMessage($message, 'error'); + + $row = ['__META' => $root]; + } + + } else { + $row = ['__META' => $root]; + } + + $row = array_merge_recursive($defaults, $row); + + /** @var PageObject $root */ + $root = $this->getFlexDirectory()->createObject($row, '/', false); + $root->name('root.md'); + $root->root(true); + + $this->_root = $root; + } + + return $root; + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return PageIndex + */ + public function withTranslated(string $languageCode = null, bool $fallback = null) + { + if (null === $languageCode) { + return $this; + } + + $entries = $this->translateEntries($this->getEntries(), $languageCode, $fallback); + $params = ['language' => $languageCode, 'language_fallback' => $fallback] + $this->getParams(); + + return $this->createFrom($entries)->setParams($params); + } + + /** + * @return string|null + */ + public function getLanguage(): ?string + { + return $this->_params['language'] ?? null; + } + + /** + * Get the collection params + * + * @return array + */ + public function getParams(): array + { + return $this->_params ?? []; + } + + /** + * Get the collection param + * + * @param string $name + * @return mixed + */ + public function getParam(string $name) + { + return $this->_params[$name] ?? null; + } + + /** + * Set parameters to the Collection + * + * @param array $params + * @return $this + */ + public function setParams(array $params) + { + $this->_params = $this->_params ? array_merge($this->_params, $params) : $params; + + return $this; + } + + /** + * Set a parameter to the Collection + * + * @param string $name + * @param mixed $value + * @return $this + */ + public function setParam(string $name, $value) + { + $this->_params[$name] = $value; + + return $this; + } + + /** + * Get the collection params + * + * @return array + */ + public function params(): array + { + return $this->getParams(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getCacheKey() + */ + public function getCacheKey(): string + { + return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1(json_encode($this->getKeys()) . $this->getKeyField() . $this->getLanguage()); + } + + /** + * Filter pages by given filters. + * + * - search: string + * - page_type: string|string[] + * - modular: bool + * - visible: bool + * - routable: bool + * - published: bool + * - page: bool + * - translated: bool + * + * @param array $filters + * @param bool $recursive + * @return static + */ + public function filterBy(array $filters, bool $recursive = false) + { + if (!$filters) { + return $this; + } + + if ($recursive) { + return $this->__call('filterBy', [$filters, true]); + } + + $list = []; + $index = $this; + foreach ($filters as $key => $value) { + switch ($key) { + case 'search': + $index = $index->search((string)$value); + break; + case 'page_type': + if (!is_array($value)) { + $value = is_string($value) && $value !== '' ? explode(',', $value) : []; + } + $index = $index->ofOneOfTheseTypes($value); + break; + case 'routable': + $index = $index->withRoutable((bool)$value); + break; + case 'published': + $index = $index->withPublished((bool)$value); + break; + case 'visible': + $index = $index->withVisible((bool)$value); + break; + case 'module': + $index = $index->withModules((bool)$value); + break; + case 'page': + $index = $index->withPages((bool)$value); + break; + case 'folder': + $index = $index->withPages(!$value); + break; + case 'translated': + $index = $index->withTranslation((bool)$value); + break; + default: + $list[$key] = $value; + } + } + + return $list ? $index->filterByParent($list) : $index; + } + + /** + * @param array $filters + * @return static + */ + protected function filterByParent(array $filters) + { + return parent::filterBy($filters); + } + + /** + * @param array $options + * @return array + */ + public function getLevelListing(array $options): array + { + // Undocumented B/C + $order = $options['order'] ?? 'asc'; + if ($order === SORT_ASC) { + $options['order'] = 'asc'; + } elseif ($order === SORT_DESC) { + $options['order'] = 'desc'; + } + + $options += [ + 'field' => null, + 'route' => null, + 'leaf_route' => null, + 'sortby' => null, + 'order' => 'asc', + 'lang' => null, + 'filters' => [], + ]; + + $options['filters'] += [ + 'type' => ['root', 'dir'], + ]; + + $key = 'page.idx.lev.' . sha1(json_encode($options, JSON_THROW_ON_ERROR) . $this->getCacheKey()); + $checksum = $this->getCacheChecksum(); + + $cache = $this->getCache('object'); + + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + + $result = null; + try { + $cached = $cache->get($key); + $test = $cached[0] ?? null; + $result = $test === $checksum ? ($cached[1] ?? null) : null; + } catch (\Psr\SimpleCache\InvalidArgumentException $e) { + $debugger->addException($e); + } + + try { + if (null === $result) { + $result = $this->getLevelListingRecurse($options); + $cache->set($key, [$checksum, $result]); + } + } catch (\Psr\SimpleCache\InvalidArgumentException $e) { + $debugger->addException($e); + } + + return $result; + } + + /** + * @param array $entries + * @param string|null $keyField + * @return static + */ + protected function createFrom(array $entries, string $keyField = null) + { + /** @var static $index */ + $index = parent::createFrom($entries, $keyField); + $index->_root = $this->getRoot(); + + return $index; + } + + /** + * @param array $entries + * @param string $lang + * @param bool|null $fallback + * @return array + */ + protected function translateEntries(array $entries, string $lang, bool $fallback = null): array + { + $languages = $this->getFallbackLanguages($lang, $fallback); + foreach ($entries as $key => &$entry) { + // Find out which version of the page we should load. + $translations = $this->getLanguageTemplates((string)$key); + if (!$translations) { + // No translations found, is this a folder? + continue; + } + + // Find a translation. + $template = null; + foreach ($languages as $code) { + if (isset($translations[$code])) { + $template = $translations[$code]; + break; + } + } + + // We couldn't find a translation, remove entry from the list. + if (!isset($code, $template)) { + unset($entries['key']); + continue; + } + + // Get the main key without template and langauge. + [$main_key,] = explode('|', $entry['storage_key'] . '|', 2); + + // Update storage key and language. + $entry['storage_key'] = $main_key . '|' . $template . '.' . $code; + $entry['lang'] = $code; + } + unset($entry); + + return $entries; + } + + /** + * @return array + */ + protected function getLanguageTemplates(string $key): array + { + $meta = $this->getMetaData($key); + $template = $meta['template'] ?? 'folder'; + $translations = $meta['markdown'] ?? []; + $list = []; + foreach ($translations as $code => $search) { + if (isset($search[$template])) { + // Use main template if possible. + $list[$code] = $template; + } elseif (!empty($search)) { + // Fall back to first matching template. + $list[$code] = key($search); + } + } + + return $list; + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return array + */ + protected function getFallbackLanguages(string $languageCode = null, bool $fallback = null): array + { + $fallback = $fallback ?? true; + if (!$fallback && null !== $languageCode) { + return [$languageCode]; + } + + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + $languageCode = $languageCode ?? ''; + if ($languageCode === '' && $fallback) { + return $language->getFallbackLanguages(null, true); + } + + return $fallback ? $language->getFallbackLanguages($languageCode, true) : [$languageCode]; + } + + /** + * @param array $options + * @return array + */ + protected function getLevelListingRecurse(array $options): array + { + $filters = $options['filters'] ?? []; + $field = $options['field']; + $route = $options['route']; + $leaf_route = $options['leaf_route']; + $sortby = $options['sortby']; + $order = $options['order']; + $language = $options['lang']; + + $status = 'error'; + $msg = null; + $response = []; + $children = null; + $sub_route = null; + $extra = null; + + // Handle leaf_route + $leaf = null; + if ($leaf_route && $route !== $leaf_route) { + $nodes = explode('/', $leaf_route); + $sub_route = '/' . implode('/', array_slice($nodes, 1, $options['level']++)); + $options['route'] = $sub_route; + + [$status,,$leaf,$extra] = $this->getLevelListingRecurse($options); + } + + // Handle no route, assume page tree root + if (!$route) { + $page = $this->getRoot(); + } else { + $page = $this->get(trim($route, '/')); + } + $path = $page ? $page->path() : null; + + if ($field) { + // Get forced filters from the field. + $blueprint = $page ? $page->getBlueprint() : $this->getFlexDirectory()->getBlueprint(); + $settings = $blueprint->schema()->getProperty($field); + $filters = array_merge([], $filters, $settings['filters'] ?? []); + } + + // Clean up filter. + $filter_type = (array)($filters['type'] ?? []); + unset($filters['type']); + $filters = array_filter($filters, static function($val) { return $val !== null && $val !== ''; }); + + if ($page) { + if ($page->root() && (!$filter_type || in_array('root', $filter_type, true))) { + if ($field) { + $response[] = [ + 'name' => '', + 'value' => '/', + 'item-key' => '', + 'filename' => '.', + 'extension' => '', + 'type' => 'root', + 'modified' => $page->modified(), + 'size' => 0, + 'symlink' => false, + 'has-children' => false + ]; + } else { + $response[] = [ + 'item-key' => '-root-', + 'icon' => 'root', + 'title' => 'Root', // FIXME + 'route' => [ + 'display' => '<root>', // FIXME + 'raw' => '_root', + ], + 'modified' => $page->modified(), + 'extras' => [ + 'template' => $page->template(), + //'lang' => null, + //'translated' => null, + 'langs' => [], + 'published' => false, + 'visible' => false, + 'routable' => false, + 'tags' => ['root', 'non-routable'], + 'actions' => ['edit'], // FIXME + ] + ]; + } + } + + $status = 'success'; + $msg = 'PLUGIN_ADMIN.PAGE_ROUTE_FOUND'; + + /** @var PageIndex $children */ + $children = $page->children()->getIndex(); + $selectedChildren = $children->filterBy($filters, true); + + /** @var Header $header */ + $header = $page->header(); + + if (!$field && $header->get('admin.children_display_order') === 'collection' && ($orderby = $header->get('content.order.by'))) { + // Use custom sorting by page header. + $sortby = $orderby; + $order = $header->get('content.order.dir', $order); + $custom = $header->get('content.order.custom'); + } + + if ($sortby) { + // Sort children. + $selectedChildren = $selectedChildren->order($sortby, $order, $custom ?? null); + } + + /** @var UserInterface|null $user */ + $user = Grav::instance()['user'] ?? null; + + /** @var PageObject $child */ + foreach ($selectedChildren as $child) { + $selected = $child->path() === $extra; + $includeChildren = is_array($leaf) && !empty($leaf) && $selected; + if ($field) { + $child_count = count($child->children()); + $payload = [ + 'name' => $child->menu(), + 'value' => $child->rawRoute(), + 'item-key' => basename($child->rawRoute() ?? ''), + 'filename' => $child->folder(), + 'extension' => $child->extension(), + 'type' => 'dir', + 'modified' => $child->modified(), + 'size' => $child_count, + 'symlink' => false, + 'has-children' => $child_count > 0 + ]; + } else { + $lang = $child->findTranslation($language) ?? 'n/a'; + /** @var PageObject $child */ + $child = $child->getTranslation($language) ?? $child; + + // TODO: all these features are independent from each other, we cannot just have one icon/color to catch all. + // TODO: maybe icon by home/modular/page/folder (or even from blueprints) and color by visibility etc.. + if ($child->home()) { + $icon = 'home'; + } elseif ($child->isModule()) { + $icon = 'modular'; + } elseif ($child->visible()) { + $icon = 'visible'; + } elseif ($child->isPage()) { + $icon = 'page'; + } else { + // TODO: add support + $icon = 'folder'; + } + $tags = [ + $child->published() ? 'published' : 'non-published', + $child->visible() ? 'visible' : 'non-visible', + $child->routable() ? 'routable' : 'non-routable' + ]; + $extras = [ + 'template' => $child->template(), + 'lang' => $lang ?: null, + 'translated' => $lang ? $child->hasTranslation($language, false) : null, + 'langs' => $child->getAllLanguages(true) ?: null, + 'published' => $child->published(), + 'published_date' => $this->jsDate($child->publishDate()), + 'unpublished_date' => $this->jsDate($child->unpublishDate()), + 'visible' => $child->visible(), + 'routable' => $child->routable(), + 'tags' => $tags, + 'actions' => $this->getListingActions($child, $user), + ]; + $extras = array_filter($extras, static function ($v) { + return $v !== null; + }); + $tmp = $child->children()->getIndex(); + $child_count = $tmp->count(); + $count = $filters ? $tmp->filterBy($filters, true)->count() : null; + $route = $child->getRoute(); + $payload = [ + 'item-key' => basename($child->rawRoute() ?? $child->getKey()), + 'icon' => $icon, + 'title' => htmlspecialchars($child->menu()), + 'route' => [ + 'display' => ($route ? ($route->toString(false) ?: '/') : null) ?? '', + 'raw' => $child->rawRoute(), + ], + 'modified' => $this->jsDate($child->modified()), + 'child_count' => $child_count ?: null, + 'count' => $count ?? null, + 'filters_hit' => $filters ? ($child->filterBy($filters, false) ?: null) : null, + 'extras' => $extras + ]; + $payload = array_filter($payload, static function ($v) { + return $v !== null; + }); + } + + // Add children if any + if ($includeChildren) { + $payload['children'] = array_values($leaf); + } + + $response[] = $payload; + } + } else { + $msg = 'PLUGIN_ADMIN.PAGE_ROUTE_NOT_FOUND'; + } + + if ($field) { + $temp_array = []; + foreach ($response as $index => $item) { + $temp_array[$item['type']][$index] = $item; + } + + $sorted = Utils::sortArrayByArray($temp_array, $filter_type); + $response = Utils::arrayFlatten($sorted); + } + + return [$status, $msg ?? 'PLUGIN_ADMIN.NO_ROUTE_PROVIDED', $response, $path]; + } + + /** + * @param PageObject $object + * @param UserInterface $user + * @return array + */ + protected function getListingActions(PageObject $object, UserInterface $user): array + { + $actions = []; + if ($object->isAuthorized('read', null, $user)) { + $actions[] = 'preview'; + $actions[] = 'edit'; + } + if ($object->isAuthorized('update', null, $user)) { + $actions[] = 'copy'; + $actions[] = 'move'; + } + if ($object->isAuthorized('delete', null, $user)) { + $actions[] = 'delete'; + } + + return $actions; + } + + /** + * @param FlexStorageInterface $storage + * @return CompiledJsonFile|CompiledYamlFile|null + */ + protected static function getIndexFile(FlexStorageInterface $storage) + { + if (!method_exists($storage, 'isIndexed') || !$storage->isIndexed()) { + return null; + } + + // Load saved index file. + $grav = Grav::instance(); + $locator = $grav['locator']; + + $filename = $locator->findResource('user-data://flex/indexes/pages.json', true, true); + + return CompiledJsonFile::instance($filename); + } + + /** + * @param int|null $timestamp + * @return string|null + */ + private function jsDate(int $timestamp = null): ?string + { + if (!$timestamp) { + return null; + } + + $config = Grav::instance()['config']; + $dateFormat = $config->get('system.pages.dateformat.long'); + + return date($dateFormat, $timestamp) ?: null; + } + + /** + * Add a single page to a collection + * + * @param PageInterface $page + * @return PageCollection + */ + public function addPage(PageInterface $page) + { + return $this->getCollection()->addPage($page); + } + + /** + * + * Create a copy of this collection + * + * @return static + */ + public function copy() + { + return clone $this; + } + + /** + * + * Merge another collection with the current collection + * + * @param PageCollectionInterface $collection + * @return PageCollection + */ + public function merge(PageCollectionInterface $collection) + { + return $this->getCollection()->merge($collection); + } + + + /** + * Intersect another collection with the current collection + * + * @param PageCollectionInterface $collection + * @return PageCollection + */ + public function intersect(PageCollectionInterface $collection) + { + return $this->getCollection()->intersect($collection); + } + + /** + * Split collection into array of smaller collections. + * + * @param int $size + * @return PageCollection[] + */ + public function batch($size) + { + return $this->getCollection()->batch($size); + } + + /** + * Remove item from the list. + * + * @param PageInterface|string|null $key + * + * @return $this + * @throws InvalidArgumentException + */ + public function remove($key = null) + { + return $this->getCollection()->remove($key); + } + + /** + * Reorder collection. + * + * @param string $by + * @param string $dir + * @param array $manual + * @param string $sort_flags + * @return static + */ + public function order($by, $dir = 'asc', $manual = null, $sort_flags = null) + { + /** @var PageCollectionInterface $collection */ + $collection = $this->__call('order', [$by, $dir, $manual, $sort_flags]); + + return $collection; + } + + /** + * Check to see if this item is the first in the collection. + * + * @param string $path + * @return bool True if item is first. + */ + public function isFirst($path): bool + { + /** @var bool $result */ + $result = $this->__call('isFirst', [$path]); + + return $result; + + } + + /** + * Check to see if this item is the last in the collection. + * + * @param string $path + * @return bool True if item is last. + */ + public function isLast($path): bool + { + /** @var bool $result */ + $result = $this->__call('isLast', [$path]); + + return $result; + } + + /** + * Gets the previous sibling based on current position. + * + * @param string $path + * @return PageObject|null The previous item. + */ + public function prevSibling($path) + { + /** @var PageObject|null $result */ + $result = $this->__call('prevSibling', [$path]); + + return $result; + } + + /** + * Gets the next sibling based on current position. + * + * @param string $path + * @return PageObject|null The next item. + */ + public function nextSibling($path) + { + /** @var PageObject|null $result */ + $result = $this->__call('nextSibling', [$path]); + + return $result; + } + + /** + * Returns the adjacent sibling based on a direction. + * + * @param string $path + * @param int $direction either -1 or +1 + * @return PageObject|false The sibling item. + */ + public function adjacentSibling($path, $direction = 1) + { + /** @var PageObject|false $result */ + $result = $this->__call('adjacentSibling', [$path, $direction]); + + return $result; + } + + /** + * Returns the item in the current position. + * + * @param string $path the path the item + * @return int|null The index of the current page, null if not found. + */ + public function currentPosition($path): ?int + { + /** @var int|null $result */ + $result = $this->__call('currentPosition', [$path]); + + return $result; + } + + /** + * Returns the items between a set of date ranges of either the page date field (default) or + * an arbitrary datetime page field where end date is optional + * Dates can be passed in as text that strtotime() can process + * http://php.net/manual/en/function.strtotime.php + * + * @param string $startDate + * @param bool $endDate + * @param string|null $field + * @return static + * @throws Exception + */ + public function dateRange($startDate, $endDate = false, $field = null) + { + $collection = $this->__call('dateRange', [$startDate, $endDate, $field]); + + return $collection; + } + + /** + * Mimicks Pages class. + * + * @return $this + * @deprecated 1.7 Not needed anymore in Flex Pages (does nothing). + */ + public function all() + { + return $this; + } + + /** + * Creates new collection with only visible pages + * + * @return static The collection with only visible pages + */ + public function visible() + { + $collection = $this->__call('visible', []); + + return $collection; + } + + /** + * Creates new collection with only non-visible pages + * + * @return static The collection with only non-visible pages + */ + public function nonVisible() + { + $collection = $this->__call('nonVisible', []); + + return $collection; + } + + /** + * Creates new collection with only non-modular pages + * + * @return static The collection with only non-modular pages + */ + public function pages() + { + $collection = $this->__call('pages', []); + + return $collection; + } + + /** + * Creates new collection with only modular pages + * + * @return static The collection with only modular pages + */ + public function modules() + { + $collection = $this->__call('modules', []); + + return $collection; + } + + /** + * Creates new collection with only modular pages + * + * @return static The collection with only modular pages + */ + public function modular() + { + return $this->modules(); + } + + /** + * Creates new collection with only non-modular pages + * + * @return static The collection with only non-modular pages + */ + public function nonModular() + { + return $this->pages(); + } + + /** + * Creates new collection with only published pages + * + * @return static The collection with only published pages + */ + public function published() + { + $collection = $this->__call('published', []); + + return $collection; + } + + /** + * Creates new collection with only non-published pages + * + * @return static The collection with only non-published pages + */ + public function nonPublished() + { + $collection = $this->__call('nonPublished', []); + + return $collection; + } + + /** + * Creates new collection with only routable pages + * + * @return static The collection with only routable pages + */ + public function routable() + { + $collection = $this->__call('routable', []); + + return $collection; + } + + /** + * Creates new collection with only non-routable pages + * + * @return static The collection with only non-routable pages + */ + public function nonRoutable() + { + $collection = $this->__call('nonRoutable', []); + + return $collection; + } + + /** + * Creates new collection with only pages of the specified type + * + * @param string $type + * @return static The collection + */ + public function ofType($type) + { + $collection = $this->__call('ofType', []); + + return $collection; + } + + /** + * Creates new collection with only pages of one of the specified types + * + * @param string[] $types + * @return static The collection + */ + public function ofOneOfTheseTypes($types) + { + $collection = $this->__call('ofOneOfTheseTypes', []); + + return $collection; + } + + /** + * Creates new collection with only pages of one of the specified access levels + * + * @param array $accessLevels + * @return static The collection + */ + public function ofOneOfTheseAccessLevels($accessLevels) + { + $collection = $this->__call('ofOneOfTheseAccessLevels', []); + + return $collection; + } + + /** + * Converts collection into an array. + * + * @return array + */ + public function toArray() + { + return $this->getCollection()->toArray(); + } + + /** + * Get the extended version of this Collection with each page keyed by route + * + * @return array + * @throws Exception + */ + public function toExtendedArray() + { + return $this->getCollection()->toExtendedArray(); + } + +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/PageObject.php b/system/src/Grav/Common/Flex/Types/Pages/PageObject.php new file mode 100644 index 0000000..933a00a --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/PageObject.php @@ -0,0 +1,696 @@ + true, + 'full_order' => true, + 'filterBy' => true, + 'translated' => false, + ] + parent::getCachedMethods(); + } + + /** + * @return void + */ + public function initialize(): void + { + if (!$this->_initialized) { + Grav::instance()->fireEvent('onPageProcessed', new Event(['page' => $this])); + $this->_initialized = true; + } + } + + public function translated(): bool + { + return $this->translatedLanguages(true) ? true : false; + } + + /** + * @param string|array $query + * @return Route|null + */ + public function getRoute($query = []): ?Route + { + $route = $this->route(); + if (null === $route) { + return null; + } + + $route = RouteFactory::createFromString($route); + if ($lang = $route->getLanguage()) { + $grav = Grav::instance(); + if (!$grav['config']->get('system.languages.include_default_lang')) { + /** @var Language $language */ + $language = $grav['language']; + if ($lang === $language->getDefault()) { + $route = $route->withLanguage(''); + } + } + } + if (is_array($query)) { + foreach ($query as $key => $value) { + $route = $route->withQueryParam($key, $value); + } + } else { + $route = $route->withAddedPath($query); + } + + return $route; + } + + /** + * @inheritdoc PageInterface + */ + public function getFormValue(string $name, $default = null, string $separator = null) + { + $test = new stdClass(); + + $value = $this->pageContentValue($name, $test); + if ($value !== $test) { + return $value; + } + + switch ($name) { + case 'name': + // TODO: this should not be template! + return $this->getProperty('template'); + case 'route': + $filesystem = Filesystem::getInstance(false); + $key = $filesystem->dirname($this->hasKey() ? '/' . $this->getKey() : '/'); + return $key !== '/' ? $key : null; + case 'full_route': + return $this->hasKey() ? '/' . $this->getKey() : ''; + case 'full_order': + return $this->full_order(); + case 'lang': + return $this->getLanguage() ?? ''; + case 'translations': + return $this->getLanguages(); + } + + return parent::getFormValue($name, $default, $separator); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getCacheKey() + */ + public function getCacheKey(): string + { + $cacheKey = parent::getCacheKey(); + if ($cacheKey) { + /** @var Language $language */ + $language = Grav::instance()['language']; + $cacheKey .= '_' . $language->getActive(); + } + + return $cacheKey; + } + + /** + * @param array $variables + * @return array + */ + protected function onBeforeSave(array $variables) + { + $reorder = $variables[0] ?? true; + + $meta = $this->getMetaData(); + if (($meta['copy'] ?? false) === true) { + $this->folder = $this->getKey(); + } + + // Figure out storage path to the new route. + $parentKey = $this->getProperty('parent_key'); + if ($parentKey !== '') { + $parentRoute = $this->getProperty('route'); + + // Root page cannot be moved. + if ($this->root()) { + throw new RuntimeException(sprintf('Root page cannot be moved to %s', $parentRoute)); + } + + // Make sure page isn't being moved under itself. + $key = $this->getStorageKey(); + + /** @var PageObject|null $parent */ + $parent = $parentKey !== false ? $this->getFlexDirectory()->getObject($parentKey, 'storage_key') : null; + if (!$parent) { + // Page cannot be moved to non-existing location. + throw new RuntimeException(sprintf('Page /%s cannot be moved to non-existing path %s', $key, $parentRoute)); + } + + // TODO: make sure that the page doesn't exist yet if moved/copied. + } + + if ($reorder === true && !$this->root()) { + $reorder = $this->_reorder; + } + + // Force automatic reorder if item is supposed to be added to the last. + if (!is_array($reorder) && (int)$this->order() >= 999999) { + $reorder = []; + } + + // Reorder siblings. + $siblings = is_array($reorder) ? ($this->reorderSiblings($reorder) ?? []) : []; + + $data = $this->prepareStorage(); + unset($data['header']); + + foreach ($siblings as $sibling) { + $data = $sibling->prepareStorage(); + unset($data['header']); + } + + return ['reorder' => $reorder, 'siblings' => $siblings]; + } + + /** + * @param array $variables + * @return array + */ + protected function onSave(array $variables): array + { + /** @var PageCollection $siblings */ + $siblings = $variables['siblings']; + foreach ($siblings as $sibling) { + $sibling->save(false); + } + + return $variables; + } + + /** + * @param array $variables + */ + protected function onAfterSave(array $variables): void + { + $this->getFlexDirectory()->reloadIndex(); + } + + /** + * @param array|bool $reorder + * @return FlexObject|FlexObjectInterface + */ + public function save($reorder = true) + { + $variables = $this->onBeforeSave(func_get_args()); + + // Backwards compatibility with older plugins. + $fireEvents = $reorder && $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true); + $grav = $this->getContainer(); + if ($fireEvents) { + $self = $this; + $grav->fireEvent('onAdminSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => &$self])); + if ($self !== $this) { + throw new RuntimeException('Switching Flex Page object during onAdminSave event is not supported! Please update plugin.'); + } + } + + /** @var static $instance */ + $instance = parent::save(); + $variables = $this->onSave($variables); + + $this->onAfterSave($variables); + + // Backwards compatibility with older plugins. + if ($fireEvents) { + $grav->fireEvent('onAdminAfterSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => $this])); + } + + // Reset original after save events have all been called. + $this->_original = null; + + return $instance; + } + + /** + * @return PageObject + */ + public function delete() + { + $result = parent::delete(); + + // Backwards compatibility with older plugins. + $fireEvents = $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true); + if ($fireEvents) { + $this->getContainer()->fireEvent('onAdminAfterDelete', new Event(['object' => $this])); + } + + return $result; + } + + /** + * Prepare move page to new location. Moves also everything that's under the current page. + * + * You need to call $this->save() in order to perform the move. + * + * @param PageInterface $parent New parent page. + * @return $this + */ + public function move(PageInterface $parent) + { + if (!$parent instanceof FlexObjectInterface) { + throw new RuntimeException('Failed: Parent is not Flex Object'); + } + + $this->_reorder = []; + $this->setProperty('parent_key', $parent->getStorageKey()); + $this->storeOriginal(); + + return $this; + } + + /** + * @param UserInterface $user + * @param string $action + * @param string $scope + * @param bool $isMe + * @return bool|null + */ + protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe): ?bool + { + // Special case: creating a new page means checking parent for its permissions. + if ($action === 'create' && !$this->exists()) { + $parent = $this->parent(); + if ($parent && method_exists($parent, 'isAuthorized')) { + return $parent->isAuthorized($action, $scope, $user); + } + + return false; + } + + return parent::isAuthorizedOverride($user, $action, $scope, $isMe); + } + + /** + * @param array $ordering + * @return PageCollection|null + */ + protected function reorderSiblings(array $ordering) + { + $storageKey = $this->getMasterKey(); + $filesystem = Filesystem::getInstance(false); + $oldParentKey = ltrim($filesystem->dirname("/{$storageKey}"), '/'); + $newParentKey = $this->getProperty('parent_key'); + $isMoved = $oldParentKey !== $newParentKey; + $order = !$isMoved ? $this->order() : false; + if ($order !== false) { + $order = (int)$order; + } + + $parent = $this->parent(); + if (!$parent) { + throw new RuntimeException('Cannot reorder a page which has no parent'); + } + + /** @var PageCollection $siblings */ + $siblings = $parent->children(); + $siblings = $siblings->getCollection()->withOrdered(); + + // Handle special case where ordering isn't given. + if ($ordering === []) { + if ($order >= 999999) { + // Set ordering to point to be the last item. + $order = 0; + foreach ($siblings as $sibling) { + $order = max($order, (int)$sibling->order()); + } + $this->order($order + 1); + } + + // Do not change sibling ordering. + return null; + } + + $siblings = $siblings->orderBy(['order' => 'ASC']); + + if ($storageKey !== null) { + if ($order !== false) { + // Add current page back to the list if it's ordered. + $siblings->set($storageKey, $this); + } else { + // Remove old copy of the current page from the siblings list. + $siblings->remove($storageKey); + } + } + + // Add missing siblings into the end of the list, keeping the previous ordering between them. + foreach ($siblings as $sibling) { + $basename = preg_replace('|^\d+\.|', '', $sibling->getProperty('folder')); + if (!in_array($basename, $ordering, true)) { + $ordering[] = $basename; + } + } + + // Reorder. + $ordering = array_flip(array_values($ordering)); + $count = count($ordering); + foreach ($siblings as $sibling) { + $basename = preg_replace('|^\d+\.|', '', $sibling->getProperty('folder')); + $newOrder = $ordering[$basename] ?? null; + $newOrder = null !== $newOrder ? $newOrder + 1 : (int)$sibling->order() + $count; + $sibling->order($newOrder); + } + + $siblings = $siblings->orderBy(['order' => 'ASC']); + $siblings->removeElement($this); + + // If menu item was moved, just make it to be the last in order. + if ($isMoved && $this->order() !== false) { + $parentKey = $this->getProperty('parent_key'); + if ($parentKey === '') { + $newParent = $this->getFlexDirectory()->getIndex()->getRoot(); + } else { + $newParent = $this->getFlexDirectory()->getObject($parentKey, 'storage_key'); + if (!$newParent instanceof PageInterface) { + throw new RuntimeException("New parent page '{$parentKey}' not found."); + } + } + /** @var PageCollection $newSiblings */ + $newSiblings = $newParent->children(); + $newSiblings = $newSiblings->getCollection()->withOrdered(); + $order = 0; + foreach ($newSiblings as $sibling) { + $order = max($order, (int)$sibling->order()); + } + $this->order($order + 1); + } + + return $siblings; + } + + /** + * @return string + */ + public function full_order(): string + { + $route = $this->path() . '/' . $this->folder(); + + return preg_replace(PageIndex::ORDER_LIST_REGEX, '\\1', $route) ?? $route; + } + + /** + * @param string $name + * @return Blueprint + */ + protected function doGetBlueprint(string $name = ''): Blueprint + { + try { + // Make sure that pages has been initialized. + Pages::getTypes(); + + // TODO: We need to move raw blueprint logic to Grav itself to remove admin dependency here. + if ($name === 'raw') { + // Admin RAW mode. + if ($this->isAdminSite()) { + /** @var Admin $admin */ + $admin = Grav::instance()['admin']; + + $template = $this->isModule() ? 'modular_raw' : ($this->root() ? 'root_raw' : 'raw'); + + return $admin->blueprints("admin/pages/{$template}"); + } + } + + $template = $this->getProperty('template') . ($name ? '.' . $name : ''); + + $blueprint = $this->getFlexDirectory()->getBlueprint($template, 'blueprints://pages'); + } catch (RuntimeException $e) { + $template = 'default' . ($name ? '.' . $name : ''); + + $blueprint = $this->getFlexDirectory()->getBlueprint($template, 'blueprints://pages'); + } + + $isNew = $blueprint->get('initialized', false) === false; + if ($isNew === true && $name === '') { + // Support onBlueprintCreated event just like in Pages::blueprints($template) + $blueprint->set('initialized', true); + Grav::instance()->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $template])); + } + + return $blueprint; + } + + /** + * @param array $options + * @return array + */ + public function getLevelListing(array $options): array + { + $index = $this->getFlexDirectory()->getIndex(); + if (!is_callable([$index, 'getLevelListing'])) { + return []; + } + + // Deal with relative paths. + $initial = $options['initial'] ?? null; + $var = $initial ? 'leaf_route' : 'route'; + $route = $options[$var] ?? ''; + if ($route !== '' && !str_starts_with($route, '/')) { + $filesystem = Filesystem::getInstance(); + + $route = "/{$this->getKey()}/{$route}"; + $route = $filesystem->normalize($route); + + $options[$var] = $route; + } + + [$status, $message, $response,] = $index->getLevelListing($options); + + return [$status, $message, $response, $options[$var] ?? null]; + } + + /** + * Filter page (true/false) by given filters. + * + * - search: string + * - extension: string + * - module: bool + * - visible: bool + * - routable: bool + * - published: bool + * - page: bool + * - translated: bool + * + * @param array $filters + * @param bool $recursive + * @return bool + */ + public function filterBy(array $filters, bool $recursive = false): bool + { + foreach ($filters as $key => $value) { + switch ($key) { + case 'search': + $matches = $this->search((string)$value) > 0.0; + break; + case 'page_type': + $types = $value ? explode(',', $value) : []; + $matches = in_array($this->template(), $types, true); + break; + case 'extension': + $matches = Utils::contains((string)$value, $this->extension()); + break; + case 'routable': + $matches = $this->isRoutable() === (bool)$value; + break; + case 'published': + $matches = $this->isPublished() === (bool)$value; + break; + case 'visible': + $matches = $this->isVisible() === (bool)$value; + break; + case 'module': + $matches = $this->isModule() === (bool)$value; + break; + case 'page': + $matches = $this->isPage() === (bool)$value; + break; + case 'folder': + $matches = $this->isPage() === !$value; + break; + case 'translated': + $matches = $this->hasTranslation() === (bool)$value; + break; + default: + $matches = true; + break; + } + + // If current filter does not match, we still may have match as a parent. + if ($matches === false) { + return $recursive && $this->children()->getIndex()->filterBy($filters, true)->count() > 0; + } + } + + return true; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::exists() + */ + public function exists(): bool + { + return $this->root ?: parent::exists(); + } + + /** + * @return array + */ + public function __debugInfo(): array + { + $list = parent::__debugInfo(); + + return $list + [ + '_content_meta:private' => $this->getContentMeta(), + '_content:private' => $this->getRawContent() + ]; + } + + /** + * @param array $elements + * @param bool $extended + */ + protected function filterElements(array &$elements, bool $extended = false): void + { + // Change parent page if needed. + if (array_key_exists('route', $elements) && isset($elements['folder'], $elements['name'])) { + $elements['template'] = $elements['name']; + + // Figure out storage path to the new route. + $parentKey = trim($elements['route'] ?? '', '/'); + if ($parentKey !== '') { + /** @var PageObject|null $parent */ + $parent = $this->getFlexDirectory()->getObject($parentKey); + $parentKey = $parent ? $parent->getStorageKey() : $parentKey; + } + + $elements['parent_key'] = $parentKey; + } + + // Deal with ordering=bool and order=page1,page2,page3. + if ($this->root()) { + // Root page doesn't have ordering. + unset($elements['ordering'], $elements['order']); + } elseif (array_key_exists('ordering', $elements) && array_key_exists('order', $elements)) { + // Store ordering. + $ordering = $elements['order'] ?? null; + $this->_reorder = !empty($ordering) ? explode(',', $ordering) : []; + + $order = false; + if ((bool)($elements['ordering'] ?? false)) { + $order = $this->order(); + if ($order === false) { + $order = 999999; + } + } + + $elements['order'] = $order; + } + + parent::filterElements($elements, true); + } + + /** + * @return array + */ + public function prepareStorage(): array + { + $meta = $this->getMetaData(); + $oldLang = $meta['lang'] ?? ''; + $newLang = $this->getProperty('lang') ?? ''; + + // Always clone the page to the new language. + if ($oldLang !== $newLang) { + $meta['clone'] = true; + } + + // Make sure that certain elements are always sent to the storage layer. + $elements = [ + '__META' => $meta, + 'storage_key' => $this->getStorageKey(), + 'parent_key' => $this->getProperty('parent_key'), + 'order' => $this->getProperty('order'), + 'folder' => preg_replace('|^\d+\.|', '', $this->getProperty('folder') ?? ''), + 'template' => preg_replace('|modular/|', '', $this->getProperty('template') ?? ''), + 'lang' => $newLang + ] + parent::prepareStorage(); + + return $elements; + } +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php b/system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php new file mode 100644 index 0000000..ba7fa06 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php @@ -0,0 +1,700 @@ +flags = FilesystemIterator::KEY_AS_FILENAME | FilesystemIterator::CURRENT_AS_FILEINFO + | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS; + + $grav = Grav::instance(); + + $config = $grav['config']; + $this->ignore_hidden = (bool)$config->get('system.pages.ignore_hidden'); + $this->ignore_files = (array)$config->get('system.pages.ignore_files'); + $this->ignore_folders = (array)$config->get('system.pages.ignore_folders'); + $this->include_default_lang_file_extension = (bool)$config->get('system.languages.include_default_lang_file_extension', true); + $this->recurse = (bool)($options['recurse'] ?? true); + $this->regex = '/(\.([\w\d_-]+))?\.md$/D'; + } + + /** + * @param string $key + * @param bool $variations + * @return array + */ + public function parseKey(string $key, bool $variations = true): array + { + if (mb_strpos($key, '|') !== false) { + [$key, $params] = explode('|', $key, 2); + } else { + $params = ''; + } + $key = ltrim($key, '/'); + + $keys = parent::parseKey($key, false) + ['params' => $params]; + + if ($variations) { + $keys += $this->parseParams($key, $params); + } + + return $keys; + } + + /** + * @param string $key + * @return string + */ + public function readFrontmatter(string $key): string + { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + try { + if ($file instanceof MarkdownFile) { + $frontmatter = $file->frontmatter(); + } else { + $frontmatter = $file->raw(); + } + } catch (RuntimeException $e) { + $frontmatter = 'ERROR: ' . $e->getMessage(); + } finally { + $file->free(); + unset($file); + } + + return $frontmatter; + } + + /** + * @param string $key + * @return string + */ + public function readRaw(string $key): string + { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + try { + $raw = $file->raw(); + } catch (RuntimeException $e) { + $raw = 'ERROR: ' . $e->getMessage(); + } finally { + $file->free(); + unset($file); + } + + return $raw; + } + + /** + * @param array $keys + * @param bool $includeParams + * @return string + */ + public function buildStorageKey(array $keys, bool $includeParams = true): string + { + $key = $keys['key'] ?? null; + if (null === $key) { + $key = $keys['parent_key'] ?? ''; + if ($key !== '') { + $key .= '/'; + } + $order = $keys['order'] ?? null; + $folder = $keys['folder'] ?? 'undefined'; + $key .= is_numeric($order) ? sprintf('%02d.%s', $order, $folder) : $folder; + } + + $params = $includeParams ? $this->buildStorageKeyParams($keys) : ''; + + return $params ? "{$key}|{$params}" : $key; + } + + /** + * @param array $keys + * @return string + */ + public function buildStorageKeyParams(array $keys): string + { + $params = $keys['template'] ?? ''; + $language = $keys['lang'] ?? ''; + if ($language) { + $params .= '.' . $language; + } + + return $params; + } + + /** + * @param array $keys + * @return string + */ + public function buildFolder(array $keys): string + { + return $this->dataFolder . '/' . $this->buildStorageKey($keys, false); + } + + /** + * @param array $keys + * @return string + */ + public function buildFilename(array $keys): string + { + $file = $this->buildStorageKeyParams($keys); + + // Template is optional; if it is missing, we need to have to load the object metadata. + if ($file && $file[0] === '.') { + $meta = $this->getObjectMeta($this->buildStorageKey($keys, false)); + $file = ($meta['template'] ?? 'folder') . $file; + } + + return $file . $this->dataExt; + } + + /** + * @param array $keys + * @return string + */ + public function buildFilepath(array $keys): string + { + $folder = $this->buildFolder($keys); + $filename = $this->buildFilename($keys); + + return rtrim($folder, '/') !== $folder ? $folder . $filename : $folder . '/' . $filename; + } + + /** + * @param array $row + * @param bool $setDefaultLang + * @return array + */ + public function extractKeysFromRow(array $row, bool $setDefaultLang = true): array + { + $meta = $row['__META'] ?? null; + $storageKey = $row['storage_key'] ?? $meta['storage_key'] ?? ''; + $keyMeta = $storageKey !== '' ? $this->extractKeysFromStorageKey($storageKey) : null; + $parentKey = $row['parent_key'] ?? $meta['parent_key'] ?? $keyMeta['parent_key'] ?? ''; + $order = $row['order'] ?? $meta['order'] ?? $keyMeta['order'] ?? null; + $folder = $row['folder'] ?? $meta['folder'] ?? $keyMeta['folder'] ?? ''; + $template = $row['template'] ?? $meta['template'] ?? $keyMeta['template'] ?? ''; + $lang = $row['lang'] ?? $meta['lang'] ?? $keyMeta['lang'] ?? ''; + + // Handle default language, if it should be saved without language extension. + if ($setDefaultLang && empty($meta['markdown'][$lang])) { + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + $default = $language->getDefault(); + // Make sure that the default language file doesn't exist before overriding it. + if (empty($meta['markdown'][$default])) { + if ($this->include_default_lang_file_extension) { + if ($lang === '') { + $lang = $language->getDefault(); + } + } elseif ($lang === $language->getDefault()) { + $lang = ''; + } + } + } + + $keys = [ + 'key' => null, + 'params' => null, + 'parent_key' => $parentKey, + 'order' => is_numeric($order) ? (int)$order : null, + 'folder' => $folder, + 'template' => $template, + 'lang' => $lang + ]; + + $keys['key'] = $this->buildStorageKey($keys, false); + $keys['params'] = $this->buildStorageKeyParams($keys); + + return $keys; + } + + /** + * @param string $key + * @return array + */ + public function extractKeysFromStorageKey(string $key): array + { + if (mb_strpos($key, '|') !== false) { + [$key, $params] = explode('|', $key, 2); + [$template, $language] = mb_strpos($params, '.') !== false ? explode('.', $params, 2) : [$params, '']; + } else { + $params = $template = $language = ''; + } + $objectKey = basename($key); + if (preg_match('|^(\d+)\.(.+)$|', $objectKey, $matches)) { + [, $order, $folder] = $matches; + } else { + [$order, $folder] = ['', $objectKey]; + } + + $filesystem = Filesystem::getInstance(false); + + $parentKey = ltrim($filesystem->dirname('/' . $key), '/'); + + return [ + 'key' => $key, + 'params' => $params, + 'parent_key' => $parentKey, + 'order' => is_numeric($order) ? (int)$order : null, + 'folder' => $folder, + 'template' => $template, + 'lang' => $language + ]; + } + + /** + * @param string $key + * @param string $params + * @return array + */ + protected function parseParams(string $key, string $params): array + { + if (mb_strpos($params, '.') !== false) { + [$template, $language] = explode('.', $params, 2); + } else { + $template = $params; + $language = ''; + } + + if ($template === '') { + $meta = $this->getObjectMeta($key); + $template = $meta['template'] ?? 'folder'; + } + + return [ + 'file' => $template . ($language ? '.' . $language : ''), + 'template' => $template, + 'lang' => $language + ]; + } + + /** + * Prepares the row for saving and returns the storage key for the record. + * + * @param array $row + */ + protected function prepareRow(array &$row): void + { + // Remove keys used in the filesystem. + unset($row['parent_key'], $row['order'], $row['folder'], $row['template'], $row['lang']); + } + + /** + * @param string $key + * @return array + */ + protected function loadRow(string $key): ?array + { + $data = parent::loadRow($key); + + // Special case for root page. + if ($key === '' && null !== $data) { + $data['root'] = true; + } + + return $data; + } + + /** + * Page storage supports moving and copying the pages and their languages. + * + * $row['__META']['copy'] = true Use this if you want to copy the whole folder, otherwise it will be moved + * $row['__META']['clone'] = true Use this if you want to clone the file, otherwise it will be renamed + * + * @param string $key + * @param array $row + * @return array + */ + protected function saveRow(string $key, array $row): array + { + // Initialize all key-related variables. + $newKeys = $this->extractKeysFromRow($row); + $newKey = $this->buildStorageKey($newKeys); + $newFolder = $this->buildFolder($newKeys); + $newFilename = $this->buildFilename($newKeys); + $newFilepath = rtrim($newFolder, '/') !== $newFolder ? $newFolder . $newFilename : $newFolder . '/' . $newFilename; + + try { + if ($key === '' && empty($row['root'])) { + throw new RuntimeException('Page has no path'); + } + + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage("Save page: {$newKey}", 'debug'); + + // Check if the row already exists. + $oldKey = $row['__META']['storage_key'] ?? null; + if (is_string($oldKey)) { + // Initialize all old key-related variables. + $oldKeys = $this->extractKeysFromRow(['__META' => $row['__META']], false); + $oldFolder = $this->buildFolder($oldKeys); + $oldFilename = $this->buildFilename($oldKeys); + + // Check if folder has changed. + if ($oldFolder !== $newFolder && file_exists($oldFolder)) { + $isCopy = $row['__META']['copy'] ?? false; + if ($isCopy) { + if (strpos($newFolder, $oldFolder . '/') === 0) { + throw new RuntimeException(sprintf('Page /%s cannot be copied to itself', $oldKey)); + } + + $this->copyRow($oldKey, $newKey); + $debugger->addMessage("Page copied: {$oldFolder} => {$newFolder}", 'debug'); + } else { + if (strpos($newFolder, $oldFolder . '/') === 0) { + throw new RuntimeException(sprintf('Page /%s cannot be moved to itself', $oldKey)); + } + + $this->renameRow($oldKey, $newKey); + $debugger->addMessage("Page moved: {$oldFolder} => {$newFolder}", 'debug'); + } + } + + // Check if filename has changed. + if ($oldFilename !== $newFilename) { + // Get instance of the old file (we have already copied/moved it). + $oldFilepath = "{$newFolder}/{$oldFilename}"; + $file = $this->getFile($oldFilepath); + + // Rename the file if we aren't supposed to clone it. + $isClone = $row['__META']['clone'] ?? false; + if (!$isClone && $file->exists()) { + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $toPath = $locator->isStream($newFilepath) ? $locator->findResource($newFilepath, true, true) : GRAV_ROOT . "/{$newFilepath}"; + $success = $file->rename($toPath); + if (!$success) { + throw new RuntimeException("Changing page template failed: {$oldFilepath} => {$newFilepath}"); + } + $debugger->addMessage("Page template changed: {$oldFilename} => {$newFilename}", 'debug'); + } else { + $file = null; + $debugger->addMessage("Page template created: {$newFilename}", 'debug'); + } + } + } + + // Clean up the data to be saved. + $this->prepareRow($row); + unset($row['__META'], $row['__ERROR']); + + if (!isset($file)) { + $file = $this->getFile($newFilepath); + } + + // Compare existing file content to the new one and save the file only if content has been changed. + $file->free(); + $oldRaw = $file->raw(); + $file->content($row); + $newRaw = $file->raw(); + if ($oldRaw !== $newRaw) { + $file->save($row); + $debugger->addMessage("Page content saved: {$newFilepath}", 'debug'); + } else { + $debugger->addMessage('Page content has not been changed, do not update the file', 'debug'); + } + } catch (RuntimeException $e) { + $name = isset($file) ? $file->filename() : $newKey; + + throw new RuntimeException(sprintf('Flex saveRow(%s): %s', $name, $e->getMessage())); + } finally { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->clearCache(); + + if (isset($file)) { + $file->free(); + unset($file); + } + } + + $row['__META'] = $this->getObjectMeta($newKey, true); + + return $row; + } + + /** + * Check if page folder should be deleted. + * + * Deleting page can be done either by deleting everything or just a single language. + * If key contains the language, delete only it, unless it is the last language. + * + * @param string $key + * @return bool + */ + protected function canDeleteFolder(string $key): bool + { + // Return true if there's no language in the key. + $keys = $this->extractKeysFromStorageKey($key); + if (!$keys['lang']) { + return true; + } + + // Get the main key and reload meta. + $key = $this->buildStorageKey($keys); + $meta = $this->getObjectMeta($key, true); + + // Return true if there aren't any markdown files left. + return empty($meta['markdown'] ?? []); + } + + /** + * Get key from the filesystem path. + * + * @param string $path + * @return string + */ + protected function getKeyFromPath(string $path): string + { + if ($this->base_path) { + $path = $this->base_path . '/' . $path; + } + + return $path; + } + + /** + * Returns list of all stored keys in [key => timestamp] pairs. + * + * @return array + */ + protected function buildIndex(): array + { + $this->clearCache(); + + return $this->getIndexMeta(); + } + + /** + * @param string $key + * @param bool $reload + * @return array + */ + protected function getObjectMeta(string $key, bool $reload = false): array + { + $keys = $this->extractKeysFromStorageKey($key); + $key = $keys['key']; + + if ($reload || !isset($this->meta[$key])) { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if (mb_strpos($key, '@@') === false) { + $path = $this->getStoragePath($key); + if (is_string($path)) { + $path = $locator->isStream($path) ? $locator->findResource($path) : GRAV_ROOT . "/{$path}"; + } else { + $path = null; + } + } else { + $path = null; + } + + $modified = 0; + $markdown = []; + $children = []; + + if (is_string($path) && is_dir($path)) { + $modified = filemtime($path); + $iterator = new FilesystemIterator($path, $this->flags); + + /** @var SplFileInfo $info */ + foreach ($iterator as $k => $info) { + // Ignore all hidden files if set. + if ($k === '' || ($this->ignore_hidden && $k[0] === '.')) { + continue; + } + + if ($info->isDir()) { + // Ignore all folders in ignore list. + if ($this->ignore_folders && in_array($k, $this->ignore_folders, true)) { + continue; + } + + $children[$k] = false; + } else { + // Ignore all files in ignore list. + if ($this->ignore_files && in_array($k, $this->ignore_files, true)) { + continue; + } + + $timestamp = $info->getMTime(); + + // Page is the one that matches to $page_extensions list with the lowest index number. + if (preg_match($this->regex, $k, $matches)) { + $mark = $matches[2] ?? ''; + $ext = $matches[1] ?? ''; + $ext .= $this->dataExt; + $markdown[$mark][basename($k, $ext)] = $timestamp; + } + + $modified = max($modified, $timestamp); + } + } + } + + $rawRoute = trim(preg_replace(PageIndex::PAGE_ROUTE_REGEX, '/', "/{$key}") ?? '', '/'); + $route = PageIndex::normalizeRoute($rawRoute); + + ksort($markdown, SORT_NATURAL | SORT_FLAG_CASE); + ksort($children, SORT_NATURAL | SORT_FLAG_CASE); + + $file = array_key_first($markdown[''] ?? (reset($markdown) ?: [])); + + $meta = [ + 'key' => $route, + 'storage_key' => $key, + 'template' => $file, + 'storage_timestamp' => $modified, + ]; + if ($markdown) { + $meta['markdown'] = $markdown; + } + if ($children) { + $meta['children'] = $children; + } + $meta['checksum'] = md5(json_encode($meta) ?: ''); + + // Cache meta as copy. + $this->meta[$key] = $meta; + } else { + $meta = $this->meta[$key]; + } + + $params = $keys['params']; + if ($params) { + $language = $keys['lang']; + $template = $keys['template'] ?: array_key_first($meta['markdown'][$language]) ?? $meta['template']; + $meta['exists'] = ($template && !empty($meta['children'])) || isset($meta['markdown'][$language][$template]); + $meta['storage_key'] .= '|' . $params; + $meta['template'] = $template; + $meta['lang'] = $language; + } + + return $meta; + } + + /** + * @return array + */ + protected function getIndexMeta(): array + { + $queue = ['']; + $list = []; + do { + $current = array_pop($queue); + if ($current === null) { + break; + } + + $meta = $this->getObjectMeta($current); + $storage_key = $meta['storage_key']; + + if (!empty($meta['children'])) { + $prefix = $storage_key . ($storage_key !== '' ? '/' : ''); + + foreach ($meta['children'] as $child => $value) { + $queue[] = $prefix . $child; + } + } + + $list[$storage_key] = $meta; + } while ($queue); + + ksort($list, SORT_NATURAL | SORT_FLAG_CASE); + + // Update parent timestamps. + foreach (array_reverse($list) as $storage_key => $meta) { + if ($storage_key !== '') { + $filesystem = Filesystem::getInstance(false); + + $storage_key = (string)$storage_key; + $parentKey = $filesystem->dirname($storage_key); + if ($parentKey === '.') { + $parentKey = ''; + } + + /** @phpstan-var array{'storage_key': string, 'storage_timestamp': int, 'children': array} $parent */ + $parent = &$list[$parentKey]; + $basename = basename($storage_key); + + if (isset($parent['children'][$basename])) { + $timestamp = $meta['storage_timestamp']; + $parent['children'][$basename] = $timestamp; + if ($basename && $basename[0] === '_') { + $parent['storage_timestamp'] = max($parent['storage_timestamp'], $timestamp); + } + } + } + } + + return $list; + } + + /** + * @return string + */ + protected function getNewKey(): string + { + throw new RuntimeException('Generating random key is disabled for pages'); + } +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/Traits/PageContentTrait.php b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageContentTrait.php new file mode 100644 index 0000000..1bde7b0 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageContentTrait.php @@ -0,0 +1,75 @@ +getProperty($property) : null; + if (null === $value) { + $value = $this->language() . ($var ?? ($this->modified() . md5($this->filePath() ?? $this->getKey()))); + + $this->setProperty($property, $value); + if ($this->doHasProperty($property)) { + $value = $this->getProperty($property); + } + } + + return $value; + } + + + /** + * @inheritdoc + */ + public function date($var = null): int + { + return $this->loadHeaderProperty( + 'date', + $var, + function ($value) { + $value = $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : false; + + if (!$value) { + // Get the specific translation updated date. + $meta = $this->getMetaData(); + $language = $meta['lang'] ?? ''; + $template = $this->getProperty('template'); + $value = $meta['markdown'][$language][$template] ?? 0; + } + + return $value ?: $this->modified(); + } + ); + } + + /** + * @inheritdoc + * @param bool $bool + */ + public function isPage(bool $bool = true): bool + { + $meta = $this->getMetaData(); + + return empty($meta['markdown']) !== $bool; + } +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php new file mode 100644 index 0000000..d8193df --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php @@ -0,0 +1,233 @@ +path() ?? ''; + + return $pages->children($path); + } + + /** + * Check to see if this item is the first in an array of sub-pages. + * + * @return bool True if item is first. + */ + public function isFirst(): bool + { + if (Utils::isAdminPlugin()) { + return parent::isFirst(); + } + + $path = $this->path(); + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if (null !== $path && $collection instanceof PageCollectionInterface) { + return $collection->isFirst($path); + } + + return true; + } + + /** + * Check to see if this item is the last in an array of sub-pages. + * + * @return bool True if item is last + */ + public function isLast(): bool + { + if (Utils::isAdminPlugin()) { + return parent::isLast(); + } + + $path = $this->path(); + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if (null !== $path && $collection instanceof PageCollectionInterface) { + return $collection->isLast($path); + } + + return true; + } + + /** + * Returns the adjacent sibling based on a direction. + * + * @param int $direction either -1 or +1 + * @return PageInterface|false the sibling page + */ + public function adjacentSibling($direction = 1) + { + if (Utils::isAdminPlugin()) { + return parent::adjacentSibling($direction); + } + + $path = $this->path(); + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if (null !== $path && $collection instanceof PageCollectionInterface) { + return $collection->adjacentSibling($path, $direction); + } + + return false; + } + + /** + * Helper method to return an ancestor page. + * + * @param string|null $lookup Name of the parent folder + * @return PageInterface|null page you were looking for if it exists + */ + public function ancestor($lookup = null) + { + if (Utils::isAdminPlugin()) { + return parent::ancestor($lookup); + } + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->ancestor($this->getProperty('parent_route'), $lookup); + } + + /** + * Method that contains shared logic for inherited() and inheritedField() + * + * @param string $field Name of the parent folder + * @return array + */ + protected function getInheritedParams($field): array + { + if (Utils::isAdminPlugin()) { + return parent::getInheritedParams($field); + } + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + $inherited = $pages->inherited($this->getProperty('parent_route'), $field); + $inheritedParams = $inherited ? (array)$inherited->value('header.' . $field) : []; + $currentParams = (array)$this->getFormValue('header.' . $field); + if ($inheritedParams && is_array($inheritedParams)) { + $currentParams = array_replace_recursive($inheritedParams, $currentParams); + } + + return [$inherited, $currentParams]; + } + + /** + * Helper method to return a page. + * + * @param string $url the url of the page + * @param bool $all + * @return PageInterface|null page you were looking for if it exists + */ + public function find($url, $all = false) + { + if (Utils::isAdminPlugin()) { + return parent::find($url, $all); + } + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->find($url, $all); + } + + /** + * Get a collection of pages in the current context. + * + * @param string|array $params + * @param bool $pagination + * @return PageCollectionInterface|Collection + * @throws InvalidArgumentException + */ + public function collection($params = 'content', $pagination = true) + { + if (Utils::isAdminPlugin()) { + return parent::collection($params, $pagination); + } + + if (is_string($params)) { + // Look into a page header field. + $params = (array)$this->getFormValue('header.' . $params); + } elseif (!is_array($params)) { + throw new InvalidArgumentException('Argument should be either header variable name or array of parameters'); + } + + $context = [ + 'pagination' => $pagination, + 'self' => $this + ]; + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->getCollection($params, $context); + } + + /** + * @param string|array $value + * @param bool $only_published + * @return PageCollectionInterface|Collection + */ + public function evaluate($value, $only_published = true) + { + if (Utils::isAdminPlugin()) { + return parent::collection($value, $only_published); + } + + $params = [ + 'items' => $value, + 'published' => $only_published + ]; + $context = [ + 'event' => false, + 'pagination' => false, + 'url_taxonomy_filters' => false, + 'self' => $this + ]; + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->getCollection($params, $context); + } +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/Traits/PageRoutableTrait.php b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageRoutableTrait.php new file mode 100644 index 0000000..3b490a5 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageRoutableTrait.php @@ -0,0 +1,122 @@ +root()) { + return null; + } + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + $filesystem = Filesystem::getInstance(false); + + // FIXME: this does not work, needs to use $pages->get() with cached parent id! + $key = $this->getKey(); + $parent_route = $filesystem->dirname('/' . $key); + + return $parent_route !== '/' ? $pages->find($parent_route) : $pages->root(); + } + + /** + * Returns the item in the current position. + * + * @return int|null the index of the current page. + */ + public function currentPosition(): ?int + { + $path = $this->path(); + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if (null !== $path && $collection instanceof PageCollectionInterface) { + return $collection->currentPosition($path); + } + + return 1; + } + + /** + * Returns whether or not this page is the currently active page requested via the URL. + * + * @return bool True if it is active + */ + public function active(): bool + { + $grav = Grav::instance(); + $uri_path = rtrim(urldecode($grav['uri']->path()), '/') ?: '/'; + $routes = $grav['pages']->routes(); + + return isset($routes[$uri_path]) && $routes[$uri_path] === $this->path(); + } + + /** + * Returns whether or not this URI's URL contains the URL of the active page. + * Or in other words, is this page's URL in the current URL + * + * @return bool True if active child exists + */ + public function activeChild(): bool + { + $grav = Grav::instance(); + /** @var Uri $uri */ + $uri = $grav['uri']; + /** @var Pages $pages */ + $pages = $grav['pages']; + $uri_path = rtrim(urldecode($uri->path()), '/'); + $routes = $pages->routes(); + + if (isset($routes[$uri_path])) { + $page = $pages->find($uri->route()); + /** @var PageInterface|null $child_page */ + $child_page = $page ? $page->parent() : null; + while ($child_page && !$child_page->root()) { + if ($this->path() === $child_page->path()) { + return true; + } + $child_page = $child_page->parent(); + } + } + + return false; + } +} diff --git a/system/src/Grav/Common/Flex/Types/Pages/Traits/PageTranslateTrait.php b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageTranslateTrait.php new file mode 100644 index 0000000..dff42c9 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Pages/Traits/PageTranslateTrait.php @@ -0,0 +1,108 @@ +getLanguageTemplates(); + if (!$translated) { + return $translated; + } + + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $languages = $language->getLanguages(); + $languages[] = ''; + $defaultCode = $language->getDefault(); + + if (isset($translated[$defaultCode])) { + unset($translated['']); + } + + foreach ($translated as $key => &$template) { + $template .= $key !== '' ? ".{$key}.md" : '.md'; + } + unset($template); + + $translated = array_intersect_key($translated, array_flip($languages)); + + $folder = $this->getStorageFolder(); + if (!$folder) { + return []; + } + $folder = $locator->isStream($folder) ? $locator->getResource($folder) : GRAV_ROOT . "/{$folder}"; + + $list = array_fill_keys($languages, null); + foreach ($translated as $languageCode => $languageFile) { + $languageExtension = $languageCode ? ".{$languageCode}.md" : '.md'; + $path = "{$folder}/{$languageFile}"; + + // FIXME: use flex, also rawRoute() does not fully work? + $aPage = new Page(); + $aPage->init(new SplFileInfo($path), $languageExtension); + if ($onlyPublished && !$aPage->published()) { + continue; + } + + $header = $aPage->header(); + // @phpstan-ignore-next-line + $routes = $header->routes ?? []; + $route = $routes['default'] ?? $aPage->rawRoute(); + if (!$route) { + $route = $aPage->route(); + } + + $list[$languageCode ?: $defaultCode] = $route ?? ''; + } + + $list = array_filter($list, static function ($var) { + return null !== $var; + }); + + // Hack to get the same result as with old pages. + foreach ($list as &$path) { + if ($path === '') { + $path = null; + } + } + + return $list; + } +} diff --git a/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupCollection.php b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupCollection.php new file mode 100644 index 0000000..93abbf8 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupCollection.php @@ -0,0 +1,56 @@ + + */ +class UserGroupCollection extends FlexCollection +{ + /** + * @return array + */ + public static function getCachedMethods(): array + { + return [ + 'authorize' => 'session', + ] + parent::getCachedMethods(); + } + + /** + * Checks user authorization to the action. + * + * @param string $action + * @param string|null $scope + * @return bool|null + */ + public function authorize(string $action, string $scope = null): ?bool + { + $authorized = null; + /** @var UserGroupObject $object */ + foreach ($this as $object) { + $auth = $object->authorize($action, $scope); + if ($auth === true) { + $authorized = true; + } elseif ($auth === false) { + return false; + } + } + + return $authorized; + } +} diff --git a/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupIndex.php b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupIndex.php new file mode 100644 index 0000000..4bee5ac --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupIndex.php @@ -0,0 +1,24 @@ + + */ +class UserGroupIndex extends FlexIndex +{ +} diff --git a/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupObject.php b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupObject.php new file mode 100644 index 0000000..fb69eab --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/UserGroups/UserGroupObject.php @@ -0,0 +1,113 @@ + 'session', + ] + parent::getCachedMethods(); + } + + /** + * Checks user authorization to the action. + * + * @param string $action + * @param string|null $scope + * @return bool|null + */ + public function authorize(string $action, string $scope = null): ?bool + { + if ($scope === 'test') { + $scope = null; + } elseif (!$this->getProperty('enabled', true)) { + return null; + } + + $access = $this->getAccess(); + + $authorized = $access->authorize($action, $scope); + if (is_bool($authorized)) { + return $authorized; + } + + return $access->authorize('admin.super') ? true : null; + } + + /** + * @return Access + */ + protected function getAccess(): Access + { + if (null === $this->_access) { + $this->getProperty('access'); + } + + return $this->_access; + } + + /** + * @param mixed $value + * @return array + */ + protected function offsetLoad_access($value): array + { + if (!$value instanceof Access) { + $value = new Access($value); + } + + $this->_access = $value; + + return $value->jsonSerialize(); + } + + /** + * @param mixed $value + * @return array + */ + protected function offsetPrepare_access($value): array + { + return $this->offsetLoad_access($value); + } + + /** + * @param array|null $value + * @return array|null + */ + protected function offsetSerialize_access(?array $value): ?array + { + return $value; + } +} diff --git a/system/src/Grav/Common/Flex/Types/Users/Storage/UserFileStorage.php b/system/src/Grav/Common/Flex/Types/Users/Storage/UserFileStorage.php new file mode 100644 index 0000000..f565c9f --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Users/Storage/UserFileStorage.php @@ -0,0 +1,47 @@ +update($data)` instead (same but with data validation & filtering, file upload support). + */ + public function merge(array $data) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->update($data) method instead', E_USER_DEPRECATED); + + $this->setElements($this->getBlueprint()->mergeData($this->toArray(), $data)); + + return $this; + } + + /** + * Return media object for the User's avatar. + * + * @return ImageMedium|StaticImageMedium|null + * @deprecated 1.6 Use ->getAvatarImage() method instead. + */ + public function getAvatarMedia() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getAvatarImage() method instead', E_USER_DEPRECATED); + + return $this->getAvatarImage(); + } + + /** + * Return the User's avatar URL + * + * @return string + * @deprecated 1.6 Use ->getAvatarUrl() method instead. + */ + public function avatarUrl() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getAvatarUrl() method instead', E_USER_DEPRECATED); + + return $this->getAvatarUrl(); + } + + /** + * Checks user authorization to the action. + * Ensures backwards compatibility + * + * @param string $action + * @return bool + * @deprecated 1.5 Use ->authorize() method instead. + */ + public function authorise($action) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->authorize() method instead', E_USER_DEPRECATED); + + return $this->authorize($action) ?? false; + } + + /** + * Implements Countable interface. + * + * @return int + * @deprecated 1.6 Method makes no sense for user account. + */ + public function count() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + + return count($this->jsonSerialize()); + } +} diff --git a/system/src/Grav/Common/Flex/Types/Users/UserCollection.php b/system/src/Grav/Common/Flex/Types/Users/UserCollection.php new file mode 100644 index 0000000..e17525a --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Users/UserCollection.php @@ -0,0 +1,135 @@ + + */ +class UserCollection extends FlexCollection implements UserCollectionInterface +{ + /** + * @return array + */ + public static function getCachedMethods(): array + { + return [ + 'authorize' => 'session', + ] + parent::getCachedMethods(); + } + + /** + * Load user account. + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $username + * @return UserObject + */ + public function load($username): UserInterface + { + $username = (string)$username; + + if ($username !== '') { + $key = $this->filterUsername($username); + $user = $this->get($key); + if ($user) { + return $user; + } + } else { + $key = ''; + } + + $directory = $this->getFlexDirectory(); + + /** @var UserObject $object */ + $object = $directory->createObject( + [ + 'username' => $username, + 'state' => 'enabled' + ], + $key + ); + + return $object; + } + + /** + * Find a user by username, email, etc + * + * @param string $query the query to search for + * @param string|string[] $fields the fields to search + * @return UserObject + */ + public function find($query, $fields = ['username', 'email']): UserInterface + { + if (is_string($query) && $query !== '') { + foreach ((array)$fields as $field) { + if ($field === 'key') { + $user = $this->get($query); + } elseif ($field === 'storage_key') { + $user = $this->withKeyField('storage_key')->get($query); + } elseif ($field === 'flex_key') { + $user = $this->withKeyField('flex_key')->get($query); + } elseif ($field === 'username') { + $user = $this->get($this->filterUsername($query)); + } else { + $user = parent::find($query, $field); + } + if ($user) { + return $user; + } + } + } + + return $this->load(''); + } + + /** + * Delete user account. + * + * @param string $username + * @return bool True if user account was found and was deleted. + */ + public function delete($username): bool + { + $user = $this->load($username); + + $exists = $user->exists(); + if ($exists) { + $user->delete(); + } + + return $exists; + } + + /** + * @param string $key + * @return string + */ + protected function filterUsername(string $key) + { + $storage = $this->getFlexDirectory()->getStorage(); + if (method_exists($storage, 'normalizeKey')) { + return $storage->normalizeKey($key); + } + + return mb_strtolower($key); + } +} diff --git a/system/src/Grav/Common/Flex/Types/Users/UserIndex.php b/system/src/Grav/Common/Flex/Types/Users/UserIndex.php new file mode 100644 index 0000000..4a3f587 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Users/UserIndex.php @@ -0,0 +1,204 @@ + + */ +class UserIndex extends FlexIndex implements UserCollectionInterface +{ + public const VERSION = parent::VERSION . '.1'; + + /** + * @param FlexStorageInterface $storage + * @return array + */ + public static function loadEntriesFromStorage(FlexStorageInterface $storage): array + { + // Load saved index. + $index = static::loadIndex($storage); + + $version = $index['version'] ?? 0; + $force = static::VERSION !== $version; + + // TODO: Following check flex index to be out of sync after some saves, disabled until better solution is found. + //$timestamp = $index['timestamp'] ?? 0; + //if (!$force && $timestamp && $timestamp > time() - 1) { + // return $index['index']; + //} + + // Load up to date index. + $entries = parent::loadEntriesFromStorage($storage); + + return static::updateIndexFile($storage, $index['index'], $entries, ['force_update' => $force]); + } + + /** + * @param array $meta + * @param array $data + * @param FlexStorageInterface $storage + * @return void + */ + public static function updateObjectMeta(array &$meta, array $data, FlexStorageInterface $storage) + { + // Username can also be number and stored as such. + $key = (string)($data['username'] ?? $meta['key'] ?? $meta['storage_key']); + $meta['key'] = static::filterUsername($key, $storage); + $meta['email'] = isset($data['email']) ? mb_strtolower($data['email']) : null; + } + + /** + * Load user account. + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $username + * @return UserObject + */ + public function load($username): UserInterface + { + $username = (string)$username; + + if ($username !== '') { + $key = static::filterUsername($username, $this->getFlexDirectory()->getStorage()); + $user = $this->get($key); + if ($user) { + return $user; + } + } else { + $key = ''; + } + + $directory = $this->getFlexDirectory(); + + /** @var UserObject $object */ + $object = $directory->createObject( + [ + 'username' => $username, + 'state' => 'enabled' + ], + $key + ); + + return $object; + } + + /** + * Delete user account. + * + * @param string $username + * @return bool True if user account was found and was deleted. + */ + public function delete($username): bool + { + $user = $this->load($username); + + $exists = $user->exists(); + if ($exists) { + $user->delete(); + } + + return $exists; + } + + /** + * Find a user by username, email, etc + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserObject + */ + public function find($query, $fields = ['username', 'email']): UserInterface + { + if (is_string($query) && $query !== '') { + foreach ((array)$fields as $field) { + if ($field === 'key') { + $user = $this->get($query); + } elseif ($field === 'storage_key') { + $user = $this->withKeyField('storage_key')->get($query); + } elseif ($field === 'flex_key') { + $user = $this->withKeyField('flex_key')->get($query); + } elseif ($field === 'email') { + $user = $this->withKeyField('email')->get($query); + } elseif ($field === 'username') { + $user = $this->get(static::filterUsername($query, $this->getFlexDirectory()->getStorage())); + } else { + $user = $this->__call('find', [$query, $field]); + } + if ($user) { + return $user; + } + } + } + + return $this->load(''); + } + + /** + * @param string $key + * @param FlexStorageInterface $storage + * @return string + */ + protected static function filterUsername(string $key, FlexStorageInterface $storage): string + { + return $storage->normalizeKey($key); + } + + /** + * @param FlexStorageInterface $storage + * @return CompiledYamlFile|null + */ + protected static function getIndexFile(FlexStorageInterface $storage) + { + // Load saved index file. + $grav = Grav::instance(); + $locator = $grav['locator']; + $filename = $locator->findResource('user-data://flex/indexes/accounts.yaml', true, true); + + return CompiledYamlFile::instance($filename); + } + + /** + * @param array $entries + * @param array $added + * @param array $updated + * @param array $removed + */ + protected static function onChanges(array $entries, array $added, array $updated, array $removed) + { + $message = sprintf('Flex: User index updated, %d objects (%d added, %d updated, %d removed).', count($entries), count($added), count($updated), count($removed)); + + $grav = Grav::instance(); + + /** @var Logger $logger */ + $logger = $grav['log']; + $logger->addDebug($message); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage($message, 'debug'); + } +} diff --git a/system/src/Grav/Common/Flex/Types/Users/UserObject.php b/system/src/Grav/Common/Flex/Types/Users/UserObject.php new file mode 100644 index 0000000..40e6101 --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Users/UserObject.php @@ -0,0 +1,905 @@ + 'session', + 'load' => false, + 'find' => false, + 'remove' => false, + 'get' => true, + 'set' => false, + 'undef' => false, + 'def' => false, + ] + parent::getCachedMethods(); + } + + /** + * UserObject constructor. + * @param array $elements + * @param string $key + * @param FlexDirectory $directory + * @param bool $validate + */ + public function __construct(array $elements, $key, FlexDirectory $directory, bool $validate = false) + { + // User can only be authenticated via login. + unset($elements['authenticated'], $elements['authorized']); + + // Define username if it's not set. + if (!isset($elements['username'])) { + $storageKey = $elements['__META']['storage_key'] ?? null; + if (null !== $storageKey && $key === $directory->getStorage()->normalizeKey($storageKey)) { + $elements['username'] = $storageKey; + } else { + $elements['username'] = $key; + } + } + + // Define state if it isn't set. + if (!isset($elements['state'])) { + $elements['state'] = 'enabled'; + } + + parent::__construct($elements, $key, $directory, $validate); + } + + /** + * @return void + */ + public function onPrepareRegistration(): void + { + if (!$this->getProperty('access')) { + /** @var Config $config */ + $config = Grav::instance()['config']; + + $groups = $config->get('plugins.login.user_registration.groups', ''); + $access = $config->get('plugins.login.user_registration.access', ['site' => ['login' => true]]); + + $this->setProperty('groups', $groups); + $this->setProperty('access', $access); + } + } + + /** + * Helper to get content editor will fall back if not set + * + * @return string + */ + public function getContentEditor(): string + { + return $this->getProperty('content_editor', 'default'); + } + + /** + * Get value by using dot notation for nested arrays/objects. + * + * @example $value = $this->get('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string|null $separator Separator, defaults to '.' + * @return mixed Value. + */ + public function get($name, $default = null, $separator = null) + { + return $this->getNestedProperty($name, $default, $separator); + } + + /** + * Set value by using dot notation for nested arrays/objects. + * + * @example $data->set('this.is.my.nested.variable', $value); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function set($name, $value, $separator = null) + { + $this->setNestedProperty($name, $value, $separator); + + return $this; + } + + /** + * Unset value by using dot notation for nested arrays/objects. + * + * @example $data->undef('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function undef($name, $separator = null) + { + $this->unsetNestedProperty($name, $separator); + + return $this; + } + + /** + * Set default value by using dot notation for nested arrays/objects. + * + * @example $data->def('this.is.my.nested.variable', 'default'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function def($name, $default = null, $separator = null) + { + $this->defNestedProperty($name, $default, $separator); + + return $this; + } + + /** + * Checks user authorization to the action. + * + * @param string $action + * @param string|null $scope + * @return bool|null + */ + public function authorize(string $action, string $scope = null): ?bool + { + if ($scope === 'test') { + // Special scope to test user permissions. + $scope = null; + } else { + // User needs to be enabled. + if ($this->getProperty('state') !== 'enabled') { + return false; + } + + // User needs to be logged in. + if (!$this->getProperty('authenticated')) { + return false; + } + + if (strpos($action, 'login') === false && !$this->getProperty('authorized')) { + // User needs to be authorized (2FA). + return false; + } + + // Workaround bug in Login::isUserAuthorizedForPage() <= Login v3.0.4 + if ((string)(int)$action === $action) { + return false; + } + } + + // Check user access. + $access = $this->getAccess(); + $authorized = $access->authorize($action, $scope); + if (is_bool($authorized)) { + return $authorized; + } + + // If specific rule isn't hit, check if user is super user. + if ($access->authorize('admin.super') === true) { + return true; + } + + // Check group access. + return $this->getGroups()->authorize($action, $scope); + } + + /** + * @param string $property + * @param mixed $default + * @return mixed + */ + public function getProperty($property, $default = null) + { + $value = parent::getProperty($property, $default); + + if ($property === 'avatar') { + $settings = $this->getMediaFieldSettings($property); + $value = $this->parseFileProperty($value, $settings); + } + + return $value; + } + + /** + * Convert object into an array. + * + * @return array + */ + public function toArray() + { + $array = $this->jsonSerialize(); + + $settings = $this->getMediaFieldSettings('avatar'); + $array['avatar'] = $this->parseFileProperty($array['avatar'] ?? null, $settings); + + return $array; + } + + /** + * Convert object into YAML string. + * + * @param int $inline The level where you switch to inline YAML. + * @param int $indent The amount of spaces to use for indentation of nested nodes. + * @return string A YAML string representing the object. + */ + public function toYaml($inline = 5, $indent = 2) + { + $yaml = new YamlFormatter(['inline' => $inline, 'indent' => $indent]); + + return $yaml->encode($this->toArray()); + } + + /** + * Convert object into JSON string. + * + * @return string + */ + public function toJson() + { + $json = new JsonFormatter(); + + return $json->encode($this->toArray()); + } + + /** + * Join nested values together by using blueprints. + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value Value to be joined. + * @param string|null $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function join($name, $value, $separator = null) + { + $separator = $separator ?? '.'; + $old = $this->get($name, null, $separator); + if ($old !== null) { + if (!is_array($old)) { + throw new RuntimeException('Value ' . $old); + } + + if (is_object($value)) { + $value = (array) $value; + } elseif (!is_array($value)) { + throw new RuntimeException('Value ' . $value); + } + + $value = $this->getBlueprint()->mergeData($old, $value, $name, $separator); + } + + $this->set($name, $value, $separator); + + return $this; + } + + /** + * Get nested structure containing default values defined in the blueprints. + * + * Fields without default value are ignored in the list. + + * @return array + */ + public function getDefaults() + { + return $this->getBlueprint()->getDefaults(); + } + + /** + * Set default values by using blueprints. + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value Value to be joined. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function joinDefaults($name, $value, $separator = null) + { + if (is_object($value)) { + $value = (array) $value; + } + + $old = $this->get($name, null, $separator); + if ($old !== null) { + $value = $this->getBlueprint()->mergeData($value, $old, $name, $separator ?? '.'); + } + + $this->setNestedProperty($name, $value, $separator); + + return $this; + } + + /** + * Get value from the configuration and join it with given data. + * + * @param string $name Dot separated path to the requested value. + * @param array|object $value Value to be joined. + * @param string $separator Separator, defaults to '.' + * @return array + * @throws RuntimeException + */ + public function getJoined($name, $value, $separator = null) + { + if (is_object($value)) { + $value = (array) $value; + } elseif (!is_array($value)) { + throw new RuntimeException('Value ' . $value); + } + + $old = $this->get($name, null, $separator); + + if ($old === null) { + // No value set; no need to join data. + return $value; + } + + if (!is_array($old)) { + throw new RuntimeException('Value ' . $old); + } + + // Return joined data. + return $this->getBlueprint()->mergeData($old, $value, $name, $separator ?? '.'); + } + + /** + * Set default values to the configuration if variables were not set. + * + * @param array $data + * @return $this + */ + public function setDefaults(array $data) + { + $this->setElements($this->getBlueprint()->mergeData($data, $this->toArray())); + + return $this; + } + + /** + * Validate by blueprints. + * + * @return $this + * @throws \Exception + */ + public function validate() + { + $this->getBlueprint()->validate($this->toArray()); + + return $this; + } + + /** + * Filter all items by using blueprints. + * @return $this + */ + public function filter() + { + $this->setElements($this->getBlueprint()->filter($this->toArray())); + + return $this; + } + + /** + * Get extra items which haven't been defined in blueprints. + * + * @return array + */ + public function extra() + { + return $this->getBlueprint()->extra($this->toArray()); + } + + /** + * Return unmodified data as raw string. + * + * NOTE: This function only returns data which has been saved to the storage. + * + * @return string + */ + public function raw() + { + $file = $this->file(); + + return $file ? $file->raw() : ''; + } + + /** + * Set or get the data storage. + * + * @param FileInterface|null $storage Optionally enter a new storage. + * @return FileInterface|null + */ + public function file(FileInterface $storage = null) + { + if (null !== $storage) { + $this->_storage = $storage; + } + + return $this->_storage; + } + + /** + * @return bool + */ + public function isValid(): bool + { + return $this->getProperty('state') !== null; + } + + /** + * Save user + * + * @return static + */ + public function save() + { + // TODO: We may want to handle this in the storage layer in the future. + $key = $this->getStorageKey(); + if (!$key || strpos($key, '@@')) { + $storage = $this->getFlexDirectory()->getStorage(); + if ($storage instanceof FileStorage) { + $this->setStorageKey($this->getKey()); + } + } + + $password = $this->getProperty('password') ?? $this->getProperty('password1'); + if (null !== $password && '' !== $password) { + $password2 = $this->getProperty('password2'); + if (!\is_string($password) || ($password2 && $password !== $password2)) { + throw new \RuntimeException('Passwords did not match.'); + } + + $this->setProperty('hashed_password', Authentication::create($password)); + } + $this->unsetProperty('password'); + $this->unsetProperty('password1'); + $this->unsetProperty('password2'); + + // Backwards compatibility with older plugins. + $fireEvents = $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true); + $grav = $this->getContainer(); + if ($fireEvents) { + $self = $this; + $grav->fireEvent('onAdminSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => &$self])); + if ($self !== $this) { + throw new RuntimeException('Switching Flex User object during onAdminSave event is not supported! Please update plugin.'); + } + } + + $instance = parent::save(); + + // Backwards compatibility with older plugins. + if ($fireEvents) { + $grav->fireEvent('onAdminAfterSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => $this])); + } + + return $instance; + } + + /** + * @return array + */ + public function prepareStorage(): array + { + $elements = parent::prepareStorage(); + + // Do not save authorization information. + unset($elements['authenticated'], $elements['authorized']); + + return $elements; + } + + /** + * @return MediaCollectionInterface + */ + public function getMedia() + { + /** @var Media $media */ + $media = $this->getFlexMedia(); + + // Deal with shared avatar folder. + $path = $this->getAvatarFile(); + if ($path && !$media[$path] && is_file($path)) { + $medium = MediumFactory::fromFile($path); + if ($medium) { + $media->add($path, $medium); + $name = basename($path); + if ($name !== $path) { + $media->add($name, $medium); + } + } + } + + return $media; + } + + /** + * @return string|null + */ + public function getMediaFolder(): ?string + { + $folder = $this->getFlexMediaFolder(); + + // Check for shared media + if (!$folder && !$this->getFlexDirectory()->getMediaFolder()) { + $this->_loadMedia = false; + $folder = $this->getBlueprint()->fields()['avatar']['destination'] ?? 'user://accounts/avatars'; + } + + return $folder; + } + + /** + * @param string $name + * @return Blueprint + */ + protected function doGetBlueprint(string $name = ''): Blueprint + { + $blueprint = $this->getFlexDirectory()->getBlueprint($name ? '.' . $name : $name); + + // HACK: With folder storage we need to ignore the avatar destination. + if ($this->getFlexDirectory()->getMediaFolder()) { + $field = $blueprint->get('form/fields/avatar'); + if ($field) { + unset($field['destination']); + $blueprint->set('form/fields/avatar', $field); + } + } + + return $blueprint; + } + + /** + * @param UserInterface $user + * @param string $action + * @param string $scope + * @param bool $isMe + * @return bool|null + */ + protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe = false): ?bool + { + if ($user instanceof self && $user->getStorageKey() === $this->getStorageKey()) { + // User cannot delete his own account, otherwise he has full access. + return $action !== 'delete'; + } + + return parent::isAuthorizedOverride($user, $action, $scope, $isMe); + } + + /** + * @return string|null + */ + protected function getAvatarFile(): ?string + { + $avatars = $this->getElement('avatar'); + if (is_array($avatars) && $avatars) { + $avatar = array_shift($avatars); + + return $avatar['path'] ?? null; + } + + return null; + } + + /** + * Gets the associated media collection (original images). + * + * @return MediaCollectionInterface Representation of associated media. + */ + protected function getOriginalMedia() + { + $folder = $this->getMediaFolder(); + if ($folder) { + $folder .= '/original'; + } + + return (new Media($folder ?? '', $this->getMediaOrder()))->setTimestamps(); + } + + /** + * @param array $files + */ + protected function setUpdatedMedia(array $files): void + { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + $media = $this->getMedia(); + + $list = []; + $list_original = []; + foreach ($files as $field => $group) { + if ($field === '') { + continue; + } + $field = (string)$field; + + // Load settings for the field. + $settings = $this->getMediaFieldSettings($field); + + foreach ($group as $filename => $file) { + if ($file) { + // File upload. + $filename = $file->getClientFilename(); + + /** @var FormFlashFile $file */ + $data = $file->jsonSerialize(); + unset($data['tmp_name'], $data['path']); + } else { + // File delete. + $data = null; + } + + if ($file) { + // Check file upload against media limits. + $filename = $media->checkUploadedFile($file, $filename, $settings); + } + + $self = $settings['self']; + if ($this->_loadMedia && $self) { + $filepath = $filename; + } else { + $filepath = "{$settings['destination']}/{$filename}"; + + // For backwards compatibility we are always using relative path from the installation root. + if ($locator->isStream($filepath)) { + $filepath = $locator->findResource($filepath, false, true); + } + } + + // Special handling for original images. + if (strpos($field, '/original')) { + if ($this->_loadMedia && $self) { + $list_original[$filename] = [$file, $settings]; + } + continue; + } + + $list[$filename] = [$file, $settings]; + + if (null !== $data) { + $data['name'] = $filename; + $data['path'] = $filepath; + + $this->setNestedProperty("{$field}\n{$filepath}", $data, "\n"); + } else { + $this->unsetNestedProperty("{$field}\n{$filepath}", "\n"); + } + } + } + + $this->_uploads = $list; + $this->_uploads_original = $list_original; + } + + protected function saveUpdatedMedia(): void + { + $media = $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + throw new RuntimeException('Internal error UO101'); + } + + // Upload/delete original sized images. + /** + * @var string $filename + * @var UploadedFileInterface|array|null $file + */ + foreach ($this->_uploads_original ?? [] as $filename => $file) { + $filename = 'original/' . $filename; + if (is_array($file)) { + [$file, $settings] = $file; + } else { + $settings = null; + } + if ($file instanceof UploadedFileInterface) { + $media->copyUploadedFile($file, $filename, $settings); + } else { + $media->deleteFile($filename, $settings); + } + } + + // Upload/delete altered files. + /** + * @var string $filename + * @var UploadedFileInterface|array|null $file + */ + foreach ($this->getUpdatedMedia() as $filename => $file) { + if (is_array($file)) { + [$file, $settings] = $file; + } else { + $settings = null; + } + if ($file instanceof UploadedFileInterface) { + $media->copyUploadedFile($file, $filename, $settings); + } else { + $media->deleteFile($filename, $settings); + } + } + + $this->setUpdatedMedia([]); + $this->clearMediaCache(); + } + + /** + * @return array + */ + protected function doSerialize(): array + { + return [ + 'type' => $this->getFlexType(), + 'key' => $this->getKey(), + 'elements' => $this->jsonSerialize(), + 'storage' => $this->getMetaData() + ]; + } + + /** + * @return UserGroupIndex + */ + protected function getUserGroups() + { + $grav = Grav::instance(); + + /** @var Flex $flex */ + $flex = $grav['flex']; + + /** @var UserGroupCollection|null $groups */ + $groups = $flex->getDirectory('user-groups'); + if ($groups) { + /** @var UserGroupIndex $index */ + $index = $groups->getIndex(); + + return $index; + } + + return $grav['user_groups']; + } + + /** + * @return UserGroupIndex + */ + protected function getGroups() + { + if (null === $this->_groups) { + $this->_groups = $this->getUserGroups()->select((array)$this->getProperty('groups')); + } + + return $this->_groups; + } + + /** + * @return Access + */ + protected function getAccess(): Access + { + if (null === $this->_access) { + $this->getProperty('access'); + } + + return $this->_access; + } + + /** + * @param mixed $value + * @return array + */ + protected function offsetLoad_access($value): array + { + if (!$value instanceof Access) { + $value = new Access($value); + } + + $this->_access = $value; + + return $value->jsonSerialize(); + } + + /** + * @param mixed $value + * @return array + */ + protected function offsetPrepare_access($value): array + { + return $this->offsetLoad_access($value); + } + + /** + * @param array|null $value + * @return array|null + */ + protected function offsetSerialize_access(?array $value): ?array + { + return $value; + } +} diff --git a/system/src/Grav/Common/Form/FormFlash.php b/system/src/Grav/Common/Form/FormFlash.php index b67313b..430f15e 100644 --- a/system/src/Grav/Common/Form/FormFlash.php +++ b/system/src/Grav/Common/Form/FormFlash.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Form * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,7 +11,12 @@ namespace Grav\Common\Form; use Grav\Common\Filesystem\Folder; use Grav\Framework\Form\FormFlash as FrameworkFormFlash; +use function is_array; +/** + * Class FormFlash + * @package Grav\Common\Form + */ class FormFlash extends FrameworkFormFlash { /** @@ -26,7 +31,7 @@ class FormFlash extends FrameworkFormFlash continue; } foreach ($files as $file) { - if (\is_array($file)) { + if (is_array($file)) { $file['tmp_name'] = $this->getTmpDir() . '/' . $file['tmp_name']; $fields[$field][$file['path'] ?? $file['name']] = $file; } diff --git a/system/src/Grav/Common/GPM/AbstractCollection.php b/system/src/Grav/Common/GPM/AbstractCollection.php index a9e1ac9..b5b867e 100644 --- a/system/src/Grav/Common/GPM/AbstractCollection.php +++ b/system/src/Grav/Common/GPM/AbstractCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,13 +11,23 @@ namespace Grav\Common\GPM; use Grav\Common\Iterator; +/** + * Class AbstractCollection + * @package Grav\Common\GPM + */ abstract class AbstractCollection extends Iterator { + /** + * @return string + */ public function toJson() { - return json_encode($this->toArray()); + return json_encode($this->toArray(), JSON_THROW_ON_ERROR); } + /** + * @return array + */ public function toArray() { $items = []; diff --git a/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php index bc4ecab..5d6ad9f 100644 --- a/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php +++ b/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,10 +11,18 @@ namespace Grav\Common\GPM\Common; use Grav\Common\Iterator; +/** + * Class AbstractPackageCollection + * @package Grav\Common\GPM\Common + */ abstract class AbstractPackageCollection extends Iterator { + /** @var string */ protected $type; + /** + * @return string + */ public function toJson() { $items = []; @@ -23,9 +31,12 @@ abstract class AbstractPackageCollection extends Iterator $items[$name] = $package->toArray(); } - return json_encode($items); + return json_encode($items, JSON_THROW_ON_ERROR); } + /** + * @return array + */ public function toArray() { $items = []; diff --git a/system/src/Grav/Common/GPM/Common/CachedCollection.php b/system/src/Grav/Common/GPM/Common/CachedCollection.php index a6fca4d..172ba93 100644 --- a/system/src/Grav/Common/GPM/Common/CachedCollection.php +++ b/system/src/Grav/Common/GPM/Common/CachedCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,10 +11,20 @@ namespace Grav\Common\GPM\Common; use Grav\Common\Iterator; +/** + * Class CachedCollection + * @package Grav\Common\GPM\Common + */ class CachedCollection extends Iterator { - protected static $cache; + /** @var array */ + protected static $cache = []; + /** + * CachedCollection constructor. + * + * @param array $items + */ public function __construct($items) { parent::__construct(); diff --git a/system/src/Grav/Common/GPM/Common/Package.php b/system/src/Grav/Common/GPM/Common/Package.php index 40d3759..e06ebb6 100644 --- a/system/src/Grav/Common/GPM/Common/Package.php +++ b/system/src/Grav/Common/GPM/Common/Package.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,13 +11,19 @@ namespace Grav\Common\GPM\Common; use Grav\Common\Data\Data; +/** + * @property string $name + */ class Package { - /** - * @var Data - */ + /** @var Data */ protected $data; + /** + * Package constructor. + * @param Data $package + * @param string|null $type + */ public function __construct(Data $package, $type = null) { $this->data = $package; @@ -35,26 +41,45 @@ class Package return $this->data; } + /** + * @param string $key + * @return mixed + */ public function __get($key) { return $this->data->get($key); } + /** + * @param string $key + * @param mixed $value + * @return void + */ public function __set($key, $value) { - return $this->data->set($key, $value); + $this->data->set($key, $value); } + /** + * @param string $key + * @return bool + */ public function __isset($key) { return isset($this->data->{$key}); } + /** + * @return string + */ public function __toString() { return $this->toJson(); } + /** + * @return string + */ public function toJson() { return $this->data->toJson(); diff --git a/system/src/Grav/Common/GPM/GPM.php b/system/src/Grav/Common/GPM/GPM.php index 6c9e7b0..222aa0a 100644 --- a/system/src/Grav/Common/GPM/GPM.php +++ b/system/src/Grav/Common/GPM/GPM.php @@ -3,44 +3,43 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\GPM; +use Exception; use Grav\Common\Grav; use Grav\Common\Filesystem\Folder; use Grav\Common\Inflector; use Grav\Common\Iterator; use Grav\Common\Utils; use RocketTheme\Toolbox\File\YamlFile; +use RuntimeException; +use stdClass; +use function array_key_exists; +use function count; +use function in_array; +use function is_array; +use function is_object; +/** + * Class GPM + * @package Grav\Common\GPM + */ class GPM extends Iterator { - /** - * Local installed Packages - * @var Local\Packages - */ + /** @var Local\Packages Local installed Packages */ private $installed; - - /** - * Remote available Packages - * @var Remote\Packages - */ + /** @var Remote\Packages|null Remote available Packages */ private $repository; - - /** - * @var Remote\GravCore - */ + /** @var Remote\GravCore|null Remove Grav Packages */ public $grav; - /** - * Internal cache - * @var array - */ + /** @var array Internal cache */ protected $cache; - + /** @var array */ protected $install_paths = [ 'plugins' => 'user/plugins/%name%', 'themes' => 'user/themes/%name%', @@ -49,8 +48,9 @@ class GPM extends Iterator /** * Creates a new GPM instance with Local and Remote packages available + * * @param bool $refresh Applies to Remote Packages only and forces a refetch of data - * @param callable $callback Either a function or callback in array notation + * @param callable|null $callback Either a function or callback in array notation */ public function __construct($refresh = false, $callback = null) { @@ -60,7 +60,7 @@ class GPM extends Iterator try { $this->repository = new Remote\Packages($refresh, $callback); $this->grav = new Remote\GravCore($refresh, $callback); - } catch (\Exception $e) { + } catch (Exception $e) { } } @@ -92,11 +92,13 @@ class GPM extends Iterator $items[$type] = $to_install; $items['total'] += count($to_install); } + return $items; } /** * Returns the amount of locally installed packages + * * @return int Amount of installed packages */ public function countInstalled() @@ -110,29 +112,22 @@ class GPM extends Iterator * Return the instance of a specific Package * * @param string $slug The slug of the Package - * @return Local\Package The instance of the Package + * @return Local\Package|null The instance of the Package */ public function getInstalledPackage($slug) { - if (isset($this->installed['plugins'][$slug])) { - return $this->installed['plugins'][$slug]; - } - - if (isset($this->installed['themes'][$slug])) { - return $this->installed['themes'][$slug]; - } - - return null; + return $this->getInstalledPlugin($slug) ?? $this->getInstalledTheme($slug); } /** * Return the instance of a specific Plugin + * * @param string $slug The slug of the Plugin - * @return Local\Package The instance of the Plugin + * @return Local\Package|null The instance of the Plugin */ public function getInstalledPlugin($slug) { - return $this->installed['plugins'][$slug]; + return $this->installed['plugins'][$slug] ?? null; } /** @@ -144,33 +139,56 @@ class GPM extends Iterator return $this->installed['plugins']; } + + /** + * Returns the plugin's enabled state + * + * @param string $slug + * @return bool True if the Plugin is Enabled. False if manually set to enable:false. Null otherwise. + */ + public function isPluginEnabled($slug): bool + { + $grav = Grav::instance(); + + return ($grav['config']['plugins'][$slug]['enabled'] ?? false) === true; + } + /** * Checks if a Plugin is installed + * * @param string $slug The slug of the Plugin * @return bool True if the Plugin has been installed. False otherwise */ - public function isPluginInstalled($slug) + public function isPluginInstalled($slug): bool { return isset($this->installed['plugins'][$slug]); } + /** + * @param string $slug + * @return bool + */ public function isPluginInstalledAsSymlink($slug) { - return $this->installed['plugins'][$slug]->symlink; + $plugin = $this->getInstalledPlugin($slug); + + return (bool)($plugin->symlink ?? false); } /** * Return the instance of a specific Theme + * * @param string $slug The slug of the Theme - * @return Local\Package The instance of the Theme + * @return Local\Package|null The instance of the Theme */ public function getInstalledTheme($slug) { - return $this->installed['themes'][$slug]; + return $this->installed['themes'][$slug] ?? null; } /** * Returns the Locally installed themes + * * @return Iterator The installed themes */ public function getInstalledThemes() @@ -178,40 +196,52 @@ class GPM extends Iterator return $this->installed['themes']; } + /** + * Checks if a Theme is enabled + * + * @param string $slug The slug of the Theme + * @return bool True if the Theme has been set to the default theme. False if installed, but not enabled. Null otherwise. + */ + public function isThemeEnabled($slug): bool + { + $grav = Grav::instance(); + + $current_theme = $grav['config']['system']['pages']['theme'] ?? null; + + return $current_theme === $slug; + } + /** * Checks if a Theme is installed + * * @param string $slug The slug of the Theme * @return bool True if the Theme has been installed. False otherwise */ - public function isThemeInstalled($slug) + public function isThemeInstalled($slug): bool { return isset($this->installed['themes'][$slug]); } /** * Returns the amount of updates available + * * @return int Amount of available updates */ public function countUpdates() { - $count = 0; - - $count += count($this->getUpdatablePlugins()); - $count += count($this->getUpdatableThemes()); - - return $count; + return count($this->getUpdatablePlugins()) + count($this->getUpdatableThemes()); } /** * Returns an array of Plugins and Themes that can be updated. * Plugins and Themes are extended with the `available` property that relies to the remote version + * * @param array $list_type_update specifies what type of package to update * @return array Array of updatable Plugins and Themes. * Format: ['total' => int, 'plugins' => array, 'themes' => array] */ public function getUpdatable($list_type_update = ['plugins' => true, 'themes' => true]) { - $items = ['total' => 0]; foreach ($list_type_update as $type => $type_updatable) { if ($type_updatable === false) { @@ -222,17 +252,24 @@ class GPM extends Iterator $items[$type] = $to_update; $items['total'] += count($to_update); } + return $items; } /** * Returns an array of Plugins that can be updated. * The Plugins are extended with the `available` property that relies to the remote version + * * @return array Array of updatable Plugins */ public function getUpdatablePlugins() { $items = []; + + if (null === $this->repository) { + return $items; + } + $repository = $this->repository['plugins']; // local cache to speed things up @@ -245,7 +282,7 @@ class GPM extends Iterator continue; } - $local_version = $plugin->version ?: 'Unknown'; + $local_version = $plugin->version ?? 'Unknown'; $remote_version = $repository[$slug]->version; if (version_compare($local_version, $remote_version) < 0) { @@ -265,11 +302,14 @@ class GPM extends Iterator * Get the latest release of a package from the GPM * * @param string $package_name - * * @return string|null */ public function getLatestVersionOfPackage($package_name) { + if (null === $this->repository) { + return null; + } + $repository = $this->repository['plugins']; if (isset($repository[$package_name])) { return $repository[$package_name]->available ?: $repository[$package_name]->version; @@ -286,6 +326,7 @@ class GPM extends Iterator /** * Check if a Plugin or Theme is updatable + * * @param string $slug The slug of the package * @return bool True if updatable. False otherwise or if not found */ @@ -296,6 +337,7 @@ class GPM extends Iterator /** * Checks if a Plugin is updatable + * * @param string $plugin The slug of the Plugin * @return bool True if the Plugin is updatable. False otherwise */ @@ -307,11 +349,17 @@ class GPM extends Iterator /** * Returns an array of Themes that can be updated. * The Themes are extended with the `available` property that relies to the remote version + * * @return array Array of updatable Themes */ public function getUpdatableThemes() { $items = []; + + if (null === $this->repository) { + return $items; + } + $repository = $this->repository['themes']; // local cache to speed things up @@ -324,7 +372,7 @@ class GPM extends Iterator continue; } - $local_version = $plugin->version ?: 'Unknown'; + $local_version = $plugin->version ?? 'Unknown'; $remote_version = $repository[$slug]->version; if (version_compare($local_version, $remote_version) < 0) { @@ -342,6 +390,7 @@ class GPM extends Iterator /** * Checks if a Theme is Updatable + * * @param string $theme The slug of the Theme * @return bool True if the Theme is updatable. False otherwise */ @@ -354,11 +403,14 @@ class GPM extends Iterator * Get the release type of a package (stable / testing) * * @param string $package_name - * * @return string|null */ public function getReleaseType($package_name) { + if (null === $this->repository) { + return null; + } + $repository = $this->repository['plugins']; if (isset($repository[$package_name])) { return $repository[$package_name]->release_type; @@ -377,7 +429,6 @@ class GPM extends Iterator * Returns true if the package latest release is stable * * @param string $package_name - * * @return bool */ public function isStableRelease($package_name) @@ -389,58 +440,66 @@ class GPM extends Iterator * Returns true if the package latest release is testing * * @param string $package_name - * * @return bool */ public function isTestingRelease($package_name) { - $hasTesting = isset($this->getInstalledPackage($package_name)->testing); - $testing = $hasTesting ? $this->getInstalledPackage($package_name)->testing : false; + $package = $this->getInstalledPackage($package_name); + $testing = $package->testing ?? false; return $this->getReleaseType($package_name) === 'testing' || $testing; } /** * Returns a Plugin from the repository + * * @param string $slug The slug of the Plugin - * @return mixed Package if found, NULL if not + * @return Remote\Package|null Package if found, NULL if not */ public function getRepositoryPlugin($slug) { - return @$this->repository['plugins'][$slug]; + $packages = $this->getRepositoryPlugins(); + + return $packages ? ($packages[$slug] ?? null) : null; } /** * Returns the list of Plugins available in the repository - * @return Iterator The Plugins remotely available + * + * @return Iterator|null The Plugins remotely available */ public function getRepositoryPlugins() { - return $this->repository['plugins']; + return $this->repository['plugins'] ?? null; } /** * Returns a Theme from the repository + * * @param string $slug The slug of the Theme - * @return mixed Package if found, NULL if not + * @return Remote\Package|null Package if found, NULL if not */ public function getRepositoryTheme($slug) { - return @$this->repository['themes'][$slug]; + $packages = $this->getRepositoryThemes(); + + return $packages ? ($packages[$slug] ?? null) : null; } /** * Returns the list of Themes available in the repository - * @return Iterator The Themes remotely available + * + * @return Iterator|null The Themes remotely available */ public function getRepositoryThemes() { - return $this->repository['themes']; + return $this->repository['themes'] ?? null; } /** * Returns the list of Plugins and Themes available in the repository - * @return Remote\Packages Available Plugins and Themes + * + * @return Remote\Packages|null Available Plugins and Themes * Format: ['plugins' => array, 'themes' => array] */ public function getRepository() @@ -450,20 +509,16 @@ class GPM extends Iterator /** * Searches for a Package in the repository + * * @param string $search Can be either the slug or the name * @param bool $ignore_exception True if should not fire an exception (for use in Twig) - * @return Remote\Package|bool Package if found, FALSE if not + * @return Remote\Package|false Package if found, FALSE if not */ public function findPackage($search, $ignore_exception = false) { $search = strtolower($search); - $found = $this->getRepositoryTheme($search); - if ($found) { - return $found; - } - - $found = $this->getRepositoryPlugin($search); + $found = $this->getRepositoryPlugin($search) ?? $this->getRepositoryTheme($search); if ($found) { return $found; } @@ -471,31 +526,27 @@ class GPM extends Iterator $themes = $this->getRepositoryThemes(); $plugins = $this->getRepositoryPlugins(); - if (!$themes && !$plugins) { - if (!is_writable(ROOT_DIR . '/cache/gpm')) { - throw new \RuntimeException("The cache/gpm folder is not writable. Please check the folder permissions."); + if (null === $themes || null === $plugins) { + if (!is_writable(GRAV_ROOT . '/cache/gpm')) { + throw new RuntimeException('The cache/gpm folder is not writable. Please check the folder permissions.'); } if ($ignore_exception) { return false; } - throw new \RuntimeException("GPM not reachable. Please check your internet connection or check the Grav site is reachable"); + throw new RuntimeException('GPM not reachable. Please check your internet connection or check the Grav site is reachable'); } - if ($themes) { - foreach ($themes as $slug => $theme) { - if ($search == $slug || $search == $theme->name) { - return $theme; - } + foreach ($themes as $slug => $theme) { + if ($search === $slug || $search === $theme->name) { + return $theme; } } - if ($plugins) { - foreach ($plugins as $slug => $plugin) { - if ($search == $slug || $search == $plugin->name) { - return $plugin; - } + foreach ($plugins as $slug => $plugin) { + if ($search === $slug || $search === $plugin->name) { + return $plugin; } } @@ -507,15 +558,19 @@ class GPM extends Iterator * * @param string $package_file * @param string $tmp - * @return null|string + * @return string|null */ public static function downloadPackage($package_file, $tmp) { $package = parse_url($package_file); - $filename = basename($package['path']); + if (!is_array($package)) { + throw new \RuntimeException("Malformed GPM URL: {$package_file}"); + } - if (Grav::instance()['config']->get('system.gpm.official_gpm_only') && $package['host'] !== 'getgrav.org') { - throw new \RuntimeException("Only official GPM URLs are allowed. You can modify this behavior in the System configuration."); + $filename = basename($package['path'] ?? ''); + + if (Grav::instance()['config']->get('system.gpm.official_gpm_only') && ($package['host'] ?? null) !== 'getgrav.org') { + throw new RuntimeException('Only official GPM URLs are allowed. You can modify this behavior in the System configuration.'); } $output = Response::get($package_file, []); @@ -534,16 +589,16 @@ class GPM extends Iterator * * @param string $package_file * @param string $tmp - * @return null|string + * @return string|null */ public static function copyPackage($package_file, $tmp) { $package_file = realpath($package_file); - if (file_exists($package_file)) { + if ($package_file && file_exists($package_file)) { $filename = basename($package_file); Folder::create($tmp); - copy(realpath($package_file), $tmp . DS . $filename); + copy($package_file, $tmp . DS . $filename); return $tmp . DS . $filename; } @@ -554,15 +609,14 @@ class GPM extends Iterator * Try to guess the package type from the source files * * @param string $source - * @return bool|string + * @return string|false */ public static function getPackageType($source) { $plugin_regex = '/^class\\s{1,}[a-zA-Z0-9]{1,}\\s{1,}extends.+Plugin/m'; $theme_regex = '/^class\\s{1,}[a-zA-Z0-9]{1,}\\s{1,}extends.+Theme/m'; - if ( - file_exists($source . 'system/defines.php') && + if (file_exists($source . 'system/defines.php') && file_exists($source . 'system/config/system.yaml') ) { return 'grav'; @@ -581,8 +635,13 @@ class GPM extends Iterator if (Utils::contains($name, 'plugin')) { return 'plugin'; } - foreach (glob($source . '*.php') as $filename) { + + $glob = glob($source . '*.php') ?: []; + foreach ($glob as $filename) { $contents = file_get_contents($filename); + if (!$contents) { + continue; + } if (preg_match($theme_regex, $contents)) { return 'theme'; } @@ -599,19 +658,22 @@ class GPM extends Iterator * Try to guess the package name from the source files * * @param string $source - * @return bool|string + * @return string|false */ public static function getPackageName($source) { $ignore_yaml_files = ['blueprints', 'languages']; - foreach (glob($source . '*.yaml') as $filename) { + $glob = glob($source . '*.yaml') ?: []; + foreach ($glob as $filename) { $name = strtolower(basename($filename, '.yaml')); if (in_array($name, $ignore_yaml_files)) { continue; } + return $name; } + return false; } @@ -619,7 +681,7 @@ class GPM extends Iterator * Find/Parse the blueprint file * * @param string $source - * @return array|bool + * @return array|false */ public static function getBlueprints($source) { @@ -651,11 +713,13 @@ class GPM extends Iterator } else { $install_path = $locator->findResource('plugins://', false) . DS . $name; } + return $install_path; } /** * Searches for a list of Packages in the repository + * * @param array $searches An array of either slugs or names * @return array Array of found Packages * Format: ['total' => int, 'not_found' => array, ] @@ -695,7 +759,7 @@ class GPM extends Iterator $type = 'plugins'; } - $not_found = new \stdClass(); + $not_found = new stdClass(); $not_found->name = $inflector::camelize($search); $not_found->slug = $search; $not_found->package_type = $type; @@ -712,7 +776,6 @@ class GPM extends Iterator * Return the list of packages that have the passed one as dependency * * @param string $slug The slug name of the package - * * @return array */ public function getPackagesThatDependOnPackage($slug) @@ -721,23 +784,21 @@ class GPM extends Iterator $themes = $this->getInstalledThemes(); $packages = array_merge($plugins->toArray(), $themes->toArray()); - $dependent_packages = []; - + $list = []; foreach ($packages as $package_name => $package) { - if (isset($package['dependencies'])) { - foreach ($package['dependencies'] as $dependency) { - if (is_array($dependency) && isset($dependency['name'])) { - $dependency = $dependency['name']; - } + $dependencies = $package['dependencies'] ?? []; + foreach ($dependencies as $dependency) { + if (is_array($dependency) && isset($dependency['name'])) { + $dependency = $dependency['name']; + } - if ($dependency === $slug) { - $dependent_packages[] = $package_name; - } + if ($dependency === $slug) { + $list[] = $package_name; } } } - return $dependent_packages; + return $list; } @@ -746,12 +807,11 @@ class GPM extends Iterator * * @param string $package_slug * @param string $dependency_slug - * - * @return mixed + * @return mixed|null */ public function getVersionOfDependencyRequiredByPackage($package_slug, $dependency_slug) { - $dependencies = $this->getInstalledPackage($package_slug)->dependencies; + $dependencies = $this->getInstalledPackage($package_slug)->dependencies ?? []; foreach ($dependencies as $dependency) { if (isset($dependency[$dependency_slug])) { return $dependency[$dependency_slug]; @@ -768,35 +828,28 @@ class GPM extends Iterator * @param string $slug * @param string $version_with_operator * @param array $ignore_packages_list - * * @return bool - * @throws \RuntimeException + * @throws RuntimeException */ - public function checkNoOtherPackageNeedsThisDependencyInALowerVersion( - $slug, - $version_with_operator, - $ignore_packages_list - ) { - + public function checkNoOtherPackageNeedsThisDependencyInALowerVersion($slug, $version_with_operator, $ignore_packages_list) + { // check if any of the currently installed package need this in a lower version than the one we need. In case, abort and tell which package $dependent_packages = $this->getPackagesThatDependOnPackage($slug); $version = $this->calculateVersionNumberFromDependencyVersion($version_with_operator); if (count($dependent_packages)) { foreach ($dependent_packages as $dependent_package) { - $other_dependency_version_with_operator = $this->getVersionOfDependencyRequiredByPackage($dependent_package, - $slug); + $other_dependency_version_with_operator = $this->getVersionOfDependencyRequiredByPackage($dependent_package, $slug); $other_dependency_version = $this->calculateVersionNumberFromDependencyVersion($other_dependency_version_with_operator); // check version is compatible with the one needed by the current package if ($this->versionFormatIsNextSignificantRelease($other_dependency_version_with_operator)) { - $compatible = $this->checkNextSignificantReleasesAreCompatible($version, - $other_dependency_version); - if (!$compatible) { - if (!in_array($dependent_package, $ignore_packages_list, true)) { - throw new \RuntimeException("Package $slug is required in an older version by package $dependent_package. This package needs a newer version, and because of this it cannot be installed. The $dependent_package package must be updated to use a newer release of $slug.", - 2); - } + $compatible = $this->checkNextSignificantReleasesAreCompatible($version, $other_dependency_version); + if (!$compatible && !in_array($dependent_package, $ignore_packages_list, true)) { + throw new RuntimeException( + "Package $slug is required in an older version by package $dependent_package. This package needs a newer version, and because of this it cannot be installed. The $dependent_package package must be updated to use a newer release of $slug.", + 2 + ); } } } @@ -809,14 +862,14 @@ class GPM extends Iterator * Check the passed packages list can be updated * * @param array $packages_names_list - * - * @throws \Exception + * @return void + * @throws Exception */ public function checkPackagesCanBeInstalled($packages_names_list) { foreach ($packages_names_list as $package_name) { - $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package_name, - $this->getLatestVersionOfPackage($package_name), $packages_names_list); + $latest = $this->getLatestVersionOfPackage($package_name); + $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package_name, $latest, $packages_names_list); } } @@ -829,27 +882,25 @@ class GPM extends Iterator * `update` means the package is already installed and must be updated as a dependency needs a higher version. * * @param array $packages - * - * @return mixed - * @throws \Exception + * @return array + * @throws RuntimeException */ public function getDependencies($packages) { $dependencies = $this->calculateMergedDependenciesOfPackages($packages); foreach ($dependencies as $dependency_slug => $dependencyVersionWithOperator) { - if (\in_array($dependency_slug, $packages, true)) { + $dependency_slug = (string)$dependency_slug; + if (in_array($dependency_slug, $packages, true)) { unset($dependencies[$dependency_slug]); continue; } // Check PHP version if ($dependency_slug === 'php') { - $current_php_version = phpversion(); - if (version_compare($this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator), - $current_php_version) === 1 - ) { + $testVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator); + if (version_compare($testVersion, PHP_VERSION) === 1) { //Needs a Grav update first - throw new \RuntimeException("One of the packages require PHP {$dependencies['php']}. Please update PHP to resolve this"); + throw new RuntimeException("One of the packages require PHP {$dependencies['php']}. Please update PHP to resolve this"); } unset($dependencies[$dependency_slug]); @@ -858,11 +909,10 @@ class GPM extends Iterator //First, check for Grav dependency. If a dependency requires Grav > the current version, abort and tell. if ($dependency_slug === 'grav') { - if (version_compare($this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator), - GRAV_VERSION) === 1 - ) { + $testVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator); + if (version_compare($testVersion, GRAV_VERSION) === 1) { //Needs a Grav update first - throw new \RuntimeException("One of the packages require Grav {$dependencies['grav']}. Please update Grav to the latest release."); + throw new RuntimeException("One of the packages require Grav {$dependencies['grav']}. Please update Grav to the latest release."); } unset($dependencies[$dependency_slug]); @@ -886,15 +936,15 @@ class GPM extends Iterator $currentlyInstalledVersion = $package_yaml['version']; // if requirement is next significant release, check is compatible with currently installed version, might not be - if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)) { - if ($this->firstVersionIsLower($dependencyVersion, $currentlyInstalledVersion)) { - $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion, - $currentlyInstalledVersion); + if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator) + && $this->firstVersionIsLower($dependencyVersion, $currentlyInstalledVersion)) { + $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion, $currentlyInstalledVersion); - if (!$compatible) { - throw new \RuntimeException('Dependency ' . $dependency_slug . ' is required in an older version than the one installed. This package must be updated. Please get in touch with its developer.', - 2); - } + if (!$compatible) { + throw new RuntimeException( + 'Dependency ' . $dependency_slug . ' is required in an older version than the one installed. This package must be updated. Please get in touch with its developer.', + 2 + ); } } @@ -903,19 +953,19 @@ class GPM extends Iterator if ($this->firstVersionIsLower($latestRelease, $dependencyVersion)) { //throw an exception if a required version cannot be found in the GPM yet - throw new \RuntimeException('Dependency ' . $package_yaml['name'] . ' is required in version ' . $dependencyVersion . ' which is higher than the latest release, ' . $latestRelease . '. Try running `bin/gpm -f index` to force a refresh of the GPM cache', - 1); + throw new RuntimeException( + 'Dependency ' . $package_yaml['name'] . ' is required in version ' . $dependencyVersion . ' which is higher than the latest release, ' . $latestRelease . '. Try running `bin/gpm -f index` to force a refresh of the GPM cache', + 1 + ); } if ($this->firstVersionIsLower($currentlyInstalledVersion, $dependencyVersion)) { $dependencies[$dependency_slug] = 'update'; + } elseif ($currentlyInstalledVersion === $latestRelease) { + unset($dependencies[$dependency_slug]); } else { - if ($currentlyInstalledVersion == $latestRelease) { - unset($dependencies[$dependency_slug]); - } else { - // an update is not strictly required mark as 'ignore' - $dependencies[$dependency_slug] = 'ignore'; - } + // an update is not strictly required mark as 'ignore' + $dependencies[$dependency_slug] = 'ignore'; } } else { $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator); @@ -924,12 +974,16 @@ class GPM extends Iterator if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)) { $latestVersionOfPackage = $this->getLatestVersionOfPackage($dependency_slug); if ($this->firstVersionIsLower($dependencyVersion, $latestVersionOfPackage)) { - $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion, - $latestVersionOfPackage); + $compatible = $this->checkNextSignificantReleasesAreCompatible( + $dependencyVersion, + $latestVersionOfPackage + ); if (!$compatible) { - throw new \Exception('Dependency ' . $dependency_slug . ' is required in an older version than the latest release available, and it cannot be installed. This package must be updated. Please get in touch with its developer.', - 2); + throw new RuntimeException( + 'Dependency ' . $dependency_slug . ' is required in an older version than the latest release available, and it cannot be installed. This package must be updated. Please get in touch with its developer.', + 2 + ); } } } @@ -944,14 +998,26 @@ class GPM extends Iterator return $dependencies; } + /** + * @param array $dependencies_slugs + * @return void + */ public function checkNoOtherPackageNeedsTheseDependenciesInALowerVersion($dependencies_slugs) { foreach ($dependencies_slugs as $dependency_slug) { - $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($dependency_slug, - $this->getLatestVersionOfPackage($dependency_slug), $dependencies_slugs); + $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion( + $dependency_slug, + $this->getLatestVersionOfPackage($dependency_slug), + $dependencies_slugs + ); } } + /** + * @param string $firstVersion + * @param string $secondVersion + * @return bool + */ private function firstVersionIsLower($firstVersion, $secondVersion) { return version_compare($firstVersion, $secondVersion) === -1; @@ -961,83 +1027,69 @@ class GPM extends Iterator * Calculates and merges the dependencies of a package * * @param string $packageName The package information - * * @param array $dependencies The dependencies array - * * @return array - * @throws \Exception */ private function calculateMergedDependenciesOfPackage($packageName, $dependencies) { $packageData = $this->findPackage($packageName); - //Check for dependencies - if (isset($packageData->dependencies)) { - foreach ($packageData->dependencies as $dependency) { - $current_package_name = $dependency['name']; - if (isset($dependency['version'])) { - $current_package_version_information = $dependency['version']; + if (empty($packageData->dependencies)) { + return $dependencies; + } + + foreach ($packageData->dependencies as $dependency) { + $dependencyName = $dependency['name'] ?? null; + if (!$dependencyName) { + continue; + } + + $dependencyVersion = $dependency['version'] ?? '*'; + + if (!isset($dependencies[$dependencyName])) { + // Dependency added for the first time + $dependencies[$dependencyName] = $dependencyVersion; + + //Factor in the package dependencies too + $dependencies = $this->calculateMergedDependenciesOfPackage($dependencyName, $dependencies); + } elseif ($dependencyVersion !== '*') { + // Dependency already added by another package + // If this package requires a version higher than the currently stored one, store this requirement instead + $currentDependencyVersion = $dependencies[$dependencyName]; + $currently_stored_version_number = $this->calculateVersionNumberFromDependencyVersion($currentDependencyVersion); + + $currently_stored_version_is_in_next_significant_release_format = false; + if ($this->versionFormatIsNextSignificantRelease($currentDependencyVersion)) { + $currently_stored_version_is_in_next_significant_release_format = true; } - if (!isset($dependencies[$current_package_name])) { - // Dependency added for the first time + if (!$currently_stored_version_number) { + $currently_stored_version_number = '*'; + } - if (!isset($current_package_version_information)) { - $dependencies[$current_package_name] = '*'; - } else { - $dependencies[$current_package_name] = $current_package_version_information; + $current_package_version_number = $this->calculateVersionNumberFromDependencyVersion($dependencyVersion); + if (!$current_package_version_number) { + throw new RuntimeException("Bad format for version of dependency {$dependencyName} for package {$packageName}", 1); + } + + $current_package_version_is_in_next_significant_release_format = false; + if ($this->versionFormatIsNextSignificantRelease($dependencyVersion)) { + $current_package_version_is_in_next_significant_release_format = true; + } + + //If I had stored '*', change right away with the more specific version required + if ($currently_stored_version_number === '*') { + $dependencies[$dependencyName] = $dependencyVersion; + } elseif (!$currently_stored_version_is_in_next_significant_release_format && !$current_package_version_is_in_next_significant_release_format) { + //Comparing versions equals or higher, a simple version_compare is enough + if (version_compare($currently_stored_version_number, $current_package_version_number) === -1) { + //Current package version is higher + $dependencies[$dependencyName] = $dependencyVersion; } - - //Factor in the package dependencies too - $dependencies = $this->calculateMergedDependenciesOfPackage($current_package_name, $dependencies); } else { - // Dependency already added by another package - //if this package requires a version higher than the currently stored one, store this requirement instead - if (isset($current_package_version_information) && $current_package_version_information !== '*') { - - $currently_stored_version_information = $dependencies[$current_package_name]; - $currently_stored_version_number = $this->calculateVersionNumberFromDependencyVersion($currently_stored_version_information); - - $currently_stored_version_is_in_next_significant_release_format = false; - if ($this->versionFormatIsNextSignificantRelease($currently_stored_version_information)) { - $currently_stored_version_is_in_next_significant_release_format = true; - } - - if (!$currently_stored_version_number) { - $currently_stored_version_number = '*'; - } - - $current_package_version_number = $this->calculateVersionNumberFromDependencyVersion($current_package_version_information); - if (!$current_package_version_number) { - throw new \RuntimeException('Bad format for version of dependency ' . $current_package_name . ' for package ' . $packageName, - 1); - } - - $current_package_version_is_in_next_significant_release_format = false; - if ($this->versionFormatIsNextSignificantRelease($current_package_version_information)) { - $current_package_version_is_in_next_significant_release_format = true; - } - - //If I had stored '*', change right away with the more specific version required - if ($currently_stored_version_number === '*') { - $dependencies[$current_package_name] = $current_package_version_information; - } else { - if (!$currently_stored_version_is_in_next_significant_release_format && !$current_package_version_is_in_next_significant_release_format) { - //Comparing versions equals or higher, a simple version_compare is enough - if (version_compare($currently_stored_version_number, - $current_package_version_number) === -1 - ) { //Current package version is higher - $dependencies[$current_package_name] = $current_package_version_information; - } - } else { - $compatible = $this->checkNextSignificantReleasesAreCompatible($currently_stored_version_number, - $current_package_version_number); - if (!$compatible) { - throw new \RuntimeException('Dependency ' . $current_package_name . ' is required in two incompatible versions', - 2); - } - } - } + $compatible = $this->checkNextSignificantReleasesAreCompatible($currently_stored_version_number, $current_package_version_number); + if (!$compatible) { + throw new RuntimeException("Dependency {$dependencyName} is required in two incompatible versions", 2); } } } @@ -1050,9 +1102,7 @@ class GPM extends Iterator * Calculates and merges the dependencies of the passed packages * * @param array $packages - * - * @return mixed - * @throws \Exception + * @return array */ public function calculateMergedDependenciesOfPackages($packages) { @@ -1075,8 +1125,7 @@ class GPM extends Iterator * $versionInformation == '' => returns null * * @param string $version - * - * @return null|string + * @return string|null */ public function calculateVersionNumberFromDependencyVersion($version) { @@ -1102,7 +1151,6 @@ class GPM extends Iterator * Example: returns true for $version: '~2.0' * * @param string $version - * * @return bool */ public function versionFormatIsNextSignificantRelease($version): bool @@ -1116,7 +1164,6 @@ class GPM extends Iterator * Example: returns true for $version: '>=2.0' * * @param string $version - * * @return bool */ public function versionFormatIsEqualOrHigher($version): bool @@ -1134,7 +1181,6 @@ class GPM extends Iterator * * @param string $version1 the version string (e.g. '2.0.0' or '1.0') * @param string $version2 the version string (e.g. '2.0.0' or '1.0') - * * @return bool */ public function checkNextSignificantReleasesAreCompatible($version1, $version2): bool @@ -1142,13 +1188,13 @@ class GPM extends Iterator $version1array = explode('.', $version1); $version2array = explode('.', $version2); - if (\count($version1array) > \count($version2array)) { - list($version1array, $version2array) = [$version2array, $version1array]; + if (count($version1array) > count($version2array)) { + [$version1array, $version2array] = [$version2array, $version1array]; } $i = 0; - while ($i < \count($version1array) - 1) { - if ($version1array[$i] != $version2array[$i]) { + while ($i < count($version1array) - 1) { + if ($version1array[$i] !== $version2array[$i]) { return false; } $i++; @@ -1156,5 +1202,4 @@ class GPM extends Iterator return true; } - } diff --git a/system/src/Grav/Common/GPM/Installer.php b/system/src/Grav/Common/GPM/Installer.php index cd51163..639240b 100644 --- a/system/src/Grav/Common/GPM/Installer.php +++ b/system/src/Grav/Common/GPM/Installer.php @@ -3,15 +3,25 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\GPM; +use DirectoryIterator; use Grav\Common\Filesystem\Folder; use Grav\Common\Grav; +use RuntimeException; +use ZipArchive; +use function count; +use function in_array; +use function is_string; +/** + * Class Installer + * @package Grav\Common\GPM + */ class Installer { /** @const No error */ @@ -33,31 +43,19 @@ class Installer /** @const Invalid source file */ public const INVALID_SOURCE = 128; - /** - * Destination folder on which validation checks are applied - * @var string - */ + /** @var string Destination folder on which validation checks are applied */ protected static $target; - /** - * @var int Error Code - */ + /** @var int|string Error code or string */ protected static $error = 0; - /** - * @var int Zip Error Code - */ + /** @var int Zip Error Code */ protected static $error_zip = 0; - /** - * @var string Post install message - */ + /** @var string Post install message */ protected static $message = ''; - /** - * Default options for the install - * @var array - */ + /** @var array Default options for the install */ protected static $options = [ 'overwrite' => true, 'ignore_symlinks' => true, @@ -74,7 +72,7 @@ class Installer * @param string $zip the local path to ZIP package * @param string $destination The local path to the Grav Instance * @param array $options Options to use for installing. ie, ['install_path' => 'user/themes/antimatter'] - * @param string $extracted The local path to the extacted ZIP package + * @param string|null $extracted The local path to the extacted ZIP package * @param bool $keepExtracted True if you want to keep the original files * @return bool True if everything went fine, False otherwise. */ @@ -84,8 +82,10 @@ class Installer $options = array_merge(self::$options, $options); $install_path = rtrim($destination . DS . ltrim($options['install_path'], DS), DS); - if (!self::isGravInstance($destination) || !self::isValidDestination($install_path, - $options['exclude_checks']) + if (!self::isGravInstance($destination) || !self::isValidDestination( + $install_path, + $options['exclude_checks'] + ) ) { return false; } @@ -135,7 +135,10 @@ class Installer } if (!$options['sophisticated']) { - if ($options['theme']) { + $isTheme = $options['theme'] ?? false; + // Make sure that themes are always being copied, even if option was not set! + $isTheme = $isTheme || preg_match('|/themes/[^/]+|ui', $install_path); + if ($isTheme) { self::copyInstall($extracted, $install_path); } else { self::moveInstall($extracted, $install_path); @@ -160,7 +163,6 @@ class Installer self::$error = self::OK; return true; - } /** @@ -168,11 +170,11 @@ class Installer * * @param string $zip_file * @param string $destination - * @return bool|string + * @return string|false */ public static function unZip($zip_file, $destination) { - $zip = new \ZipArchive(); + $zip = new ZipArchive(); $archive = $zip->open($zip_file); if ($archive === true) { @@ -188,7 +190,11 @@ class Installer return false; } - $package_folder_name = preg_replace('#\./$#', '', $zip->getNameIndex(0)); + $package_folder_name = $zip->getNameIndex(0); + if ($package_folder_name === false) { + throw new \RuntimeException('Bad package file: ' . basename($zip_file)); + } + $package_folder_name = preg_replace('#\./$#', '', $package_folder_name); $zip->close(); return $destination . '/' . $package_folder_name; @@ -196,6 +202,7 @@ class Installer self::$error = self::ZIP_EXTRACT_ERROR; self::$error_zip = $archive; + return false; } @@ -204,23 +211,20 @@ class Installer * * @param string $installer_file_folder The folder path that contains install.php * @param bool $is_install True if install, false if removal - * - * @return null|string + * @return string|null */ private static function loadInstaller($installer_file_folder, $is_install) { - $installer = null; - $installer_file_folder = rtrim($installer_file_folder, DS); $install_file = $installer_file_folder . DS . 'install.php'; - if (file_exists($install_file)) { - require_once $install_file; - } else { + if (!file_exists($install_file)) { return null; } + require_once $install_file; + if ($is_install) { $slug = ''; if (($pos = strpos($installer_file_folder, 'grav-plugin-')) !== false) { @@ -243,19 +247,18 @@ class Installer return $class_name; } - $class_name_alphanumeric = preg_replace('/[^a-zA-Z0-9]+/', '', $class_name); + $class_name_alphanumeric = preg_replace('/[^a-zA-Z0-9]+/', '', $class_name) ?? $class_name; if (class_exists($class_name_alphanumeric)) { return $class_name_alphanumeric; } - return $installer; + return null; } /** * @param string $source_path * @param string $install_path - * * @return bool */ public static function moveInstall($source_path, $install_path) @@ -272,13 +275,12 @@ class Installer /** * @param string $source_path * @param string $install_path - * * @return bool */ public static function copyInstall($source_path, $install_path) { if (empty($source_path)) { - throw new \RuntimeException("Directory $source_path is missing"); + throw new RuntimeException("Directory $source_path is missing"); } Folder::rcopy($source_path, $install_path); @@ -291,14 +293,12 @@ class Installer * @param string $install_path * @param array $ignores * @param bool $keep_source - * * @return bool */ public static function sophisticatedInstall($source_path, $install_path, $ignores = [], $keep_source = false) { - foreach (new \DirectoryIterator($source_path) as $file) { - - if ($file->isLink() || $file->isDot() || \in_array($file->getFilename(), $ignores, true)) { + foreach (new DirectoryIterator($source_path) as $file) { + if ($file->isLink() || $file->isDot() || in_array($file->getFilename(), $ignores, true)) { continue; } @@ -313,7 +313,8 @@ class Installer } if ($file->getFilename() === 'bin') { - foreach (glob($path . DS . '*') as $bin_file) { + $glob = glob($path . DS . '*') ?: []; + foreach ($glob as $bin_file) { @chmod($bin_file, 0755); } } @@ -331,7 +332,6 @@ class Installer * * @param string $path The slug of the package(s) * @param array $options Options to use for uninstalling - * * @return bool True if everything went fine, False otherwise. */ public static function uninstall($path, $options = []) @@ -373,7 +373,6 @@ class Installer * * @param string $destination The directory to run validations at * @param array $exclude An array of constants to exclude from the validation - * * @return bool True if validation passed. False otherwise */ public static function isValidDestination($destination, $exclude = []) @@ -391,7 +390,7 @@ class Installer self::$error = self::NOT_DIRECTORY; } - if (\count($exclude) && \in_array(self::$error, $exclude, true)) { + if (count($exclude) && in_array(self::$error, $exclude, true)) { return true; } @@ -402,7 +401,6 @@ class Installer * Validates if the given path is a Grav Instance * * @param string $target The local path to the Grav Instance - * * @return bool True if is a Grav Instance. False otherwise */ public static function isGravInstance($target) @@ -410,8 +408,7 @@ class Installer self::$error = 0; self::$target = $target; - if ( - !file_exists($target . DS . 'index.php') || + if (!file_exists($target . DS . 'index.php') || !file_exists($target . DS . 'bin') || !file_exists($target . DS . 'user') || !file_exists($target . DS . 'system' . DS . 'config' . DS . 'system.yaml') @@ -424,6 +421,7 @@ class Installer /** * Returns the last message added by the installer + * * @return string The message */ public static function getMessage() @@ -433,6 +431,7 @@ class Installer /** * Returns the last error occurred in a string message format + * * @return string The message of the last error */ public static function lastErrorMsg() @@ -473,36 +472,36 @@ class Installer case self::ZIP_EXTRACT_ERROR: $msg = 'Unable to extract the package. '; if (self::$error_zip) { - switch(self::$error_zip) { - case \ZipArchive::ER_EXISTS: + switch (self::$error_zip) { + case ZipArchive::ER_EXISTS: $msg .= 'File already exists.'; break; - case \ZipArchive::ER_INCONS: + case ZipArchive::ER_INCONS: $msg .= 'Zip archive inconsistent.'; break; - case \ZipArchive::ER_MEMORY: + case ZipArchive::ER_MEMORY: $msg .= 'Memory allocation failure.'; break; - case \ZipArchive::ER_NOENT: + case ZipArchive::ER_NOENT: $msg .= 'No such file.'; break; - case \ZipArchive::ER_NOZIP: + case ZipArchive::ER_NOZIP: $msg .= 'Not a zip archive.'; break; - case \ZipArchive::ER_OPEN: + case ZipArchive::ER_OPEN: $msg .= "Can't open file."; break; - case \ZipArchive::ER_READ: + case ZipArchive::ER_READ: $msg .= 'Read error.'; break; - case \ZipArchive::ER_SEEK: + case ZipArchive::ER_SEEK: $msg .= 'Seek error.'; break; } @@ -523,6 +522,7 @@ class Installer /** * Returns the last error code of the occurred error + * * @return int|string The code of the last error */ public static function lastErrorCode() @@ -534,8 +534,8 @@ class Installer * Allows to manually set an error * * @param int|string $error the Error code + * @return void */ - public static function setError($error) { self::$error = $error; diff --git a/system/src/Grav/Common/GPM/Licenses.php b/system/src/Grav/Common/GPM/Licenses.php index 14c9258..825343d 100644 --- a/system/src/Grav/Common/GPM/Licenses.php +++ b/system/src/Grav/Common/GPM/Licenses.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,6 +11,8 @@ namespace Grav\Common\GPM; use Grav\Common\File\CompiledYamlFile; use Grav\Common\Grav; +use RocketTheme\Toolbox\File\FileInterface; +use function is_string; /** * Class Licenses @@ -19,23 +21,16 @@ use Grav\Common\Grav; */ class Licenses { - - /** - * Regex to validate the format of a License - * - * @var string - */ + /** @var string Regex to validate the format of a License */ protected static $regex = '^(?:[A-F0-9]{8}-){3}(?:[A-F0-9]{8}){1}$'; - + /** @var FileInterface */ protected static $file; - /** * Returns the license for a Premium package * * @param string $slug * @param string $license - * * @return bool */ public static function set($slug, $license) @@ -48,7 +43,7 @@ class Licenses return false; } - if (!\is_string($license)) { + if (!is_string($license)) { if (isset($data['licenses'][$slug])) { unset($data['licenses'][$slug]); } else { @@ -67,21 +62,21 @@ class Licenses /** * Returns the license for a Premium package * - * @param string $slug - * - * @return array|string + * @param string|null $slug + * @return string[]|string */ public static function get($slug = null) { $licenses = self::getLicenseFile(); $data = (array)$licenses->content(); $licenses->free(); - $slug = strtolower($slug); - if (!$slug) { + if (null === $slug) { return $data['licenses'] ?? []; } + $slug = strtolower($slug); + return $data['licenses'][$slug] ?? ''; } @@ -89,8 +84,7 @@ class Licenses /** * Validates the License format * - * @param string $license - * + * @param string|null $license * @return bool */ public static function validate($license = null) @@ -99,16 +93,15 @@ class Licenses return false; } - return preg_match('#' . self::$regex. '#', $license); + return (bool)preg_match('#' . self::$regex. '#', $license); } /** * Get the License File object * - * @return \RocketTheme\Toolbox\File\FileInterface + * @return FileInterface */ public static function getLicenseFile() - { if (!isset(self::$file)) { $path = Grav::instance()['locator']->findResource('user-data://') . '/licenses.yaml'; diff --git a/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php index be565c9..d1f3e46 100644 --- a/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php +++ b/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,8 +11,17 @@ namespace Grav\Common\GPM\Local; use Grav\Common\GPM\Common\AbstractPackageCollection as BaseCollection; +/** + * Class AbstractPackageCollection + * @package Grav\Common\GPM\Local + */ abstract class AbstractPackageCollection extends BaseCollection { + /** + * AbstractPackageCollection constructor. + * + * @param array $items + */ public function __construct($items) { parent::__construct(); diff --git a/system/src/Grav/Common/GPM/Local/Package.php b/system/src/Grav/Common/GPM/Local/Package.php index 88241a4..ffe2c63 100644 --- a/system/src/Grav/Common/GPM/Local/Package.php +++ b/system/src/Grav/Common/GPM/Local/Package.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,12 +11,22 @@ namespace Grav\Common\GPM\Local; use Grav\Common\Data\Data; use Grav\Common\GPM\Common\Package as BasePackage; -use Grav\Framework\Parsedown\Parsedown; +use Parsedown; +/** + * Class Package + * @package Grav\Common\GPM\Local + */ class Package extends BasePackage { + /** @var array */ protected $settings; + /** + * Package constructor. + * @param Data $package + * @param string|null $package_type + */ public function __construct(Data $package, $package_type = null) { $data = new Data($package->blueprints()->toArray()); @@ -32,10 +42,10 @@ class Package extends BasePackage } /** - * @return mixed + * @return bool */ public function isEnabled() { - return $this->settings['enabled']; + return (bool)$this->settings['enabled']; } } diff --git a/system/src/Grav/Common/GPM/Local/Packages.php b/system/src/Grav/Common/GPM/Local/Packages.php index 14a0b0b..0290296 100644 --- a/system/src/Grav/Common/GPM/Local/Packages.php +++ b/system/src/Grav/Common/GPM/Local/Packages.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,6 +11,10 @@ namespace Grav\Common\GPM\Local; use Grav\Common\GPM\Common\CachedCollection; +/** + * Class Packages + * @package Grav\Common\GPM\Local + */ class Packages extends CachedCollection { public function __construct() diff --git a/system/src/Grav/Common/GPM/Local/Plugins.php b/system/src/Grav/Common/GPM/Local/Plugins.php index 19adfb8..8a6574b 100644 --- a/system/src/Grav/Common/GPM/Local/Plugins.php +++ b/system/src/Grav/Common/GPM/Local/Plugins.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,11 +11,13 @@ namespace Grav\Common\GPM\Local; use Grav\Common\Grav; +/** + * Class Plugins + * @package Grav\Common\GPM\Local + */ class Plugins extends AbstractPackageCollection { - /** - * @var string - */ + /** @var string */ protected $type = 'plugins'; /** diff --git a/system/src/Grav/Common/GPM/Local/Themes.php b/system/src/Grav/Common/GPM/Local/Themes.php index 8607e6b..73253cc 100644 --- a/system/src/Grav/Common/GPM/Local/Themes.php +++ b/system/src/Grav/Common/GPM/Local/Themes.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,11 +11,13 @@ namespace Grav\Common\GPM\Local; use Grav\Common\Grav; +/** + * Class Themes + * @package Grav\Common\GPM\Local + */ class Themes extends AbstractPackageCollection { - /** - * @var string - */ + /** @var string */ protected $type = 'themes'; /** @@ -23,6 +25,9 @@ class Themes extends AbstractPackageCollection */ public function __construct() { - parent::__construct(Grav::instance()['themes']->all()); + /** @var \Grav\Common\Themes $themes */ + $themes = Grav::instance()['themes']; + + parent::__construct($themes->all()); } } diff --git a/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php index 429c5b1..9e68d66 100644 --- a/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php +++ b/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,37 +13,36 @@ use Grav\Common\Grav; use Grav\Common\GPM\Common\AbstractPackageCollection as BaseCollection; use Grav\Common\GPM\Response; use \Doctrine\Common\Cache\FilesystemCache; +use RuntimeException; +/** + * Class AbstractPackageCollection + * @package Grav\Common\GPM\Remote + */ class AbstractPackageCollection extends BaseCollection { - /** - * The cached data previously fetched - * @var string - */ + /** @var string The cached data previously fetched */ protected $raw; - - /** - * The lifetime to store the entry in seconds - * @var int - */ - private $lifetime = 86400; - + /** @var string */ protected $repository; - + /** @var FilesystemCache */ protected $cache; + /** @var int The lifetime to store the entry in seconds */ + private $lifetime = 86400; + /** * AbstractPackageCollection constructor. * - * @param null $repository + * @param string|null $repository * @param bool $refresh - * @param null $callback + * @param callable|null $callback */ public function __construct($repository = null, $refresh = false, $callback = null) { parent::__construct(); if ($repository === null) { - throw new \RuntimeException('A repository is required to indicate the origin of the remote collection'); + throw new RuntimeException('A repository is required to indicate the origin of the remote collection'); } $channel = Grav::instance()['config']->get('system.gpm.releases', 'stable'); @@ -55,7 +54,7 @@ class AbstractPackageCollection extends BaseCollection $this->fetch($refresh, $callback); foreach (json_decode($this->raw, true) as $slug => $data) { - // Temporarily fix for using multisites + // Temporarily fix for using multi-sites if (isset($data['install_path'])) { $path = preg_replace('~^user/~i', 'user://', $data['install_path']); $data['install_path'] = Grav::instance()['locator']->findResource($path, false, true); @@ -64,6 +63,11 @@ class AbstractPackageCollection extends BaseCollection } } + /** + * @param bool $refresh + * @param callable|null $callback + * @return string + */ public function fetch($refresh = false, $callback = null) { if (!$this->raw || $refresh) { diff --git a/system/src/Grav/Common/GPM/Remote/GravCore.php b/system/src/Grav/Common/GPM/Remote/GravCore.php index 1d30120..f93209e 100644 --- a/system/src/Grav/Common/GPM/Remote/GravCore.php +++ b/system/src/Grav/Common/GPM/Remote/GravCore.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,20 +11,30 @@ namespace Grav\Common\GPM\Remote; use Grav\Common\Grav; use \Doctrine\Common\Cache\FilesystemCache; +use InvalidArgumentException; +/** + * Class GravCore + * @package Grav\Common\GPM\Remote + */ class GravCore extends AbstractPackageCollection { + /** @var string */ protected $repository = 'https://getgrav.org/downloads/grav.json'; - private $data; + /** @var array */ + private $data; + /** @var string */ private $version; + /** @var string */ private $date; + /** @var string|null */ private $min_php; /** * @param bool $refresh - * @param null $callback - * @throws \InvalidArgumentException + * @param callable|null $callback + * @throws InvalidArgumentException */ public function __construct($refresh = false, $callback = null) { @@ -61,8 +71,7 @@ class GravCore extends AbstractPackageCollection /** * Returns the changelog list for each version of Grav * - * @param string $diff the version number to start the diff from - * + * @param string|null $diff the version number to start the diff from * @return array changelog list for each version */ public function getChangelog($diff = null) @@ -118,14 +127,15 @@ class GravCore extends AbstractPackageCollection /** * Returns the minimum PHP version * - * @return null|string + * @return string */ public function getMinPHPVersion() { // If non min set, assume current PHP version if (null === $this->min_php) { - $this->min_php = phpversion(); + $this->min_php = PHP_VERSION; } + return $this->min_php; } diff --git a/system/src/Grav/Common/GPM/Remote/Package.php b/system/src/Grav/Common/GPM/Remote/Package.php index 196e92b..c37d6a0 100644 --- a/system/src/Grav/Common/GPM/Remote/Package.php +++ b/system/src/Grav/Common/GPM/Remote/Package.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -12,16 +12,54 @@ namespace Grav\Common\GPM\Remote; use Grav\Common\Data\Data; use Grav\Common\GPM\Common\Package as BasePackage; +/** + * Class Package + * @package Grav\Common\GPM\Remote + */ class Package extends BasePackage implements \JsonSerializable { + /** + * Package constructor. + * @param array $package + * @param string|null $package_type + */ public function __construct($package, $package_type = null) { $data = new Data($package); parent::__construct($data, $package_type); } + /** + * @return array + */ public function jsonSerialize() { - return $this->data; + return $this->data->toArray(); + } + + /** + * Returns the changelog list for each version of a package + * + * @param string|null $diff the version number to start the diff from + * @return array changelog list for each version + */ + public function getChangelog($diff = null) + { + if (!$diff) { + return $this->data['changelog']; + } + + $diffLog = []; + foreach ((array)$this->data['changelog'] as $version => $changelog) { + preg_match("/[\w\-.]+/", $version, $cleanVersion); + + if (!$cleanVersion || version_compare($diff, $cleanVersion[0], '>=')) { + continue; + } + + $diffLog[$version] = $changelog; + } + + return $diffLog; } } diff --git a/system/src/Grav/Common/GPM/Remote/Packages.php b/system/src/Grav/Common/GPM/Remote/Packages.php index 46bc31f..f55c12d 100644 --- a/system/src/Grav/Common/GPM/Remote/Packages.php +++ b/system/src/Grav/Common/GPM/Remote/Packages.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,8 +11,17 @@ namespace Grav\Common\GPM\Remote; use Grav\Common\GPM\Common\CachedCollection; +/** + * Class Packages + * @package Grav\Common\GPM\Remote + */ class Packages extends CachedCollection { + /** + * Packages constructor. + * @param bool $refresh + * @param callable|null $callback + */ public function __construct($refresh = false, $callback = null) { $items = [ diff --git a/system/src/Grav/Common/GPM/Remote/Plugins.php b/system/src/Grav/Common/GPM/Remote/Plugins.php index 1d905e3..a134a10 100644 --- a/system/src/Grav/Common/GPM/Remote/Plugins.php +++ b/system/src/Grav/Common/GPM/Remote/Plugins.php @@ -3,25 +3,27 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\GPM\Remote; +/** + * Class Plugins + * @package Grav\Common\GPM\Remote + */ class Plugins extends AbstractPackageCollection { - /** - * @var string - */ + /** @var string */ protected $type = 'plugins'; - + /** @var string */ protected $repository = 'https://getgrav.org/downloads/plugins.json'; /** * Local Plugins Constructor * @param bool $refresh - * @param callable $callback Either a function or callback in array notation + * @param callable|null $callback Either a function or callback in array notation */ public function __construct($refresh = false, $callback = null) { diff --git a/system/src/Grav/Common/GPM/Remote/Themes.php b/system/src/Grav/Common/GPM/Remote/Themes.php index 8024b40..160ac97 100644 --- a/system/src/Grav/Common/GPM/Remote/Themes.php +++ b/system/src/Grav/Common/GPM/Remote/Themes.php @@ -3,25 +3,27 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\GPM\Remote; +/** + * Class Themes + * @package Grav\Common\GPM\Remote + */ class Themes extends AbstractPackageCollection { - /** - * @var string - */ + /** @var string */ protected $type = 'themes'; - + /** @var string */ protected $repository = 'https://getgrav.org/downloads/themes.json'; /** * Local Themes Constructor * @param bool $refresh - * @param callable $callback Either a function or callback in array notation + * @param callable|null $callback Either a function or callback in array notation */ public function __construct($refresh = false, $callback = null) { diff --git a/system/src/Grav/Common/GPM/Response.php b/system/src/Grav/Common/GPM/Response.php index 221ecf0..47cef7c 100644 --- a/system/src/Grav/Common/GPM/Response.php +++ b/system/src/Grav/Common/GPM/Response.php @@ -3,202 +3,107 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\GPM; +use Exception; use Grav\Common\Utils; use Grav\Common\Grav; +use Symfony\Component\HttpClient\CurlHttpClient; +use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\HttpOptions; +use Symfony\Component\HttpClient\NativeHttpClient; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use function call_user_func; +use function defined; +use function function_exists; +/** + * Class Response + * @package Grav\Common\GPM + */ class Response { - /** - * The callback for the progress - * - * @var callable Either a function or callback in array notation - */ + /** @var callable The callback for the progress, either a function or callback in array notation */ public static $callback = null; - - /** - * Which method to use for HTTP calls, can be 'curl', 'fopen' or 'auto'. Auto is default and fopen is the preferred method - * - * @var string - */ - private static $method = 'auto'; - - /** - * Default parameters for `curl` and `fopen` - * - * @var array - */ - private static $defaults = [ - - 'curl' => [ - CURLOPT_REFERER => 'Grav GPM', - CURLOPT_USERAGENT => 'Grav GPM', - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_FAILONERROR => true, - CURLOPT_TIMEOUT => 15, - CURLOPT_HEADER => false, - //CURLOPT_SSL_VERIFYPEER => true, // this is set in the constructor since it's a setting - /** - * Example of callback parameters from within your own class - */ - //CURLOPT_NOPROGRESS => false, - //CURLOPT_PROGRESSFUNCTION => [$this, 'progress'] - ], - 'fopen' => [ - 'method' => 'GET', - 'user_agent' => 'Grav GPM', - 'max_redirects' => 5, - 'follow_location' => 1, - 'timeout' => 15, - /* // this is set in the constructor since it's a setting - 'ssl' => [ - 'verify_peer' => true, - 'verify_peer_name' => true, - ], - */ - /** - * Example of callback parameters from within your own class - */ - //'notification' => [$this, 'progress'] - ] + /** @var string[] */ + private static $headers = [ + 'User-Agent' => 'Grav CMS' ]; - /** - * Sets the preferred method to use for making HTTP calls. - * - * @param string $method Default is `auto` - * - * @return Response - */ - public static function setMethod($method = 'auto') - { - if (!\in_array($method, ['auto', 'curl', 'fopen'], true)) { - $method = 'auto'; - } - - self::$method = $method; - - return new self(); - } - /** * Makes a request to the URL by using the preferred method * - * @param string $uri URL to call - * @param array $options An array of parameters for both `curl` and `fopen` - * @param callable $callback Either a function or callback in array notation - * + * @param string $uri URL to call + * @param array $overrides An array of parameters for both `curl` and `fopen` + * @param callable|null $callback Either a function or callback in array notation * @return string The response of the request + * @throws TransportExceptionInterface */ - public static function get($uri = '', $options = [], $callback = null) + public static function get($uri = '', $overrides = [], $callback = null) { - if (!self::isCurlAvailable() && !self::isFopenAvailable()) { - throw new \RuntimeException('Could not start an HTTP request. `allow_url_open` is disabled and `cURL` is not available'); + if (empty($uri)) { + throw new TransportException('missing URI'); } // check if this function is available, if so use it to stop any timeouts try { - if (function_exists('set_time_limit') && !Utils::isFunctionDisabled('set_time_limit')) { - set_time_limit(0); + if (Utils::functionExists('set_time_limit')) { + @set_time_limit(0); } - } catch (\Exception $e) { + } catch (Exception $e) { } $config = Grav::instance()['config']; - $overrides = []; + $referer = defined('GRAV_CLI') ? 'grav_cli' : Grav::instance()['uri']->rootUrl(true); + $options = new HttpOptions(); - // Override CA Bundle - $caPathOrFile = \Composer\CaBundle\CaBundle::getSystemCaRootBundlePath(); - if (is_dir($caPathOrFile) || (is_link($caPathOrFile) && is_dir(readlink($caPathOrFile)))) { - $overrides['curl'][CURLOPT_CAPATH] = $caPathOrFile; - $overrides['fopen']['ssl']['capath'] = $caPathOrFile; - } else { - $overrides['curl'][CURLOPT_CAINFO] = $caPathOrFile; - $overrides['fopen']['ssl']['cafile'] = $caPathOrFile; + // Set default Headers + $options->setHeaders(array_merge([ 'Referer' => $referer ], self::$headers)); + + // Disable verify Peer if required + $verify_peer = $config->get('system.gpm.verify_peer', true); + if ($verify_peer !== true) { + $options->verifyPeer($verify_peer); } - // SSL Verify Peer and Proxy Setting - $settings = [ - 'method' => $config->get('system.gpm.method', self::$method), - 'verify_peer' => $config->get('system.gpm.verify_peer', true), - // `system.proxy_url` is for fallback - // introduced with 1.1.0-beta.1 probably safe to remove at some point - 'proxy_url' => $config->get('system.gpm.proxy_url', $config->get('system.proxy_url', false)), - ]; - - if (!$settings['verify_peer']) { - $overrides = array_replace_recursive([], $overrides, [ - 'curl' => [ - CURLOPT_SSL_VERIFYPEER => $settings['verify_peer'] - ], - 'fopen' => [ - 'ssl' => [ - 'verify_peer' => $settings['verify_peer'], - 'verify_peer_name' => $settings['verify_peer'], - ] - ] - ]); + // Set proxy url if provided + $proxy_url = $config->get('system.gpm.proxy_url', false); + if ($proxy_url) { + $options->setProxy($proxy_url); } - // Proxy Setting - if ($settings['proxy_url']) { - $proxy = parse_url($settings['proxy_url']); - $fopen_proxy = ($proxy['scheme'] ?: 'http') . '://' . $proxy['host'] . (isset($proxy['port']) ? ':' . $proxy['port'] : ''); - - $overrides = array_replace_recursive([], $overrides, [ - 'curl' => [ - CURLOPT_PROXY => $proxy['host'], - CURLOPT_PROXYTYPE => 'HTTP' - ], - 'fopen' => [ - 'proxy' => $fopen_proxy, - 'request_fulluri' => true - ] - ]); - - if (isset($proxy['port'])) { - $overrides['curl'][CURLOPT_PROXYPORT] = $proxy['port']; - } - - if (isset($proxy['user'], $proxy['pass'])) { - $fopen_auth = $auth = base64_encode($proxy['user'] . ':' . $proxy['pass']); - $overrides['curl'][CURLOPT_PROXYUSERPWD] = $proxy['user'] . ':' . $proxy['pass']; - $overrides['fopen']['header'] = "Proxy-Authorization: Basic $fopen_auth"; - } + // Use callback if provided + if ($callback) { + self::$callback = $callback; + $options->setOnProgress([Response::class, 'progress']); } - $options = array_replace_recursive(self::$defaults, $options, $overrides); - $method = 'get' . ucfirst(strtolower($settings['method'])); + $preferred_method = $config->get('system.gpm.method', 'auto'); - self::$callback = $callback; - return static::$method($uri, $options, $callback); + $settings = array_merge_recursive($options->toArray(), $overrides); + + switch ($preferred_method) { + case 'curl': + $client = new CurlHttpClient($settings); + break; + case 'fopen': + case 'native': + $client = new NativeHttpClient($settings); + break; + default: + $client = HttpClient::create($settings); + } + + $response = $client->request('GET', $uri); + + return $response->getContent(); } - /** - * Checks if cURL is available - * - * @return bool - */ - public static function isCurlAvailable() - { - return function_exists('curl_version'); - } - - /** - * Checks if the remote fopen request is enabled in PHP - * - * @return bool - */ - public static function isFopenAvailable() - { - return preg_match('/1|yes|on|true/i', ini_get('allow_url_fopen')); - } /** * Is this a remote file or not @@ -214,221 +119,25 @@ class Response /** * Progress normalized for cURL and Fopen * Accepts a variable length of arguments passed in by stream method + * + * @return void */ - public static function progress() + public static function progress(int $bytes_transferred, int $filesize, array $info) { - static $filesize = null; - - $args = func_get_args(); - $isCurlResource = is_resource($args[0]) && get_resource_type($args[0]) === 'curl'; - - $notification_code = !$isCurlResource ? $args[0] : false; - $bytes_transferred = $isCurlResource ? $args[2] : $args[4]; - - if ($isCurlResource) { - $filesize = $args[1]; - } elseif ($notification_code == STREAM_NOTIFY_FILE_SIZE_IS) { - $filesize = $args[5]; - } if ($bytes_transferred > 0) { - if ($notification_code == STREAM_NOTIFY_PROGRESS | STREAM_NOTIFY_COMPLETED || $isCurlResource) { + $percent = $filesize <= 0 ? 0 : (int)(($bytes_transferred * 100) / $filesize); - $progress = [ - 'code' => $notification_code, - 'filesize' => $filesize, - 'transferred' => $bytes_transferred, - 'percent' => $filesize <= 0 ? '-' : round(($bytes_transferred * 100) / $filesize, 1) - ]; + $progress = [ + 'code' => $info['http_code'], + 'filesize' => $filesize, + 'transferred' => $bytes_transferred, + 'percent' => $percent < 100 ? $percent : 100 + ]; - if (self::$callback !== null) { - call_user_func(self::$callback, $progress); - } + if (self::$callback !== null) { + call_user_func(self::$callback, $progress); } } } - - /** - * Automatically picks the preferred method - * - * @return string The response of the request - */ - private static function getAuto() - { - if (!ini_get('open_basedir') && self::isFopenAvailable()) { - return self::getFopen(func_get_args()); - } - - if (self::isCurlAvailable()) { - return self::getCurl(func_get_args()); - } - - return null; - } - - /** - * Starts a HTTP request via fopen - * - * @return string The response of the request - */ - private static function getFopen() - { - if (\count($args = func_get_args()) === 1) { - $args = $args[0]; - } - - $uri = $args[0]; - $options = $args[1] ?? []; - $callback = $args[2] ?? null; - - if ($callback) { - $options['fopen']['notification'] = ['self', 'progress']; - } - - if (isset($options['fopen']['ssl'])) { - $ssl = $options['fopen']['ssl']; - unset($options['fopen']['ssl']); - - $stream = stream_context_create([ - 'http' => $options['fopen'], - 'ssl' => $ssl - ], $options['fopen']); - } else { - $stream = stream_context_create(['http' => $options['fopen']], $options['fopen']); - } - - - $content = @file_get_contents($uri, false, $stream); - - if ($content === false) { - $code = null; - // Function file_get_contents() creates local variable $http_response_header, check it - if (isset($http_response_header)) { - $code = explode(' ', $http_response_header[0] ?? '')[1] ?? null; - } - - switch ($code) { - case '404': - throw new \RuntimeException('Page not found'); - case '401': - throw new \RuntimeException('Invalid LICENSE'); - default: - throw new \RuntimeException("Error while trying to download (code: {$code}): {$uri}\n"); - } - } - - return $content; - } - - /** - * Starts a HTTP request via cURL - * - * @return string The response of the request - */ - private static function getCurl() - { - $args = func_get_args(); - $args = count($args) > 1 ? $args : array_shift($args); - - $uri = $args[0]; - $options = $args[1] ?? []; - $callback = $args[2] ?? null; - - $ch = curl_init($uri); - - $response = static::curlExecFollow($ch, $options, $callback); - $errno = curl_errno($ch); - - if ($errno) { - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error_message = curl_strerror($errno) . "\n" . curl_error($ch); - - switch ($code) { - case '404': - throw new \RuntimeException('Page not found'); - case '401': - throw new \RuntimeException('Invalid LICENSE'); - default: - throw new \RuntimeException("Error while trying to download (code: $code): $uri \nMessage: $error_message"); - } - } - - curl_close($ch); - - return $response; - } - - /** - * @param resource $ch - * @param array $options - * @param bool $callback - * - * @return bool|mixed - */ - private static function curlExecFollow($ch, $options, $callback) - { - if ($callback) { - curl_setopt_array( - $ch, - [ - CURLOPT_NOPROGRESS => false, - CURLOPT_PROGRESSFUNCTION => ['self', 'progress'] - ] - ); - } - - // no open_basedir set, we can proceed normally - if (!ini_get('open_basedir')) { - curl_setopt_array($ch, $options['curl']); - return curl_exec($ch); - } - - $max_redirects = $options['curl'][CURLOPT_MAXREDIRS] ?? 5; - $options['curl'][CURLOPT_FOLLOWLOCATION] = false; - - // open_basedir set but no redirects to follow, we can disable followlocation and proceed normally - curl_setopt_array($ch, $options['curl']); - if ($max_redirects <= 0) { - return curl_exec($ch); - } - - $uri = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); - $rch = curl_copy_handle($ch); - - curl_setopt($rch, CURLOPT_HEADER, true); - curl_setopt($rch, CURLOPT_NOBODY, true); - curl_setopt($rch, CURLOPT_FORBID_REUSE, false); - curl_setopt($rch, CURLOPT_RETURNTRANSFER, true); - - do { - curl_setopt($rch, CURLOPT_URL, $uri); - $header = curl_exec($rch); - - if (curl_errno($rch)) { - $code = 0; - } else { - $code = (int)curl_getinfo($rch, CURLINFO_HTTP_CODE); - if ($code === 301 || $code === 302 || $code === 303) { - preg_match('/(?:^|\n)Location:(.*?)\n/i', $header, $matches); - $uri = trim(array_pop($matches)); - } else { - $code = 0; - } - } - } while ($code && --$max_redirects); - - curl_close($rch); - - if (!$max_redirects) { - if ($max_redirects === null) { - trigger_error('Too many redirects. When following redirects, libcurl hit the maximum amount.', E_USER_WARNING); - } - - return false; - } - - curl_setopt($ch, CURLOPT_URL, $uri); - - return curl_exec($ch); - } } diff --git a/system/src/Grav/Common/GPM/Upgrader.php b/system/src/Grav/Common/GPM/Upgrader.php index 5d9406d..dfa6eb1 100644 --- a/system/src/Grav/Common/GPM/Upgrader.php +++ b/system/src/Grav/Common/GPM/Upgrader.php @@ -3,13 +3,14 @@ /** * @package Grav\Common\GPM * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\GPM; use Grav\Common\GPM\Remote\GravCore; +use InvalidArgumentException; /** * Class Upgrader @@ -18,21 +19,18 @@ use Grav\Common\GPM\Remote\GravCore; */ class Upgrader { - /** - * Remote details about latest Grav version - * - * @var GravCore - */ + /** @var GravCore Remote details about latest Grav version */ private $remote; + /** @var string|null */ private $min_php; /** * Creates a new GPM instance with Local and Remote packages available * * @param boolean $refresh Applies to Remote Packages only and forces a refetch of data - * @param callable $callback Either a function or callback in array notation - * @throws \InvalidArgumentException + * @param callable|null $callback Either a function or callback in array notation + * @throws InvalidArgumentException */ public function __construct($refresh = false, $callback = null) { @@ -82,8 +80,7 @@ class Upgrader /** * Returns the changelog list for each version of Grav * - * @param string $diff the version number to start the diff from - * + * @param string|null $diff the version number to start the diff from * @return array return the changelog list for each version */ public function getChangelog($diff = null) @@ -98,8 +95,7 @@ class Upgrader */ public function meetsRequirements() { - $current_php_version = phpversion(); - if (version_compare($current_php_version, $this->minPHPVersion(), '<')) { + if (version_compare(PHP_VERSION, $this->minPHPVersion(), '<')) { return false; } @@ -109,20 +105,21 @@ class Upgrader /** * Get minimum PHP version from remote * - * @return null + * @return string */ public function minPHPVersion() { if (null === $this->min_php) { $this->min_php = $this->remote->getMinPHPVersion(); } + return $this->min_php; } /** * Checks if the currently installed Grav is upgradable to a newer version * - * @return boolean True if it's upgradable, False otherwise. + * @return bool True if it's upgradable, False otherwise. */ public function isUpgradable() { @@ -132,9 +129,8 @@ class Upgrader /** * Checks if Grav is currently symbolically linked * - * @return boolean True if Grav is symlinked, False otherwise. + * @return bool True if Grav is symlinked, False otherwise. */ - public function isSymlink() { return $this->remote->isSymlink(); diff --git a/system/src/Grav/Common/Getters.php b/system/src/Grav/Common/Getters.php index e69116e..8f3a73a 100644 --- a/system/src/Grav/Common/Getters.php +++ b/system/src/Grav/Common/Getters.php @@ -3,25 +3,29 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; -abstract class Getters implements \ArrayAccess, \Countable +use ArrayAccess; +use Countable; +use function count; + +/** + * Class Getters + * @package Grav\Common + */ +abstract class Getters implements ArrayAccess, Countable { - /** - * Define variable used in getters. - * - * @var string - */ + /** @var string Define variable used in getters. */ protected $gettersVariable = null; /** * Magic setter method * - * @param mixed $offset Medium name value + * @param int|string $offset Medium name value * @param mixed $value Medium value */ public function __set($offset, $value) @@ -32,8 +36,7 @@ abstract class Getters implements \ArrayAccess, \Countable /** * Magic getter method * - * @param mixed $offset Medium name value - * + * @param int|string $offset Medium name value * @return mixed Medium value */ public function __get($offset) @@ -44,8 +47,7 @@ abstract class Getters implements \ArrayAccess, \Countable /** * Magic method to determine if the attribute is set * - * @param mixed $offset Medium name value - * + * @param int|string $offset Medium name value * @return boolean True if the value is set */ public function __isset($offset) @@ -56,7 +58,7 @@ abstract class Getters implements \ArrayAccess, \Countable /** * Magic method to unset the attribute * - * @param mixed $offset The name value to unset + * @param int|string $offset The name value to unset */ public function __unset($offset) { @@ -64,8 +66,7 @@ abstract class Getters implements \ArrayAccess, \Countable } /** - * @param mixed $offset - * + * @param int|string $offset * @return bool */ public function offsetExists($offset) @@ -80,8 +81,7 @@ abstract class Getters implements \ArrayAccess, \Countable } /** - * @param mixed $offset - * + * @param int|string $offset * @return mixed */ public function offsetGet($offset) @@ -96,7 +96,7 @@ abstract class Getters implements \ArrayAccess, \Countable } /** - * @param mixed $offset + * @param int|string $offset * @param mixed $value */ public function offsetSet($offset, $value) @@ -110,7 +110,7 @@ abstract class Getters implements \ArrayAccess, \Countable } /** - * @param mixed $offset + * @param int|string $offset */ public function offsetUnset($offset) { @@ -129,10 +129,10 @@ abstract class Getters implements \ArrayAccess, \Countable { if ($this->gettersVariable) { $var = $this->gettersVariable; - return \count($this->{$var}); + return count($this->{$var}); } - return \count($this->toArray()); + return count($this->toArray()); } /** diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php index c7287db..c9097ee 100644 --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ -3,7 +3,7 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,17 +11,15 @@ namespace Grav\Common; use Grav\Common\Config\Config; use Grav\Common\Config\Setup; +use Grav\Common\Helpers\Exif; use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Page\Medium\ImageMedium; use Grav\Common\Page\Medium\Medium; +use Grav\Common\Page\Pages; use Grav\Common\Processors\AssetsProcessor; use Grav\Common\Processors\BackupsProcessor; -use Grav\Common\Processors\ConfigurationProcessor; use Grav\Common\Processors\DebuggerAssetsProcessor; -use Grav\Common\Processors\DebuggerProcessor; -use Grav\Common\Processors\ErrorsProcessor; use Grav\Common\Processors\InitializeProcessor; -use Grav\Common\Processors\LoggerProcessor; use Grav\Common\Processors\PagesProcessor; use Grav\Common\Processors\PluginsProcessor; use Grav\Common\Processors\RenderProcessor; @@ -30,13 +28,40 @@ use Grav\Common\Processors\SchedulerProcessor; use Grav\Common\Processors\TasksProcessor; use Grav\Common\Processors\ThemesProcessor; use Grav\Common\Processors\TwigProcessor; +use Grav\Common\Scheduler\Scheduler; +use Grav\Common\Service\AccountsServiceProvider; +use Grav\Common\Service\AssetsServiceProvider; +use Grav\Common\Service\BackupsServiceProvider; +use Grav\Common\Service\ConfigServiceProvider; +use Grav\Common\Service\ErrorServiceProvider; +use Grav\Common\Service\FilesystemServiceProvider; +use Grav\Common\Service\FlexServiceProvider; +use Grav\Common\Service\InflectorServiceProvider; +use Grav\Common\Service\LoggerServiceProvider; +use Grav\Common\Service\OutputServiceProvider; +use Grav\Common\Service\PagesServiceProvider; +use Grav\Common\Service\RequestServiceProvider; +use Grav\Common\Service\SessionServiceProvider; +use Grav\Common\Service\StreamsServiceProvider; +use Grav\Common\Service\TaskServiceProvider; +use Grav\Common\Twig\Twig; use Grav\Framework\DI\Container; use Grav\Framework\Psr7\Response; use Grav\Framework\RequestHandler\RequestHandler; +use Grav\Framework\Session\Messages; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use RocketTheme\Toolbox\Event\Event; -use RocketTheme\Toolbox\Event\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use function array_key_exists; +use function call_user_func_array; +use function function_exists; +use function get_class; +use function in_array; +use function is_callable; +use function is_int; +use function strlen; /** * Grav container is the heart of Grav. @@ -45,14 +70,10 @@ use RocketTheme\Toolbox\Event\EventDispatcher; */ class Grav extends Container { - /** - * @var string Processed output for the page. - */ + /** @var string Processed output for the page. */ public $output; - /** - * @var static The singleton instance - */ + /** @var static The singleton instance */ protected static $instance; /** @@ -60,40 +81,37 @@ class Grav extends Container * to the dependency injection container. */ protected static $diMap = [ - 'Grav\Common\Service\AccountsServiceProvider', - 'Grav\Common\Service\AssetsServiceProvider', - 'Grav\Common\Service\BackupsServiceProvider', - 'Grav\Common\Service\ConfigServiceProvider', - 'Grav\Common\Service\ErrorServiceProvider', - 'Grav\Common\Service\FilesystemServiceProvider', - 'Grav\Common\Service\InflectorServiceProvider', - 'Grav\Common\Service\LoggerServiceProvider', - 'Grav\Common\Service\OutputServiceProvider', - 'Grav\Common\Service\PagesServiceProvider', - 'Grav\Common\Service\RequestServiceProvider', - 'Grav\Common\Service\SessionServiceProvider', - 'Grav\Common\Service\StreamsServiceProvider', - 'Grav\Common\Service\TaskServiceProvider', - 'browser' => 'Grav\Common\Browser', - 'cache' => 'Grav\Common\Cache', - 'events' => 'RocketTheme\Toolbox\Event\EventDispatcher', - 'exif' => 'Grav\Common\Helpers\Exif', - 'plugins' => 'Grav\Common\Plugins', - 'scheduler' => 'Grav\Common\Scheduler\Scheduler', - 'taxonomy' => 'Grav\Common\Taxonomy', - 'themes' => 'Grav\Common\Themes', - 'twig' => 'Grav\Common\Twig\Twig', - 'uri' => 'Grav\Common\Uri', + AccountsServiceProvider::class, + AssetsServiceProvider::class, + BackupsServiceProvider::class, + ConfigServiceProvider::class, + ErrorServiceProvider::class, + FilesystemServiceProvider::class, + FlexServiceProvider::class, + InflectorServiceProvider::class, + LoggerServiceProvider::class, + OutputServiceProvider::class, + PagesServiceProvider::class, + RequestServiceProvider::class, + SessionServiceProvider::class, + StreamsServiceProvider::class, + TaskServiceProvider::class, + 'browser' => Browser::class, + 'cache' => Cache::class, + 'events' => EventDispatcher::class, + 'exif' => Exif::class, + 'plugins' => Plugins::class, + 'scheduler' => Scheduler::class, + 'taxonomy' => Taxonomy::class, + 'themes' => Themes::class, + 'twig' => Twig::class, + 'uri' => Uri::class, ]; /** * @var array All middleware processors that are processed in $this->process() */ protected $middleware = [ - 'configurationProcessor', - 'loggerProcessor', - 'errorsProcessor', - 'debuggerProcessor', 'initializeProcessor', 'pluginsProcessor', 'themesProcessor', @@ -108,14 +126,18 @@ class Grav extends Container 'renderProcessor', ]; + /** @var array */ protected $initialized = []; /** * Reset the Grav instance. + * + * @return void */ public static function resetInstance() { if (self::$instance) { + // @phpstan-ignore-next-line self::$instance = null; } } @@ -124,12 +146,11 @@ class Grav extends Container * Return the Grav instance. Create it if it's not already instanced * * @param array $values - * * @return Grav */ public static function instance(array $values = []) { - if (!self::$instance) { + if (null === self::$instance) { self::$instance = static::load($values); } elseif ($values) { $instance = self::$instance; @@ -142,9 +163,25 @@ class Grav extends Container } /** - * Setup Grav instance using specific environment. + * Get Grav version. * - * Initializes Grav streams by + * @return string + */ + public function getVersion(): string + { + return GRAV_VERSION; + } + + /** + * @return bool + */ + public function isSetup(): bool + { + return isset($this->initialized['setup']); + } + + /** + * Setup Grav instance using specific environment. * * @param string|null $environment * @return $this @@ -157,15 +194,14 @@ class Grav extends Container $this->initialized['setup'] = true; - $this->measureTime('_setup', 'Site Setup', function () use ($environment) { - // Force environment if passed to the method. - if ($environment) { - Setup::$environment = $environment; - } + // Force environment if passed to the method. + if ($environment) { + Setup::$environment = $environment; + } - $this['setup']; - $this['streams']; - }); + // Initialize setup and streams. + $this['setup']; + $this['streams']; return $this; } @@ -176,14 +212,14 @@ class Grav extends Container * Call after `$grav->setup($environment)` * * - Load configuration + * - Initialize logger * - Disable debugger * - Set timezone, locale - * - Load plugins - * - Set Users type to be used in the site + * - Load plugins (call PluginsLoadedEvent) + * - Set Pages and Users type to be used in the site * * This method WILL NOT initialize assets, twig or pages. * - * @param string|null $environment * @return $this */ public function initializeCli() @@ -195,6 +231,8 @@ class Grav extends Container /** * Process a request + * + * @return void */ public function process() { @@ -209,18 +247,6 @@ class Grav extends Container $container = new Container( [ - 'configurationProcessor' => function () { - return new ConfigurationProcessor($this); - }, - 'loggerProcessor' => function () { - return new LoggerProcessor($this); - }, - 'errorsProcessor' => function () { - return new ErrorsProcessor($this); - }, - 'debuggerProcessor' => function () { - return new DebuggerProcessor($this); - }, 'initializeProcessor' => function () { return new InitializeProcessor($this); }, @@ -260,58 +286,139 @@ class Grav extends Container ] ); - $default = function (ServerRequestInterface $request) { - return new Response(404); + $default = static function () { + return new Response(404, ['Expires' => 0, 'Cache-Control' => 'no-store, max-age=0'], 'Not Found'); }; - /** @var Debugger $debugger */ - $debugger = $this['debugger']; - $collection = new RequestHandler($this->middleware, $default, $container); $response = $collection->handle($this['request']); $body = $response->getBody(); + /** @var Messages $messages */ + $messages = $this['messages']; + + // Prevent caching if session messages were displayed in the page. + $noCache = $messages->isCleared(); + if ($noCache) { + $response = $response->withHeader('Cache-Control', 'no-store, max-age=0'); + } + // Handle ETag and If-None-Match headers. if ($response->getHeaderLine('ETag') === '1') { $etag = md5($body); - $response = $response->withHeader('ETag', $etag); + $response = $response->withHeader('ETag', '"' . $etag . '"'); - if ($this['request']->getHeaderLine('If-None-Match') === $etag) { + $search = trim($this['request']->getHeaderLine('If-None-Match'), '"'); + if ($noCache === false && $search === $etag) { $response = $response->withStatus(304); $body = ''; } } + // Echo page content. $this->header($response); echo $body; - $debugger->render(); + $this['debugger']->render(); - register_shutdown_function([$this, 'shutdown']); - } - - /** - * Set the system locale based on the language and configuration - */ - public function setLocale() - { - // Initialize Locale if set and configured. - if ($this['language']->enabled() && $this['config']->get('system.languages.override_locale')) { - $language = $this['language']->getLanguage(); - setlocale(LC_ALL, \strlen($language) < 3 ? ($language . '_' . strtoupper($language)) : $language); - } elseif ($this['config']->get('system.default_locale')) { - setlocale(LC_ALL, $this['config']->get('system.default_locale')); + // Response object can turn off all shutdown processing. This can be used for example to speed up AJAX responses. + // Note that using this feature will also turn off response compression. + if ($response->getHeaderLine('Grav-Internal-SkipShutdown') !== '1') { + register_shutdown_function([$this, 'shutdown']); } } /** - * Redirect browser to another location. + * Terminates Grav request with a response. + * + * Please use this method instead of calling `die();` or `exit();`. Note that you need to create a response object. + * + * @param ResponseInterface $response + * @return void + */ + public function close(ResponseInterface $response): void + { + // Make sure nothing extra gets written to the response. + while (ob_get_level()) { + ob_end_clean(); + } + + // Close the session. + if (isset($this['session'])) { + $this['session']->close(); + } + + /** @var ServerRequestInterface $request */ + $request = $this['request']; + + /** @var Debugger $debugger */ + $debugger = $this['debugger']; + $response = $debugger->logRequest($request, $response); + + $body = $response->getBody(); + + /** @var Messages $messages */ + $messages = $this['messages']; + + // Prevent caching if session messages were displayed in the page. + $noCache = $messages->isCleared(); + if ($noCache) { + $response = $response->withHeader('Cache-Control', 'no-store, max-age=0'); + } + + // Handle ETag and If-None-Match headers. + if ($response->getHeaderLine('ETag') === '1') { + $etag = md5($body); + $response = $response->withHeader('ETag', '"' . $etag . '"'); + + $search = trim($this['request']->getHeaderLine('If-None-Match'), '"'); + if ($noCache === false && $search === $etag) { + $response = $response->withStatus(304); + $body = ''; + } + } + + // Echo page content. + $this->header($response); + echo $body; + exit(); + } + + /** + * @param ResponseInterface $response + * @return void + * @deprecated 1.7 Do not use + */ + public function exit(ResponseInterface $response): void + { + $this->close($response); + } + + /** + * Terminates Grav request and redirects browser to another location. + * + * Please use this method instead of calling `header("Location: {$url}", true, 302); exit();`. * * @param string $route Internal route. - * @param int $code Redirection code (30x) + * @param int|null $code Redirection code (30x) + * @return void */ - public function redirect($route, $code = null) + public function redirect($route, $code = null): void + { + $response = $this->getRedirectResponse($route, $code); + + $this->close($response); + } + + /** + * Returns redirect response object from Grav. + * + * @param string $route Internal route. + * @param int|null $code Redirection code (30x) + * @return ResponseInterface + */ + public function getRedirectResponse($route, $code = null): ResponseInterface { /** @var Uri $uri */ $uri = $this['uri']; @@ -319,23 +426,25 @@ class Grav extends Container // Clean route for redirect $route = preg_replace("#^\/[\\\/]+\/#", '/', $route); - // Check for code in route - $regex = '/.*(\[(30[1-7])\])$/'; - preg_match($regex, $route, $matches); - if ($matches) { - $route = str_replace($matches[1], '', $matches[0]); - $code = $matches[2]; + if ($code < 300 || $code > 399) { + $code = null; + } + + if (null === $code) { + // Check for redirect code in the route: e.g. /new/[301], /new[301]/route or /new[301].html + $regex = '/.*(\[(30[1-7])\])(.\w+|\/.*?)?$/'; + preg_match($regex, $route, $matches); + if ($matches) { + $route = str_replace($matches[1], '', $matches[0]); + $code = $matches[2]; + } } if ($code === null) { $code = $this['config']->get('system.pages.redirect_default_code', 302); } - if (isset($this['session'])) { - $this['session']->close(); - } - - if ($uri->isExternal($route)) { + if ($uri::isExternal($route)) { $url = $route; } else { $url = rtrim($uri->rootUrl(), '/') . '/'; @@ -347,8 +456,7 @@ class Grav extends Container } } - header("Location: {$url}", true, $code); - exit(); + return new Response($code, ['Location' => $url]); } /** @@ -356,6 +464,7 @@ class Grav extends Container * * @param string $route Internal route. * @param int $code Redirection code (30x) + * @return void */ public function redirectLangSafe($route, $code = null) { @@ -370,6 +479,7 @@ class Grav extends Container * Set response header. * * @param ResponseInterface|null $response + * @return void */ public function header(ResponseInterface $response = null) { @@ -381,36 +491,86 @@ class Grav extends Container header("HTTP/{$response->getProtocolVersion()} {$response->getStatusCode()} {$response->getReasonPhrase()}"); foreach ($response->getHeaders() as $key => $values) { + // Skip internal Grav headers. + if (strpos($key, 'Grav-Internal-') === 0) { + continue; + } foreach ($values as $i => $value) { header($key . ': ' . $value, $i === 0); } } } + /** + * Set the system locale based on the language and configuration + * + * @return void + */ + public function setLocale() + { + // Initialize Locale if set and configured. + if ($this['language']->enabled() && $this['config']->get('system.languages.override_locale')) { + $language = $this['language']->getLanguage(); + setlocale(LC_ALL, strlen($language) < 3 ? ($language . '_' . strtoupper($language)) : $language); + } elseif ($this['config']->get('system.default_locale')) { + setlocale(LC_ALL, $this['config']->get('system.default_locale')); + } + } + + /** + * @param object $event + * @return object + */ + public function dispatchEvent($event) + { + /** @var EventDispatcherInterface $events */ + $events = $this['events']; + $eventName = get_class($event); + + $timestamp = microtime(true); + $event = $events->dispatch($event); + + /** @var Debugger $debugger */ + $debugger = $this['debugger']; + $debugger->addEvent($eventName, $event, $events, $timestamp); + + return $event; + } + /** * Fires an event with optional parameters. * * @param string $eventName - * @param Event $event - * + * @param Event|null $event * @return Event */ public function fireEvent($eventName, Event $event = null) { - /** @var EventDispatcher $events */ + /** @var EventDispatcherInterface $events */ $events = $this['events']; + if (null === $event) { + $event = new Event(); + } - return $events->dispatch($eventName, $event); + $timestamp = microtime(true); + $events->dispatch($event, $eventName); + + /** @var Debugger $debugger */ + $debugger = $this['debugger']; + $debugger->addEvent($eventName, $event, $events, $timestamp); + + return $event; } /** * Set the final content length for the page and flush the buffer * + * @return void */ public function shutdown() { // Prevent user abort allowing onShutdown event to run without interruptions. - if (\function_exists('ignore_user_abort')) { + if (function_exists('ignore_user_abort')) { @ignore_user_abort(true); } @@ -419,32 +579,33 @@ class Grav extends Container $this['session']->close(); } - if ($this['config']->get('system.debugger.shutdown.close_connection', true)) { + /** @var Config $config */ + $config = $this['config']; + if ($config->get('system.debugger.shutdown.close_connection', true)) { // Flush the response and close the connection to allow time consuming tasks to be performed without leaving // the connection to the client open. This will make page loads to feel much faster. // FastCGI allows us to flush all response data to the client and finish the request. - $success = \function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false; - + $success = function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false; if (!$success) { // Unfortunately without FastCGI there is no way to force close the connection. // We need to ask browser to close the connection for us. - if ($this['config']->get('system.cache.gzip')) { - // Flush gzhandler buffer if gzip setting was enabled. - ob_end_flush(); - } else { + if ($config->get('system.cache.gzip')) { + // Flush gzhandler buffer if gzip setting was enabled to get the size of the compressed output. + ob_end_flush(); + } elseif ($config->get('system.cache.allow_webserver_gzip')) { + // Let web server to do the hard work. + header('Content-Encoding: identity'); + } elseif (function_exists('apache_setenv')) { // Without gzip we have no other choice than to prevent server from compressing the output. // This action turns off mod_deflate which would prevent us from closing the connection. - if ($this['config']->get('system.cache.allow_webserver_gzip')) { - header('Content-Encoding: identity'); - } else { - header('Content-Encoding: none'); - } - + @apache_setenv('no-gzip', '1'); + } else { + // Fall back to unknown content encoding, it prevents most servers from deflating the content. + header('Content-Encoding: none'); } - // Get length and close the connection. header('Content-Length: ' . ob_get_length()); header('Connection: close'); @@ -468,7 +629,7 @@ class Grav extends Container * * @param string $method * @param array $args - * @return + * @return mixed|null */ public function __call($method, $args) { @@ -499,7 +660,6 @@ class Grav extends Container * Initialize and return a Grav instance * * @param array $values - * * @return static */ protected static function load(array $values) @@ -513,9 +673,7 @@ class Grav extends Container return $container; }; - $container->measureTime('_services', 'Services', function () use ($container) { - $container->registerServices(); - }); + $container->registerServices(); return $container; } @@ -531,7 +689,7 @@ class Grav extends Container protected function registerServices() { foreach (self::$diMap as $serviceKey => $serviceClass) { - if (\is_int($serviceKey)) { + if (is_int($serviceKey)) { $this->register(new $serviceClass); } else { $this[$serviceKey] = function ($c) use ($serviceClass) { @@ -545,6 +703,7 @@ class Grav extends Container * This attempts to find media, other files, and download them * * @param string $path + * @return PageInterface|false */ public function fallbackUrl($path) { @@ -561,7 +720,7 @@ class Grav extends Container $supported_types = $config->get('media.types'); // Check whitelist first, then ensure extension is a valid media type - if (!empty($fallback_types) && !\in_array($uri_extension, $fallback_types, true)) { + if (!empty($fallback_types) && !in_array($uri_extension, $fallback_types, true)) { return false; } if (!array_key_exists($uri_extension, $supported_types)) { @@ -570,8 +729,9 @@ class Grav extends Container $path_parts = pathinfo($path); - /** @var PageInterface $page */ - $page = $this['pages']->dispatch($path_parts['dirname'], true); + /** @var Pages $pages */ + $pages = $this['pages']; + $page = $pages->find($path_parts['dirname'], true); if ($page) { $media = $page->media()->all(); @@ -583,8 +743,8 @@ class Grav extends Container /** @var Medium $medium */ $medium = $media[$media_file]; foreach ($uri->query(null, true) as $action => $params) { - if (\in_array($action, ImageMedium::$magic_actions, true)) { - \call_user_func_array([&$medium, $action], explode(',', $params)); + if (in_array($action, ImageMedium::$magic_actions, true)) { + call_user_func_array([&$medium, $action], explode(',', $params)); } } Utils::download($medium->path(), false); @@ -603,7 +763,7 @@ class Grav extends Container if ($extension) { $download = true; - if (\in_array(ltrim($extension, '.'), $config->get('system.media.unsupported_inline_types', []), true)) { + if (in_array(ltrim($extension, '.'), $config->get('system.media.unsupported_inline_types', []), true)) { $download = false; } Utils::download($page->path() . DIRECTORY_SEPARATOR . $uri->basename(), $download); diff --git a/system/src/Grav/Common/GravTrait.php b/system/src/Grav/Common/GravTrait.php index a82c23a..4638905 100644 --- a/system/src/Grav/Common/GravTrait.php +++ b/system/src/Grav/Common/GravTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,6 +14,7 @@ namespace Grav\Common; */ trait GravTrait { + /** @var Grav */ protected static $grav; /** @@ -24,7 +25,7 @@ trait GravTrait { user_error(__TRAIT__ . ' is deprecated since Grav 1.4, use Grav::instance() instead', E_USER_DEPRECATED); - if (!self::$grav) { + if (null === self::$grav) { self::$grav = Grav::instance(); } diff --git a/system/src/Grav/Common/Helpers/Base32.php b/system/src/Grav/Common/Helpers/Base32.php index f138557..825c1ec 100644 --- a/system/src/Grav/Common/Helpers/Base32.php +++ b/system/src/Grav/Common/Helpers/Base32.php @@ -3,15 +3,26 @@ /** * @package Grav\Common\Helpers * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Helpers; +use function chr; +use function count; +use function ord; +use function strlen; + +/** + * Class Base32 + * @package Grav\Common\Helpers + */ class Base32 { + /** @var string */ protected static $base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + /** @var array */ protected static $base32Lookup = [ 0xFF,0xFF,0x1A,0x1B,0x1C,0x1D,0x1E,0x1F, // '0', '1', '2', '3', '4', '5', '6', '7' 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, // '8', '9', ':', ';', '<', '=', '>', '?' @@ -33,17 +44,18 @@ class Base32 */ public static function encode($bytes) { - $i = 0; $index = 0; + $i = 0; + $index = 0; $base32 = ''; - $bytesLen = \strlen($bytes); + $bytesLen = strlen($bytes); while ($i < $bytesLen) { - $currByte = \ord($bytes[$i]); + $currByte = ord($bytes[$i]); /* Is the current digit going to span a byte boundary? */ if ($index > 3) { if (($i + 1) < $bytesLen) { - $nextByte = \ord($bytes[$i+1]); + $nextByte = ord($bytes[$i+1]); } else { $nextByte = 0; } @@ -75,15 +87,15 @@ class Base32 public static function decode($base32) { $bytes = []; - $base32Len = \strlen($base32); - $base32LookupLen = \count(self::$base32Lookup); + $base32Len = strlen($base32); + $base32LookupLen = count(self::$base32Lookup); for ($i = $base32Len * 5 / 8 - 1; $i >= 0; --$i) { $bytes[] = 0; } for ($i = 0, $index = 0, $offset = 0; $i < $base32Len; $i++) { - $lookup = \ord($base32[$i]) - \ord('0'); + $lookup = ord($base32[$i]) - ord('0'); /* Skip chars outside the lookup table */ if ($lookup < 0 || $lookup >= $base32LookupLen) { @@ -102,7 +114,7 @@ class Base32 if ($index === 0) { $bytes[$offset] |= $digit; $offset++; - if ($offset >= \count($bytes)) { + if ($offset >= count($bytes)) { break; } } else { @@ -112,7 +124,7 @@ class Base32 $index = ($index + 5) % 8; $bytes[$offset] |= ($digit >> $index); $offset++; - if ($offset >= \count($bytes)) { + if ($offset >= count($bytes)) { break; } $bytes[$offset] |= $digit << (8 - $index); @@ -121,7 +133,7 @@ class Base32 $bites = ''; foreach ($bytes as $byte) { - $bites .= \chr($byte); + $bites .= chr($byte); } return $bites; diff --git a/system/src/Grav/Common/Helpers/Excerpts.php b/system/src/Grav/Common/Helpers/Excerpts.php index f07c3aa..cd285cf 100644 --- a/system/src/Grav/Common/Helpers/Excerpts.php +++ b/system/src/Grav/Common/Helpers/Excerpts.php @@ -3,16 +3,24 @@ /** * @package Grav\Common\Helpers * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Helpers; +use DOMDocument; +use DOMElement; use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Page\Markdown\Excerpts as ExcerptsObject; +use Grav\Common\Page\Medium\Link; use Grav\Common\Page\Medium\Medium; +use function is_array; +/** + * Class Excerpts + * @package Grav\Common\Helpers + */ class Excerpts { /** @@ -32,7 +40,7 @@ class Excerpts $excerpt = static::processLinkExcerpt($excerpt, $page, 'image'); $excerpt['element']['attributes']['src'] = $excerpt['element']['attributes']['href']; - unset ($excerpt['element']['attributes']['href']); + unset($excerpt['element']['attributes']['href']); $excerpt = static::processImageExcerpt($excerpt, $page); @@ -43,6 +51,26 @@ class Excerpts return $html; } + /** + * Process Grav page link URL from HTML tag + * + * @param string $html HTML tag e.g. `Page Link` + * @param PageInterface|null $page Page, defaults to the current page object + * @return string Returns final HTML string + */ + public static function processLinkHtml($html, PageInterface $page = null) + { + $excerpt = static::getExcerptFromHtml($html, 'a'); + + $original_href = $excerpt['element']['attributes']['href']; + $excerpt = static::processLinkExcerpt($excerpt, $page, 'link'); + $excerpt['element']['attributes']['data-href'] = $original_href; + + $html = static::getHtmlFromExcerpt($excerpt); + + return $html; + } + /** * Get an Excerpt array from a chunk of HTML * @@ -52,22 +80,35 @@ class Excerpts */ public static function getExcerptFromHtml($html, $tag) { - $doc = new \DOMDocument(); - $doc->loadHTML($html); - $images = $doc->getElementsByTagName($tag); - $excerpt = null; + $doc = new DOMDocument('1.0', 'UTF-8'); + $internalErrors = libxml_use_internal_errors(true); + $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + libxml_use_internal_errors($internalErrors); - foreach ($images as $image) { + $elements = $doc->getElementsByTagName($tag); + $excerpt = null; + $inner = []; + + /** @var DOMElement $element */ + foreach ($elements as $element) { $attributes = []; - foreach ($image->attributes as $name => $value) { + foreach ($element->attributes as $name => $value) { $attributes[$name] = $value->value; } $excerpt = [ 'element' => [ - 'name' => $image->tagName, + 'name' => $element->tagName, 'attributes' => $attributes ] ]; + + foreach ($element->childNodes as $node) { + $inner[] = $doc->saveHTML($node); + } + + $excerpt = array_merge_recursive($excerpt, ['element' => ['text' => implode('', $inner)]]); + + } return $excerpt; @@ -95,7 +136,7 @@ class Excerpts if (isset($element['text'])) { $html .= '>'; - $html .= $element['text']; + $html .= is_array($element['text']) ? static::getHtmlFromExcerpt(['element' => $element['text']]) : $element['text']; $html .= ''; } else { $html .= ' />'; @@ -139,7 +180,7 @@ class Excerpts * @param Medium $medium * @param string|array $url * @param PageInterface|null $page Page, defaults to the current page object - * @return Medium + * @return Medium|Link */ public static function processMediaActions($medium, $url, PageInterface $page = null) { diff --git a/system/src/Grav/Common/Helpers/Exif.php b/system/src/Grav/Common/Helpers/Exif.php index 15aff66..36e391f 100644 --- a/system/src/Grav/Common/Helpers/Exif.php +++ b/system/src/Grav/Common/Helpers/Exif.php @@ -3,39 +3,46 @@ /** * @package Grav\Common\Helpers * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Helpers; use Grav\Common\Grav; +use PHPExif\Reader\Reader; +use RuntimeException; +use function function_exists; +/** + * Class Exif + * @package Grav\Common\Helpers + */ class Exif { + /** @var Reader */ public $reader; /** * Exif constructor. - * @throws \RuntimeException + * @throws RuntimeException */ public function __construct() { if (Grav::instance()['config']->get('system.media.auto_metadata_exif')) { - if (function_exists('exif_read_data') && class_exists('\PHPExif\Reader\Reader')) { - $this->reader = \PHPExif\Reader\Reader::factory(\PHPExif\Reader\Reader::TYPE_NATIVE); + if (function_exists('exif_read_data') && class_exists(Reader::class)) { + $this->reader = Reader::factory(Reader::TYPE_NATIVE); } else { - throw new \RuntimeException('Please enable the Exif extension for PHP or disable Exif support in Grav system configuration'); + throw new RuntimeException('Please enable the Exif extension for PHP or disable Exif support in Grav system configuration'); } } } + /** + * @return Reader + */ public function getReader() { - if ($this->reader) { - return $this->reader; - } - - return false; + return $this->reader; } } diff --git a/system/src/Grav/Common/Helpers/LogViewer.php b/system/src/Grav/Common/Helpers/LogViewer.php index 397fb17..a03fde8 100644 --- a/system/src/Grav/Common/Helpers/LogViewer.php +++ b/system/src/Grav/Common/Helpers/LogViewer.php @@ -3,14 +3,24 @@ /** * @package Grav\Common\Helpers * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Helpers; +use DateTime; +use function array_slice; +use function is_array; +use function is_string; + +/** + * Class LogViewer + * @package Grav\Common\Helpers + */ class LogViewer { + /** @var string */ protected $pattern = '/\[(?P.*)\] (?P\w+).(?P\w+): (?P.*[^ ]+) (?P[^ ]+) (?P[^ ]+)/'; /** @@ -24,7 +34,7 @@ class LogViewer public function objectTail($filepath, $lines = 1, $desc = true) { $data = $this->tail($filepath, $lines); - $tailed_log = explode(PHP_EOL, $data); + $tailed_log = $data ? explode(PHP_EOL, $data) : []; $line_objects = []; foreach ($tailed_log as $line) { @@ -39,17 +49,22 @@ class LogViewer * * @param string $filepath * @param int $lines - * @return bool|string + * @return string|false */ - public function tail($filepath, $lines = 1) { + public function tail($filepath, $lines = 1) + { - $f = @fopen($filepath, "rb"); - if ($f === false) return false; + $f = $filepath ? @fopen($filepath, 'rb') : false; + if ($f === false) { + return false; + } - else $buffer = ($lines < 2 ? 64 : ($lines < 10 ? 512 : 4096)); + $buffer = ($lines < 2 ? 64 : ($lines < 10 ? 512 : 4096)); fseek($f, -1, SEEK_END); - if (fread($f, 1) != "\n") $lines -= 1; + if (fread($f, 1) != "\n") { + $lines -= 1; + } // Start reading $output = ''; @@ -83,7 +98,7 @@ class LogViewer * Helper class to get level color * * @param string $level - * @return mixed|string + * @return string */ public static function levelColor($level) { @@ -108,9 +123,10 @@ class LogViewer */ public function parse($line) { - if( !is_string($line) || strlen($line) === 0) { + if (!is_string($line) || strlen($line) === 0) { return array(); } + preg_match($this->pattern, $line, $data); if (!isset($data['date'])) { return array(); @@ -123,7 +139,7 @@ class LogViewer } return array( - 'date' => \DateTime::createFromFormat('Y-m-d H:i:s', $data['date']), + 'date' => DateTime::createFromFormat('Y-m-d H:i:s', $data['date']), 'logger' => $data['logger'], 'level' => $data['level'], 'message' => $data['message'], @@ -143,7 +159,7 @@ class LogViewer public static function parseTrace($trace, $rows = 10) { $lines = array_filter(preg_split('/#\d*/m', $trace)); + return array_slice($lines, 0, $rows); } - } diff --git a/system/src/Grav/Common/Helpers/Truncator.php b/system/src/Grav/Common/Helpers/Truncator.php index d116bb3..0a701b6 100644 --- a/system/src/Grav/Common/Helpers/Truncator.php +++ b/system/src/Grav/Common/Helpers/Truncator.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Helpers * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,6 +15,8 @@ use DOMElement; use DOMNode; use DOMWordsIterator; use DOMLettersIterator; +use function in_array; +use function strlen; /** * This file is part of https://github.com/Bluetel-Solutions/twig-truncate-extension @@ -25,11 +27,11 @@ use DOMLettersIterator; * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ - -class Truncator { - +class Truncator +{ /** * Safely truncates HTML by a given number of words. + * * @param string $html Input HTML. * @param int $limit Limit to how many words we preserve. * @param string $ellipsis String to use as ellipsis (if any). @@ -49,10 +51,8 @@ class Truncator { $words = new DOMWordsIterator($container); $truncated = false; foreach ($words as $word) { - // If we have exceeded the limit, we delete the remainder of the content. if ($words->key() >= $limit) { - // Grab current position. $currentWordPosition = $words->currentWordPosition(); $curNode = $currentWordPosition[0]; @@ -75,7 +75,6 @@ class Truncator { break; } - } // Return original HTML if not truncated. @@ -88,6 +87,7 @@ class Truncator { /** * Safely truncates HTML by a given number of letters. + * * @param string $html Input HTML. * @param int $limit Limit to how many letters we preserve. * @param string $ellipsis String to use as ellipsis (if any). @@ -107,10 +107,8 @@ class Truncator { $letters = new DOMLettersIterator($container); $truncated = false; foreach ($letters as $letter) { - // If we have exceeded the limit, we want to delete the remainder of this document. if ($letters->key() >= $limit) { - $currentText = $letters->currentTextPosition(); $currentText[0]->nodeValue = mb_substr($currentText[0]->nodeValue, 0, $currentText[1] + 1); self::removeProceedingNodes($currentText[0], $container); @@ -135,8 +133,9 @@ class Truncator { /** * Builds a DOMDocument object from a string containing HTML. + * * @param string $html HTML to load - * @returns DOMDocument Returns a DOMDocument object. + * @return DOMDocument Returns a DOMDocument object. */ public static function htmlToDomDocument($html) { @@ -160,12 +159,14 @@ class Truncator { /** * Removes all nodes after the current node. + * * @param DOMNode|DOMElement $domNode * @param DOMNode|DOMElement $topNode * @return void */ private static function removeProceedingNodes($domNode, $topNode) { + /** @var DOMNode|null $nextNode */ $nextNode = $domNode->nextSibling; if ($nextNode !== null) { @@ -190,25 +191,25 @@ class Truncator { * Clean extra code * * @param DOMDocument $doc - * @param $container + * @param DOMNode $container * @return string */ - private static function getCleanedHTML(DOMDocument $doc, $container) + private static function getCleanedHTML(DOMDocument $doc, DOMNode $container) { while ($doc->firstChild) { $doc->removeChild($doc->firstChild); } - while ($container->firstChild ) { + while ($container->firstChild) { $doc->appendChild($container->firstChild); } - $html = trim($doc->saveHTML()); - return $html; + return trim($doc->saveHTML()); } /** * Inserts an ellipsis + * * @param DOMNode|DOMElement $domNode Element to insert after. * @param string $ellipsis Text used to suffix our document. * @return void @@ -221,12 +222,13 @@ class Truncator { // Append as text node to parent instead $textNode = new DOMText($ellipsis); - if ($domNode->parentNode->parentNode->nextSibling) { + /** @var DOMNode|null $nextSibling */ + $nextSibling = $domNode->parentNode->parentNode->nextSibling; + if ($nextSibling) { $domNode->parentNode->parentNode->insertBefore($textNode, $domNode->parentNode->parentNode->nextSibling); } else { $domNode->parentNode->parentNode->appendChild($textNode); } - } else { // Append to current node $domNode->nodeValue = rtrim($domNode->nodeValue) . $ellipsis; @@ -234,7 +236,12 @@ class Truncator { } /** - * + * @param string $text + * @param int $length + * @param string $ending + * @param bool $exact + * @param bool $considerHtml + * @return string */ public function truncate( $text, @@ -248,11 +255,13 @@ class Truncator { if (strlen(preg_replace('/<.*?>/', '', $text)) <= $length) { return $text; } + // splits all html-tags to scanable lines preg_match_all('/(<.+?>)?([^<>]*)/s', $text, $lines, PREG_SET_ORDER); $total_length = strlen($ending); - $open_tags = array(); $truncate = ''; + $open_tags = []; + foreach ($lines as $line_matchings) { // if there is any html-tag in this line, handle it and add it (uncounted) to the output if (!empty($line_matchings[1])) { @@ -260,14 +269,14 @@ class Truncator { if (preg_match('/^<(\s*.+?\/\s*|\s*(img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param)(\s.+?)?)>$/is', $line_matchings[1])) { // do nothing // if tag is a closing tag - } else if (preg_match('/^<\s*\/([^\s]+?)\s*>$/s', $line_matchings[1], $tag_matchings)) { + } elseif (preg_match('/^<\s*\/([^\s]+?)\s*>$/s', $line_matchings[1], $tag_matchings)) { // delete tag from $open_tags list $pos = array_search($tag_matchings[1], $open_tags); if ($pos !== false) { unset($open_tags[$pos]); } // if tag is an opening tag - } else if (preg_match('/^<\s*([^\s>!]+).*?>$/s', $line_matchings[1], $tag_matchings)) { + } elseif (preg_match('/^<\s*([^\s>!]+).*?>$/s', $line_matchings[1], $tag_matchings)) { // add tag to the beginning of $open_tags list array_unshift($open_tags, strtolower($tag_matchings[1])); } @@ -301,35 +310,35 @@ class Truncator { $total_length += $content_length; } // if the maximum length is reached, get off the loop - if($total_length>= $length) { + if ($total_length>= $length) { break; } } } else { if (strlen($text) <= $length) { return $text; - } else { - $truncate = substr($text, 0, $length - strlen($ending)); } + + $truncate = substr($text, 0, $length - strlen($ending)); } // if the words shouldn't be cut in the middle... if (!$exact) { // ...search the last occurance of a space... $spacepos = strrpos($truncate, ' '); - if (isset($spacepos)) { + if (false !== $spacepos) { // ...and cut the text in this position $truncate = substr($truncate, 0, $spacepos); } } // add the defined ending to the text $truncate .= $ending; - if($considerHtml) { + if (isset($open_tags)) { // close all unclosed html-tags foreach ($open_tags as $tag) { $truncate .= ''; } } + return $truncate; } - } diff --git a/system/src/Grav/Common/Helpers/YamlLinter.php b/system/src/Grav/Common/Helpers/YamlLinter.php index b7e608d..45b5144 100644 --- a/system/src/Grav/Common/Helpers/YamlLinter.php +++ b/system/src/Grav/Common/Helpers/YamlLinter.php @@ -3,38 +3,61 @@ /** * @package Grav\Common\Helpers * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Helpers; +use Exception; use Grav\Common\Grav; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use RegexIterator; use RocketTheme\Toolbox\File\MarkdownFile; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; use Symfony\Component\Yaml\Yaml; +/** + * Class YamlLinter + * @package Grav\Common\Helpers + */ class YamlLinter { - public static function lint() + /** + * @param string|null $folder + * @return array + */ + public static function lint(string $folder = null) { - $errors = static::lintConfig(); - $errors = $errors + static::lintPages(); - $errors = $errors + static::lintBlueprints(); - - return $errors; + if (null !== $folder) { + $folder = $folder ?: GRAV_ROOT; + + return static::recurseFolder($folder); + } + + return array_merge(static::lintConfig(), static::lintPages(), static::lintBlueprints()); } + /** + * @return array + */ public static function lintPages() { return static::recurseFolder('page://'); } + /** + * @return array + */ public static function lintConfig() { return static::recurseFolder('config://'); } + /** + * @return array + */ public static function lintBlueprints() { /** @var UniformResourceLocator $locator */ @@ -47,26 +70,31 @@ class YamlLinter return static::recurseFolder('blueprints://'); } - public static function recurseFolder($path, $extensions = 'md|yaml') + /** + * @param string $path + * @param string $extensions + * @return array + */ + public static function recurseFolder($path, $extensions = '(md|yaml)') { $lint_errors = []; /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; - $flags = \RecursiveDirectoryIterator::SKIP_DOTS; + $flags = RecursiveDirectoryIterator::SKIP_DOTS | RecursiveDirectoryIterator::FOLLOW_SYMLINKS; if ($locator->isStream($path)) { $directory = $locator->getRecursiveIterator($path, $flags); } else { - $directory = new \RecursiveDirectoryIterator($path, $flags); + $directory = new RecursiveDirectoryIterator($path, $flags); } - $recursive = new \RecursiveIteratorIterator($directory, \RecursiveIteratorIterator::SELF_FIRST); - $iterator = new \RegexIterator($recursive, '/^.+\.'.$extensions.'$/i'); + $recursive = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); + $iterator = new RegexIterator($recursive, '/^.+\.'.$extensions.'$/ui'); - /** @var \RecursiveDirectoryIterator $file */ + /** @var RecursiveDirectoryIterator $file */ foreach ($iterator as $filepath => $file) { try { Yaml::parse(static::extractYaml($filepath)); - } catch (\Exception $e) { + } catch (Exception $e) { $lint_errors[str_replace(GRAV_ROOT, '', $filepath)] = $e->getMessage(); } } @@ -74,16 +102,20 @@ class YamlLinter return $lint_errors; } + /** + * @param string $path + * @return string + */ protected static function extractYaml($path) { $extension = pathinfo($path, PATHINFO_EXTENSION); if ($extension === 'md') { $file = MarkdownFile::instance($path); $contents = $file->frontmatter(); + $file->free(); } else { $contents = file_get_contents($path); } return $contents; } - } diff --git a/system/src/Grav/Common/Inflector.php b/system/src/Grav/Common/Inflector.php index eca93c1..50c218f 100644 --- a/system/src/Grav/Common/Inflector.php +++ b/system/src/Grav/Common/Inflector.php @@ -3,33 +3,53 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; +use DateInterval; +use DateTime; +use Grav\Common\Language\Language; +use function in_array; +use function is_array; +use function strlen; + /** * This file was originally part of the Akelos Framework */ - class Inflector { + /** @var bool */ + protected static $initialized = false; + /** @var array|null */ protected static $plural; + /** @var array|null */ protected static $singular; + /** @var array|null */ protected static $uncountable; + /** @var array|null */ protected static $irregular; + /** @var array|null */ protected static $ordinals; + /** + * @return void + */ public static function init() { - if (empty(static::$plural)) { + if (!static::$initialized) { + static::$initialized = true; + /** @var Language $language */ $language = Grav::instance()['language']; - static::$plural = $language->translate('GRAV.INFLECTOR_PLURALS', null, true) ?: []; - static::$singular = $language->translate('GRAV.INFLECTOR_SINGULAR', null, true) ?: []; - static::$uncountable = $language->translate('GRAV.INFLECTOR_UNCOUNTABLE', null, true) ?: []; - static::$irregular = $language->translate('GRAV.INFLECTOR_IRREGULAR', null, true) ?: []; - static::$ordinals = $language->translate('GRAV.INFLECTOR_ORDINALS', null, true) ?: []; + if (!$language->isDebug()) { + static::$plural = $language->translate('GRAV.INFLECTOR_PLURALS', null, true); + static::$singular = $language->translate('GRAV.INFLECTOR_SINGULAR', null, true); + static::$uncountable = $language->translate('GRAV.INFLECTOR_UNCOUNTABLE', null, true); + static::$irregular = $language->translate('GRAV.INFLECTOR_IRREGULAR', null, true); + static::$ordinals = $language->translate('GRAV.INFLECTOR_ORDINALS', null, true); + } } } @@ -38,8 +58,7 @@ class Inflector * * @param string $word English noun to pluralize * @param int $count The count - * - * @return string Plural noun + * @return string|false Plural noun */ public static function pluralize($word, $count = 2) { @@ -51,26 +70,31 @@ class Inflector $lowercased_word = strtolower($word); - foreach (static::$uncountable as $_uncountable) { - if (substr($lowercased_word, -1 * strlen($_uncountable)) === $_uncountable) { - return $word; + if (is_array(static::$uncountable)) { + foreach (static::$uncountable as $_uncountable) { + if (substr($lowercased_word, -1 * strlen($_uncountable)) === $_uncountable) { + return $word; + } } } - foreach (static::$irregular as $_plural => $_singular) { - if (preg_match('/(' . $_plural . ')$/i', $word, $arr)) { - return preg_replace('/(' . $_plural . ')$/i', substr($arr[0], 0, 1) . substr($_singular, 1), $word); + if (is_array(static::$irregular)) { + foreach (static::$irregular as $_plural => $_singular) { + if (preg_match('/(' . $_plural . ')$/i', $word, $arr)) { + return preg_replace('/(' . $_plural . ')$/i', substr($arr[0], 0, 1) . substr($_singular, 1), $word); + } } } - foreach (static::$plural as $rule => $replacement) { - if (preg_match($rule, $word)) { - return preg_replace($rule, $replacement, $word); + if (is_array(static::$plural)) { + foreach (static::$plural as $rule => $replacement) { + if (preg_match($rule, $word)) { + return preg_replace($rule, $replacement, $word); + } } } return false; - } /** @@ -90,21 +114,28 @@ class Inflector } $lowercased_word = strtolower($word); - foreach (static::$uncountable as $_uncountable) { - if (substr($lowercased_word, -1 * strlen($_uncountable)) === $_uncountable) { - return $word; + + if (is_array(static::$uncountable)) { + foreach (static::$uncountable as $_uncountable) { + if (substr($lowercased_word, -1 * strlen($_uncountable)) === $_uncountable) { + return $word; + } } } - foreach (static::$irregular as $_plural => $_singular) { - if (preg_match('/(' . $_singular . ')$/i', $word, $arr)) { - return preg_replace('/(' . $_singular . ')$/i', substr($arr[0], 0, 1) . substr($_plural, 1), $word); + if (is_array(static::$irregular)) { + foreach (static::$irregular as $_plural => $_singular) { + if (preg_match('/(' . $_singular . ')$/i', $word, $arr)) { + return preg_replace('/(' . $_singular . ')$/i', substr($arr[0], 0, 1) . substr($_plural, 1), $word); + } } } - foreach (static::$singular as $rule => $replacement) { - if (preg_match($rule, $word)) { - return preg_replace($rule, $replacement, $word); + if (is_array(static::$singular)) { + foreach (static::$singular as $rule => $replacement) { + if (preg_match($rule, $word)) { + return preg_replace($rule, $replacement, $word); + } } } @@ -144,8 +175,7 @@ class Inflector * * @see variablize * - * @param string $word Word to convert to camel case - * + * @param string $word Word to convert to camel case * @return string UpperCamelCasedWord */ public static function camelize($word) @@ -161,8 +191,7 @@ class Inflector * * This can be really useful for creating friendly URLs. * - * @param string $word Word to underscore - * + * @param string $word Word to underscore * @return string Underscored word */ public static function underscorize($word) @@ -182,8 +211,7 @@ class Inflector * * This can be really useful for creating friendly URLs. * - * @param string $word Word to hyphenate - * + * @param string $word Word to hyphenate * @return string hyphenized word */ public static function hyphenize($word) @@ -230,8 +258,7 @@ class Inflector * * @see camelize * - * @param string $word Word to lowerCamelCase - * + * @param string $word Word to lowerCamelCase * @return string Returns a lowerCamelCasedWord */ public static function variablize($word) @@ -249,8 +276,7 @@ class Inflector * * @see classify * - * @param string $class_name Class name for getting related table_name. - * + * @param string $class_name Class name for getting related table_name. * @return string plural_table_name */ public static function tableize($class_name) @@ -266,8 +292,7 @@ class Inflector * * @see tableize * - * @param string $table_name Table name for getting related ClassName. - * + * @param string $table_name Table name for getting related ClassName. * @return string SingularClassName */ public static function classify($table_name) @@ -280,15 +305,18 @@ class Inflector * * This method converts 13 to 13th, 2 to 2nd ... * - * @param int $number Number to get its ordinal value - * + * @param int $number Number to get its ordinal value * @return string Ordinal representation of given string. */ public static function ordinalize($number) { + if (!is_array(static::$ordinals)) { + return (string)$number; + } + static::init(); - if (\in_array($number % 100, range(11, 13), true)) { + if (in_array($number % 100, range(11, 13), true)) { return $number . static::$ordinals['default']; } @@ -308,15 +336,14 @@ class Inflector * Converts a number of days to a number of months * * @param int $days - * * @return int */ public static function monthize($days) { - $now = new \DateTime(); - $end = new \DateTime(); + $now = new DateTime(); + $end = new DateTime(); - $duration = new \DateInterval("P{$days}D"); + $duration = new DateInterval("P{$days}D"); $diff = $end->add($duration)->diff($now); diff --git a/system/src/Grav/Common/Iterator.php b/system/src/Grav/Common/Iterator.php index 5f6dc7e..cc7cf92 100644 --- a/system/src/Grav/Common/Iterator.php +++ b/system/src/Grav/Common/Iterator.php @@ -3,7 +3,7 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,14 +15,20 @@ use RocketTheme\Toolbox\ArrayTraits\Constructor; use RocketTheme\Toolbox\ArrayTraits\Countable; use RocketTheme\Toolbox\ArrayTraits\Export; use RocketTheme\Toolbox\ArrayTraits\Serializable; +use function array_slice; +use function count; +use function is_callable; +use function is_object; +/** + * Class Iterator + * @package Grav\Common + */ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable { use Constructor, ArrayAccessWithGetters, ArrayIterator, Countable, Serializable, Export; - /** - * @var array - */ + /** @var array */ protected $items = []; /** @@ -30,7 +36,6 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable * * @param string $key * @param mixed $args - * * @return mixed */ public function __call($key, $args) @@ -44,7 +49,7 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable public function __clone() { foreach ($this as $key => $value) { - if (\is_object($value)) { + if (is_object($value)) { $this->{$key} = clone $this->{$key}; } } @@ -64,6 +69,7 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable * Remove item from the list. * * @param string $key + * @return void */ public function remove($key) { @@ -84,7 +90,6 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable * Return nth item. * * @param int $key - * * @return mixed|bool */ public function nth($key) @@ -133,7 +138,7 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable /** * @param mixed $needle Searched value. * - * @return string|bool Key if found, otherwise false. + * @return string|int|false Key if found, otherwise false. */ public function indexOf($needle) { @@ -170,13 +175,12 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable * Slice the list. * * @param int $offset - * @param int $length - * + * @param int|null $length * @return $this */ public function slice($offset, $length = null) { - $this->items = \array_slice($this->items, $offset, $length); + $this->items = array_slice($this->items, $offset, $length); return $this; } @@ -185,12 +189,11 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable * Pick one or more random entries. * * @param int $num Specifies how many entries should be picked. - * * @return $this */ public function random($num = 1) { - $count = \count($this->items); + $count = count($this->items); if ($num > $count) { $num = $count; } @@ -204,7 +207,6 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable * Append new elements to the list. * * @param array|Iterator $items Items to be appended. Existing keys will be overridden with the new values. - * * @return $this */ public function append($items) @@ -228,8 +230,7 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable public function filter(callable $callback = null) { foreach ($this->items as $key => $value) { - if ( - (!$callback && !(bool)$value) || + if ((!$callback && !(bool)$value) || ($callback && !$callback($value, $key)) ) { unset($this->items[$key]); @@ -244,16 +245,13 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable * Sorts elements from the list and returns a copy of the list in the proper order * * @param callable|null $callback - * * @param bool $desc - * * @return $this|array - * @internal param bool $asc * */ public function sort(callable $callback = null, $desc = false) { - if (!$callback || !\is_callable($callback)) { + if (!$callback || !is_callable($callback)) { return $this; } diff --git a/system/src/Grav/Common/Language/Language.php b/system/src/Grav/Common/Language/Language.php index a625146..6709754 100644 --- a/system/src/Grav/Common/Language/Language.php +++ b/system/src/Grav/Common/Language/Language.php @@ -3,65 +3,96 @@ /** * @package Grav\Common\Language * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Language; +use Grav\Common\Debugger; use Grav\Common\Grav; use Grav\Common\Config\Config; use Negotiation\AcceptLanguage; use Negotiation\LanguageNegotiator; +use function array_key_exists; +use function count; +use function in_array; +use function is_array; +use function is_string; +/** + * Class Language + * @package Grav\Common\Language + */ class Language { + /** @var Grav */ protected $grav; - protected $enabled = true; - /** - * @var array - */ - protected $languages = []; - protected $page_extensions = []; - protected $fallback_languages = []; - protected $default; - protected $active = null; - - /** @var Config $config */ + /** @var Config */ protected $config; - + /** @var bool */ + protected $enabled = true; + /** @var array */ + protected $languages = []; + /** @var array */ + protected $fallback_languages = []; + /** @var array */ + protected $fallback_extensions = []; + /** @var array */ + protected $page_extensions = []; + /** @var string|false */ + protected $default; + /** @var string|false */ + protected $active; + /** @var array */ protected $http_accept_language; + /** @var bool */ protected $lang_in_url = false; /** * Constructor * - * @param \Grav\Common\Grav $grav + * @param Grav $grav */ public function __construct(Grav $grav) { $this->grav = $grav; $this->config = $grav['config']; - $this->languages = $this->config->get('system.languages.supported', []); + + $languages = $this->config->get('system.languages.supported', []); + foreach ($languages as &$language) { + $language = (string)$language; + } + unset($language); + + $this->languages = $languages; + $this->init(); } /** * Initialize the default and enabled languages + * + * @return void */ public function init() { $default = $this->config->get('system.languages.default_lang'); - if (isset($default) && $this->validate($default)) { - $this->default = $default; - } else { - $this->default = reset($this->languages); + if (null !== $default) { + $default = (string)$default; } - $this->page_extensions = null; + // Note that reset returns false on empty languages. + $this->default = $default ?? reset($this->languages); + + $this->resetFallbackPageExtensions(); if (empty($this->languages)) { + // If no languages are set, turn of multi-language support. $this->enabled = false; + } elseif ($default && !in_array($default, $this->languages, true)) { + // If default language isn't in the language list, we need to add it. + array_unshift($this->languages, $default); } } @@ -75,6 +106,16 @@ class Language return $this->enabled; } + /** + * Returns true if language debugging is turned on. + * + * @return bool + */ + public function isDebug(): bool + { + return !$this->config->get('system.languages.translations', true); + } + /** * Gets the array of supported languages * @@ -89,24 +130,27 @@ class Language * Sets the current supported languages manually * * @param array $langs + * @return void */ public function setLanguages($langs) { $this->languages = $langs; + $this->init(); } /** * Gets a pipe-separated string of available languages * + * @param string|null $delimiter Delimiter to be quoted. * @return string */ - public function getAvailable() + public function getAvailable($delimiter = null) { $languagesArray = $this->languages; //Make local copy - $languagesArray = array_map(function($value) { - return preg_quote($value); + $languagesArray = array_map(static function ($value) use ($delimiter) { + return preg_quote($value, $delimiter); }, $languagesArray); sort($languagesArray); @@ -117,7 +161,7 @@ class Language /** * Gets language, active if set, else default * - * @return string + * @return string|false */ public function getLanguage() { @@ -127,7 +171,7 @@ class Language /** * Gets current default language * - * @return mixed + * @return string|false */ public function getDefault() { @@ -138,11 +182,11 @@ class Language * Sets default language manually * * @param string $lang - * - * @return bool + * @return string|bool */ public function setDefault($lang) { + $lang = (string)$lang; if ($this->validate($lang)) { $this->default = $lang; @@ -155,7 +199,7 @@ class Language /** * Gets current active language * - * @return string + * @return string|false */ public function getActive() { @@ -165,13 +209,17 @@ class Language /** * Sets active language manually * - * @param string $lang - * - * @return string|bool + * @param string|false $lang + * @return string|false */ public function setActive($lang) { + $lang = (string)$lang; if ($this->validate($lang)) { + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addMessage('Active language set to ' . $lang, 'debug'); + $this->active = $lang; return $lang; @@ -184,7 +232,6 @@ class Language * Sets the active language based on the first part of the URL * * @param string $uri - * * @return string */ public function setActiveFromUri($uri) @@ -196,7 +243,7 @@ class Language // Try setting language from prefix of URL (/en/blah/blah). if (preg_match($regex, $uri, $matches)) { $this->lang_in_url = true; - $this->active = $matches[2]; + $this->setActive($matches[2]); $uri = preg_replace("/\\" . $matches[1] . '/', '', $uri, 1); // Store in session if language is different. @@ -210,22 +257,20 @@ class Language // Try getting language from the session, else no active. if (isset($this->grav['session']) && $this->grav['session']->isStarted() && $this->config->get('system.languages.session_store_active', true)) { - $this->active = $this->grav['session']->active_language ?: null; + $this->setActive($this->grav['session']->active_language ?: null); } // if still null, try from http_accept_language header if ($this->active === null && $this->config->get('system.languages.http_accept_language') && $accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? false) { - $negotiator = new LanguageNegotiator(); $best_language = $negotiator->getBest($accept, $this->languages); if ($best_language instanceof AcceptLanguage) { - $this->active = $best_language->getType(); + $this->setActive($best_language->getType()); } else { - $this->active = $this->getDefault(); + $this->setActive($this->getDefault()); } - } } } @@ -241,6 +286,10 @@ class Language */ public function getLanguageURLPrefix($lang = null) { + if (!$this->enabled()) { + return ''; + } + // if active lang is not passed in, use current active if (!$lang) { $lang = $this->getLanguage(); @@ -257,6 +306,10 @@ class Language */ public function isIncludeDefaultLanguage($lang = null) { + if (!$this->enabled()) { + return false; + } + // if active lang is not passed in, use current active if (!$lang) { $lang = $this->getLanguage(); @@ -275,52 +328,63 @@ class Language return (bool) $this->lang_in_url; } + /** + * Get full list of used language page extensions: [''=>'.md', 'en'=>'.en.md', ...] + * + * @param string|null $fileExtension + * @return array + */ + public function getPageExtensions($fileExtension = null) + { + $fileExtension = $fileExtension ?: CONTENT_EXT; + + if (!isset($this->fallback_extensions[$fileExtension])) { + $extensions[''] = $fileExtension; + foreach ($this->languages as $code) { + $extensions[$code] = ".{$code}{$fileExtension}"; + } + + $this->fallback_extensions[$fileExtension] = $extensions; + } + + return $this->fallback_extensions[$fileExtension]; + } /** * Gets an array of valid extensions with active first, then fallback extensions * - * @param string|null $file_ext - * - * @return array + * @param string|null $fileExtension + * @param string|null $languageCode + * @param bool $assoc Return values in ['en' => '.en.md', ...] format. + * @return array Key is the language code, value is the file extension to be used. */ - public function getFallbackPageExtensions($file_ext = null) + public function getFallbackPageExtensions(string $fileExtension = null, string $languageCode = null, bool $assoc = false) { - if (empty($this->page_extensions)) { - if (!$file_ext) { - $file_ext = CONTENT_EXT; + $fileExtension = $fileExtension ?: CONTENT_EXT; + $key = $fileExtension . '-' . ($languageCode ?? 'default') . '-' . (int)$assoc; + + if (!isset($this->fallback_extensions[$key])) { + $all = $this->getPageExtensions($fileExtension); + $list = []; + $fallback = $this->getFallbackLanguages($languageCode, true); + foreach ($fallback as $code) { + $ext = $all[$code] ?? null; + if (null !== $ext) { + $list[$code] = $ext; + } + } + if (!$assoc) { + $list = array_values($list); } - if ($this->enabled()) { - $valid_lang_extensions = []; - foreach ($this->languages as $lang) { - $valid_lang_extensions[] = '.' . $lang . $file_ext; - } - - if ($this->active) { - $active_extension = '.' . $this->active . $file_ext; - $key = \array_search($active_extension, $valid_lang_extensions, true); - - // Default behavior is to find any language other than active - if ($this->config->get('system.languages.pages_fallback_only')) { - $slice = \array_slice($valid_lang_extensions, 0, $key+1); - $valid_lang_extensions = array_reverse($slice); - } else { - unset($valid_lang_extensions[$key]); - array_unshift($valid_lang_extensions, $active_extension); - } - } - $valid_lang_extensions[] = $file_ext; - $this->page_extensions = $valid_lang_extensions; - } else { - $this->page_extensions = (array)$file_ext; - } + $this->fallback_extensions[$key] = $list; } - return $this->page_extensions; + return $this->fallback_extensions[$key]; } /** - * Resets the page_extensions value. + * Resets the fallback_languages value. * * Useful to re-initialize the pages and change site language at runtime, example: * @@ -329,48 +393,90 @@ class Language * $this->grav['language']->resetFallbackPageExtensions(); * $this->grav['pages']->init(); * ``` + * + * @return void */ public function resetFallbackPageExtensions() { - $this->page_extensions = null; + $this->fallback_languages = []; + $this->fallback_extensions = []; + $this->page_extensions = []; } /** - * Gets an array of languages with active first, then fallback languages + * Gets an array of languages with active first, then fallback languages. * + * + * @param string|null $languageCode + * @param bool $includeDefault If true, list contains '', which can be used for default * @return array */ - public function getFallbackLanguages() + public function getFallbackLanguages(string $languageCode = null, bool $includeDefault = false) { - if (empty($this->fallback_languages)) { - if ($this->enabled()) { - $fallback_languages = $this->languages; - - if ($this->active) { - $active_extension = $this->active; - $key = \array_search($active_extension, $fallback_languages, true); - unset($fallback_languages[$key]); - array_unshift($fallback_languages, $active_extension); - } - $this->fallback_languages = $fallback_languages; - } - // always add english in case a translation doesn't exist - $this->fallback_languages[] = 'en'; + // Handle default. + if ($languageCode === '' || !$this->enabled()) { + return ['']; } - return $this->fallback_languages; + $default = $this->getDefault() ?? 'en'; + $active = $languageCode ?? $this->getActive() ?? $default; + $key = $active . '-' . (int)$includeDefault; + + if (!isset($this->fallback_languages[$key])) { + $fallback = $this->config->get('system.languages.content_fallback.' . $active); + $fallback_languages = []; + + if (null === $fallback && $this->config->get('system.languages.pages_fallback_only', false)) { + user_error('Configuration option `system.languages.pages_fallback_only` is deprecated since Grav 1.7, use `system.languages.content_fallback` instead', E_USER_DEPRECATED); + + // Special fallback list returns itself and all the previous items in reverse order: + // active: 'v2', languages: ['v1', 'v2', 'v3', 'v4'] => ['v2', 'v1', ''] + if ($includeDefault) { + $fallback_languages[''] = ''; + } + foreach ($this->languages as $code) { + $fallback_languages[$code] = $code; + if ($code === $active) { + break; + } + } + $fallback_languages = array_reverse($fallback_languages); + } else { + if (null === $fallback) { + $fallback = [$default]; + } elseif (!is_array($fallback)) { + $fallback = is_string($fallback) && $fallback !== '' ? explode(',', $fallback) : []; + } + array_unshift($fallback, $active); + $fallback = array_unique($fallback); + + foreach ($fallback as $code) { + // Default fallback list has active language followed by default language and extensionless file: + // active: 'fi', default: 'en', languages: ['sv', 'en', 'de', 'fi'] => ['fi', 'en', ''] + $fallback_languages[$code] = $code; + if ($includeDefault && $code === $default) { + $fallback_languages[''] = ''; + } + } + } + + $fallback_languages = array_values($fallback_languages); + + $this->fallback_languages[$key] = $fallback_languages; + } + + return $this->fallback_languages[$key]; } /** * Ensures the language is valid and supported * * @param string $lang - * * @return bool */ public function validate($lang) { - return \in_array($lang, $this->languages, true); + return in_array($lang, $this->languages, true); } /** @@ -378,45 +484,40 @@ class Language * * @param string|array $args The first argument is the lookup key value * Other arguments can be passed and replaced in the translation with sprintf syntax - * @param array $languages + * @param array|null $languages * @param bool $array_support * @param bool $html_out - * - * @return string + * @return string|string[] */ public function translate($args, array $languages = null, $array_support = false, $html_out = false) { - if (\is_array($args)) { + if (is_array($args)) { $lookup = array_shift($args); } else { $lookup = $args; $args = []; } - if ($this->config->get('system.languages.translations', true)) { - if ($this->enabled() && $lookup) { - if (empty($languages)) { - if ($this->config->get('system.languages.translations_fallback', true)) { - $languages = $this->getFallbackLanguages(); - } else { - $languages = (array)$this->getLanguage(); - } - } - } else { - $languages = ['en']; + if (!$this->isDebug()) { + if ($lookup && $this->enabled() && empty($languages)) { + $languages = $this->getTranslatedLanguages(); } + $languages = $languages ?: ['en']; + foreach ((array)$languages as $lang) { $translation = $this->getTranslation($lang, $lookup, $array_support); if ($translation) { - if (\count($args) >= 1) { + if (is_string($translation) && count($args) >= 1) { return vsprintf($translation, $args); } return $translation; } } + } elseif ($array_support) { + return [$lookup]; } if ($html_out) { @@ -433,29 +534,24 @@ class Language * @param string $index * @param array|null $languages * @param bool $html_out - * * @return string */ public function translateArray($key, $index, $languages = null, $html_out = false) { - if ($this->config->get('system.languages.translations', true)) { - if ($this->enabled() && $key) { - if (empty($languages)) { - if ($this->config->get('system.languages.translations_fallback', true)) { - $languages = $this->getFallbackLanguages(); - } else { - $languages = (array)$this->getDefault(); - } - } - } else { - $languages = ['en']; - } + if ($this->isDebug()) { + return $key . '[' . $index . ']'; + } - foreach ((array)$languages as $lang) { - $translation_array = (array)Grav::instance()['languages']->get($lang . '.' . $key, null); - if ($translation_array && array_key_exists($index, $translation_array)) { - return $translation_array[$index]; - } + if ($key && empty($languages) && $this->enabled()) { + $languages = $this->getTranslatedLanguages(); + } + + $languages = $languages ?: ['en']; + + foreach ((array)$languages as $lang) { + $translation_array = (array)Grav::instance()['languages']->get($lang . '.' . $key, null); + if ($translation_array && array_key_exists($index, $translation_array)) { + return $translation_array[$index]; } } @@ -472,11 +568,14 @@ class Language * @param string $lang lang code * @param string $key key to lookup with * @param bool $array_support - * - * @return string + * @return string|string[] */ public function getTranslation($lang, $key, $array_support = false) { + if ($this->isDebug()) { + return $key; + } + $translation = Grav::instance()['languages']->get($lang . '.' . $key, null); if (!$array_support && is_array($translation)) { return (string)array_shift($translation); @@ -527,11 +626,37 @@ class Language * * @param string $code * @param string $type - * @return bool + * @return string|false */ public function getLanguageCode($code, $type = 'name') { return LanguageCodes::get($code, $type); } + /** + * @return array + */ + public function __debugInfo() + { + $vars = get_object_vars($this); + unset($vars['grav'], $vars['config']); + + return $vars; + } + + /** + * @return array + */ + protected function getTranslatedLanguages(): array + { + if ($this->config->get('system.languages.translations_fallback', true)) { + $languages = $this->getFallbackLanguages(); + } else { + $languages = [$this->getLanguage()]; + } + + $languages[] = 'en'; + + return array_values(array_unique($languages)); + } } diff --git a/system/src/Grav/Common/Language/LanguageCodes.php b/system/src/Grav/Common/Language/LanguageCodes.php index 780df7c..9282070 100644 --- a/system/src/Grav/Common/Language/LanguageCodes.php +++ b/system/src/Grav/Common/Language/LanguageCodes.php @@ -3,14 +3,19 @@ /** * @package Grav\Common\Language * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Language; +/** + * Class LanguageCodes + * @package Grav\Common\Language + */ class LanguageCodes { + /** @var array */ protected static $codes = [ 'af' => [ 'name' => 'Afrikaans', 'nativeName' => 'Afrikaans' ], 'ak' => [ 'name' => 'Akan', 'nativeName' => 'Akan' ], // unverified native name @@ -87,7 +92,7 @@ class LanguageCodes 'la' => [ 'name' => 'Latin', 'nativeName' => 'Latina' ], 'lb' => [ 'name' => 'Luxembourgish', 'nativeName' => 'Lëtzebuergesch' ], 'lg' => [ 'name' => 'Luganda', 'nativeName' => 'Luganda' ], - 'lt' => [ 'name' => 'Lithuanian', 'nativeName' => 'Lietuvių kalba' ], + 'lt' => [ 'name' => 'Lithuanian', 'nativeName' => 'Lietuvių' ], 'lv' => [ 'name' => 'Latvian', 'nativeName' => 'Latviešu' ], 'mai' => [ 'name' => 'Maithili', 'nativeName' => 'मैथिली মৈথিলী' ], 'mg' => [ 'name' => 'Malagasy', 'nativeName' => 'Malagasy' ], @@ -150,11 +155,19 @@ class LanguageCodes 'zu' => [ 'name' => 'Zulu', 'nativeName' => 'isiZulu' ] ]; + /** + * @param string $code + * @return string|false + */ public static function getName($code) { return static::get($code, 'name'); } + /** + * @param string $code + * @return string|false + */ public static function getNativeName($code) { if (isset(static::$codes[$code])) { @@ -168,6 +181,10 @@ class LanguageCodes return $code; } + /** + * @param string $code + * @return string + */ public static function getOrientation($code) { if (isset(static::$codes[$code])) { @@ -178,11 +195,19 @@ class LanguageCodes return 'ltr'; } + /** + * @param string $code + * @return bool + */ public static function isRtl($code) { return static::getOrientation($code) === 'rtl'; } + /** + * @param array $keys + * @return array + */ public static function getNames(array $keys) { $results = []; @@ -194,6 +219,11 @@ class LanguageCodes return $results; } + /** + * @param string $code + * @param string $type + * @return string|false + */ public static function get($code, $type) { if (isset(static::$codes[$code][$type])) { @@ -202,4 +232,18 @@ class LanguageCodes return false; } + + /** + * @param bool $native + * @return array + */ + public static function getList($native = true) + { + $list = []; + foreach (static::$codes as $key => $names) { + $list[$key] = $native ? $names['nativeName'] : $names['name']; + } + + return $list; + } } diff --git a/system/src/Grav/Common/Markdown/Parsedown.php b/system/src/Grav/Common/Markdown/Parsedown.php index 8b8565f..bdc5bdc 100644 --- a/system/src/Grav/Common/Markdown/Parsedown.php +++ b/system/src/Grav/Common/Markdown/Parsedown.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Markdown * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,16 +11,19 @@ namespace Grav\Common\Markdown; use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Page\Markdown\Excerpts; -use Grav\Framework\Parsedown\Parsedown as ParsedownLib; -class Parsedown extends ParsedownLib +/** + * Class Parsedown + * @package Grav\Common\Markdown + */ +class Parsedown extends \Parsedown { use ParsedownGravTrait; /** * Parsedown constructor. * - * @param Excerpts|null $excerpts + * @param Excerpts|PageInterface|null $excerpts * @param array|null $defaults */ public function __construct($excerpts = null, $defaults = null) @@ -36,5 +39,4 @@ class Parsedown extends ParsedownLib $this->init($excerpts, $defaults); } - } diff --git a/system/src/Grav/Common/Markdown/ParsedownExtra.php b/system/src/Grav/Common/Markdown/ParsedownExtra.php index 70c8596..b8e760e 100644 --- a/system/src/Grav/Common/Markdown/ParsedownExtra.php +++ b/system/src/Grav/Common/Markdown/ParsedownExtra.php @@ -3,26 +3,30 @@ /** * @package Grav\Common\Markdown * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Markdown; +use Exception; use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Page\Markdown\Excerpts; -use Grav\Framework\Parsedown\ParsedownExtra as ParsedownExtraLib; -class ParsedownExtra extends ParsedownExtraLib +/** + * Class ParsedownExtra + * @package Grav\Common\Markdown + */ +class ParsedownExtra extends \ParsedownExtra { use ParsedownGravTrait; /** * ParsedownExtra constructor. * - * @param Excerpts|null $excerpts + * @param Excerpts|PageInterface|null $excerpts * @param array|null $defaults - * @throws \Exception + * @throws Exception */ public function __construct($excerpts = null, $defaults = null) { diff --git a/system/src/Grav/Common/Markdown/ParsedownGravTrait.php b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php index aa76cdf..7c2b0d6 100644 --- a/system/src/Grav/Common/Markdown/ParsedownGravTrait.php +++ b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Markdown * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,23 +11,34 @@ namespace Grav\Common\Markdown; use Grav\Common\Page\Markdown\Excerpts; use Grav\Common\Page\Interfaces\PageInterface; +use function call_user_func_array; +use function in_array; +use function strlen; +/** + * Trait ParsedownGravTrait + * @package Grav\Common\Markdown + */ trait ParsedownGravTrait { + /** @var array */ + public $completable_blocks = []; + /** @var array */ + public $continuable_blocks = []; + /** @var Excerpts */ protected $excerpts; - + /** @var array */ protected $special_chars; + /** @var string */ protected $twig_link_regex = '/\!*\[(?:.*)\]\((\{([\{%#])\s*(.*?)\s*(?:\2|\})\})\)/'; - public $completable_blocks = []; - public $continuable_blocks = []; - /** * Initialization function to setup key variables needed by the MarkdownGravLinkTrait * * @param PageInterface|Excerpts|null $excerpts * @param array|null $defaults + * @return void */ protected function init($excerpts = null, $defaults = null) { @@ -79,6 +90,7 @@ trait ParsedownGravTrait * @param bool $continuable * @param bool $completable * @param int|null $index + * @return void */ public function addBlockType($type, $tag, $continuable = false, $completable = false, $index = null) { @@ -110,6 +122,7 @@ trait ParsedownGravTrait * @param string $type * @param string $tag * @param int|null $index + * @return void */ public function addInlineType($type, $tag, $index = null) { @@ -128,12 +141,11 @@ trait ParsedownGravTrait * Overrides the default behavior to allow for plugin-provided blocks to be continuable * * @param string $Type - * * @return bool */ protected function isBlockContinuable($Type) { - $continuable = \in_array($Type, $this->continuable_blocks, true) + $continuable = in_array($Type, $this->continuable_blocks, true) || method_exists($this, 'block' . $Type . 'Continue'); return $continuable; @@ -143,12 +155,11 @@ trait ParsedownGravTrait * Overrides the default behavior to allow for plugin-provided blocks to be completable * * @param string $Type - * * @return bool */ protected function isBlockCompletable($Type) { - $completable = \in_array($Type, $this->completable_blocks, true) + $completable = in_array($Type, $this->completable_blocks, true) || method_exists($this, 'block' . $Type . 'Complete'); return $completable; @@ -159,7 +170,6 @@ trait ParsedownGravTrait * Make the element function publicly accessible, Medium uses this to render from Twig * * @param array $Element - * * @return string markup */ public function elementToHtml(array $Element) @@ -171,7 +181,6 @@ trait ParsedownGravTrait * Setter for special chars * * @param array $special_chars - * * @return $this */ public function setSpecialChars($special_chars) @@ -196,6 +205,10 @@ trait ParsedownGravTrait return null; } + /** + * @param array $excerpt + * @return array|null + */ protected function inlineSpecialCharacter($excerpt) { if ($excerpt['text'][0] === '&' && !preg_match('/^&#?\w+;/', $excerpt['text'])) { @@ -215,6 +228,10 @@ trait ParsedownGravTrait return null; } + /** + * @param array $excerpt + * @return array + */ protected function inlineImage($excerpt) { if (preg_match($this->twig_link_regex, $excerpt['text'], $matches)) { @@ -237,6 +254,10 @@ trait ParsedownGravTrait return $excerpt; } + /** + * @param array $excerpt + * @return array + */ protected function inlineLink($excerpt) { $type = $excerpt['type'] ?? 'link'; @@ -263,13 +284,17 @@ trait ParsedownGravTrait /** * For extending this class via plugins + * + * @param string $method + * @param array $args + * @return mixed|null */ public function __call($method, $args) { if (isset($this->{$method}) === true) { $func = $this->{$method}; - return \call_user_func_array($func, $args); + return call_user_func_array($func, $args); } return null; diff --git a/system/src/Grav/Common/Media/Interfaces/AudioMediaInterface.php b/system/src/Grav/Common/Media/Interfaces/AudioMediaInterface.php new file mode 100644 index 0000000..d9c69d2 --- /dev/null +++ b/system/src/Grav/Common/Media/Interfaces/AudioMediaInterface.php @@ -0,0 +1,25 @@ +get('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string|null $separator Separator, defaults to '.' + * @return mixed Value. + */ + public function get($name, $default = null, $separator = null); + + /** + * Set value by using dot notation for nested arrays/objects. + * + * @example $data->set('this.is.my.nested.variable', $value); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + public function set($name, $value, $separator = null); } diff --git a/system/src/Grav/Common/Media/Interfaces/MediaPlayerInterface.php b/system/src/Grav/Common/Media/Interfaces/MediaPlayerInterface.php new file mode 100644 index 0000000..9307915 --- /dev/null +++ b/system/src/Grav/Common/Media/Interfaces/MediaPlayerInterface.php @@ -0,0 +1,56 @@ + 'user://pages/media']; // Settings from the form field. + * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings); + * $media->copyUploadedFile($uploadedFile, $filename); + + * @param UploadedFileInterface $uploadedFile + * @param string|null $filename + * @param array|null $settings + * @return string + * @throws RuntimeException + */ + public function checkUploadedFile(UploadedFileInterface $uploadedFile, string $filename = null, array $settings = null): string; + + /** + * Copy uploaded file to the media collection. + * + * WARNING: Always check uploaded file before copying it! + * + * @example + * $filename = null; // Override filename if needed (ignored if randomizing filenames). + * $settings = ['destination' => 'user://pages/media']; // Settings from the form field. + * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings); + * $media->copyUploadedFile($uploadedFile, $filename); + * + * @param UploadedFileInterface $uploadedFile + * @param string $filename + * @param array|null $settings + * @return void + * @throws RuntimeException + */ + public function copyUploadedFile(UploadedFileInterface $uploadedFile, string $filename, array $settings = null): void; + + /** + * Delete real file from the media collection. + * + * @param string $filename + * @param array|null $settings + * @return void + */ + public function deleteFile(string $filename, array $settings = null): void; + + /** + * Rename file inside the media collection. + * + * @param string $from + * @param string $to + * @param array|null $settings + */ + public function renameFile(string $from, string $to, array $settings = null): void; +} diff --git a/system/src/Grav/Common/Media/Interfaces/VideoMediaInterface.php b/system/src/Grav/Common/Media/Interfaces/VideoMediaInterface.php new file mode 100644 index 0000000..ff0655a --- /dev/null +++ b/system/src/Grav/Common/Media/Interfaces/VideoMediaInterface.php @@ -0,0 +1,32 @@ +attributes['controlsList'] = $controlsList; + + return $this; + } + + /** + * Parsedown element for source display mode + * + * @param array $attributes + * @param bool $reset + * @return array + */ + protected function sourceParsedownElement(array $attributes, $reset = true) + { + $location = $this->url($reset); + + return [ + 'name' => 'audio', + 'rawHtml' => 'Your browser does not support the audio tag.', + 'attributes' => $attributes + ]; + } +} diff --git a/system/src/Grav/Common/Media/Traits/ImageLoadingTrait.php b/system/src/Grav/Common/Media/Traits/ImageLoadingTrait.php new file mode 100644 index 0000000..c023763 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/ImageLoadingTrait.php @@ -0,0 +1,37 @@ +get('system.images.defaults.loading', 'auto'); + } + if ($value && $value !== 'auto') { + $this->attributes['loading'] = $value; + } + + return $this; + } +} diff --git a/system/src/Grav/Common/Media/Traits/ImageMediaTrait.php b/system/src/Grav/Common/Media/Traits/ImageMediaTrait.php new file mode 100644 index 0000000..6181c94 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/ImageMediaTrait.php @@ -0,0 +1,420 @@ + [0, 1], + 'forceResize' => [0, 1], + 'cropResize' => [0, 1], + 'crop' => [0, 1, 2, 3], + 'zoomCrop' => [0, 1] + ]; + + /** @var string */ + protected $sizes = '100vw'; + + + /** + * Allows the ability to override the image's pretty name stored in cache + * + * @param string $name + */ + public function setImagePrettyName($name) + { + $this->set('prettyname', $name); + if ($this->image) { + $this->image->setPrettyName($name); + } + } + + /** + * @return string + */ + public function getImagePrettyName() + { + if ($this->get('prettyname')) { + return $this->get('prettyname'); + } + + $basename = $this->get('basename'); + if (preg_match('/[a-z0-9]{40}-(.*)/', $basename, $matches)) { + $basename = $matches[1]; + } + return $basename; + } + + /** + * Simply processes with no extra methods. Useful for triggering events. + * + * @return $this + */ + public function cache() + { + if (!$this->image) { + $this->image(); + } + + return $this; + } + + /** + * Generate alternative image widths, using either an array of integers, or + * a min width, a max width, and a step parameter to fill out the necessary + * widths. Existing image alternatives won't be overwritten. + * + * @param int|int[] $min_width + * @param int $max_width + * @param int $step + * @return $this + */ + public function derivatives($min_width, $max_width = 2500, $step = 200) + { + if (!empty($this->alternatives)) { + $max = max(array_keys($this->alternatives)); + $base = $this->alternatives[$max]; + } else { + $base = $this; + } + + $widths = []; + + if (func_num_args() === 1) { + foreach ((array) func_get_arg(0) as $width) { + if ($width < $base->get('width')) { + $widths[] = $width; + } + } + } else { + $max_width = min($max_width, $base->get('width')); + + for ($width = $min_width; $width < $max_width; $width += $step) { + $widths[] = $width; + } + } + + foreach ($widths as $width) { + // Only generate image alternatives that don't already exist + if (array_key_exists((int) $width, $this->alternatives)) { + continue; + } + + $derivative = MediumFactory::fromFile($base->get('filepath')); + + // It's possible that MediumFactory::fromFile returns null if the + // original image file no longer exists and this class instance was + // retrieved from the page cache + if (null !== $derivative) { + $index = 2; + $alt_widths = array_keys($this->alternatives); + sort($alt_widths); + + foreach ($alt_widths as $i => $key) { + if ($width > $key) { + $index += max($i, 1); + } + } + + $basename = preg_replace('/(@\d+x)?$/', "@{$width}w", $base->get('basename'), 1); + $derivative->setImagePrettyName($basename); + + $ratio = $base->get('width') / $width; + $height = $derivative->get('height') / $ratio; + + $derivative->resize($width, $height); + $derivative->set('width', $width); + $derivative->set('height', $height); + + $this->addAlternative($ratio, $derivative); + } + } + + return $this; + } + + /** + * Clear out the alternatives. + */ + public function clearAlternatives() + { + $this->alternatives = []; + } + + /** + * Sets or gets the quality of the image + * + * @param int|null $quality 0-100 quality + * @return int|$this + */ + public function quality($quality = null) + { + if ($quality) { + if (!$this->image) { + $this->image(); + } + + $this->quality = $quality; + + return $this; + } + + return $this->quality; + } + + /** + * Sets image output format. + * + * @param string $format + * @return $this + */ + public function format($format) + { + if (!$this->image) { + $this->image(); + } + + $this->format = $format; + + return $this; + } + + /** + * Set or get sizes parameter for srcset media action + * + * @param string|null $sizes + * @return string + */ + public function sizes($sizes = null) + { + if ($sizes) { + $this->sizes = $sizes; + + return $this; + } + + return empty($this->sizes) ? '100vw' : $this->sizes; + } + + /** + * Allows to set the width attribute from Markdown or Twig + * Examples: ![Example](myimg.png?width=200&height=400) + * ![Example](myimg.png?resize=100,200&width=100&height=200) + * ![Example](myimg.png?width=auto&height=auto) + * ![Example](myimg.png?width&height) + * {{ page.media['myimg.png'].width().height().html }} + * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }} + * + * @param string|int $value A value or 'auto' or empty to use the width of the image + * @return $this + */ + public function width($value = 'auto') + { + if (!$value || $value === 'auto') { + $this->attributes['width'] = $this->get('width'); + } else { + $this->attributes['width'] = $value; + } + + return $this; + } + + /** + * Allows to set the height attribute from Markdown or Twig + * Examples: ![Example](myimg.png?width=200&height=400) + * ![Example](myimg.png?resize=100,200&width=100&height=200) + * ![Example](myimg.png?width=auto&height=auto) + * ![Example](myimg.png?width&height) + * {{ page.media['myimg.png'].width().height().html }} + * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }} + * + * @param string|int $value A value or 'auto' or empty to use the height of the image + * @return $this + */ + public function height($value = 'auto') + { + if (!$value || $value === 'auto') { + $this->attributes['height'] = $this->get('height'); + } else { + $this->attributes['height'] = $value; + } + + return $this; + } + + /** + * Filter image by using user defined filter parameters. + * + * @param string $filter Filter to be used. + * @return $this + */ + public function filter($filter = 'image.filters.default') + { + $filters = (array) $this->get($filter, []); + foreach ($filters as $params) { + $params = (array) $params; + $method = array_shift($params); + $this->__call($method, $params); + } + + return $this; + } + + /** + * Return the image higher quality version + * + * @return ImageMediaInterface|$this the alternative version with higher quality + */ + public function higherQualityAlternative() + { + if ($this->alternatives) { + /** @var ImageMedium $max */ + $max = reset($this->alternatives); + /** @var ImageMedium $alternative */ + foreach ($this->alternatives as $alternative) { + if ($alternative->quality() > $max->quality()) { + $max = $alternative; + } + } + + return $max; + } + + return $this; + } + + /** + * Gets medium image, resets image manipulation operations. + * + * @return $this + */ + protected function image() + { + $locator = Grav::instance()['locator']; + + // Use existing cache folder or if it doesn't exist, create it. + $cacheDir = $locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true); + + // Make sure we free previous image. + unset($this->image); + + /** @var MediaCollectionInterface $media */ + $media = $this->get('media'); + if ($media && method_exists($media, 'getImageFileObject')) { + $this->image = $media->getImageFileObject($this); + } else { + $this->image = ImageFile::open($this->get('filepath')); + } + + $this->image + ->setCacheDir($cacheDir) + ->setActualCacheDir($cacheDir) + ->setPrettyName($this->getImagePrettyName()); + + // Fix orientation if enabled + $config = Grav::instance()['config']; + if ($config->get('system.images.auto_fix_orientation', false) && + extension_loaded('exif') && function_exists('exif_read_data')) { + $this->image->fixOrientation(); + } + + // Set CLS configuration + $this->auto_sizes = $config->get('system.images.cls.auto_sizes', false); + $this->aspect_ratio = $config->get('system.images.cls.aspect_ratio', false); + $this->retina_scale = $config->get('system.images.cls.retina_scale', 1); + + return $this; + } + + /** + * Save the image with cache. + * + * @return string + */ + protected function saveImage() + { + if (!$this->image) { + return parent::path(false); + } + + $this->filter(); + + if (isset($this->result)) { + return $this->result; + } + + if ($this->format === 'guess') { + $extension = strtolower($this->get('extension')); + $this->format($extension); + } + + if (!$this->debug_watermarked && $this->get('debug')) { + $ratio = $this->get('ratio'); + if (!$ratio) { + $ratio = 1; + } + + $locator = Grav::instance()['locator']; + $overlay = $locator->findResource("system://assets/responsive-overlays/{$ratio}x.png") ?: $locator->findResource('system://assets/responsive-overlays/unknown.png'); + $this->image->merge(ImageFile::open($overlay)); + } + + return $this->image->cacheFile($this->format, $this->quality, false, [$this->get('width'), $this->get('height'), $this->get('modified')]); + } +} diff --git a/system/src/Grav/Common/Media/Traits/MediaFileTrait.php b/system/src/Grav/Common/Media/Traits/MediaFileTrait.php new file mode 100644 index 0000000..7198133 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/MediaFileTrait.php @@ -0,0 +1,139 @@ +path(false); + + return file_exists($path); + } + + /** + * Get file modification time for the medium. + * + * @return int|null + */ + public function modified() + { + $path = $this->path(false); + if (!file_exists($path)) { + return null; + } + + return filemtime($path) ?: null; + } + + /** + * Get size of the medium. + * + * @return int + */ + public function size() + { + $path = $this->path(false); + if (!file_exists($path)) { + return 0; + } + + return filesize($path) ?: 0; + } + + /** + * Return PATH to file. + * + * @param bool $reset + * @return string path to file + */ + public function path($reset = true) + { + if ($reset) { + $this->reset(); + } + + return $this->get('url') ?? $this->get('filepath'); + } + + /** + * Return the relative path to file + * + * @param bool $reset + * @return string + */ + public function relativePath($reset = true) + { + if ($reset) { + $this->reset(); + } + + $path = $this->path(false); + $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $path) ?: $path; + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + if ($locator->isStream($output)) { + $output = (string)($locator->findResource($output, false) ?: $locator->findResource($output, false, true)); + } + + return $output; + } + + /** + * Return URL to file. + * + * @param bool $reset + * @return string + */ + public function url($reset = true) + { + $url = $this->get('url'); + if ($url) { + return $url; + } + + $path = $this->relativePath($reset); + + return trim($this->getGrav()['base_url'] . '/' . $this->urlQuerystring($path), '\\'); + } + + /** + * Get the URL with full querystring + * + * @param string $url + * @return string + */ + abstract public function urlQuerystring($url); + + /** + * Reset medium. + * + * @return $this + */ + abstract public function reset(); + + /** + * @return Grav + */ + abstract protected function getGrav(): Grav; +} diff --git a/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php b/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php new file mode 100644 index 0000000..3c2a38b --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php @@ -0,0 +1,609 @@ +getItems()); + } + + /** + * Set querystring to file modification timestamp (or value provided as a parameter). + * + * @param string|int|null $timestamp + * @return $this + */ + public function setTimestamp($timestamp = null) + { + if (null !== $timestamp) { + $this->timestamp = (string)($timestamp); + } elseif ($this instanceof MediaFileInterface) { + $this->timestamp = (string)$this->modified(); + } else { + $this->timestamp = ''; + } + + return $this; + } + + /** + * Returns an array containing just the metadata + * + * @return array + */ + public function metadata() + { + return $this->metadata; + } + + /** + * Add meta file for the medium. + * + * @param string $filepath + */ + abstract public function addMetaFile($filepath); + + /** + * Add alternative Medium to this Medium. + * + * @param int|float $ratio + * @param MediaObjectInterface $alternative + */ + public function addAlternative($ratio, MediaObjectInterface $alternative) + { + if (!is_numeric($ratio) || $ratio === 0) { + return; + } + + $alternative->set('ratio', $ratio); + $width = $alternative->get('width'); + + $this->alternatives[$width] = $alternative; + } + + /** + * Return string representation of the object (html). + * + * @return string + */ + abstract public function __toString(); + + /** + * Get/set querystring for the file's url + * + * @param string|null $querystring + * @param bool $withQuestionmark + * @return string + */ + public function querystring($querystring = null, $withQuestionmark = true) + { + if (null !== $querystring) { + $this->medium_querystring[] = ltrim($querystring, '?&'); + foreach ($this->alternatives as $alt) { + $alt->querystring($querystring, $withQuestionmark); + } + } + + if (empty($this->medium_querystring)) { + return ''; + } + + // join the strings + $querystring = implode('&', $this->medium_querystring); + // explode all strings + $query_parts = explode('&', $querystring); + // Join them again now ensure the elements are unique + $querystring = implode('&', array_unique($query_parts)); + + return $withQuestionmark ? ('?' . $querystring) : $querystring; + } + + /** + * Get the URL with full querystring + * + * @param string $url + * @return string + */ + public function urlQuerystring($url) + { + $querystring = $this->querystring(); + if (isset($this->timestamp) && !Utils::contains($querystring, $this->timestamp)) { + $querystring = empty($querystring) ? ('?' . $this->timestamp) : ($querystring . '&' . $this->timestamp); + } + + return ltrim($url . $querystring . $this->urlHash(), '/'); + } + + /** + * Get/set hash for the file's url + * + * @param string|null $hash + * @param bool $withHash + * @return string + */ + public function urlHash($hash = null, $withHash = true) + { + if ($hash) { + $this->set('urlHash', ltrim($hash, '#')); + } + + $hash = $this->get('urlHash', ''); + + return $withHash && !empty($hash) ? '#' . $hash : $hash; + } + + /** + * Get an element (is array) that can be rendered by the Parsedown engine + * + * @param string|null $title + * @param string|null $alt + * @param string|null $class + * @param string|null $id + * @param bool $reset + * @return array + */ + public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true) + { + $attributes = $this->attributes; + $items = $this->getItems(); + + $style = ''; + foreach ($this->styleAttributes as $key => $value) { + if (is_numeric($key)) { // Special case for inline style attributes, refer to style() method + $style .= $value; + } else { + $style .= $key . ': ' . $value . ';'; + } + } + if ($style) { + $attributes['style'] = $style; + } + + if (empty($attributes['title'])) { + if (!empty($title)) { + $attributes['title'] = $title; + } elseif (!empty($items['title'])) { + $attributes['title'] = $items['title']; + } + } + + if (empty($attributes['alt'])) { + if (!empty($alt)) { + $attributes['alt'] = $alt; + } elseif (!empty($items['alt'])) { + $attributes['alt'] = $items['alt']; + } elseif (!empty($items['alt_text'])) { + $attributes['alt'] = $items['alt_text']; + } else { + $attributes['alt'] = ''; + } + } + + if (empty($attributes['class'])) { + if (!empty($class)) { + $attributes['class'] = $class; + } elseif (!empty($items['class'])) { + $attributes['class'] = $items['class']; + } + } + + if (empty($attributes['id'])) { + if (!empty($id)) { + $attributes['id'] = $id; + } elseif (!empty($items['id'])) { + $attributes['id'] = $items['id']; + } + } + + switch ($this->mode) { + case 'text': + $element = $this->textParsedownElement($attributes, false); + break; + case 'thumbnail': + $thumbnail = $this->getThumbnail(); + $element = $thumbnail ? $thumbnail->sourceParsedownElement($attributes, false) : []; + break; + case 'source': + $element = $this->sourceParsedownElement($attributes, false); + break; + default: + $element = []; + } + + if ($reset) { + $this->reset(); + } + + $this->display('source'); + + return $element; + } + + /** + * Reset medium. + * + * @return $this + */ + public function reset() + { + $this->attributes = []; + + return $this; + } + + /** + * Add custom attribute to medium. + * + * @param string $attribute + * @param string $value + * @return $this + */ + public function attribute($attribute = null, $value = '') + { + if (!empty($attribute)) { + $this->attributes[$attribute] = $value; + } + return $this; + } + + /** + * Switch display mode. + * + * @param string $mode + * + * @return MediaObjectInterface|null + */ + public function display($mode = 'source') + { + if ($this->mode === $mode) { + return $this; + } + + $this->mode = $mode; + if ($mode === 'thumbnail') { + $thumbnail = $this->getThumbnail(); + + return $thumbnail ? $thumbnail->reset() : null; + } + + return $this->reset(); + } + + /** + * Helper method to determine if this media item has a thumbnail or not + * + * @param string $type; + * @return bool + */ + public function thumbnailExists($type = 'page') + { + $thumbs = $this->get('thumbnails'); + + return isset($thumbs[$type]); + } + + /** + * Switch thumbnail. + * + * @param string $type + * @return $this + */ + public function thumbnail($type = 'auto') + { + if ($type !== 'auto' && !in_array($type, $this->thumbnailTypes, true)) { + return $this; + } + + if ($this->thumbnailType !== $type) { + $this->_thumbnail = null; + } + + $this->thumbnailType = $type; + + return $this; + } + + /** + * Return URL to file. + * + * @param bool $reset + * @return string + */ + abstract public function url($reset = true); + + /** + * Turn the current Medium into a Link + * + * @param bool $reset + * @param array $attributes + * @return MediaLinkInterface + */ + public function link($reset = true, array $attributes = []) + { + if ($this->mode !== 'source') { + $this->display('source'); + } + + foreach ($this->attributes as $key => $value) { + empty($attributes['data-' . $key]) && $attributes['data-' . $key] = $value; + } + + empty($attributes['href']) && $attributes['href'] = $this->url(); + + return $this->createLink($attributes); + } + + /** + * Turn the current Medium into a Link with lightbox enabled + * + * @param int|null $width + * @param int|null $height + * @param bool $reset + * @return MediaLinkInterface + */ + public function lightbox($width = null, $height = null, $reset = true) + { + $attributes = ['rel' => 'lightbox']; + + if ($width && $height) { + $attributes['data-width'] = $width; + $attributes['data-height'] = $height; + } + + return $this->link($reset, $attributes); + } + + /** + * Add a class to the element from Markdown or Twig + * Example: ![Example](myimg.png?classes=float-left) or ![Example](myimg.png?classes=myclass1,myclass2) + * + * @return $this + */ + public function classes() + { + $classes = func_get_args(); + if (!empty($classes)) { + $this->attributes['class'] = implode(',', $classes); + } + + return $this; + } + + /** + * Add an id to the element from Markdown or Twig + * Example: ![Example](myimg.png?id=primary-img) + * + * @param string $id + * @return $this + */ + public function id($id) + { + if (is_string($id)) { + $this->attributes['id'] = trim($id); + } + + return $this; + } + + /** + * Allows to add an inline style attribute from Markdown or Twig + * Example: ![Example](myimg.png?style=float:left) + * + * @param string $style + * @return $this + */ + public function style($style) + { + $this->styleAttributes[] = rtrim($style, ';') . ';'; + + return $this; + } + + /** + * Allow any action to be called on this medium from twig or markdown + * + * @param string $method + * @param array $args + * @return $this + */ + public function __call($method, $args) + { + $count = count($args); + if ($count > 1 || ($count === 1 && !empty($args[0]))) { + $method .= '=' . implode(',', array_map(static function ($a) { + if (is_array($a)) { + $a = '[' . implode(',', $a) . ']'; + } + + return rawurlencode($a); + }, $args)); + } + + if (!empty($method)) { + $this->querystring($this->querystring(null, false) . '&' . $method); + } + + return $this; + } + + /** + * Parsedown element for source display mode + * + * @param array $attributes + * @param bool $reset + * @return array + */ + protected function sourceParsedownElement(array $attributes, $reset = true) + { + return $this->textParsedownElement($attributes, $reset); + } + + /** + * Parsedown element for text display mode + * + * @param array $attributes + * @param bool $reset + * @return array + */ + protected function textParsedownElement(array $attributes, $reset = true) + { + if ($reset) { + $this->reset(); + } + + $text = $attributes['title'] ?? ''; + if ($text === '') { + $text = $attributes['alt'] ?? ''; + if ($text === '') { + $text = $this->get('filename'); + } + } + + return [ + 'name' => 'p', + 'attributes' => $attributes, + 'text' => $text + ]; + } + + /** + * Get the thumbnail Medium object + * + * @return ThumbnailImageMedium|null + */ + protected function getThumbnail() + { + if (null === $this->_thumbnail) { + $types = $this->thumbnailTypes; + + if ($this->thumbnailType !== 'auto') { + array_unshift($types, $this->thumbnailType); + } + + foreach ($types as $type) { + $thumb = $this->get("thumbnails.{$type}", false); + + if ($thumb) { + $thumb = $thumb instanceof ThumbnailImageMedium ? $thumb : $this->createThumbnail($thumb); + $thumb->parent = $this; + $this->_thumbnail = $thumb; + break; + } + } + } + + return $this->_thumbnail; + } + + /** + * Get value by using dot notation for nested arrays/objects. + * + * @example $value = $this->get('this.is.my.nested.variable'); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @param string|null $separator Separator, defaults to '.' + * @return mixed Value. + */ + abstract public function get($name, $default = null, $separator = null); + + /** + * Set value by using dot notation for nested arrays/objects. + * + * @example $data->set('this.is.my.nested.variable', $value); + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + */ + abstract public function set($name, $value, $separator = null); + + /** + * @param string $thumb + */ + abstract protected function createThumbnail($thumb); + + /** + * @param array $attributes + * @return MediaLinkInterface + */ + abstract protected function createLink(array $attributes); + + /** + * @return array + */ + abstract protected function getItems(): array; +} diff --git a/system/src/Grav/Common/Media/Traits/MediaPlayerTrait.php b/system/src/Grav/Common/Media/Traits/MediaPlayerTrait.php new file mode 100644 index 0000000..66e9f47 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/MediaPlayerTrait.php @@ -0,0 +1,113 @@ +attributes['controls'] = true; + } else { + unset($this->attributes['controls']); + } + + return $this; + } + + /** + * Allows to set the loop attribute + * + * @param bool $status + * @return $this + */ + public function loop($status = false) + { + if ($status) { + $this->attributes['loop'] = true; + } else { + unset($this->attributes['loop']); + } + + return $this; + } + + /** + * Allows to set the autoplay attribute + * + * @param bool $status + * @return $this + */ + public function autoplay($status = false) + { + if ($status) { + $this->attributes['autoplay'] = true; + } else { + unset($this->attributes['autoplay']); + } + + return $this; + } + + /** + * Allows to set the muted attribute + * + * @param bool $status + * @return $this + */ + public function muted($status = false) + { + if ($status) { + $this->attributes['muted'] = true; + } else { + unset($this->attributes['muted']); + } + + return $this; + } + + /** + * Allows to set the preload behaviour + * + * @param string|null $preload + * @return $this + */ + public function preload($preload = null) + { + $validPreloadAttrs = ['auto', 'metadata', 'none']; + + if (null === $preload) { + unset($this->attributes['preload']); + } elseif (in_array($preload, $validPreloadAttrs, true)) { + $this->attributes['preload'] = $preload; + } + + return $this; + } + + /** + * Reset player. + */ + public function resetPlayer() + { + $this->attributes['controls'] = true; + } +} diff --git a/system/src/Grav/Common/Media/Traits/MediaTrait.php b/system/src/Grav/Common/Media/Traits/MediaTrait.php index dc6550d..5faba82 100644 --- a/system/src/Grav/Common/Media/Traits/MediaTrait.php +++ b/system/src/Grav/Common/Media/Traits/MediaTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Media * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,10 +15,18 @@ use Grav\Common\Media\Interfaces\MediaCollectionInterface; use Grav\Common\Page\Media; use Psr\SimpleCache\CacheInterface; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use function strlen; +/** + * Trait MediaTrait + * @package Grav\Common\Media\Traits + */ trait MediaTrait { + /** @var MediaCollectionInterface|null */ protected $media; + /** @var bool */ + protected $_loadMedia = true; /** * Get filesystem path to the associated media. @@ -40,52 +48,58 @@ trait MediaTrait /** * Get URI ot the associated media. Method will return null if path isn't URI. * - * @return null|string + * @return string|null */ public function getMediaUri() { - $folder = $this->getMediaFolder(); + $folder = $this->getMediaFolder(); + if (!$folder) { + return null; + } - if (strpos($folder, '://')) { - return $folder; - } + if (strpos($folder, '://')) { + return $folder; + } /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - $user = $locator->findResource('user://'); - if (strpos($folder, $user) === 0) { - return 'user://' . substr($folder, \strlen($user)+1); - } + $locator = Grav::instance()['locator']; + $user = $locator->findResource('user://'); + if (strpos($folder, $user) === 0) { + return 'user://' . substr($folder, strlen($user)+1); + } - return null; + return null; } /** * Gets the associated media collection. * - * @return MediaCollectionInterface Representation of associated media. + * @return MediaCollectionInterface|Media Representation of associated media. */ public function getMedia() { - if ($this->media === null) { + $media = $this->media; + if (null === $media) { $cache = $this->getMediaCache(); + $cacheKey = md5('media' . $this->getCacheKey()); // Use cached media if possible. - $cacheKey = md5('media' . $this->getCacheKey()); - if (!$media = $cache->get($cacheKey)) { - $media = new Media($this->getMediaFolder(), $this->getMediaOrder()); + $media = $cache->get($cacheKey); + if (!$media instanceof MediaCollectionInterface) { + $media = new Media($this->getMediaFolder(), $this->getMediaOrder(), $this->_loadMedia); $cache->set($cacheKey, $media); } + $this->media = $media; } - return $this->media; + return $media; } /** * Sets the associated media collection. * - * @param MediaCollectionInterface $media Representation of associated media. + * @param MediaCollectionInterface|Media $media Representation of associated media. * @return $this */ protected function setMedia(MediaCollectionInterface $media) @@ -99,8 +113,18 @@ trait MediaTrait return $this; } + /** + * @return void + */ + protected function freeMedia() + { + $this->media = null; + } + /** * Clear media cache. + * + * @return void */ protected function clearMediaCache() { @@ -108,7 +132,7 @@ trait MediaTrait $cacheKey = md5('media' . $this->getCacheKey()); $cache->delete($cacheKey); - $this->media = null; + $this->freeMedia(); } /** diff --git a/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php b/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php new file mode 100644 index 0000000..6da77ee --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php @@ -0,0 +1,668 @@ + true, // Whether path is in the media collection path itself. + 'avoid_overwriting' => false, // Do not override existing files (adds datetime postfix if conflict). + 'random_name' => false, // True if name needs to be randomized. + 'accept' => ['image/*'], // Accepted mime types or file extensions. + 'limit' => 10, // Maximum number of files. + 'filesize' => null, // Maximum filesize in MB. + 'destination' => null // Destination path, if empty, exception is thrown. + ]; + + /** + * Create Medium from an uploaded file. + * + * @param UploadedFileInterface $uploadedFile + * @param array $params + * @return Medium|null + */ + public function createFromUploadedFile(UploadedFileInterface $uploadedFile, array $params = []) + { + return MediumFactory::fromUploadedFile($uploadedFile, $params); + } + + /** + * Checks that uploaded file meets the requirements. Returns new filename. + * + * @example + * $filename = null; // Override filename if needed (ignored if randomizing filenames). + * $settings = ['destination' => 'user://pages/media']; // Settings from the form field. + * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings); + * $media->copyUploadedFile($uploadedFile, $filename); + * + * @param UploadedFileInterface $uploadedFile + * @param string|null $filename + * @param array|null $settings + * @return string + * @throws RuntimeException + */ + public function checkUploadedFile(UploadedFileInterface $uploadedFile, string $filename = null, array $settings = null): string + { + // Check if there is an upload error. + switch ($uploadedFile->getError()) { + case UPLOAD_ERR_OK: + break; + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_FILESIZE_LIMIT'), 400); + case UPLOAD_ERR_PARTIAL: + case UPLOAD_ERR_NO_FILE: + if (!$uploadedFile instanceof FormFlashFile) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILES_SENT'), 400); + } + break; + case UPLOAD_ERR_NO_TMP_DIR: + throw new RuntimeException($this->translate('PLUGIN_ADMIN.UPLOAD_ERR_NO_TMP_DIR'), 400); + case UPLOAD_ERR_CANT_WRITE: + case UPLOAD_ERR_EXTENSION: + default: + throw new RuntimeException($this->translate('PLUGIN_ADMIN.UNKNOWN_ERRORS'), 400); + } + + $metadata = [ + 'filename' => $uploadedFile->getClientFilename(), + 'mime' => $uploadedFile->getClientMediaType(), + 'size' => $uploadedFile->getSize(), + ]; + + return $this->checkFileMetadata($metadata, $filename, $settings); + } + + /** + * Checks that file metadata meets the requirements. Returns new filename. + * + * @param array $metadata + * @param array|null $settings + * @return string|null + * @throws RuntimeException + */ + public function checkFileMetadata(array $metadata, string $filename = null, array $settings = null): string + { + // Add the defaults to the settings. + $settings = $this->getUploadSettings($settings); + + // Destination is always needed (but it can be set in defaults). + $self = $settings['self'] ?? false; + if (!isset($settings['destination']) && $self === false) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.DESTINATION_NOT_SPECIFIED'), 400); + } + + if (null === $filename) { + // If no filename is given, use the filename from the uploaded file (path is not allowed). + $folder = ''; + $filename = $metadata['filename'] ?? ''; + } else { + // If caller sets the filename, we will accept any custom path. + $folder = dirname($filename); + if ($folder === '.') { + $folder = ''; + } + $filename = basename($filename); + } + $extension = pathinfo($filename, PATHINFO_EXTENSION); + + // Decide which filename to use. + if ($settings['random_name']) { + // Generate random filename if asked for. + $filename = mb_strtolower(Utils::generateRandomString(15) . '.' . $extension); + } + + // Handle conflicting filename if needed. + if ($settings['avoid_overwriting']) { + $destination = $settings['destination']; + if ($destination && $this->fileExists($filename, $destination)) { + $filename = date('YmdHis') . '-' . $filename; + } + } + $filepath = $folder . $filename; + + // Check if the filename is allowed. + if (!Utils::checkFilename($filename)) { + throw new RuntimeException( + sprintf($this->translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD'), $filepath, $this->translate('PLUGIN_ADMIN.BAD_FILENAME')) + ); + } + + // Check if the file extension is allowed. + $extension = mb_strtolower($extension); + if (!$extension || !$this->getConfig()->get("media.types.{$extension}")) { + // Not a supported type. + throw new RuntimeException($this->translate('PLUGIN_ADMIN.UNSUPPORTED_FILE_TYPE') . ': ' . $extension, 400); + } + + // Calculate maximum file size (from MB). + $filesize = $settings['filesize']; + if ($filesize) { + $max_filesize = $filesize * 1048576; + if ($metadata['size'] > $max_filesize) { + // TODO: use own language string + throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400); + } + } elseif (null === $filesize) { + // Check size against the Grav upload limit. + $grav_limit = Utils::getUploadLimit(); + if ($grav_limit > 0 && $metadata['size'] > $grav_limit) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400); + } + } + + // Handle Accepted file types. Accept can only be mime types (image/png | image/*) or file extensions (.pdf | .jpg) + $accepted = false; + $errors = []; + // Do not trust mime type sent by the browser. + $mime = Utils::getMimeByFilename($filename); + $mimeTest = $metadata['mime'] ?? $mime; + if ($mime !== $mimeTest) { + throw new RuntimeException('The mime type does not match to file extension', 400); + } + + foreach ((array)$settings['accept'] as $type) { + // Force acceptance of any file when star notation + if ($type === '*') { + $accepted = true; + break; + } + + $isMime = strstr($type, '/'); + $find = str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type); + + if ($isMime) { + $match = preg_match('#' . $find . '$#', $mime); + if (!$match) { + // TODO: translate + $errors[] = 'The MIME type "' . $mime . '" for the file "' . $filepath . '" is not an accepted.'; + } else { + $accepted = true; + break; + } + } else { + $match = preg_match('#' . $find . '$#', $filename); + if (!$match) { + // TODO: translate + $errors[] = 'The File Extension for the file "' . $filepath . '" is not an accepted.'; + } else { + $accepted = true; + break; + } + } + } + if (!$accepted) { + throw new RuntimeException(implode('
    ', $errors), 400); + } + + return $filepath; + } + + /** + * Copy uploaded file to the media collection. + * + * WARNING: Always check uploaded file before copying it! + * + * @example + * $settings = ['destination' => 'user://pages/media']; // Settings from the form field. + * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings); + * $media->copyUploadedFile($uploadedFile, $filename, $settings); + * + * @param UploadedFileInterface $uploadedFile + * @param string $filename + * @param array|null $settings + * @return void + * @throws RuntimeException + */ + public function copyUploadedFile(UploadedFileInterface $uploadedFile, string $filename, array $settings = null): void + { + // Add the defaults to the settings. + $settings = $this->getUploadSettings($settings); + + $path = $settings['destination'] ?? $this->getPath(); + if (!$path || !$filename) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE'), 400); + } + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + try { + // Clear locator cache to make sure we have up to date information from the filesystem. + $locator->clearCache(); + $this->clearCache(); + + $filesystem = Filesystem::getInstance(false); + + // Calculate path without the retina scaling factor. + $basename = $filesystem->basename($filename); + $pathname = $filesystem->pathname($filename); + + // Get name for the uploaded file. + [$base, $ext,,] = $this->getFileParts($basename); + $name = "{$pathname}{$base}.{$ext}"; + + // Upload file. + if ($uploadedFile instanceof FormFlashFile) { + // FormFlashFile needs some additional logic. + if ($uploadedFile->getError() === \UPLOAD_ERR_OK) { + // Move uploaded file. + $this->doMoveUploadedFile($uploadedFile, $filename, $path); + } elseif (strpos($filename, 'original/') === 0 && !$this->fileExists($filename, $path) && $this->fileExists($basename, $path)) { + // Original image support: override original image if it's the same as the uploaded image. + $this->doCopy($basename, $filename, $path); + } + + // FormFlashFile may also contain metadata. + $metadata = $uploadedFile->getMetaData(); + if ($metadata) { + // TODO: This overrides metadata if used with multiple retina image sizes. + $this->doSaveMetadata(['upload' => $metadata], $name, $path); + } + } else { + // Not a FormFlashFile. + $this->doMoveUploadedFile($uploadedFile, $filename, $path); + } + + // Post-processing: Special content sanitization for SVG. + $mime = Utils::getMimeByFilename($filename); + if (Utils::contains($mime, 'svg', false)) { + $this->doSanitizeSvg($filename, $path); + } + + // Add the new file into the media. + // TODO: This overrides existing media sizes if used with multiple retina image sizes. + $this->doAddUploadedMedium($name, $filename, $path); + + } catch (Exception $e) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE') . $e->getMessage(), 400); + } finally { + // Finally clear media cache. + $locator->clearCache(); + $this->clearCache(); + } + } + + /** + * Delete real file from the media collection. + * + * @param string $filename + * @param array|null $settings + * @return void + * @throws RuntimeException + */ + public function deleteFile(string $filename, array $settings = null): void + { + // Add the defaults to the settings. + $settings = $this->getUploadSettings($settings); + $filesystem = Filesystem::getInstance(false); + + // First check for allowed filename. + $basename = $filesystem->basename($filename); + if (!Utils::checkFilename($basename)) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ": {$this->translate('PLUGIN_ADMIN.BAD_FILENAME')}: " . $filename, 400); + } + + $path = $settings['destination'] ?? $this->getPath(); + if (!$path) { + return; + } + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + $locator->clearCache(); + + $pathname = $filesystem->pathname($filename); + + // Get base name of the file. + [$base, $ext,,] = $this->getFileParts($basename); + $name = "{$pathname}{$base}.{$ext}"; + + // Remove file and all all the associated metadata. + $this->doRemove($name, $path); + + // Finally clear media cache. + $locator->clearCache(); + $this->clearCache(); + } + + /** + * Rename file inside the media collection. + * + * @param string $from + * @param string $to + * @param array|null $settings + */ + public function renameFile(string $from, string $to, array $settings = null): void + { + // Add the defaults to the settings. + $settings = $this->getUploadSettings($settings); + $filesystem = Filesystem::getInstance(false); + + $path = $settings['destination'] ?? $this->getPath(); + if (!$path) { + // TODO: translate error message + throw new RuntimeException('Failed to rename file: Bad destination', 400); + } + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + $locator->clearCache(); + + // Get base name of the file. + $pathname = $filesystem->pathname($from); + + // Remove @2x, @3x and .meta.yaml + [$base, $ext,,] = $this->getFileParts($filesystem->basename($from)); + $from = "{$pathname}{$base}.{$ext}"; + + [$base, $ext,,] = $this->getFileParts($filesystem->basename($to)); + $to = "{$pathname}{$base}.{$ext}"; + + $this->doRename($from, $to, $path); + + // Finally clear media cache. + $locator->clearCache(); + $this->clearCache(); + } + + /** + * Internal logic to move uploaded file. + * + * @param UploadedFileInterface $uploadedFile + * @param string $filename + * @param string $path + */ + protected function doMoveUploadedFile(UploadedFileInterface $uploadedFile, string $filename, string $path): void + { + $filepath = sprintf('%s/%s', $path, $filename); + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + // Do not use streams internally. + if ($locator->isStream($filepath)) { + $filepath = (string)$locator->findResource($filepath, true, true); + } + + Folder::create(dirname($filepath)); + + $uploadedFile->moveTo($filepath); + } + + /** + * Internal logic to copy file. + * + * @param string $src + * @param string $dst + * @param string $path + */ + protected function doCopy(string $src, string $dst, string $path): void + { + $src = sprintf('%s/%s', $path, $src); + $dst = sprintf('%s/%s', $path, $dst); + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + // Do not use streams internally. + if ($locator->isStream($dst)) { + $dst = (string)$locator->findResource($dst, true, true); + } + + Folder::create(dirname($dst)); + + copy($src, $dst); + } + + /** + * Internal logic to rename file. + * + * @param string $from + * @param string $to + * @param string $path + */ + protected function doRename(string $from, string $to, string $path): void + { + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + $fromPath = $path . '/' . $from; + if ($locator->isStream($fromPath)) { + $fromPath = $locator->findResource($fromPath, true, true); + } + + if (!is_file($fromPath)) { + return; + } + + $mediaPath = dirname($fromPath); + $toPath = $mediaPath . '/' . $to; + if ($locator->isStream($toPath)) { + $toPath = $locator->findResource($toPath, true, true); + } + + if (is_file($toPath)) { + // TODO: translate error message + throw new RuntimeException(sprintf('File could not be renamed: %s already exists (%s)', $to, $mediaPath), 500); + } + + $result = rename($fromPath, $toPath); + if (!$result) { + // TODO: translate error message + throw new RuntimeException(sprintf('File could not be renamed: %s -> %s (%s)', $from, $to, $mediaPath), 500); + } + + // TODO: Add missing logic to handle retina files. + if (is_file($fromPath . '.meta.yaml')) { + $result = rename($fromPath . '.meta.yaml', $toPath . '.meta.yaml'); + if (!$result) { + // TODO: translate error message + throw new RuntimeException(sprintf('Meta could not be renamed: %s -> %s (%s)', $from, $to, $mediaPath), 500); + } + } + } + + /** + * Internal logic to remove file. + * + * @param string $filename + * @param string $path + */ + protected function doRemove(string $filename, string $path): void + { + $filesystem = Filesystem::getInstance(false); + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + // If path doesn't exist, there's nothing to do. + $pathname = $filesystem->pathname($filename); + if (!$this->fileExists($pathname, $path)) { + return; + } + + $folder = $locator->isStream($path) ? (string)$locator->findResource($path, true, true) : $path; + + // Remove requested media file. + if ($this->fileExists($filename, $path)) { + $result = unlink("{$folder}/{$filename}"); + if (!$result) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500); + } + } + + // Remove associated metadata. + $this->doRemoveMetadata($filename, $path); + + // Remove associated 2x, 3x and their .meta.yaml files. + $targetPath = rtrim(sprintf('%s/%s', $folder, $pathname), '/'); + $dir = scandir($targetPath, SCANDIR_SORT_NONE); + if (false === $dir) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500); + } + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + $basename = $filesystem->basename($filename); + $fileParts = (array)$filesystem->pathinfo($filename); + + foreach ($dir as $file) { + $preg_name = preg_quote($fileParts['filename'], '`'); + $preg_ext = preg_quote($fileParts['extension'] ?? '.', '`'); + $preg_filename = preg_quote($basename, '`'); + + if (preg_match("`({$preg_name}@\d+x\.{$preg_ext}(?:\.meta\.yaml)?$|{$preg_filename}\.meta\.yaml)$`", $file)) { + $testPath = $targetPath . '/' . $file; + if ($locator->isStream($testPath)) { + $testPath = (string)$locator->findResource($testPath, true, true); + $locator->clearCache($testPath); + } + + if (is_file($testPath)) { + $result = unlink($testPath); + if (!$result) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500); + } + } + } + } + } + + /** + * @param array $metadata + * @param string $filename + * @param string $path + */ + protected function doSaveMetadata(array $metadata, string $filename, string $path): void + { + $filepath = sprintf('%s/%s', $path, $filename); + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + // Do not use streams internally. + if ($locator->isStream($filepath)) { + $filepath = (string)$locator->findResource($filepath, true, true); + } + + $file = YamlFile::instance($filepath . '.meta.yaml'); + $file->save($metadata); + } + + /** + * @param string $filename + * @param string $path + */ + protected function doRemoveMetadata(string $filename, string $path): void + { + $filepath = sprintf('%s/%s', $path, $filename); + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + // Do not use streams internally. + if ($locator->isStream($filepath)) { + $filepath = (string)$locator->findResource($filepath, true); + if (!$filepath) { + return; + } + } + + $file = YamlFile::instance($filepath . '.meta.yaml'); + if ($file->exists()) { + $file->delete(); + } + } + + /** + * Get upload settings. + * + * @param array|null $settings Form field specific settings (override). + * @return array + */ + protected function getUploadSettings(?array $settings = null): array + { + return null !== $settings ? $settings + $this->_upload_defaults : $this->_upload_defaults; + } + + /** + * @param string $filename + * @param string $path + */ + protected function doSanitizeSvg(string $filename, string $path): void + { + $filepath = sprintf('%s/%s', $path, $filename); + + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + + // Do not use streams internally. + if ($locator->isStream($filepath)) { + $filepath = (string)$locator->findResource($filepath, true, true); + } + + Security::sanitizeSVG($filepath); + } + + /** + * @param string $name + * @param string $filename + * @param string $path + */ + protected function doAddUploadedMedium(string $name, string $filename, string $path): void + { + $filepath = sprintf('%s/%s', $path, $filename); + $medium = $this->createFromFile($filepath); + $realpath = $path . '/' . $name; + $this->add($realpath, $medium); + } + + /** + * @param string $string + * @return string + */ + protected function translate(string $string): string + { + return $this->getLanguage()->translate($string); + } + + abstract protected function getPath(): ?string; + + abstract protected function getGrav(): Grav; + + abstract protected function getConfig(): Config; + + abstract protected function getLanguage(): Language; + + abstract protected function clearCache(): void; +} diff --git a/system/src/Grav/Common/Media/Traits/StaticResizeTrait.php b/system/src/Grav/Common/Media/Traits/StaticResizeTrait.php new file mode 100644 index 0000000..49d75be --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/StaticResizeTrait.php @@ -0,0 +1,40 @@ +styleAttributes['width'] = $width . 'px'; + } else { + unset($this->styleAttributes['width']); + } + if ($height) { + $this->styleAttributes['height'] = $height . 'px'; + } else { + unset($this->styleAttributes['height']); + } + + return $this; + } +} diff --git a/system/src/Grav/Common/Media/Traits/ThumbnailMediaTrait.php b/system/src/Grav/Common/Media/Traits/ThumbnailMediaTrait.php new file mode 100644 index 0000000..eab7320 --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/ThumbnailMediaTrait.php @@ -0,0 +1,149 @@ +bubble('parsedownElement', [$title, $alt, $class, $id, $reset]); + } + + /** + * Return HTML markup from the medium. + * + * @param string|null $title + * @param string|null $alt + * @param string|null $class + * @param string|null $id + * @param bool $reset + * @return string + */ + public function html($title = null, $alt = null, $class = null, $id = null, $reset = true) + { + return $this->bubble('html', [$title, $alt, $class, $id, $reset]); + } + + /** + * Switch display mode. + * + * @param string $mode + * + * @return MediaLinkInterface|MediaObjectInterface|null + */ + public function display($mode = 'source') + { + return $this->bubble('display', [$mode], false); + } + + /** + * Switch thumbnail. + * + * @param string $type + * + * @return MediaLinkInterface|MediaObjectInterface + */ + public function thumbnail($type = 'auto') + { + $this->bubble('thumbnail', [$type], false); + + return $this->bubble('getThumbnail', [], false); + } + + /** + * Turn the current Medium into a Link + * + * @param bool $reset + * @param array $attributes + * @return MediaLinkInterface + */ + public function link($reset = true, array $attributes = []) + { + return $this->bubble('link', [$reset, $attributes], false); + } + + /** + * Turn the current Medium into a Link with lightbox enabled + * + * @param int|null $width + * @param int|null $height + * @param bool $reset + * @return MediaLinkInterface + */ + public function lightbox($width = null, $height = null, $reset = true) + { + return $this->bubble('lightbox', [$width, $height, $reset], false); + } + + /** + * Bubble a function call up to either the superclass function or the parent Medium instance + * + * @param string $method + * @param array $arguments + * @param bool $testLinked + * @return mixed + */ + protected function bubble($method, array $arguments = [], $testLinked = true) + { + if (!$testLinked || $this->linked) { + $parent = $this->parent; + if (null === $parent) { + return $this; + } + + $closure = [$parent, $method]; + + if (!is_callable($closure)) { + throw new BadMethodCallException(get_class($parent) . '::' . $method . '() not found.'); + } + + return $closure(...$arguments); + } + + return parent::{$method}(...$arguments); + } +} diff --git a/system/src/Grav/Common/Media/Traits/VideoMediaTrait.php b/system/src/Grav/Common/Media/Traits/VideoMediaTrait.php new file mode 100644 index 0000000..e03fbbd --- /dev/null +++ b/system/src/Grav/Common/Media/Traits/VideoMediaTrait.php @@ -0,0 +1,68 @@ +attributes['poster'] = $urlImage; + + return $this; + } + + /** + * Allows to set the playsinline attribute + * + * @param bool $status + * @return $this + */ + public function playsinline($status = false) + { + if ($status) { + $this->attributes['playsinline'] = true; + } else { + unset($this->attributes['playsinline']); + } + + return $this; + } + + /** + * Parsedown element for source display mode + * + * @param array $attributes + * @param bool $reset + * @return array + */ + protected function sourceParsedownElement(array $attributes, $reset = true) + { + $location = $this->url($reset); + + return [ + 'name' => 'video', + 'rawHtml' => 'Your browser does not support the video tag.', + 'attributes' => $attributes + ]; + } +} diff --git a/system/src/Grav/Common/Page/Collection.php b/system/src/Grav/Common/Page/Collection.php index 51de16b..e67ff04 100644 --- a/system/src/Grav/Common/Page/Collection.php +++ b/system/src/Grav/Common/Page/Collection.php @@ -3,27 +3,36 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page; +use Exception; use Grav\Common\Grav; use Grav\Common\Iterator; +use Grav\Common\Page\Interfaces\PageCollectionInterface; use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Utils; +use InvalidArgumentException; +use function array_key_exists; +use function array_keys; +use function array_search; +use function count; +use function in_array; +use function is_array; +use function is_string; -class Collection extends Iterator +/** + * Class Collection + * @package Grav\Common\Page + */ +class Collection extends Iterator implements PageCollectionInterface { - /** - * @var Pages - */ + /** @var Pages */ protected $pages; - - /** - * @var array - */ + /** @var array */ protected $params; /** @@ -51,11 +60,23 @@ class Collection extends Iterator return $this->params; } + /** + * Set parameters to the Collection + * + * @param array $params + * @return $this + */ + public function setParams(array $params) + { + $this->params = array_merge($this->params, $params); + + return $this; + } + /** * Add a single page to a collection * * @param PageInterface $page - * * @return $this */ public function addPage(PageInterface $page) @@ -94,12 +115,12 @@ class Collection extends Iterator * * Merge another collection with the current collection * - * @param Collection $collection + * @param PageCollectionInterface $collection * @return $this */ - public function merge(Collection $collection) + public function merge(PageCollectionInterface $collection) { - foreach($collection as $page) { + foreach ($collection as $page) { $this->addPage($page); } @@ -109,35 +130,21 @@ class Collection extends Iterator /** * Intersect another collection with the current collection * - * @param Collection $collection + * @param PageCollectionInterface $collection * @return $this */ - public function intersect(Collection $collection) + public function intersect(PageCollectionInterface $collection) { $array1 = $this->items; $array2 = $collection->toArray(); - $this->items = array_uintersect($array1, $array2, function($val1, $val2) { + $this->items = array_uintersect($array1, $array2, function ($val1, $val2) { return strcmp($val1['slug'], $val2['slug']); }); return $this; } - /** - * Set parameters to the Collection - * - * @param array $params - * - * @return $this - */ - public function setParams(array $params) - { - $this->params = array_merge($this->params, $params); - - return $this; - } - /** * Returns current page. * @@ -165,9 +172,8 @@ class Collection extends Iterator /** * Returns the value at specified offset. * - * @param mixed $offset The offset to retrieve. - * - * @return mixed Can return all value types. + * @param string $offset + * @return PageInterface|null */ public function offsetGet($offset) { @@ -178,7 +184,7 @@ class Collection extends Iterator * Split collection into array of smaller collections. * * @param int $size - * @return array|Collection[] + * @return Collection[] */ public function batch($size) { @@ -196,9 +202,8 @@ class Collection extends Iterator * Remove item from the list. * * @param PageInterface|string|null $key - * * @return $this - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public function remove($key = null) { @@ -207,8 +212,8 @@ class Collection extends Iterator } elseif (null === $key) { $key = (string)key($this->items); } - if (!\is_string($key)) { - throw new \InvalidArgumentException('Invalid argument $key.'); + if (!is_string($key)) { + throw new InvalidArgumentException('Invalid argument $key.'); } parent::remove($key); @@ -221,9 +226,8 @@ class Collection extends Iterator * * @param string $by * @param string $dir - * @param array $manual - * @param string $sort_flags - * + * @param array|null $manual + * @param string|null $sort_flags * @return $this */ public function order($by, $dir = 'asc', $manual = null, $sort_flags = null) @@ -237,10 +241,9 @@ class Collection extends Iterator * Check to see if this item is the first in the collection. * * @param string $path - * * @return bool True if item is first. */ - public function isFirst($path) + public function isFirst($path): bool { return $this->items && $path === array_keys($this->items)[0]; } @@ -249,12 +252,11 @@ class Collection extends Iterator * Check to see if this item is the last in the collection. * * @param string $path - * * @return bool True if item is last. */ - public function isLast($path) + public function isLast($path): bool { - return $this->items && $path === array_keys($this->items)[\count($this->items) - 1]; + return $this->items && $path === array_keys($this->items)[count($this->items) - 1]; } /** @@ -286,7 +288,6 @@ class Collection extends Iterator * * @param string $path * @param int $direction either -1 or +1 - * * @return PageInterface|Collection The sibling item. */ public function adjacentSibling($path, $direction = 1) @@ -301,19 +302,19 @@ class Collection extends Iterator } return $this; - } /** * Returns the item in the current position. * * @param string $path the path the item - * - * @return int the index of the current page. + * @return int|null The index of the current page, null if not found. */ - public function currentPosition($path) + public function currentPosition($path): ?int { - return \array_search($path, \array_keys($this->items), true); + $pos = array_search($path, array_keys($this->items), true); + + return $pos !== false ? $pos : null; } /** @@ -325,9 +326,8 @@ class Collection extends Iterator * @param string $startDate * @param bool $endDate * @param string|null $field - * * @return $this - * @throws \Exception + * @throws Exception */ public function dateRange($startDate, $endDate = false, $field = null) { @@ -392,17 +392,17 @@ class Collection extends Iterator } /** - * Creates new collection with only modular pages + * Creates new collection with only pages * - * @return Collection The collection with only modular pages + * @return Collection The collection with only pages */ - public function modular() + public function pages() { $modular = []; foreach ($this->items as $path => $slug) { $page = $this->pages->get($path); - if ($page !== null && $page->modular()) { + if ($page !== null && !$page->isModule()) { $modular[$path] = $slug; } } @@ -412,17 +412,17 @@ class Collection extends Iterator } /** - * Creates new collection with only non-modular pages + * Creates new collection with only modules * - * @return Collection The collection with only non-modular pages + * @return Collection The collection with only modules */ - public function nonModular() + public function modules() { $modular = []; foreach ($this->items as $path => $slug) { $page = $this->pages->get($path); - if ($page !== null && !$page->modular()) { + if ($page !== null && $page->isModule()) { $modular[$path] = $slug; } } @@ -431,6 +431,72 @@ class Collection extends Iterator return $this; } + /** + * Alias of pages() + * + * @return Collection The collection with only non-module pages + */ + public function nonModular() + { + $this->pages(); + + return $this; + } + + /** + * Alias of modules() + * + * @return Collection The collection with only modules + */ + public function modular() + { + $this->modules(); + + return $this; + } + + /** + * Creates new collection with only translated pages + * + * @return Collection The collection with only published pages + * @internal + */ + public function translated() + { + $published = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && $page->translated()) { + $published[$path] = $slug; + } + } + $this->items = $published; + + return $this; + } + + /** + * Creates new collection with only untranslated pages + * + * @return Collection The collection with only non-published pages + * @internal + */ + public function nonTranslated() + { + $published = []; + + foreach ($this->items as $path => $slug) { + $page = $this->pages->get($path); + if ($page !== null && !$page->translated()) { + $published[$path] = $slug; + } + } + $this->items = $published; + + return $this; + } + /** * Creates new collection with only published pages * @@ -517,7 +583,6 @@ class Collection extends Iterator * Creates new collection with only pages of the specified type * * @param string $type - * * @return Collection The collection */ public function ofType($type) @@ -540,7 +605,6 @@ class Collection extends Iterator * Creates new collection with only pages of one of the specified types * * @param string[] $types - * * @return Collection The collection */ public function ofOneOfTheseTypes($types) @@ -549,7 +613,7 @@ class Collection extends Iterator foreach ($this->items as $path => $slug) { $page = $this->pages->get($path); - if ($page !== null && \in_array($page->template(), $types, true)) { + if ($page !== null && in_array($page->template(), $types, true)) { $items[$path] = $slug; } } @@ -563,7 +627,6 @@ class Collection extends Iterator * Creates new collection with only pages of one of the specified access levels * * @param array $accessLevels - * * @return Collection The collection */ public function ofOneOfTheseAccessLevels($accessLevels) @@ -574,19 +637,19 @@ class Collection extends Iterator $page = $this->pages->get($path); if ($page !== null && isset($page->header()->access)) { - if (\is_array($page->header()->access)) { + if (is_array($page->header()->access)) { //Multiple values for access $valid = false; foreach ($page->header()->access as $index => $accessLevel) { - if (\is_array($accessLevel)) { + if (is_array($accessLevel)) { foreach ($accessLevel as $innerIndex => $innerAccessLevel) { - if (\in_array($innerAccessLevel, $accessLevels)) { + if (in_array($innerAccessLevel, $accessLevels, false)) { $valid = true; } } } else { - if (\in_array($index, $accessLevels)) { + if (in_array($index, $accessLevels, false)) { $valid = true; } } @@ -596,11 +659,10 @@ class Collection extends Iterator } } else { //Single value for access - if (\in_array($page->header()->access, $accessLevels)) { + if (in_array($page->header()->access, $accessLevels, false)) { $items[$path] = $slug; } } - } } @@ -613,7 +675,7 @@ class Collection extends Iterator * Get the extended version of this Collection with each page keyed by route * * @return array - * @throws \Exception + * @throws Exception */ public function toExtendedArray() { diff --git a/system/src/Grav/Common/Page/Header.php b/system/src/Grav/Common/Page/Header.php index e3854ae..71c7b16 100644 --- a/system/src/Grav/Common/Page/Header.php +++ b/system/src/Grav/Common/Page/Header.php @@ -3,16 +3,35 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page; +use ArrayAccess; +use JsonSerializable; use RocketTheme\Toolbox\ArrayTraits\Constructor; -use RocketTheme\Toolbox\ArrayTraits\NestedArrayAccess; +use RocketTheme\Toolbox\ArrayTraits\Export; +use RocketTheme\Toolbox\ArrayTraits\ExportInterface; +use RocketTheme\Toolbox\ArrayTraits\NestedArrayAccessWithGetters; -class Header implements \ArrayAccess +/** + * Class Header + * @package Grav\Common\Page + */ +class Header implements ArrayAccess, ExportInterface, JsonSerializable { - use NestedArrayAccess, Constructor; + use NestedArrayAccessWithGetters, Constructor, Export; + + /** @var array */ + protected $items; + + /** + * @return array + */ + public function jsonSerialize() + { + return $this->toArray(); + } } diff --git a/system/src/Grav/Common/Page/Interfaces/PageCollectionInterface.php b/system/src/Grav/Common/Page/Interfaces/PageCollectionInterface.php new file mode 100644 index 0000000..67c5b1e --- /dev/null +++ b/system/src/Grav/Common/Page/Interfaces/PageCollectionInterface.php @@ -0,0 +1,283 @@ +modules() instead + */ + public function modular(); + + /** + * Creates new collection with only non-module pages + * + * @return PageCollectionInterface The collection with only non-module pages + * @deprecated 1.7 Use $this->pages() instead + */ + public function nonModular(); + + /** + * Creates new collection with only published pages + * + * @return PageCollectionInterface The collection with only published pages + */ + public function published(); + + /** + * Creates new collection with only non-published pages + * + * @return PageCollectionInterface The collection with only non-published pages + */ + public function nonPublished(); + + /** + * Creates new collection with only routable pages + * + * @return PageCollectionInterface The collection with only routable pages + */ + public function routable(); + + /** + * Creates new collection with only non-routable pages + * + * @return PageCollectionInterface The collection with only non-routable pages + */ + public function nonRoutable(); + + /** + * Creates new collection with only pages of the specified type + * + * @param string $type + * @return PageCollectionInterface The collection + */ + public function ofType($type); + + /** + * Creates new collection with only pages of one of the specified types + * + * @param string[] $types + * @return PageCollectionInterface The collection + */ + public function ofOneOfTheseTypes($types); + + /** + * Creates new collection with only pages of one of the specified access levels + * + * @param array $accessLevels + * @return PageCollectionInterface The collection + */ + public function ofOneOfTheseAccessLevels($accessLevels); + + /** + * Converts collection into an array. + * + * @return array + */ + public function toArray(); + + /** + * Get the extended version of this Collection with each page keyed by route + * + * @return array + * @throws Exception + */ + public function toExtendedArray(); +} diff --git a/system/src/Grav/Common/Page/Interfaces/PageContentInterface.php b/system/src/Grav/Common/Page/Interfaces/PageContentInterface.php index 8a3ee83..5156ede 100644 --- a/system/src/Grav/Common/Page/Interfaces/PageContentInterface.php +++ b/system/src/Grav/Common/Page/Interfaces/PageContentInterface.php @@ -3,13 +3,13 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page\Interfaces; -use Grav\Common\Page\Media; +use Grav\Common\Media\Interfaces\MediaCollectionInterface; /** * Methods currently implemented in Flex Page emulation layer. @@ -19,8 +19,7 @@ interface PageContentInterface /** * Gets and Sets the header based on the YAML configuration at the top of the .md file * - * @param object|array $var a YAML object representing the configuration for the file - * + * @param object|array|null $var a YAML object representing the configuration for the file * @return object the current YAML configuration */ public function header($var = null); @@ -28,19 +27,23 @@ interface PageContentInterface /** * Get the summary. * - * @param int $size Max summary size. - * + * @param int|null $size Max summary size. * @param bool $textOnly Only count text size. - * * @return string */ public function summary($size = null, $textOnly = false); + /** + * Sets the summary of the page + * + * @param string $summary Summary + */ + public function setSummary($summary); + /** * Gets and Sets the content based on content portion of the .md file * - * @param string $var Content - * + * @param string|null $var Content * @return string Content */ public function content($var = null); @@ -63,8 +66,7 @@ interface PageContentInterface * Gets and Sets the Page raw content * * @param string|null $var - * - * @return null + * @return string */ public function rawMarkdown($var = null); @@ -72,8 +74,7 @@ interface PageContentInterface * Get value from a page variable (used mostly for creating edit forms). * * @param string $name Variable name. - * @param mixed $default - * + * @param mixed|null $default * @return mixed */ public function value($name, $default = null); @@ -81,18 +82,16 @@ interface PageContentInterface /** * Gets and sets the associated media as found in the page folder. * - * @param Media $var Representation of associated media. - * - * @return Media Representation of associated media. + * @param MediaCollectionInterface|null $var New media object. + * @return MediaCollectionInterface Representation of associated media. */ public function media($var = null); /** * Gets and sets the title for this Page. If no title is set, it will use the slug() to get a name * - * @param string $var the title of the Page - * - * @return string the title of the Page + * @param string|null $var New title of the Page + * @return string The title of the Page */ public function title($var = null); @@ -100,45 +99,40 @@ interface PageContentInterface * Gets and sets the menu name for this Page. This is the text that can be used specifically for navigation. * If no menu field is set, it will use the title() * - * @param string $var the menu field for the page - * - * @return string the menu field for the page + * @param string|null $var New menu field for the page + * @return string The menu field for the page */ public function menu($var = null); /** * Gets and Sets whether or not this Page is visible for navigation * - * @param bool $var true if the page is visible - * - * @return bool true if the page is visible + * @param bool|null $var New value + * @return bool True if the page is visible */ public function visible($var = null); /** * Gets and Sets whether or not this Page is considered published * - * @param bool $var true if the page is published - * - * @return bool true if the page is published + * @param bool|null $var New value + * @return bool True if the page is published */ public function published($var = null); /** * Gets and Sets the Page publish date * - * @param string $var string representation of a date - * - * @return int unix timestamp representation of the date + * @param string|null $var String representation of the new date + * @return int Unix timestamp representation of the date */ public function publishDate($var = null); /** * Gets and Sets the Page unpublish date * - * @param string $var string representation of a date - * - * @return int|null unix timestamp representation of the date + * @param string|null $var String representation of the new date + * @return int|null Unix timestamp representation of the date */ public function unpublishDate($var = null); @@ -146,9 +140,8 @@ interface PageContentInterface * Gets and Sets the process setup for this Page. This is multi-dimensional array that consists of * a simple array of arrays with the form array("markdown"=>true) for example * - * @param array $var an Array of name value pairs where the name is the process and value is true or false - * - * @return array an Array of name value pairs where the name is the process and value is true or false + * @param array|null $var New array of name value pairs where the name is the process and value is true or false + * @return array Array of name value pairs where the name is the process and value is true or false */ public function process($var = null); @@ -156,63 +149,56 @@ interface PageContentInterface * Gets and Sets the slug for the Page. The slug is used in the URL routing. If not set it uses * the parent folder from the path * - * @param string $var the slug, e.g. 'my-blog' - * - * @return string the slug + * @param string|null $var New slug, e.g. 'my-blog' + * @return string The slug */ public function slug($var = null); /** * Get/set order number of this page. * - * @param int $var - * - * @return int|bool + * @param int|null $var New order as a number + * @return string|bool Order in a form of '02.' or false if not set */ public function order($var = null); /** * Gets and sets the identifier for this Page object. * - * @param string $var the identifier - * - * @return string the identifier + * @param string|null $var New identifier + * @return string The identifier */ public function id($var = null); /** * Gets and sets the modified timestamp. * - * @param int $var modified unix timestamp - * - * @return int modified unix timestamp + * @param int|null $var New modified unix timestamp + * @return int Modified unix timestamp */ public function modified($var = null); /** * Gets and sets the option to show the last_modified header for the page. * - * @param boolean $var show last_modified header - * - * @return boolean show last_modified header + * @param bool|null $var New last_modified header value + * @return bool Show last_modified header */ public function lastModified($var = null); /** * Get/set the folder. * - * @param string $var Optional path - * - * @return string|null + * @param string|null $var New folder + * @return string|null The folder */ public function folder($var = null); /** * Gets and sets the date for this Page object. This is typically passed in via the page headers * - * @param string $var string representation of a date - * - * @return int unix timestamp representation of the date + * @param string|null $var New string representation of a date + * @return int Unix timestamp representation of the date */ public function date($var = null); @@ -220,30 +206,34 @@ interface PageContentInterface * Gets and sets the date format for this Page object. This is typically passed in via the page headers * using typical PHP date string structure - http://php.net/manual/en/function.date.php * - * @param string $var string representation of a date format - * - * @return string string representation of a date format + * @param string|null $var New string representation of a date format + * @return string String representation of a date format */ public function dateformat($var = null); /** * Gets and sets the taxonomy array which defines which taxonomies this page identifies itself with. * - * @param array $var an array of taxonomies - * - * @return array an array of taxonomies + * @param array|null $var New array of taxonomies + * @return array An array of taxonomies */ public function taxonomy($var = null); /** * Gets the configured state of the processing method. * - * @param string $process the process, eg "twig" or "markdown" - * - * @return bool whether or not the processing method is enabled for this Page + * @param string $process The process name, eg "twig" or "markdown" + * @return bool Whether or not the processing method is enabled for this Page */ public function shouldProcess($process); + /** + * Returns true if page is a module. + * + * @return bool + */ + public function isModule(): bool; + /** * Returns whether or not this Page object has a .md file associated with it or if its just a directory. * diff --git a/system/src/Grav/Common/Page/Interfaces/PageFormInterface.php b/system/src/Grav/Common/Page/Interfaces/PageFormInterface.php new file mode 100644 index 0000000..3c88ebf --- /dev/null +++ b/system/src/Grav/Common/Page/Interfaces/PageFormInterface.php @@ -0,0 +1,33 @@ + blueprint, ...], where blueprint follows the regular form blueprint format. + * + * @return array + */ + //public function getForms(): array; + + /** + * Add forms to this page. + * + * @param array $new + * @return $this + */ + public function addForms(array $new/*, $override = true*/); + + /** + * Alias of $this->getForms(); + * + * @return array + */ + public function forms();//: array; +} diff --git a/system/src/Grav/Common/Page/Interfaces/PageInterface.php b/system/src/Grav/Common/Page/Interfaces/PageInterface.php index 24bf50c..e5ea20c 100644 --- a/system/src/Grav/Common/Page/Interfaces/PageInterface.php +++ b/system/src/Grav/Common/Page/Interfaces/PageInterface.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,6 +14,12 @@ use Grav\Common\Media\Interfaces\MediaInterface; /** * Class implements page interface. */ -interface PageInterface extends PageContentInterface, PageRoutableInterface, PageTranslateInterface, MediaInterface, PageLegacyInterface +interface PageInterface extends + PageContentInterface, + PageFormInterface, + PageRoutableInterface, + PageTranslateInterface, + MediaInterface, + PageLegacyInterface { } diff --git a/system/src/Grav/Common/Page/Interfaces/PageLegacyInterface.php b/system/src/Grav/Common/Page/Interfaces/PageLegacyInterface.php index 470d80d..0d38c7d 100644 --- a/system/src/Grav/Common/Page/Interfaces/PageLegacyInterface.php +++ b/system/src/Grav/Common/Page/Interfaces/PageLegacyInterface.php @@ -4,25 +4,29 @@ namespace Grav\Common\Page\Interfaces; use Exception; use Grav\Common\Data\Blueprint; use Grav\Common\Page\Collection; +use InvalidArgumentException; use RocketTheme\Toolbox\File\MarkdownFile; +use SplFileInfo; +/** + * Interface PageLegacyInterface + * @package Grav\Common\Page\Interfaces + */ interface PageLegacyInterface { /** * Initializes the page instance variables based on a file * - * @param \SplFileInfo $file The file information for the .md file that the page represents - * @param string $extension - * + * @param SplFileInfo $file The file information for the .md file that the page represents + * @param string|null $extension * @return $this */ - public function init(\SplFileInfo $file, $extension = null); + public function init(SplFileInfo $file, $extension = null); /** * Gets and Sets the raw data * - * @param string $var Raw content string - * + * @param string|null $var Raw content string * @return string Raw content string */ public function raw($var = null); @@ -31,7 +35,6 @@ interface PageLegacyInterface * Gets and Sets the page frontmatter * * @param string|null $var - * * @return string */ public function frontmatter($var = null); @@ -49,14 +52,10 @@ interface PageLegacyInterface */ public function httpResponseCode(); - public function httpHeaders(); - /** - * Sets the summary of the page - * - * @param string $summary Summary + * @return array */ - public function setSummary($summary); + public function httpHeaders(); /** * Get the contentMeta array and initialize content first if it's not already @@ -69,7 +68,7 @@ interface PageLegacyInterface * Add an entry to the page's contentMeta array * * @param string $name - * @param string $value + * @param mixed $value */ public function addContentMeta($name, $value); @@ -77,7 +76,6 @@ interface PageLegacyInterface * Return the whole contentMeta array as it currently stands * * @param string|null $name - * * @return mixed */ public function getContentMeta($name = null); @@ -86,7 +84,6 @@ interface PageLegacyInterface * Sets the whole content meta array in one shot * * @param array $content_meta - * * @return array */ public function setContentMeta($content_meta); @@ -116,7 +113,6 @@ interface PageLegacyInterface * You need to call $this->save() in order to perform the move. * * @param PageInterface $parent New parent page. - * * @return $this */ public function move(PageInterface $parent); @@ -128,7 +124,6 @@ interface PageLegacyInterface * You need to call $this->save() in order to perform the move. * * @param PageInterface $parent New parent page. - * * @return $this */ public function copy(PageInterface $parent); @@ -202,8 +197,7 @@ interface PageLegacyInterface /** * Gets and sets the name field. If no name field is set, it will return 'default.md'. * - * @param string $var The name of this page. - * + * @param string|null $var The name of this page. * @return string The name of this page. */ public function name($var = null); @@ -219,8 +213,7 @@ interface PageLegacyInterface * Gets and sets the template field. This is used to find the correct Twig template file to render. * If no field is set, it will return the name without the .md extension * - * @param string $var the template name - * + * @param string|null $var the template name * @return string the template name */ public function template($var = null); @@ -229,26 +222,23 @@ interface PageLegacyInterface * Allows a page to override the output render format, usually the extension provided * in the URL. (e.g. `html`, `json`, `xml`, etc). * - * @param null $var - * - * @return null + * @param string|null $var + * @return string */ public function templateFormat($var = null); /** * Gets and sets the extension field. * - * @param null $var - * - * @return null|string + * @param string|null $var + * @return string|null */ public function extension($var = null); /** * Gets and sets the expires field. If not set will return the default * - * @param int $var The new expires value. - * + * @param int|null $var The new expires value. * @return int The expires value */ public function expires($var = null); @@ -257,17 +247,21 @@ interface PageLegacyInterface * Gets and sets the cache-control property. If not set it will return the default value (null) * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for more details on valid options * - * @param null $var - * @return null + * @param string|null $var + * @return string|null */ public function cacheControl($var = null); + /** + * @param bool|null $var + * @return bool + */ public function ssl($var = null); /** * Returns the state of the debugger override etting for this page * - * @return mixed + * @return bool */ public function debugger(); @@ -275,8 +269,7 @@ interface PageLegacyInterface * Function to merge page metadata tags and build an array of Metadata objects * that can then be rendered in the page. * - * @param array $var an Array of metadata values to set - * + * @param array|null $var an Array of metadata values to set * @return array an Array of metadata values for the page */ public function metadata($var = null); @@ -284,17 +277,15 @@ interface PageLegacyInterface /** * Gets and sets the option to show the etag header for the page. * - * @param bool $var show etag header - * + * @param bool|null $var show etag header * @return bool show etag header */ - public function eTag($var = null); + public function eTag($var = null): bool; /** * Gets and sets the path to the .md file for this Page object. * - * @param string $var the file path - * + * @param string|null $var the file path * @return string|null the file path */ public function filePath($var = null); @@ -309,8 +300,7 @@ interface PageLegacyInterface /** * Gets and sets the order by which any sub-pages should be sorted. * - * @param string $var the order, either "asc" or "desc" - * + * @param string|null $var the order, either "asc" or "desc" * @return string the order, either "asc" or "desc" * @deprecated 1.6 */ @@ -324,8 +314,7 @@ interface PageLegacyInterface * date - is the order based on the date set in the pages * folder - is the order based on the name of the folder with any numerics omitted * - * @param string $var supported options include "default", "title", "date", and "folder" - * + * @param string|null $var supported options include "default", "title", "date", and "folder" * @return string supported options include "default", "title", "date", and "folder" * @deprecated 1.6 */ @@ -334,8 +323,7 @@ interface PageLegacyInterface /** * Gets the manual order set in the header. * - * @param string $var supported options include "default", "title", "date", and "folder" - * + * @param string|null $var supported options include "default", "title", "date", and "folder" * @return array * @deprecated 1.6 */ @@ -345,8 +333,7 @@ interface PageLegacyInterface * Gets and sets the maxCount field which describes how many sub-pages should be displayed if the * sub_pages header property is set for this page object. * - * @param int $var the maximum number of sub-pages - * + * @param int|null $var the maximum number of sub-pages * @return int the maximum number of sub-pages * @deprecated 1.6 */ @@ -355,9 +342,9 @@ interface PageLegacyInterface /** * Gets and sets the modular var that helps identify this page is a modular child * - * @param bool $var true if modular_twig - * + * @param bool|null $var true if modular_twig * @return bool true if modular_twig + * @deprecated 1.7 Use ->isModule() or ->modularTwig() method instead. */ public function modular($var = null); @@ -365,8 +352,7 @@ interface PageLegacyInterface * Gets and sets the modular_twig var that helps identify this page as a modular child page that will need * twig processing handled differently from a regular page. * - * @param bool $var true if modular_twig - * + * @param bool|null $var true if modular_twig * @return bool true if modular_twig */ public function modularTwig($var = null); @@ -374,7 +360,7 @@ interface PageLegacyInterface /** * Returns children of this page. * - * @return \Grav\Common\Page\Collection + * @return PageCollectionInterface|Collection */ public function children(); @@ -410,16 +396,14 @@ interface PageLegacyInterface * Returns the adjacent sibling based on a direction. * * @param int $direction either -1 or +1 - * - * @return PageInterface|bool the sibling page + * @return PageInterface|false the sibling page */ public function adjacentSibling($direction = 1); /** * Helper method to return an ancestor page. * - * @param bool $lookup Name of the parent folder - * + * @param bool|null $lookup Name of the parent folder * @return PageInterface page you were looking for if it exists */ public function ancestor($lookup = null); @@ -429,7 +413,6 @@ interface PageLegacyInterface * page object is returned. * * @param string $field Name of the parent folder - * * @return PageInterface */ public function inherited($field); @@ -439,7 +422,6 @@ interface PageLegacyInterface * first occurrence of an ancestor field will be returned if at all. * * @param string $field Name of the parent folder - * * @return array */ public function inheritedField($field); @@ -449,7 +431,6 @@ interface PageLegacyInterface * * @param string $url the url of the page * @param bool $all - * * @return PageInterface page you were looking for if it exists */ public function find($url, $all = false); @@ -459,17 +440,15 @@ interface PageLegacyInterface * * @param string|array $params * @param bool $pagination - * * @return Collection - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public function collection($params = 'content', $pagination = true); /** * @param string|array $value * @param bool $only_published - * @return mixed - * @internal + * @return PageCollectionInterface|Collection */ public function evaluate($value, $only_published = true); diff --git a/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php b/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php index c3afeab..2900266 100644 --- a/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php +++ b/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php @@ -1,6 +1,10 @@ page = $page ?? Grav::instance()['page'] ?? null; @@ -42,16 +59,26 @@ class Excerpts $this->config = $config; } - public function getPage(): PageInterface + /** + * @return PageInterface|null + */ + public function getPage(): ?PageInterface { return $this->page; } + /** + * @return array + */ public function getConfig(): array { return $this->config; } + /** + * @param object $markdown + * @return void + */ public function fireInitializedEvent($markdown): void { $grav = Grav::instance(); @@ -68,8 +95,8 @@ class Excerpts */ public function processLinkExcerpt(array $excerpt, string $type = 'link'): array { + $grav = Grav::instance(); $url = htmlspecialchars_decode(rawurldecode($excerpt['element']['attributes']['href'])); - $url_parts = $this->parseUrl($url); // If there is a query, then parse it and build action calls. @@ -87,14 +114,18 @@ class Excerpts ); // Valid attributes supported. - $valid_attributes = Grav::instance()['config']->get('system.pages.markdown.valid_link_attributes'); + $valid_attributes = $grav['config']->get('system.pages.markdown.valid_link_attributes') ?? []; + $skip = []; // Unless told to not process, go through actions. if (array_key_exists('noprocess', $actions)) { + $skip = is_bool($actions['noprocess']) ? $actions : explode(',', $actions['noprocess']); unset($actions['noprocess']); - } else { - // Loop through actions for the image and call them. - foreach ($actions as $attrib => $value) { + } + + // Loop through actions for the image and call them. + foreach ($actions as $attrib => $value) { + if (!in_array($attrib, $skip)) { $key = $attrib; if (in_array($attrib, $valid_attributes, true)) { @@ -108,12 +139,12 @@ class Excerpts } } - $url_parts['query'] = http_build_query($actions, null, '&', PHP_QUERY_RFC3986); + $url_parts['query'] = http_build_query($actions, '', '&', PHP_QUERY_RFC3986); } // If no query elements left, unset query. if (empty($url_parts['query'])) { - unset ($url_parts['query']); + unset($url_parts['query']); } // Set path to / if not set. @@ -124,9 +155,11 @@ class Excerpts // If scheme isn't http(s).. if (!empty($url_parts['scheme']) && !in_array($url_parts['scheme'], ['http', 'https'])) { // Handle custom streams. - if ($type !== 'image' && !empty($url_parts['stream']) && !empty($url_parts['path'])) { - $grav = Grav::instance(); - $url_parts['path'] = $grav['base_url_relative'] . '/' . $this->resolveStream("{$url_parts['scheme']}://{$url_parts['path']}"); + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + if ($locator->isStream($url)) { + $path = $locator->findResource($url, false) ?: $locator->findResource($url, false, true); + $url_parts['path'] = $grav['base_url_relative'] . '/' . $path; unset($url_parts['stream'], $url_parts['scheme']); } @@ -162,9 +195,10 @@ class Excerpts $filename = $url_parts['scheme'] . '://' . ($url_parts['path'] ?? ''); $media = $this->page->getMedia(); - } else { $grav = Grav::instance(); + /** @var Pages $pages */ + $pages = $grav['pages']; // File is also local if scheme is http(s) and host matches. $local_file = isset($url_parts['path']) @@ -181,11 +215,10 @@ class Excerpts $media = $this->page->getMedia(); } else { // see if this is an external page to this one - $base_url = rtrim($grav['base_url_relative'] . $grav['pages']->base(), '/'); + $base_url = rtrim($grav['base_url_relative'] . $pages->base(), '/'); $page_route = '/' . ltrim(str_replace($base_url, '', $folder), '/'); - /** @var PageInterface $ext_page */ - $ext_page = $grav['pages']->dispatch($page_route, true); + $ext_page = $pages->find($page_route, true); if ($ext_page) { $media = $ext_page->getMedia(); } else { @@ -211,7 +244,6 @@ class Excerpts $id = $element_excerpt['id'] ?? ''; $excerpt['element'] = $medium->parsedownElement($title, $alt, $class, $id, true); - } else { // Not a current page media file, see if it needs converting to relative. $excerpt['element']['attributes']['src'] = Uri::buildUrl($url_parts); @@ -248,18 +280,15 @@ class Excerpts ); } - $config = $this->getConfig(); - if (!empty($config['images']['auto_fix_orientation'])) { - $actions[] = ['method' => 'fixOrientation', 'params' => '']; - } - - $defaults = $config['images']['defaults'] ?? []; + $defaults = $this->config['images']['defaults'] ?? []; if (count($defaults)) { foreach ($defaults as $method => $params) { - $actions[] = [ - 'method' => $method, - 'params' => $params, - ]; + if (array_search($method, array_column($actions, 'method')) === false) { + $actions[] = [ + 'method' => $method, + 'params' => $params, + ]; + } } } @@ -287,7 +316,7 @@ class Excerpts * Variation of parse_url() which works also with local streams. * * @param string $url - * @return array|bool + * @return array */ protected function parseUrl(string $url) { @@ -311,20 +340,4 @@ class Excerpts return $url_parts; } - - /** - * @param string $url - * @return bool|string - */ - protected function resolveStream(string $url) - { - /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - - if ($locator->isStream($url)) { - return $locator->findResource($url, false) ?: $locator->findResource($url, false, true); - } - - return $url; - } } diff --git a/system/src/Grav/Common/Page/Media.php b/system/src/Grav/Common/Page/Media.php index 88b2ced..9201e66 100644 --- a/system/src/Grav/Common/Page/Media.php +++ b/system/src/Grav/Common/Page/Media.php @@ -3,29 +3,38 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page; +use FilesystemIterator; use Grav\Common\Grav; +use Grav\Common\Media\Interfaces\MediaObjectInterface; use Grav\Common\Yaml; use Grav\Common\Page\Medium\AbstractMedia; use Grav\Common\Page\Medium\GlobalMedia; use Grav\Common\Page\Medium\MediumFactory; use RocketTheme\Toolbox\File\File; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use function in_array; +/** + * Class Media + * @package Grav\Common\Page + */ class Media extends AbstractMedia { + /** @var GlobalMedia */ protected static $global; + /** @var array */ protected $standard_exif = ['FileSize', 'MimeType', 'height', 'width']; /** * @param string $path - * @param array $media_order + * @param array|null $media_order * @param bool $load */ public function __construct($path, array $media_order = null, $load = true) @@ -44,15 +53,14 @@ class Media extends AbstractMedia */ public function __wakeup() { - if (!isset(static::$global)) { + if (null === static::$global) { // Add fallback to global media. - static::$global = new GlobalMedia(); + static::$global = GlobalMedia::getInstance(); } } /** - * @param mixed $offset - * + * @param string $offset * @return bool */ public function offsetExists($offset) @@ -61,9 +69,8 @@ class Media extends AbstractMedia } /** - * @param mixed $offset - * - * @return mixed + * @param string $offset + * @return MediaObjectInterface|null */ public function offsetGet($offset) { @@ -72,58 +79,64 @@ class Media extends AbstractMedia /** * Initialize class. + * + * @return void */ protected function init() { /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; $config = Grav::instance()['config']; - $locator = Grav::instance()['locator']; $exif_reader = isset(Grav::instance()['exif']) ? Grav::instance()['exif']->getReader() : false; $media_types = array_keys(Grav::instance()['config']->get('media.types')); + $path = $this->getPath(); // Handle special cases where page doesn't exist in filesystem. - if (!is_dir($this->getPath())) { + if (!$path || !is_dir($path)) { return; } - $iterator = new \FilesystemIterator($this->getPath(), \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::SKIP_DOTS); + $iterator = new FilesystemIterator($path, FilesystemIterator::UNIX_PATHS | FilesystemIterator::SKIP_DOTS); $media = []; - /** @var \DirectoryIterator $info */ - foreach ($iterator as $path => $info) { + foreach ($iterator as $file => $info) { // Ignore folders and Markdown files. if (!$info->isFile() || $info->getExtension() === 'md' || strpos($info->getFilename(), '.') === 0) { continue; } // Find out what type we're dealing with - list($basename, $ext, $type, $extra) = $this->getFileParts($info->getFilename()); + [$basename, $ext, $type, $extra] = $this->getFileParts($info->getFilename()); - if (!\in_array(strtolower($ext), $media_types, true)) { + if (!in_array(strtolower($ext), $media_types, true)) { continue; } if ($type === 'alternative') { - $media["{$basename}.{$ext}"][$type][$extra] = ['file' => $path, 'size' => $info->getSize()]; + $media["{$basename}.{$ext}"][$type][$extra] = ['file' => $file, 'size' => $info->getSize()]; } else { - $media["{$basename}.{$ext}"][$type] = ['file' => $path, 'size' => $info->getSize()]; + $media["{$basename}.{$ext}"][$type] = ['file' => $file, 'size' => $info->getSize()]; } } foreach ($media as $name => $types) { // First prepare the alternatives in case there is no base medium if (!empty($types['alternative'])) { + /** + * @var string|int $ratio + * @var array $alt + */ foreach ($types['alternative'] as $ratio => &$alt) { - $alt['file'] = MediumFactory::fromFile($alt['file']); + $alt['file'] = $this->createFromFile($alt['file']); - if (!$alt['file']) { + if (empty($alt['file'])) { unset($types['alternative'][$ratio]); } else { $alt['file']->set('size', $alt['size']); } } + unset($alt); } $file_path = null; @@ -139,9 +152,11 @@ class Media extends AbstractMedia $file_path = $medium->path(); $medium = MediumFactory::scaledFromMedium($medium, $max, 1)['file']; } else { - $medium = MediumFactory::fromFile($types['base']['file']); - $medium && $medium->set('size', $types['base']['size']); - $file_path = $medium->path(); + $medium = $this->createFromFile($types['base']['file']); + if ($medium) { + $medium->set('size', $types['base']['size']); + $file_path = $medium->path(); + } } if (empty($medium)) { @@ -154,7 +169,6 @@ class Media extends AbstractMedia if (file_exists($meta_path)) { $types['meta']['file'] = $meta_path; } elseif ($file_path && $exif_reader && $medium->get('mime') === 'image/jpeg' && empty($types['meta']) && $config->get('system.media.auto_metadata_exif')) { - $meta = $exif_reader->read($file_path); if ($meta) { @@ -212,10 +226,10 @@ class Media extends AbstractMedia } /** - * @return string + * @return string|null * @deprecated 1.6 Use $this->getPath() instead. */ - public function path() + public function path(): ?string { return $this->getPath(); } diff --git a/system/src/Grav/Common/Page/Medium/AbstractMedia.php b/system/src/Grav/Common/Page/Medium/AbstractMedia.php index f67cd65..db81ddd 100644 --- a/system/src/Grav/Common/Page/Medium/AbstractMedia.php +++ b/system/src/Grav/Common/Page/Medium/AbstractMedia.php @@ -3,49 +3,72 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page\Medium; +use Grav\Common\Config\Config; +use Grav\Common\Data\Blueprint; use Grav\Common\Grav; +use Grav\Common\Language\Language; use Grav\Common\Media\Interfaces\MediaCollectionInterface; use Grav\Common\Media\Interfaces\MediaObjectInterface; -use Grav\Common\Page\Page; +use Grav\Common\Media\Interfaces\MediaUploadInterface; +use Grav\Common\Media\Traits\MediaUploadTrait; +use Grav\Common\Page\Pages; use Grav\Common\Utils; use RocketTheme\Toolbox\ArrayTraits\ArrayAccess; use RocketTheme\Toolbox\ArrayTraits\Countable; use RocketTheme\Toolbox\ArrayTraits\Export; use RocketTheme\Toolbox\ArrayTraits\ExportInterface; use RocketTheme\Toolbox\ArrayTraits\Iterator; +use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use function is_array; -abstract class AbstractMedia implements ExportInterface, MediaCollectionInterface +/** + * Class AbstractMedia + * @package Grav\Common\Page\Medium + */ +abstract class AbstractMedia implements ExportInterface, MediaCollectionInterface, MediaUploadInterface { use ArrayAccess; use Countable; use Iterator; use Export; + use MediaUploadTrait; + /** @var array */ protected $items = []; + /** @var string|null */ protected $path; + /** @var array */ protected $images = []; + /** @var array */ protected $videos = []; + /** @var array */ protected $audios = []; + /** @var array */ protected $files = []; + /** @var array|null */ protected $media_order; /** * Return media path. * - * @return string + * @return string|null */ - public function getPath() + public function getPath(): ?string { return $this->path; } - public function setPath(?string $path) + /** + * @param string|null $path + * @return void + */ + public function setPath(?string $path): void { $this->path = $path; } @@ -54,7 +77,7 @@ abstract class AbstractMedia implements ExportInterface, MediaCollectionInterfac * Get medium by filename. * * @param string $filename - * @return Medium|null + * @return MediaObjectInterface|null */ public function get($filename) { @@ -80,7 +103,6 @@ abstract class AbstractMedia implements ExportInterface, MediaCollectionInterfac */ public function setTimestamps($timestamp = null) { - /** @var Medium $instance */ foreach ($this->items as $instance) { $instance->setTimestamp($timestamp); } @@ -150,14 +172,17 @@ abstract class AbstractMedia implements ExportInterface, MediaCollectionInterfac /** * @param string $name - * @param MediaObjectInterface $file + * @param MediaObjectInterface|null $file + * @return void */ public function add($name, $file) { - if (!$file) { + if (null === $file) { return; } + $this->offsetSet($name, $file); + switch ($file->type) { case 'image': $this->images[$name] = $file; @@ -173,6 +198,50 @@ abstract class AbstractMedia implements ExportInterface, MediaCollectionInterfac } } + /** + * @param string $name + * @return void + */ + public function hide($name) + { + $this->offsetUnset($name); + + unset($this->images[$name], $this->videos[$name], $this->audios[$name], $this->files[$name]); + } + + /** + * Create Medium from a file. + * + * @param string $file + * @param array $params + * @return Medium|null + */ + public function createFromFile($file, array $params = []) + { + return MediumFactory::fromFile($file, $params); + } + + /** + * Create Medium from array of parameters + * + * @param array $items + * @param Blueprint|null $blueprint + * @return Medium|null + */ + public function createFromArray(array $items = [], Blueprint $blueprint = null) + { + return MediumFactory::fromArray($items, $blueprint); + } + + /** + * @param MediaObjectInterface $mediaObject + * @return ImageFile + */ + public function getImageFileObject(MediaObjectInterface $mediaObject): ImageFile + { + return ImageFile::open($mediaObject->get('filepath')); + } + /** * Order the media based on the page's media_order * @@ -182,11 +251,14 @@ abstract class AbstractMedia implements ExportInterface, MediaCollectionInterfac protected function orderMedia($media) { if (null === $this->media_order) { - /** @var Page $page */ - $page = Grav::instance()['pages']->get($this->getPath()); - - if ($page && isset($page->header()->media_order)) { - $this->media_order = array_map('trim', explode(',', $page->header()->media_order)); + $path = $this->getPath(); + if (null !== $path) { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + $page = $pages->get($path); + if ($page && isset($page->header()->media_order)) { + $this->media_order = array_map('trim', explode(',', $page->header()->media_order)); + } } } @@ -199,6 +271,11 @@ abstract class AbstractMedia implements ExportInterface, MediaCollectionInterfac return $media; } + protected function fileExists(string $filename, string $destination): bool + { + return file_exists("{$destination}/{$filename}"); + } + /** * Get filename, extension and meta part. * @@ -239,6 +316,28 @@ abstract class AbstractMedia implements ExportInterface, MediaCollectionInterfac } } - return array($name, $extension, $type, $extra); + return [$name, $extension, $type, $extra]; + } + + protected function getGrav(): Grav + { + return Grav::instance(); + } + + protected function getConfig(): Config + { + return $this->getGrav()['config']; + } + + protected function getLanguage(): Language + { + return $this->getGrav()['language']; + } + + protected function clearCache(): void + { + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + $locator->clearCache(); } } diff --git a/system/src/Grav/Common/Page/Medium/AudioMedium.php b/system/src/Grav/Common/Page/Medium/AudioMedium.php index 74ef746..f34f0a9 100644 --- a/system/src/Grav/Common/Page/Medium/AudioMedium.php +++ b/system/src/Grav/Common/Page/Medium/AudioMedium.php @@ -3,134 +3,22 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page\Medium; -class AudioMedium extends Medium +use Grav\Common\Media\Interfaces\AudioMediaInterface; +use Grav\Common\Media\Traits\AudioMediaTrait; + +/** + * Class AudioMedium + * @package Grav\Common\Page\Medium + */ +class AudioMedium extends Medium implements AudioMediaInterface { - use StaticResizeTrait; - - /** - * Parsedown element for source display mode - * - * @param array $attributes - * @param bool $reset - * @return array - */ - protected function sourceParsedownElement(array $attributes, $reset = true) - { - $location = $this->url($reset); - - return [ - 'name' => 'audio', - 'text' => 'Your browser does not support the audio tag.', - 'attributes' => $attributes - ]; - } - - /** - * Allows to set or remove the HTML5 default controls - * - * @param bool $display - * @return $this - */ - public function controls($display = true) - { - if($display) { - $this->attributes['controls'] = true; - } else { - unset($this->attributes['controls']); - } - - return $this; - } - - /** - * Allows to set the preload behaviour - * - * @param string $preload - * @return $this - */ - public function preload($preload) - { - $validPreloadAttrs = ['auto', 'metadata', 'none']; - - if (\in_array($preload, $validPreloadAttrs, true)) { - $this->attributes['preload'] = $preload; - } - - return $this; - } - - /** - * Allows to set the controlsList behaviour - * Separate multiple values with a hyphen - * - * @param string $controlsList - * @return $this - */ - public function controlsList($controlsList) - { - $controlsList = str_replace('-', ' ', $controlsList); - $this->attributes['controlsList'] = $controlsList; - - return $this; - } - - /** - * Allows to set the muted attribute - * - * @param bool $status - * @return $this - */ - public function muted($status = false) - { - if($status) { - $this->attributes['muted'] = true; - } else { - unset($this->attributes['muted']); - } - - return $this; - } - - /** - * Allows to set the loop attribute - * - * @param bool $status - * @return $this - */ - public function loop($status = false) - { - if($status) { - $this->attributes['loop'] = true; - } else { - unset($this->attributes['loop']); - } - - return $this; - } - - /** - * Allows to set the autoplay attribute - * - * @param bool $status - * @return $this - */ - public function autoplay($status = false) - { - if($status) { - $this->attributes['autoplay'] = true; - } else { - unset($this->attributes['autoplay']); - } - - return $this; - } - + use AudioMediaTrait; /** * Reset medium. @@ -141,7 +29,7 @@ class AudioMedium extends Medium { parent::reset(); - $this->attributes['controls'] = true; + $this->resetPlayer(); return $this; } diff --git a/system/src/Grav/Common/Page/Medium/GlobalMedia.php b/system/src/Grav/Common/Page/Medium/GlobalMedia.php index 9fb30e9..4097c8f 100644 --- a/system/src/Grav/Common/Page/Medium/GlobalMedia.php +++ b/system/src/Grav/Common/Page/Medium/GlobalMedia.php @@ -3,30 +3,47 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page\Medium; use Grav\Common\Grav; +use Grav\Common\Media\Interfaces\MediaObjectInterface; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use function dirname; +/** + * Class GlobalMedia + * @package Grav\Common\Page\Medium + */ class GlobalMedia extends AbstractMedia { + /** @var self */ + protected static $instance; + + public static function getInstance(): self + { + if (null === self::$instance) { + self::$instance = new self(); + } + + return self::$instance; + } + /** * Return media path. * - * @return null + * @return string|null */ - public function getPath() + public function getPath(): ?string { return null; } /** - * @param mixed $offset - * + * @param string $offset * @return bool */ public function offsetExists($offset) @@ -35,9 +52,8 @@ class GlobalMedia extends AbstractMedia } /** - * @param mixed $offset - * - * @return mixed + * @param string $offset + * @return MediaObjectInterface|null */ public function offsetGet($offset) { @@ -52,13 +68,16 @@ class GlobalMedia extends AbstractMedia { /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; + if (!$locator->isStream($filename)) { + return null; + } - return $locator->isStream($filename) ? ($locator->findResource($filename) ?: null) : null; + return $locator->findResource($filename) ?: null; } /** * @param string $stream - * @return Medium|null + * @return MediaObjectInterface|null */ protected function addMedium($stream) { @@ -68,10 +87,10 @@ class GlobalMedia extends AbstractMedia } $path = dirname($filename); - list($basename, $ext,, $extra) = $this->getFileParts(basename($filename)); + [$basename, $ext,, $extra] = $this->getFileParts(basename($filename)); $medium = MediumFactory::fromFile($filename); - if (empty($medium)) { + if (null === $medium) { return null; } diff --git a/system/src/Grav/Common/Page/Medium/ImageFile.php b/system/src/Grav/Common/Page/Medium/ImageFile.php index d56c342..bd853bf 100644 --- a/system/src/Grav/Common/Page/Medium/ImageFile.php +++ b/system/src/Grav/Common/Page/Medium/ImageFile.php @@ -3,27 +3,49 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page\Medium; +use Exception; +use Grav\Common\Config\Config; use Grav\Common\Grav; use Gregwar\Image\Exceptions\GenerationError; use Gregwar\Image\Image; use Gregwar\Image\Source; use RocketTheme\Toolbox\Event\Event; +use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use function array_key_exists; +use function count; +use function extension_loaded; +use function in_array; +/** + * Class ImageFile + * @package Grav\Common\Page\Medium + * + * @method Image applyExifOrientation($exif_orienation) + */ class ImageFile extends Image { + /** + * Destruct also image object. + */ public function __destruct() { - $this->getAdapter()->deinit(); + $adapter = $this->adapter; + if ($adapter) { + $adapter->deinit(); + } } /** * Clear previously applied operations + * + * @return void */ public function clearOperations() { @@ -37,7 +59,6 @@ class ImageFile extends Image * @param int $quality the quality (for JPEG) * @param bool $actual * @param array $extras - * * @return string */ public function cacheFile($type = 'jpg', $quality = 80, $actual = false, $extras = []) @@ -53,8 +74,11 @@ class ImageFile extends Image // Computes the hash $this->hash = $this->getHash($type, $quality, $extras); + /** @var Config $config */ + $config = Grav::instance()['config']; + // Seo friendly image names - $seofriendly = Grav::instance()['config']->get('system.images.seofriendly', false); + $seofriendly = $config->get('system.images.seofriendly', false); if ($seofriendly) { $mini_hash = substr($this->hash, 0, 4) . substr($this->hash, -4); @@ -87,7 +111,7 @@ class ImageFile extends Image // Asking the cache for the cacheFile try { - $perms = Grav::instance()['config']->get('system.images.cache_perms', '0755'); + $perms = $config->get('system.images.cache_perms', '0755'); $perms = octdec($perms); $file = $this->getCacheSystem()->setDirectoryMode($perms)->getOrCreateFile($cacheFile, $conditions, $generate, $actual); } catch (GenerationError $e) { @@ -95,8 +119,9 @@ class ImageFile extends Image } // Nulling the resource - $this->getAdapter()->setSource(new Source\File($file)); - $this->getAdapter()->deinit(); + $adapter = $this->getAdapter(); + $adapter->setSource(new Source\File($file)); + $adapter->deinit(); if ($actual) { return $file; @@ -107,10 +132,11 @@ class ImageFile extends Image /** * Gets the hash. + * * @param string $type * @param int $quality - * @param [] $extras - * @return null + * @param array $extras + * @return string */ public function getHash($type = 'guess', $quality = 80, $extras = []) { @@ -123,6 +149,7 @@ class ImageFile extends Image /** * Generates the hash. + * * @param string $type * @param int $quality * @param array $extras @@ -131,15 +158,54 @@ class ImageFile extends Image { $inputInfos = $this->source->getInfos(); - $datas = array( + $data = [ $inputInfos, $this->serializeOperations(), $type, $quality, $extras - ); + ]; - $this->hash = sha1(serialize($datas)); + $this->hash = sha1(serialize($data)); } + /** + * Read exif rotation from file and apply it. + */ + public function fixOrientation() + { + if (!extension_loaded('exif')) { + throw new RuntimeException('You need to EXIF PHP Extension to use this function'); + } + + if (!in_array(exif_imagetype($this->source->getInfos()), [IMAGETYPE_JPEG, IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM], true)) { + return $this; + } + + // resolve any streams + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $filepath = $this->source->getInfos(); + if ($locator->isStream($filepath)) { + $filepath = $locator->findResource($this->source->getInfos(), true, true); + } + + // Make sure file exists + if (!file_exists($filepath)) { + return $this; + } + + try { + $exif = @exif_read_data($filepath); + } catch (Exception $e) { + Grav::instance()['log']->error($filepath . ' - ' . $e->getMessage()); + return $this; + } + + if ($exif === false || !array_key_exists('Orientation', $exif)) { + return $this; + } + + return $this->applyExifOrientation($exif['Orientation']); + } } diff --git a/system/src/Grav/Common/Page/Medium/ImageMedium.php b/system/src/Grav/Common/Page/Medium/ImageMedium.php index 13c142a..60d4997 100644 --- a/system/src/Grav/Common/Page/Medium/ImageMedium.php +++ b/system/src/Grav/Common/Page/Medium/ImageMedium.php @@ -3,102 +3,69 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page\Medium; +use BadFunctionCallException; use Grav\Common\Data\Blueprint; -use Grav\Common\Grav; +use Grav\Common\Media\Interfaces\ImageManipulateInterface; +use Grav\Common\Media\Interfaces\ImageMediaInterface; +use Grav\Common\Media\Interfaces\MediaLinkInterface; +use Grav\Common\Media\Traits\ImageLoadingTrait; +use Grav\Common\Media\Traits\ImageMediaTrait; use Grav\Common\Utils; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use function func_get_args; +use function in_array; -class ImageMedium extends Medium +/** + * Class ImageMedium + * @package Grav\Common\Page\Medium + */ +class ImageMedium extends Medium implements ImageMediaInterface, ImageManipulateInterface { - /** - * @var array - */ - protected $thumbnailTypes = ['page', 'media', 'default']; + use ImageMediaTrait; + use ImageLoadingTrait; /** - * @var ImageFile + * @var mixed|string */ - protected $image; - - /** - * @var string - */ - protected $format = 'guess'; - - /** - * @var int - */ - protected $quality; - - /** - * @var int - */ - protected $default_quality; - - /** - * @var bool - */ - protected $debug_watermarked = false; - - /** - * @var array - */ - public static $magic_actions = [ - 'resize', 'forceResize', 'cropResize', 'crop', 'zoomCrop', - 'negate', 'brightness', 'contrast', 'grayscale', 'emboss', - 'smooth', 'sharp', 'edge', 'colorize', 'sepia', 'enableProgressive', - 'rotate', 'flip', 'fixOrientation', 'gaussianBlur' - ]; - - /** - * @var array - */ - public static $magic_resize_actions = [ - 'resize' => [0, 1], - 'forceResize' => [0, 1], - 'cropResize' => [0, 1], - 'crop' => [0, 1, 2, 3], - 'zoomCrop' => [0, 1] - ]; - - /** - * @var string - */ - protected $sizes = '100vw'; + private $saved_image_path; /** * Construct. * * @param array $items - * @param Blueprint $blueprint + * @param Blueprint|null $blueprint */ public function __construct($items = [], Blueprint $blueprint = null) { parent::__construct($items, $blueprint); - $config = Grav::instance()['config']; + $config = $this->getGrav()['config']; + + $this->thumbnailTypes = ['page', 'media', 'default']; + $this->default_quality = $config->get('system.images.default_image_quality', 85); + $this->def('debug', $config->get('system.images.debug')); $path = $this->get('filepath'); if (!$path || !file_exists($path) || !filesize($path)) { return; } - $image_info = getimagesize($path); + $this->set('thumbnails.media', $path); - $this->def('width', $image_info[0]); - $this->def('height', $image_info[1]); - $this->def('mime', $image_info['mime']); - $this->def('debug', $config->get('system.images.debug')); - - $this->set('thumbnails.media', $this->get('filepath')); - - $this->default_quality = $config->get('system.images.default_image_quality', 85); + if (!($this->offsetExists('width') && $this->offsetExists('height') && $this->offsetExists('mime'))) { + $image_info = getimagesize($path); + if ($image_info) { + $this->def('width', $image_info[0]); + $this->def('height', $image_info[1]); + $this->def('mime', $image_info['mime']); + } + } $this->reset(); @@ -107,18 +74,67 @@ class ImageMedium extends Medium } } + /** + * @return array + */ + public function getMeta(): array + { + return [ + 'width' => $this->width, + 'height' => $this->height, + ] + parent::getMeta(); + } + + /** + * Also unset the image on destruct. + */ public function __destruct() { unset($this->image); } + /** + * Also clone image. + */ public function __clone() { - $this->image = $this->image ? clone $this->image : null; + if ($this->image) { + $this->image = clone $this->image; + } parent::__clone(); } + /** + * Reset image. + * + * @return $this + */ + public function reset() + { + parent::reset(); + + if ($this->image) { + $this->image(); + $this->medium_querystring = []; + $this->filter(); + $this->clearAlternatives(); + } + + $this->format = 'guess'; + $this->quality = $this->default_quality; + + $this->debug_watermarked = false; + + $config = $this->getGrav()['config']; + // Set CLS configuration + $this->auto_sizes = $config->get('system.images.cls.auto_sizes', false); + $this->aspect_ratio = $config->get('system.images.cls.aspect_ratio', false); + $this->retina_scale = $config->get('system.images.cls.retina_scale', 1); + + return $this; + } + /** * Add meta file for the medium. * @@ -135,14 +151,6 @@ class ImageMedium extends Medium return $this; } - /** - * Clear out the alternatives - */ - public function clearAlternatives() - { - $this->alternatives = []; - } - /** * Return PATH to image. * @@ -168,15 +176,17 @@ class ImageMedium extends Medium */ public function url($reset = true) { - /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - $image_path = $locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true); - $saved_image_path = $this->saveImage(); + $grav = $this->getGrav(); - $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $saved_image_path); + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $image_path = (string)($locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true)); + $saved_image_path = $this->saved_image_path = $this->saveImage(); + + $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $saved_image_path) ?: $saved_image_path; if ($locator->isStream($output)) { - $output = $locator->findResource($output, false); + $output = (string)($locator->findResource($output, false) ?: $locator->findResource($output, false, true)); } if (Utils::startsWith($output, $image_path)) { @@ -188,24 +198,9 @@ class ImageMedium extends Medium $this->reset(); } - return trim(Grav::instance()['base_url'] . '/' . $this->urlQuerystring($output), '\\'); + return trim($grav['base_url'] . '/' . $this->urlQuerystring($output), '\\'); } - /** - * Simply processes with no extra methods. Useful for triggering events. - * - * @return $this - */ - public function cache() - { - if (!$this->image) { - $this->image(); - } - - return $this; - } - - /** * Return srcset string for this Medium and its alternatives. * @@ -231,106 +226,6 @@ class ImageMedium extends Medium return implode(', ', $srcset); } - /** - * Allows the ability to override the image's pretty name stored in cache - * - * @param string $name - */ - public function setImagePrettyName($name) - { - $this->set('prettyname', $name); - if ($this->image) { - $this->image->setPrettyName($name); - } - } - - public function getImagePrettyName() - { - if ($this->get('prettyname')) { - return $this->get('prettyname'); - } - - $basename = $this->get('basename'); - if (preg_match('/[a-z0-9]{40}-(.*)/', $basename, $matches)) { - $basename = $matches[1]; - } - return $basename; - } - - /** - * Generate alternative image widths, using either an array of integers, or - * a min width, a max width, and a step parameter to fill out the necessary - * widths. Existing image alternatives won't be overwritten. - * - * @param int|int[] $min_width - * @param int $max_width - * @param int $step - * @return $this - */ - public function derivatives($min_width, $max_width = 2500, $step = 200) - { - if (!empty($this->alternatives)) { - $max = max(array_keys($this->alternatives)); - $base = $this->alternatives[$max]; - } else { - $base = $this; - } - - $widths = []; - - if (func_num_args() === 1) { - foreach ((array) func_get_arg(0) as $width) { - if ($width < $base->get('width')) { - $widths[] = $width; - } - } - } else { - $max_width = min($max_width, $base->get('width')); - - for ($width = $min_width; $width < $max_width; $width = $width + $step) { - $widths[] = $width; - } - } - - foreach ($widths as $width) { - // Only generate image alternatives that don't already exist - if (array_key_exists((int) $width, $this->alternatives)) { - continue; - } - - $derivative = MediumFactory::fromFile($base->get('filepath')); - - // It's possible that MediumFactory::fromFile returns null if the - // original image file no longer exists and this class instance was - // retrieved from the page cache - if (null !== $derivative) { - $index = 2; - $alt_widths = array_keys($this->alternatives); - sort($alt_widths); - - foreach ($alt_widths as $i => $key) { - if ($width > $key) { - $index += max($i, 1); - } - } - - $basename = preg_replace('/(@\d+x){0,1}$/', "@{$width}w", $base->get('basename'), 1); - $derivative->setImagePrettyName($basename); - - $ratio = $base->get('width') / $width; - $height = $derivative->get('height') / $ratio; - - $derivative->resize($width, $height); - $derivative->set('width', $width); - $derivative->set('height', $height); - - $this->addAlternative($ratio, $derivative); - } - } - - return $this; - } - /** * Parsedown element for source display mode * @@ -348,31 +243,24 @@ class ImageMedium extends Medium $attributes['sizes'] = $this->sizes(); } - return ['name' => 'img', 'attributes' => $attributes]; - } + if ($this->saved_image_path && $this->auto_sizes) { + if (!array_key_exists('height', $this->attributes) && !array_key_exists('width', $this->attributes)) { + $info = getimagesize($this->saved_image_path); + $width = intval($info[0]); + $height = intval($info[1]); - /** - * Reset image. - * - * @return $this - */ - public function reset() - { - parent::reset(); + $scaling_factor = $this->retina_scale > 0 ? $this->retina_scale : 1; + $attributes['width'] = intval($width / $scaling_factor); + $attributes['height'] = intval($height / $scaling_factor); - if ($this->image) { - $this->image(); - $this->medium_querystring = []; - $this->filter(); - $this->clearAlternatives(); + if ($this->aspect_ratio) { + $style = ($attributes['style'] ?? ' ') . "--aspect-ratio: $width/$height;"; + $attributes['style'] = trim($style); + } + } } - $this->format = 'guess'; - $this->quality = $this->default_quality; - - $this->debug_watermarked = false; - - return $this; + return ['name' => 'img', 'attributes' => $attributes]; } /** @@ -380,7 +268,7 @@ class ImageMedium extends Medium * * @param bool $reset * @param array $attributes - * @return Link + * @return MediaLinkInterface */ public function link($reset = true, array $attributes = []) { @@ -399,7 +287,7 @@ class ImageMedium extends Medium * @param int $width * @param int $height * @param bool $reset - * @return Link + * @return MediaLinkInterface */ public function lightbox($width = null, $height = null, $reset = true) { @@ -414,114 +302,38 @@ class ImageMedium extends Medium return parent::lightbox($width, $height, $reset); } - /** - * Sets or gets the quality of the image - * - * @param int $quality 0-100 quality - * @return int|$this - */ - public function quality($quality = null) + public function autoSizes($enabled = 'true') { - if ($quality) { - if (!$this->image) { - $this->image(); - } - - $this->quality = $quality; - - return $this; - } - - return $this->quality; - } - - /** - * Sets image output format. - * - * @param string $format - * @return $this - */ - public function format($format) - { - if (!$this->image) { - $this->image(); - } - - $this->format = $format; + $enabled = $enabled === 'true' ?: false; + $this->auto_sizes = $enabled; return $this; } - /** - * Set or get sizes parameter for srcset media action - * - * @param string $sizes - * @return string - */ - public function sizes($sizes = null) + public function aspectRatio($enabled = 'true') { - - if ($sizes) { - $this->sizes = $sizes; - - return $this; - } - - return empty($this->sizes) ? '100vw' : $this->sizes; - } - - /** - * Allows to set the width attribute from Markdown or Twig - * Examples: ![Example](myimg.png?width=200&height=400) - * ![Example](myimg.png?resize=100,200&width=100&height=200) - * ![Example](myimg.png?width=auto&height=auto) - * ![Example](myimg.png?width&height) - * {{ page.media['myimg.png'].width().height().html }} - * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }} - * - * @param mixed $value A value or 'auto' or empty to use the width of the image - * @return $this - */ - public function width($value = 'auto') - { - if (!$value || $value === 'auto') { - $this->attributes['width'] = $this->get('width'); - } else { - $this->attributes['width'] = $value; - } + $enabled = $enabled === 'true' ?: false; + $this->aspect_ratio = $enabled; return $this; } - /** - * Allows to set the height attribute from Markdown or Twig - * Examples: ![Example](myimg.png?width=200&height=400) - * ![Example](myimg.png?resize=100,200&width=100&height=200) - * ![Example](myimg.png?width=auto&height=auto) - * ![Example](myimg.png?width&height) - * {{ page.media['myimg.png'].width().height().html }} - * {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }} - * - * @param mixed $value A value or 'auto' or empty to use the height of the image - * @return $this - */ - public function height($value = 'auto') + public function retinaScale($scale = 1) { - if (!$value || $value === 'auto') { - $this->attributes['height'] = $this->get('height'); - } else { - $this->attributes['height'] = $value; - } + $this->retina_scale = intval($scale); return $this; } /** * Handle this commonly used variant + * + * @return $this */ public function cropZoom() { $this->__call('zoomCrop', func_get_args()); + return $this; } @@ -534,7 +346,7 @@ class ImageMedium extends Medium */ public function __call($method, $args) { - if (!\in_array($method, self::$magic_actions, true)) { + if (!in_array($method, static::$magic_actions, true)) { return parent::__call($method, $args); } @@ -544,125 +356,28 @@ class ImageMedium extends Medium } try { - call_user_func_array([$this->image, $method], $args); + $this->image->{$method}(...$args); + /** @var ImageMediaInterface $medium */ foreach ($this->alternatives as $medium) { - if (!$medium->image) { - $medium->image(); - } - $args_copy = $args; // regular image: resize 400x400 -> 200x200 // --> @2x: resize 800x800->400x400 - if (isset(self::$magic_resize_actions[$method])) { - foreach (self::$magic_resize_actions[$method] as $param) { + if (isset(static::$magic_resize_actions[$method])) { + foreach (static::$magic_resize_actions[$method] as $param) { if (isset($args_copy[$param])) { $args_copy[$param] *= $medium->get('ratio'); } } } - call_user_func_array([$medium, $method], $args_copy); + // Do the same call for alternative media. + $medium->__call($method, $args_copy); } - } catch (\BadFunctionCallException $e) { + } catch (BadFunctionCallException $e) { } return $this; } - - /** - * Gets medium image, resets image manipulation operations. - * - * @return $this - */ - protected function image() - { - $locator = Grav::instance()['locator']; - - $file = $this->get('filepath'); - - // Use existing cache folder or if it doesn't exist, create it. - $cacheDir = $locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true); - - // Make sure we free previous image. - unset($this->image); - - $this->image = ImageFile::open($file) - ->setCacheDir($cacheDir) - ->setActualCacheDir($cacheDir) - ->setPrettyName($this->getImagePrettyName()); - - return $this; - } - - /** - * Save the image with cache. - * - * @return string - */ - protected function saveImage() - { - if (!$this->image) { - return parent::path(false); - } - - $this->filter(); - - if (isset($this->result)) { - return $this->result; - } - - if (!$this->debug_watermarked && $this->get('debug')) { - $ratio = $this->get('ratio'); - if (!$ratio) { - $ratio = 1; - } - - $locator = Grav::instance()['locator']; - $overlay = $locator->findResource("system://assets/responsive-overlays/{$ratio}x.png") ?: $locator->findResource('system://assets/responsive-overlays/unknown.png'); - $this->image->merge(ImageFile::open($overlay)); - } - - return $this->image->cacheFile($this->format, $this->quality, false, [$this->get('width'), $this->get('height'), $this->get('modified')]); - } - - /** - * Filter image by using user defined filter parameters. - * - * @param string $filter Filter to be used. - */ - public function filter($filter = 'image.filters.default') - { - $filters = (array) $this->get($filter, []); - foreach ($filters as $params) { - $params = (array) $params; - $method = array_shift($params); - $this->__call($method, $params); - } - } - - /** - * Return the image higher quality version - * - * @return ImageMedium the alternative version with higher quality - */ - public function higherQualityAlternative() - { - if ($this->alternatives) { - $max = reset($this->alternatives); - foreach($this->alternatives as $alternative) - { - if($alternative->quality() > $max->quality()) - { - $max = $alternative; - } - } - - return $max; - } - - return $this; - } - } diff --git a/system/src/Grav/Common/Page/Medium/Link.php b/system/src/Grav/Common/Page/Medium/Link.php index 6978635..2bb857b 100644 --- a/system/src/Grav/Common/Page/Medium/Link.php +++ b/system/src/Grav/Common/Page/Medium/Link.php @@ -3,41 +3,62 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page\Medium; -class Link implements RenderableInterface +use BadMethodCallException; +use Grav\Common\Media\Interfaces\MediaLinkInterface; +use Grav\Common\Media\Interfaces\MediaObjectInterface; +use RuntimeException; +use function call_user_func_array; +use function get_class; +use function is_array; +use function is_callable; + +/** + * Class Link + * @package Grav\Common\Page\Medium + */ +class Link implements RenderableInterface, MediaLinkInterface { use ParsedownHtmlTrait; - /** - * @var array - */ + /** @var array */ protected $attributes = []; + /** @var MediaObjectInterface */ protected $source; /** * Construct. * @param array $attributes - * @param Medium $medium + * @param MediaObjectInterface $medium */ - public function __construct(array $attributes, Medium $medium) + public function __construct(array $attributes, MediaObjectInterface $medium) { $this->attributes = $attributes; - $this->source = $medium->reset()->thumbnail('auto')->display('thumbnail'); - $this->source->linked = true; + + $source = $medium->reset()->thumbnail('auto')->display('thumbnail'); + + // FIXME: Thumbnail can be null, maybe we should not allow that? + if (null === $source) { + throw new RuntimeException('Media has no thumbnail set'); + } + + $source->set('linked', true); + + $this->source = $source; } /** * Get an element (is array) that can be rendered by the Parsedown engine * - * @param string $title - * @param string $alt - * @param string $class - * @param string $id + * @param string|null $title + * @param string|null $alt + * @param string|null $class + * @param string|null $id * @param bool $reset * @return array */ @@ -48,7 +69,7 @@ class Link implements RenderableInterface return [ 'name' => 'a', 'attributes' => $this->attributes, - 'handler' => is_string($innerElement) ? 'line' : 'element', + 'handler' => is_array($innerElement) ? 'element' : 'line', 'text' => $innerElement ]; } @@ -62,10 +83,16 @@ class Link implements RenderableInterface */ public function __call($method, $args) { - $this->source = call_user_func_array(array($this->source, $method), $args); + $object = $this->source; + $callable = [$object, $method]; + if (!is_callable($callable)) { + throw new BadMethodCallException(get_class($object) . '::' . $method . '() not found.'); + } + + $this->source = call_user_func_array($callable, $args); // Don't start nesting links, if user has multiple link calls in his // actions, we will drop the previous links. - return $this->source instanceof Link ? $this->source : $this; + return $this->source instanceof MediaLinkInterface ? $this->source : $this; } } diff --git a/system/src/Grav/Common/Page/Medium/Medium.php b/system/src/Grav/Common/Page/Medium/Medium.php index bf0bb1f..87009ac 100644 --- a/system/src/Grav/Common/Page/Medium/Medium.php +++ b/system/src/Grav/Common/Page/Medium/Medium.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,8 +13,10 @@ use Grav\Common\File\CompiledYamlFile; use Grav\Common\Grav; use Grav\Common\Data\Data; use Grav\Common\Data\Blueprint; -use Grav\Common\Media\Interfaces\MediaObjectInterface; -use Grav\Common\Utils; +use Grav\Common\Media\Interfaces\MediaFileInterface; +use Grav\Common\Media\Interfaces\MediaLinkInterface; +use Grav\Common\Media\Traits\MediaFileTrait; +use Grav\Common\Media\Traits\MediaObjectTrait; /** * Class Medium @@ -22,59 +24,17 @@ use Grav\Common\Utils; * * @property string $mime */ -class Medium extends Data implements RenderableInterface, MediaObjectInterface +class Medium extends Data implements RenderableInterface, MediaFileInterface { + use MediaObjectTrait; + use MediaFileTrait; use ParsedownHtmlTrait; - /** - * @var string - */ - protected $mode = 'source'; - - /** - * @var Medium - */ - protected $_thumbnail = null; - - /** - * @var array - */ - protected $thumbnailTypes = ['page', 'default']; - - protected $thumbnailType = null; - - /** - * @var Medium[] - */ - protected $alternatives = []; - - /** - * @var array - */ - protected $attributes = []; - - /** - * @var array - */ - protected $styleAttributes = []; - - /** - * @var array - */ - protected $metadata = []; - - /** - * @var array - */ - protected $medium_querystring = []; - - protected $timestamp; - /** * Construct. * * @param array $items - * @param Blueprint $blueprint + * @param Blueprint|null $blueprint */ public function __construct($items = [], Blueprint $blueprint = null) { @@ -85,101 +45,23 @@ class Medium extends Data implements RenderableInterface, MediaObjectInterface } $this->def('mime', 'application/octet-stream'); + + if (!$this->offsetExists('size')) { + $path = $this->get('filepath'); + $this->def('size', filesize($path)); + } + $this->reset(); } + /** + * Clone medium. + */ public function __clone() { // Allows future compatibility as parent::__clone() works. } - /** - * Create a copy of this media object - * - * @return Medium - */ - public function copy() - { - return clone $this; - } - - /** - * Return just metadata from the Medium object - * - * @return Data - */ - public function meta() - { - return new Data($this->items); - } - - /** - * Check if this medium exists or not - * - * @return bool - */ - public function exists() - { - $path = $this->get('filepath'); - if (file_exists($path)) { - return true; - } - return false; - } - - /** - * Get file modification time for the medium. - * - * @return int|null - */ - public function modified() - { - $path = $this->get('filepath'); - - if (!file_exists($path)) { - return null; - } - - return filemtime($path) ?: null; - } - - /** - * @return int - */ - public function size() - { - $path = $this->get('filepath'); - - if (!file_exists($path)) { - return 0; - } - - return filesize($path) ?: 0; - } - - /** - * Set querystring to file modification timestamp (or value provided as a parameter). - * - * @param string|int|null $timestamp - * @return $this - */ - public function setTimestamp($timestamp = null) - { - $this->timestamp = (string)($timestamp ?? $this->modified()); - - return $this; - } - - /** - * Returns an array containing just the metadata - * - * @return array - */ - public function metadata() - { - return $this->metadata; - } - /** * Add meta file for the medium. * @@ -192,21 +74,15 @@ class Medium extends Data implements RenderableInterface, MediaObjectInterface } /** - * Add alternative Medium to this Medium. - * - * @param int|float $ratio - * @param Medium $alternative + * @return array */ - public function addAlternative($ratio, Medium $alternative) + public function getMeta(): array { - if (!is_numeric($ratio) || $ratio === 0) { - return; - } - - $alternative->set('ratio', $ratio); - $width = $alternative->get('width'); - - $this->alternatives[$width] = $alternative; + return [ + 'mime' => $this->mime, + 'size' => $this->size, + 'modified' => $this->modified, + ]; } /** @@ -220,460 +96,35 @@ class Medium extends Data implements RenderableInterface, MediaObjectInterface } /** - * Return PATH to file. - * - * @param bool $reset - * @return string path to file + * @param string $thumb */ - public function path($reset = true) + protected function createThumbnail($thumb) { - if ($reset) { - $this->reset(); - } - - return $this->get('filepath'); + return MediumFactory::fromFile($thumb, ['type' => 'thumbnail']); } /** - * Return the relative path to file - * - * @param bool $reset - * @return mixed + * @param array $attributes + * @return MediaLinkInterface */ - public function relativePath($reset = true) + protected function createLink(array $attributes) { - $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $this->get('filepath')); - - $locator = Grav::instance()['locator']; - if ($locator->isStream($output)) { - $output = $locator->findResource($output, false); - } - - if ($reset) { - $this->reset(); - } - - return str_replace(GRAV_ROOT, '', $output); - } - - /** - * Return URL to file. - * - * @param bool $reset - * @return string - */ - public function url($reset = true) - { - $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $this->get('filepath')); - - $locator = Grav::instance()['locator']; - if ($locator->isStream($output)) { - $output = $locator->findResource($output, false); - } - - if ($reset) { - $this->reset(); - } - - return trim(Grav::instance()['base_url'] . '/' . $this->urlQuerystring($output), '\\'); - } - - /** - * Get/set querystring for the file's url - * - * @param string $querystring - * @param bool $withQuestionmark - * @return string - */ - public function querystring($querystring = null, $withQuestionmark = true) - { - if (null !== $querystring) { - $this->medium_querystring[] = ltrim($querystring, '?&'); - foreach ($this->alternatives as $alt) { - $alt->querystring($querystring, $withQuestionmark); - } - } - - if (empty($this->medium_querystring)) { - return ''; - } - - // join the strings - $querystring = implode('&', $this->medium_querystring); - // explode all strings - $query_parts = explode('&', $querystring); - // Join them again now ensure the elements are unique - $querystring = implode('&', array_unique($query_parts)); - - return $withQuestionmark ? ('?' . $querystring) : $querystring; - } - - /** - * Get the URL with full querystring - * - * @param string $url - * @return string - */ - public function urlQuerystring($url) - { - $querystring = $this->querystring(); - if (isset($this->timestamp) && !Utils::contains($querystring, $this->timestamp)) { - $querystring = empty($querystring) ? ('?' . $this->timestamp) : ($querystring . '&' . $this->timestamp); - } - - return ltrim($url . $querystring . $this->urlHash(), '/'); - } - - /** - * Get/set hash for the file's url - * - * @param string $hash - * @param bool $withHash - * @return string - */ - public function urlHash($hash = null, $withHash = true) - { - if ($hash) { - $this->set('urlHash', ltrim($hash, '#')); - } - - $hash = $this->get('urlHash', ''); - - return $withHash && !empty($hash) ? '#' . $hash : $hash; - } - - /** - * Get an element (is array) that can be rendered by the Parsedown engine - * - * @param string $title - * @param string $alt - * @param string $class - * @param string $id - * @param bool $reset - * @return array - */ - public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true) - { - $attributes = $this->attributes; - - $style = ''; - foreach ($this->styleAttributes as $key => $value) { - if (is_numeric($key)) // Special case for inline style attributes, refer to style() method - $style .= $value; - else - $style .= $key . ': ' . $value . ';'; - } - if ($style) { - $attributes['style'] = $style; - } - - if (empty($attributes['title'])) { - if (!empty($title)) { - $attributes['title'] = $title; - } elseif (!empty($this->items['title'])) { - $attributes['title'] = $this->items['title']; - } - } - - if (empty($attributes['alt'])) { - if (!empty($alt)) { - $attributes['alt'] = $alt; - } elseif (!empty($this->items['alt'])) { - $attributes['alt'] = $this->items['alt']; - } elseif (!empty($this->items['alt_text'])) { - $attributes['alt'] = $this->items['alt_text']; - } else { - $attributes['alt'] = ''; - } - } - - if (empty($attributes['class'])) { - if (!empty($class)) { - $attributes['class'] = $class; - } elseif (!empty($this->items['class'])) { - $attributes['class'] = $this->items['class']; - } - } - - if (empty($attributes['id'])) { - if (!empty($id)) { - $attributes['id'] = $id; - } elseif (!empty($this->items['id'])) { - $attributes['id'] = $this->items['id']; - } - } - - switch ($this->mode) { - case 'text': - $element = $this->textParsedownElement($attributes, false); - break; - case 'thumbnail': - $element = $this->getThumbnail()->sourceParsedownElement($attributes, false); - break; - case 'source': - $element = $this->sourceParsedownElement($attributes, false); - break; - default: - $element = []; - } - - if ($reset) { - $this->reset(); - } - - $this->display('source'); - - return $element; - } - - /** - * Parsedown element for source display mode - * - * @param array $attributes - * @param bool $reset - * @return array - */ - protected function sourceParsedownElement(array $attributes, $reset = true) - { - return $this->textParsedownElement($attributes, $reset); - } - - /** - * Parsedown element for text display mode - * - * @param array $attributes - * @param bool $reset - * @return array - */ - protected function textParsedownElement(array $attributes, $reset = true) - { - $text = empty($attributes['title']) ? empty($attributes['alt']) ? $this->get('filename') : $attributes['alt'] : $attributes['title']; - - $element = [ - 'name' => 'p', - 'attributes' => $attributes, - 'text' => $text - ]; - - if ($reset) { - $this->reset(); - } - - return $element; - } - - /** - * Reset medium. - * - * @return $this - */ - public function reset() - { - $this->attributes = []; - return $this; - } - - /** - * Switch display mode. - * - * @param string $mode - * - * @return $this - */ - public function display($mode = 'source') - { - if ($this->mode === $mode) { - return $this; - } - - - $this->mode = $mode; - - return $mode === 'thumbnail' ? ($this->getThumbnail() ? $this->getThumbnail()->reset() : null) : $this->reset(); - } - - /** - * Helper method to determine if this media item has a thumbnail or not - * - * @param string $type; - * - * @return bool - */ - public function thumbnailExists($type = 'page') - { - $thumbs = $this->get('thumbnails'); - if (isset($thumbs[$type])) { - return true; - } - return false; - } - - /** - * Switch thumbnail. - * - * @param string $type - * - * @return $this - */ - public function thumbnail($type = 'auto') - { - if ($type !== 'auto' && !\in_array($type, $this->thumbnailTypes, true)) { - return $this; - } - - if ($this->thumbnailType !== $type) { - $this->_thumbnail = null; - } - - $this->thumbnailType = $type; - - return $this; - } - - - /** - * Turn the current Medium into a Link - * - * @param bool $reset - * @param array $attributes - * @return Link - */ - public function link($reset = true, array $attributes = []) - { - if ($this->mode !== 'source') { - $this->display('source'); - } - - foreach ($this->attributes as $key => $value) { - empty($attributes['data-' . $key]) && $attributes['data-' . $key] = $value; - } - - empty($attributes['href']) && $attributes['href'] = $this->url(); - return new Link($attributes, $this); } /** - * Turn the current Medium into a Link with lightbox enabled - * - * @param int $width - * @param int $height - * @param bool $reset - * @return Link + * @return Grav */ - public function lightbox($width = null, $height = null, $reset = true) + protected function getGrav(): Grav { - $attributes = ['rel' => 'lightbox']; - - if ($width && $height) { - $attributes['data-width'] = $width; - $attributes['data-height'] = $height; - } - - return $this->link($reset, $attributes); + return Grav::instance(); } /** - * Add a class to the element from Markdown or Twig - * Example: ![Example](myimg.png?classes=float-left) or ![Example](myimg.png?classes=myclass1,myclass2) - * - * @return $this + * @return array */ - public function classes() + protected function getItems(): array { - $classes = func_get_args(); - if (!empty($classes)) { - $this->attributes['class'] = implode(',', $classes); - } - - return $this; + return $this->items; } - - /** - * Add an id to the element from Markdown or Twig - * Example: ![Example](myimg.png?id=primary-img) - * - * @param string $id - * @return $this - */ - public function id($id) - { - if (is_string($id)) { - $this->attributes['id'] = trim($id); - } - - return $this; - } - - /** - * Allows to add an inline style attribute from Markdown or Twig - * Example: ![Example](myimg.png?style=float:left) - * - * @param string $style - * @return $this - */ - public function style($style) - { - $this->styleAttributes[] = rtrim($style, ';') . ';'; - return $this; - } - - /** - * Allow any action to be called on this medium from twig or markdown - * - * @param string $method - * @param mixed $args - * @return $this - */ - public function __call($method, $args) - { - $qs = $method; - if (\count($args) > 1 || (\count($args) === 1 && !empty($args[0]))) { - $qs .= '=' . implode(',', array_map(function ($a) { - if (is_array($a)) { - $a = '[' . implode(',', $a) . ']'; - } - return rawurlencode($a); - }, $args)); - } - - if (!empty($qs)) { - $this->querystring($this->querystring(null, false) . '&' . $qs); - } - - return $this; - } - - /** - * Get the thumbnail Medium object - * - * @return ThumbnailImageMedium - */ - protected function getThumbnail() - { - if (!$this->_thumbnail) { - $types = $this->thumbnailTypes; - - if ($this->thumbnailType !== 'auto') { - array_unshift($types, $this->thumbnailType); - } - - foreach ($types as $type) { - $thumb = $this->get('thumbnails.' . $type, false); - - if ($thumb) { - $thumb = $thumb instanceof ThumbnailImageMedium ? $thumb : MediumFactory::fromFile($thumb, ['type' => 'thumbnail']); - $thumb->parent = $this; - } - - if ($thumb) { - $this->_thumbnail = $thumb; - break; - } - } - } - - return $this->_thumbnail; - } - } diff --git a/system/src/Grav/Common/Page/Medium/MediumFactory.php b/system/src/Grav/Common/Page/Medium/MediumFactory.php index dc372d8..7dee4ea 100644 --- a/system/src/Grav/Common/Page/Medium/MediumFactory.php +++ b/system/src/Grav/Common/Page/Medium/MediumFactory.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,8 +11,17 @@ namespace Grav\Common\Page\Medium; use Grav\Common\Grav; use Grav\Common\Data\Blueprint; +use Grav\Common\Media\Interfaces\ImageMediaInterface; +use Grav\Common\Media\Interfaces\MediaObjectInterface; use Grav\Framework\Form\FormFlashFile; +use Psr\Http\Message\UploadedFileInterface; +use function dirname; +use function is_array; +/** + * Class MediumFactory + * @package Grav\Common\Page\Medium + */ class MediumFactory { /** @@ -20,7 +29,7 @@ class MediumFactory * * @param string $file * @param array $params - * @return Medium + * @return Medium|null */ public static function fromFile($file, array $params = []) { @@ -31,16 +40,21 @@ class MediumFactory $parts = pathinfo($file); $path = $parts['dirname']; $filename = $parts['basename']; - $ext = $parts['extension']; + $ext = $parts['extension'] ?? ''; $basename = $parts['filename']; $config = Grav::instance()['config']; - $media_params = $config->get('media.types.' . strtolower($ext)); - if (!\is_array($media_params)) { + $media_params = $ext ? $config->get('media.types.' . strtolower($ext)) : null; + if (!is_array($media_params)) { return null; } + // Remove empty 'image' attribute + if (isset($media_params['image']) && empty($media_params['image'])) { + unset($media_params['image']); + } + $params += $media_params; // Add default settings for undefined variables. @@ -71,23 +85,33 @@ class MediumFactory /** * Create Medium from an uploaded file * - * @param FormFlashFile $uploadedFile + * @param UploadedFileInterface $uploadedFile * @param array $params - * @return Medium + * @return Medium|null */ - public static function fromUploadedFile(FormFlashFile $uploadedFile, array $params = []) + public static function fromUploadedFile(UploadedFileInterface $uploadedFile, array $params = []) { - $parts = pathinfo($uploadedFile->getClientFilename()); + // For now support only FormFlashFiles, which exist over multiple requests. Also ignore errored and moved media. + if (!$uploadedFile instanceof FormFlashFile || $uploadedFile->getError() !== \UPLOAD_ERR_OK || $uploadedFile->isMoved()) { + return null; + } + + $clientName = $uploadedFile->getClientFilename(); + if (!$clientName) { + return null; + } + + $parts = pathinfo($clientName); $filename = $parts['basename']; - $ext = $parts['extension']; + $ext = $parts['extension'] ?? ''; $basename = $parts['filename']; $file = $uploadedFile->getTmpFile(); - $path = dirname($file); + $path = $file ? dirname($file) : ''; $config = Grav::instance()['config']; - $media_params = $config->get('media.types.' . strtolower($ext)); - if (!\is_array($media_params)) { + $media_params = $ext ? $config->get('media.types.' . strtolower($ext)) : null; + if (!is_array($media_params)) { return null; } @@ -104,7 +128,7 @@ class MediumFactory 'basename' => $basename, 'extension' => $ext, 'path' => $path, - 'modified' => filemtime($file), + 'modified' => $file ? filemtime($file) : 0, 'thumbnails' => [] ]; @@ -149,14 +173,14 @@ class MediumFactory /** * Create a new ImageMedium by scaling another ImageMedium object. * - * @param ImageMedium $medium + * @param ImageMediaInterface|MediaObjectInterface $medium * @param int $from * @param int $to - * @return Medium|array + * @return ImageMediaInterface|MediaObjectInterface|array */ public static function scaledFromMedium($medium, $from, $to) { - if (! $medium instanceof ImageMedium) { + if (!$medium instanceof ImageMedium) { return $medium; } diff --git a/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php b/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php index c6d75bc..44ab952 100644 --- a/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php +++ b/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -12,20 +12,22 @@ namespace Grav\Common\Page\Medium; use Grav\Common\Markdown\Parsedown; use Grav\Common\Page\Markdown\Excerpts; +/** + * Trait ParsedownHtmlTrait + * @package Grav\Common\Page\Medium + */ trait ParsedownHtmlTrait { - /** - * @var \Grav\Common\Markdown\Parsedown - */ + /** @var Parsedown|null */ protected $parsedown; /** * Return HTML markup from the medium. * - * @param string $title - * @param string $alt - * @param string $class - * @param string $id + * @param string|null $title + * @param string|null $alt + * @param string|null $class + * @param string|null $id * @param bool $reset * @return string */ diff --git a/system/src/Grav/Common/Page/Medium/RenderableInterface.php b/system/src/Grav/Common/Page/Medium/RenderableInterface.php index 50a6e67..ac72447 100644 --- a/system/src/Grav/Common/Page/Medium/RenderableInterface.php +++ b/system/src/Grav/Common/Page/Medium/RenderableInterface.php @@ -3,34 +3,39 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page\Medium; +/** + * Interface RenderableInterface + * @package Grav\Common\Page\Medium + */ interface RenderableInterface { /** * Return HTML markup from the medium. * - * @param string $title - * @param string $alt - * @param string $class + * @param string|null $title + * @param string|null $alt + * @param string|null $class + * @param string|null $id * @param bool $reset * @return string */ - public function html($title = null, $alt = null, $class = null, $reset = true); + public function html($title = null, $alt = null, $class = null, $id = null, $reset = true); /** * Return Parsedown Element from the medium. * - * @param string $title - * @param string $alt - * @param string $class - * @param string $id + * @param string|null $title + * @param string|null $alt + * @param string|null $class + * @param string|null $id * @param bool $reset - * @return string + * @return array */ public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true); } diff --git a/system/src/Grav/Common/Page/Medium/StaticImageMedium.php b/system/src/Grav/Common/Page/Medium/StaticImageMedium.php index 1fa5439..ba92fa1 100644 --- a/system/src/Grav/Common/Page/Medium/StaticImageMedium.php +++ b/system/src/Grav/Common/Page/Medium/StaticImageMedium.php @@ -3,15 +3,24 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page\Medium; -class StaticImageMedium extends Medium +use Grav\Common\Media\Interfaces\ImageMediaInterface; +use Grav\Common\Media\Traits\ImageLoadingTrait; +use Grav\Common\Media\Traits\StaticResizeTrait; + +/** + * Class StaticImageMedium + * @package Grav\Common\Page\Medium + */ +class StaticImageMedium extends Medium implements ImageMediaInterface { use StaticResizeTrait; + use ImageLoadingTrait; /** * Parsedown element for source display mode @@ -22,7 +31,9 @@ class StaticImageMedium extends Medium */ protected function sourceParsedownElement(array $attributes, $reset = true) { - empty($attributes['src']) && $attributes['src'] = $this->url($reset); + if (empty($attributes['src'])) { + $attributes['src'] = $this->url($reset); + } return ['name' => 'img', 'attributes' => $attributes]; } diff --git a/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php b/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php index 8d6d43f..d95b2d6 100644 --- a/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php +++ b/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php @@ -3,26 +3,22 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page\Medium; +use Grav\Common\Media\Traits\StaticResizeTrait as NewResizeTrait; + +user_error('Grav\Common\Page\Medium\StaticResizeTrait is deprecated since Grav 1.7, use Grav\Common\Media\Traits\StaticResizeTrait instead', E_USER_DEPRECATED); + +/** + * Trait StaticResizeTrait + * @package Grav\Common\Page\Medium + * @deprecated 1.7 Use `Grav\Common\Media\Traits\StaticResizeTrait` instead + */ trait StaticResizeTrait { - /** - * Resize media by setting attributes - * - * @param int $width - * @param int $height - * @return $this - */ - public function resize($width = null, $height = null) - { - $this->styleAttributes['width'] = $width . 'px'; - $this->styleAttributes['height'] = $height . 'px'; - - return $this; - } + use NewResizeTrait; } diff --git a/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php b/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php index 380baee..a56a20d 100644 --- a/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php +++ b/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php @@ -3,130 +3,19 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page\Medium; +use Grav\Common\Media\Traits\ThumbnailMediaTrait; + +/** + * Class ThumbnailImageMedium + * @package Grav\Common\Page\Medium + */ class ThumbnailImageMedium extends ImageMedium { - /** - * @var Medium - */ - public $parent = null; - - /** - * @var bool - */ - public $linked = false; - - /** - * Return srcset string for this Medium and its alternatives. - * - * @param bool $reset - * @return string - */ - public function srcset($reset = true) - { - return ''; - } - - /** - * Get an element (is array) that can be rendered by the Parsedown engine - * - * @param string $title - * @param string $alt - * @param string $class - * @param string $id - * @param bool $reset - * @return array - */ - public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true) - { - return $this->bubble('parsedownElement', [$title, $alt, $class, $id, $reset]); - } - - /** - * Return HTML markup from the medium. - * - * @param string $title - * @param string $alt - * @param string $class - * @param string $id - * @param bool $reset - * @return string - */ - public function html($title = null, $alt = null, $class = null, $id = null, $reset = true) - { - return $this->bubble('html', [$title, $alt, $class, $id, $reset]); - } - - /** - * Switch display mode. - * - * @param string $mode - * - * @return $this - */ - public function display($mode = 'source') - { - return $this->bubble('display', [$mode], false); - } - - /** - * Switch thumbnail. - * - * @param string $type - * - * @return $this - */ - public function thumbnail($type = 'auto') - { - $this->bubble('thumbnail', [$type], false); - - return $this->bubble('getThumbnail', [], false); - } - - /** - * Turn the current Medium into a Link - * - * @param bool $reset - * @param array $attributes - * @return Link - */ - public function link($reset = true, array $attributes = []) - { - return $this->bubble('link', [$reset, $attributes], false); - } - - /** - * Turn the current Medium into a Link with lightbox enabled - * - * @param int $width - * @param int $height - * @param bool $reset - * @return Link - */ - public function lightbox($width = null, $height = null, $reset = true) - { - return $this->bubble('lightbox', [$width, $height, $reset], false); - } - - /** - * Bubble a function call up to either the superclass function or the parent Medium instance - * - * @param string $method - * @param array $arguments - * @param bool $testLinked - * @return Medium - */ - protected function bubble($method, array $arguments = [], $testLinked = true) - { - if (!$testLinked || $this->linked) { - return $this->parent ? call_user_func_array(array($this->parent, $method), $arguments) : $this; - } - - return call_user_func_array(array($this, 'parent::' . $method), $arguments); - } + use ThumbnailMediaTrait; } diff --git a/system/src/Grav/Common/Page/Medium/VideoMedium.php b/system/src/Grav/Common/Page/Medium/VideoMedium.php index c0b488a..bfcf550 100644 --- a/system/src/Grav/Common/Page/Medium/VideoMedium.php +++ b/system/src/Grav/Common/Page/Medium/VideoMedium.php @@ -3,148 +3,22 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page\Medium; -class VideoMedium extends Medium +use Grav\Common\Media\Interfaces\VideoMediaInterface; +use Grav\Common\Media\Traits\VideoMediaTrait; + +/** + * Class VideoMedium + * @package Grav\Common\Page\Medium + */ +class VideoMedium extends Medium implements VideoMediaInterface { - use StaticResizeTrait; - - /** - * Parsedown element for source display mode - * - * @param array $attributes - * @param bool $reset - * @return array - */ - protected function sourceParsedownElement(array $attributes, $reset = true) - { - $location = $this->url($reset); - - return [ - 'name' => 'video', - 'text' => 'Your browser does not support the video tag.', - 'attributes' => $attributes - ]; - } - - /** - * Allows to set or remove the HTML5 default controls - * - * @param bool $display - * @return $this - */ - public function controls($display = true) - { - if($display) { - $this->attributes['controls'] = true; - } else { - unset($this->attributes['controls']); - } - - return $this; - } - - /** - * Allows to set the video's poster image - * - * @param string $urlImage - * @return $this - */ - public function poster($urlImage) - { - $this->attributes['poster'] = $urlImage; - - return $this; - } - - /** - * Allows to set the loop attribute - * - * @param bool $status - * @return $this - */ - public function loop($status = false) - { - if($status) { - $this->attributes['loop'] = true; - } else { - unset($this->attributes['loop']); - } - - return $this; - } - - /** - * Allows to set the autoplay attribute - * - * @param bool $status - * @return $this - */ - public function autoplay($status = false) - { - if ($status) { - $this->attributes['autoplay'] = ''; - } else { - unset($this->attributes['autoplay']); - } - - return $this; - } - - /** - * Allows ability to set the preload option - * - * @param null $status - * @return $this - */ - public function preload($status = null) - { - if ($status) { - $this->attributes['preload'] = $status; - } else { - unset($this->attributes['preload']); - } - - return $this; - } - - /** - * Allows to set the playsinline attribute - * - * @param bool $status - * @return $this - */ - public function playsinline($status = false) - { - if($status) { - $this->attributes['playsinline'] = true; - } else { - unset($this->attributes['playsinline']); - } - - return $this; - } - - /** - * Allows to set the muted attribute - * - * @param bool $status - * @return $this - */ - public function muted($status = false) - { - if($status) { - $this->attributes['muted'] = true; - } else { - unset($this->attributes['muted']); - } - - return $this; - } + use VideoMediaTrait; /** * Reset medium. @@ -155,7 +29,7 @@ class VideoMedium extends Medium { parent::reset(); - $this->attributes['controls'] = true; + $this->resetPlayer(); return $this; } diff --git a/system/src/Grav/Common/Page/Page.php b/system/src/Grav/Common/Page/Page.php index b33d514..a035613 100644 --- a/system/src/Grav/Common/Page/Page.php +++ b/system/src/Grav/Common/Page/Page.php @@ -3,107 +3,163 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page; +use Exception; use Grav\Common\Cache; use Grav\Common\Config\Config; use Grav\Common\Data\Blueprint; use Grav\Common\File\CompiledYamlFile; use Grav\Common\Filesystem\Folder; use Grav\Common\Grav; +use Grav\Common\Language\Language; use Grav\Common\Markdown\Parsedown; use Grav\Common\Markdown\ParsedownExtra; +use Grav\Common\Page\Interfaces\PageCollectionInterface; use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Media\Traits\MediaTrait; use Grav\Common\Page\Markdown\Excerpts; -use Grav\Common\Taxonomy; +use Grav\Common\Page\Traits\PageFormTrait; +use Grav\Common\Twig\Twig; use Grav\Common\Uri; use Grav\Common\Utils; use Grav\Common\Yaml; -use Negotiation\Accept; -use Negotiation\Negotiator; +use Grav\Framework\Flex\Flex; +use InvalidArgumentException; use RocketTheme\Toolbox\Event\Event; use RocketTheme\Toolbox\File\MarkdownFile; +use RuntimeException; +use SplFileInfo; +use function dirname; +use function in_array; +use function is_array; +use function is_object; +use function is_string; +use function strlen; define('PAGE_ORDER_PREFIX_REGEX', '/^[0-9]+\./u'); +/** + * Class Page + * @package Grav\Common\Page + */ class Page implements PageInterface { + use PageFormTrait; use MediaTrait; - /** - * @var string Filename. Leave as null if page is folder. - */ + /** @var string|null Filename. Leave as null if page is folder. */ protected $name; + /** @var bool */ + protected $initialized = false; + /** @var string */ protected $folder; + /** @var string */ protected $path; + /** @var string */ protected $extension; + /** @var string */ protected $url_extension; - + /** @var string */ protected $id; + /** @var string */ protected $parent; + /** @var string */ protected $template; + /** @var int */ protected $expires; + /** @var string */ protected $cache_control; + /** @var bool */ protected $visible; + /** @var bool */ protected $published; + /** @var int */ protected $publish_date; + /** @var int|null */ protected $unpublish_date; + /** @var string */ protected $slug; + /** @var string */ protected $route; + /** @var string|null */ protected $raw_route; + /** @var string */ protected $url; - protected $routes; - protected $routable; - protected $modified; - protected $redirect; - protected $external_url; - protected $items; - protected $header; - protected $frontmatter; - protected $language; - protected $content; - protected $content_meta; - protected $summary; - protected $raw_content; - protected $pagination; - protected $metadata; - protected $title; - protected $max_count; - protected $menu; - protected $date; - protected $dateformat; - protected $taxonomy; - protected $order_by; - protected $order_dir; - protected $order_manual; - protected $modular; - protected $modular_twig; - protected $process; - protected $summary_size; - protected $markdown_extra; - protected $etag; - protected $last_modified; - protected $home_route; - protected $hide_home_route; - protected $ssl; - protected $template_format; - protected $debugger; /** @var array */ - protected $forms; + protected $routes; + /** @var bool */ + protected $routable; + /** @var int */ + protected $modified; + /** @var string */ + protected $redirect; + /** @var string */ + protected $external_url; + /** @var object|null */ + protected $header; + /** @var string */ + protected $frontmatter; + /** @var string */ + protected $language; + /** @var string|null */ + protected $content; + /** @var array */ + protected $content_meta; + /** @var string|null */ + protected $summary; + /** @var string */ + protected $raw_content; + /** @var array|null */ + protected $metadata; + /** @var string */ + protected $title; + /** @var int */ + protected $max_count; + /** @var string */ + protected $menu; + /** @var int */ + protected $date; + /** @var string */ + protected $dateformat; + /** @var array */ + protected $taxonomy; + /** @var string */ + protected $order_by; + /** @var string */ + protected $order_dir; + /** @var array|string|null */ + protected $order_manual; + /** @var bool */ + protected $modular_twig; + /** @var array */ + protected $process; + /** @var int|null */ + protected $summary_size; + /** @var bool */ + protected $markdown_extra; + /** @var bool */ + protected $etag; + /** @var bool */ + protected $last_modified; + /** @var string */ + protected $home_route; + /** @var bool */ + protected $hide_home_route; + /** @var bool */ + protected $ssl; + /** @var string */ + protected $template_format; + /** @var bool */ + protected $debugger; - /** - * @var PageInterface Unmodified (original) version of the page. Used for copying and moving the page. - */ + /** @var PageInterface|null Unmodified (original) version of the page. Used for copying and moving the page. */ private $_original; - - /** - * @var string Action - */ + /** @var string Action */ private $_action; /** @@ -122,15 +178,16 @@ class Page implements PageInterface /** * Initializes the page instance variables based on a file * - * @param \SplFileInfo $file The file information for the .md file that the page represents - * @param string $extension - * + * @param SplFileInfo $file The file information for the .md file that the page represents + * @param string|null $extension * @return $this */ - public function init(\SplFileInfo $file, $extension = null) + public function init(SplFileInfo $file, $extension = null) { $config = Grav::instance()['config']; + $this->initialized = true; + // some extension logic if (empty($extension)) { $this->extension('.' . $file->getExtension()); @@ -158,10 +215,12 @@ class Page implements PageInterface $this->published(); $this->urlExtension(); - return $this; } + /** + * @return void + */ protected function processFrontmatter() { // Quick check for twig output tags in frontmatter if enabled @@ -183,22 +242,38 @@ class Page implements PageInterface * Return an array with the routes of other translated languages * * @param bool $onlyPublished only return published translations - * * @return array the page translated languages */ public function translatedLanguages($onlyPublished = false) { - $filename = substr($this->name, 0, -(strlen($this->extension()))); - $config = Grav::instance()['config']; - $languages = $config->get('system.languages.supported', []); + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + + $languages = $language->getLanguages(); + $defaultCode = $language->getDefault(); + + $name = substr($this->name, 0, -strlen($this->extension())); $translatedLanguages = []; - foreach ($languages as $language) { - $path = $this->path . DS . $this->folder . DS . $filename . '.' . $language . '.md'; - if (file_exists($path)) { - $aPage = new Page(); - $aPage->init(new \SplFileInfo($path), $language . '.md'); + foreach ($languages as $languageCode) { + $languageExtension = ".{$languageCode}.md"; + $path = $this->path . DS . $this->folder . DS . $name . $languageExtension; + $exists = file_exists($path); + // Default language may be saved without language file location. + if (!$exists && $languageCode === $defaultCode) { + $languageExtension = '.md'; + $path = $this->path . DS . $this->folder . DS . $name . $languageExtension; + $exists = file_exists($path); + } + + if ($exists) { + $aPage = new Page(); + $aPage->init(new SplFileInfo($path), $languageExtension); + $aPage->route($this->route()); + $aPage->rawRoute($this->rawRoute()); $route = $aPage->header()->routes['default'] ?? $aPage->rawRoute(); if (!$route) { $route = $aPage->route(); @@ -208,7 +283,7 @@ class Page implements PageInterface continue; } - $translatedLanguages[$language] = $route; + $translatedLanguages[$languageCode] = $route; } } @@ -219,37 +294,25 @@ class Page implements PageInterface * Return an array listing untranslated languages available * * @param bool $includeUnpublished also list unpublished translations - * * @return array the page untranslated languages */ public function untranslatedLanguages($includeUnpublished = false) { - $filename = substr($this->name, 0, -strlen($this->extension())); - $config = Grav::instance()['config']; - $languages = $config->get('system.languages.supported', []); - $untranslatedLanguages = []; + $grav = Grav::instance(); - foreach ($languages as $language) { - $path = $this->path . DS . $this->folder . DS . $filename . '.' . $language . '.md'; - if (file_exists($path)) { - $aPage = new Page(); - $aPage->init(new \SplFileInfo($path), $language . '.md'); - if ($includeUnpublished && !$aPage->published()) { - $untranslatedLanguages[] = $language; - } - } else { - $untranslatedLanguages[] = $language; - } - } + /** @var Language $language */ + $language = $grav['language']; - return $untranslatedLanguages; + $languages = $language->getLanguages(); + $translated = array_keys($this->translatedLanguages(!$includeUnpublished)); + + return array_values(array_diff($languages, $translated)); } /** * Gets and Sets the raw data * - * @param string $var Raw content string - * + * @param string|null $var Raw content string * @return string Raw content string */ public function raw($var = null) @@ -282,7 +345,6 @@ class Page implements PageInterface */ public function frontmatter($var = null) { - if ($var) { $this->frontmatter = (string)$var; @@ -305,8 +367,7 @@ class Page implements PageInterface /** * Gets and Sets the header based on the YAML configuration at the top of the .md file * - * @param object|array $var a YAML object representing the configuration for the file - * + * @param object|array|null $var a YAML object representing the configuration for the file * @return object the current YAML configuration */ public function header($var = null) @@ -337,8 +398,10 @@ class Page implements PageInterface $frontmatterFile = CompiledYamlFile::instance($this->path . '/' . $this->folder . '/frontmatter.yaml'); if ($frontmatterFile->exists()) { $frontmatter_data = (array)$frontmatterFile->content(); - $this->header = (object)array_replace_recursive($frontmatter_data, - (array)$this->header); + $this->header = (object)array_replace_recursive( + $frontmatter_data, + (array)$this->header + ); $frontmatterFile->free(); } // Process frontmatter with Twig if enabled @@ -346,7 +409,7 @@ class Page implements PageInterface $this->processFrontmatter(); } } - } catch (\Exception $e) { + } catch (Exception $e) { $file->raw(Grav::instance()['language']->translate([ 'GRAV.FRONTMATTER_ERROR_PAGE', $this->slug(), @@ -360,8 +423,6 @@ class Page implements PageInterface } $var = true; } - - } if ($var) { @@ -465,8 +526,7 @@ class Page implements PageInterface /** * Get page language * - * @param string $var - * + * @param string|null $var * @return mixed */ public function language($var = null) @@ -497,6 +557,9 @@ class Page implements PageInterface return (int)($this->header()->http_response_code ?? 200); } + /** + * @return array + */ public function httpHeaders() { $headers = []; @@ -545,10 +608,8 @@ class Page implements PageInterface /** * Get the summary. * - * @param int $size Max summary size. - * + * @param int|null $size Max summary size. * @param bool $textOnly Only count text size. - * * @return string */ public function summary($size = null, $textOnly = false) @@ -568,8 +629,7 @@ class Page implements PageInterface $content = $textOnly ? strip_tags($this->content()) : $this->content(); $summary_size = $this->summary_size; } else { - $content = strip_tags($this->summary); - // Use mb_strwidth to deal with the 2 character widths characters + $content = $textOnly ? strip_tags($this->summary) : $this->summary; $summary_size = mb_strwidth($content, 'utf-8'); } @@ -580,7 +640,7 @@ class Page implements PageInterface return $content; } if (($format === 'short') && isset($summary_size)) { - // Use mb_strimwidth to slice the string + // Slice the string if (mb_strwidth($content, 'utf8') > $summary_size) { return mb_substr($content, 0, $summary_size); } @@ -629,8 +689,7 @@ class Page implements PageInterface /** * Gets and Sets the content based on content portion of the .md file * - * @param string $var Content - * + * @param string|null $var Content * @return string Content */ public function content($var = null) @@ -673,14 +732,20 @@ class Page implements PageInterface $process_markdown = $this->shouldProcess('markdown'); $process_twig = $this->shouldProcess('twig') || $this->modularTwig(); - $cache_enable = $this->header->cache_enable ?? $config->get('system.cache.enabled', - true); - $twig_first = $this->header->twig_first ?? $config->get('system.pages.twig_first', - true); + $cache_enable = $this->header->cache_enable ?? $config->get( + 'system.cache.enabled', + true + ); + $twig_first = $this->header->twig_first ?? $config->get( + 'system.pages.twig_first', + false + ); // never cache twig means it's always run after content - $never_cache_twig = $this->header->never_cache_twig ?? $config->get('system.pages.never_cache_twig', - false); + $never_cache_twig = $this->header->never_cache_twig ?? $config->get( + 'system.pages.never_cache_twig', + true + ); // if no cached-content run everything if ($never_cache_twig) { @@ -703,7 +768,6 @@ class Page implements PageInterface if ($process_twig) { $this->processTwig(); } - } else { if ($this->content === false || $cache_enable === false) { $this->content = $this->raw_content; @@ -719,10 +783,9 @@ class Page implements PageInterface // Content Processed but not cached yet Grav::instance()->fireEvent('onPageContentProcessed', new Event(['page' => $this])); - } else { if ($process_markdown) { - $this->processMarkdown(); + $this->processMarkdown($process_twig); } // Content Processed but not cached yet @@ -772,7 +835,7 @@ class Page implements PageInterface * Add an entry to the page's contentMeta array * * @param string $name - * @param string $value + * @param mixed $value */ public function addContentMeta($name, $value) { @@ -784,16 +847,12 @@ class Page implements PageInterface * * @param string|null $name * - * @return string + * @return mixed|null */ public function getContentMeta($name = null) { if ($name) { - if (isset($this->content_meta[$name])) { - return $this->content_meta[$name]; - } - - return null; + return $this->content_meta[$name] ?? null; } return $this->content_meta; @@ -813,8 +872,11 @@ class Page implements PageInterface /** * Process the Markdown content. Uses Parsedown or Parsedown Extra depending on configuration + * + * @param bool $keepTwig If true, content between twig tags will not be processed. + * @return void */ - protected function processMarkdown() + protected function processMarkdown(bool $keepTwig = false) { /** @var Config $config */ $config = Grav::instance()['config']; @@ -846,24 +908,55 @@ class Page implements PageInterface $parsedown = new Parsedown($excerpts); } - $this->content = $parsedown->text($this->content); + $content = $this->content; + if ($keepTwig) { + $token = [ + '/' . Utils::generateRandomString(3), + Utils::generateRandomString(3) . '/' + ]; + // Base64 encode any twig. + $content = preg_replace_callback( + ['/({#.*?#})/mu', '/({{.*?}})/mu', '/({%.*?%})/mu'], + static function ($matches) use ($token) { return $token[0] . base64_encode($matches[1]) . $token[1]; }, + $content + ); + } + + $content = $parsedown->text($content); + + if ($keepTwig) { + // Base64 decode the encoded twig. + $content = preg_replace_callback( + ['`' . $token[0] . '([A-Za-z0-9+/]+={0,2})' . $token[1] . '`mu'], + static function ($matches) { return base64_decode($matches[1]); }, + $content + ); + } + + $this->content = $content; } /** * Process the Twig page content. + * + * @return void */ private function processTwig() { + /** @var Twig $twig */ $twig = Grav::instance()['twig']; $this->content = $twig->processPage($this, $this->content); } /** * Fires the onPageContentProcessed event, and caches the page content using a unique ID for the page + * + * @return void */ public function cachePageContent() { + /** @var Cache $cache */ $cache = Grav::instance()['cache']; $cache_id = md5('page' . $this->getCacheKey()); $cache->save($cache_id, ['content' => $this->content, 'content_meta' => $this->content_meta]); @@ -883,6 +976,7 @@ class Page implements PageInterface * Needed by the onPageContentProcessed event to set the raw page content * * @param string $content + * @return void */ public function setRawContent($content) { @@ -894,7 +988,6 @@ class Page implements PageInterface * * @param string $name Variable name. * @param mixed $default - * * @return mixed */ public function value($name, $default = null) @@ -922,13 +1015,16 @@ class Page implements PageInterface return $this->slug(); } if ($name === 'name') { + $name = $this->name(); $language = $this->language() ? '.' . $this->language() : ''; - $name_val = str_replace($language . '.md', '', $this->name()); - if ($this->modular()) { - return 'modular/' . $name_val; + $pattern = '%(' . preg_quote($language, '%') . ')?\.md$%'; + $name = preg_replace($pattern, '', $name); + + if ($this->isModule()) { + return 'modular/' . $name; } - return $name_val; + return $name; } if ($name === 'media') { return $this->media()->all(); @@ -975,7 +1071,6 @@ class Page implements PageInterface * Gets and Sets the Page raw content * * @param string|null $var - * * @return string */ public function rawMarkdown($var = null) @@ -987,6 +1082,15 @@ class Page implements PageInterface return $this->raw_content; } + /** + * @return bool + * @internal + */ + public function translated(): bool + { + return $this->initialized; + } + /** * Get file object to the page. * @@ -1004,7 +1108,7 @@ class Page implements PageInterface /** * Save page if there's a file assigned to it. * - * @param bool|mixed $reorder Internal use. + * @param bool|array $reorder Internal use. */ public function save($reorder = true) { @@ -1024,6 +1128,14 @@ class Page implements PageInterface $this->doReorder($reorder); } + // We need to signal Flex Pages about the change. + /** @var Flex|null $flex */ + $flex = Grav::instance()['flex'] ?? null; + $directory = $flex ? $flex->getDirectory('pages') : null; + if (null !== $directory) { + $directory->clearCache(); + } + $this->_original = null; } @@ -1033,7 +1145,6 @@ class Page implements PageInterface * You need to call $this->save() in order to perform the move. * * @param PageInterface $parent New parent page. - * * @return $this */ public function move(PageInterface $parent) @@ -1046,10 +1157,10 @@ class Page implements PageInterface $this->_action = 'move'; if ($this->route() === $parent->route()) { - throw new \RuntimeException('Failed: Cannot set page parent to self'); + throw new RuntimeException('Failed: Cannot set page parent to self'); } if (Utils::startsWith($parent->rawRoute(), $this->rawRoute())) { - throw new \RuntimeException('Failed: Cannot set page parent to a child of current page'); + throw new RuntimeException('Failed: Cannot set page parent to a child of current page'); } $this->parent($parent); @@ -1077,7 +1188,6 @@ class Page implements PageInterface * You need to call $this->save() in order to perform the move. * * @param PageInterface $parent New parent page. - * * @return $this */ public function copy(PageInterface $parent) @@ -1132,7 +1242,8 @@ class Page implements PageInterface /** * Validate page header. * - * @throws \Exception + * @return void + * @throws Exception */ public function validate() { @@ -1142,6 +1253,8 @@ class Page implements PageInterface /** * Filter page header from illegal contents. + * + * @return void */ public function filter() { @@ -1205,92 +1318,10 @@ class Page implements PageInterface return $this->id(); } - /** - * Returns normalized list of name => form pairs. - * - * @return array - */ - public function forms() - { - if (null === $this->forms) { - $header = $this->header(); - - // Call event to allow filling the page header form dynamically (e.g. use case: Comments plugin) - $grav = Grav::instance(); - $grav->fireEvent('onFormPageHeaderProcessed', new Event(['page' => $this, 'header' => $header])); - - $rules = $header->rules ?? null; - if (!\is_array($rules)) { - $rules = []; - } - - $forms = []; - - // First grab page.header.form - $form = $this->normalizeForm($header->form ?? null, null, $rules); - if ($form) { - $forms[$form['name']] = $form; - } - - // Append page.header.forms (override singular form if it clashes) - $headerForms = $header->forms ?? null; - if (\is_array($headerForms)) { - foreach ($headerForms as $name => $form) { - $form = $this->normalizeForm($form, $name, $rules); - if ($form) { - $forms[$form['name']] = $form; - } - } - } - - $this->forms = $forms; - } - - return $this->forms; - } - - /** - * @param array $new - */ - public function addForms(array $new) - { - // Initialize forms. - $this->forms(); - - foreach ($new as $form) { - $form = $this->normalizeForm($form); - if ($form) { - $this->forms[$form['name']] = $form; - } - } - } - - protected function normalizeForm($form, $name = null, array $rules = []) - { - if (!\is_array($form)) { - return null; - } - - // Ignore numeric indexes on name. - if (!$name || (string)(int)$name === (string)$name) { - $name = null; - } - - $name = $name ?? $form['name'] ?? $this->slug(); - - $formRules = $form['rules'] ?? null; - if (!\is_array($formRules)) { - $formRules = []; - } - - return ['name' => $name, 'rules' => $rules + $formRules] + $form; - } - /** * Gets and sets the associated media as found in the page folder. * - * @param Media $var Representation of associated media. - * + * @param Media|null $var Representation of associated media. * @return Media Representation of associated media. */ public function media($var = null) @@ -1299,7 +1330,10 @@ class Page implements PageInterface $this->setMedia($var); } - return $this->getMedia(); + /** @var Media $media */ + $media = $this->getMedia(); + + return $media; } /** @@ -1327,8 +1361,7 @@ class Page implements PageInterface /** * Gets and sets the name field. If no name field is set, it will return 'default.md'. * - * @param string $var The name of this page. - * + * @param string|null $var The name of this page. * @return string The name of this page. */ public function name($var = null) @@ -1354,8 +1387,7 @@ class Page implements PageInterface * Gets and sets the template field. This is used to find the correct Twig template file to render. * If no field is set, it will return the name without the .md extension * - * @param string $var the template name - * + * @param string|null $var the template name * @return string the template name */ public function template($var = null) @@ -1364,74 +1396,37 @@ class Page implements PageInterface $this->template = $var; } if (empty($this->template)) { - $this->template = ($this->modular() ? 'modular/' : '') . str_replace($this->extension(), '', $this->name()); + $this->template = ($this->isModule() ? 'modular/' : '') . str_replace($this->extension(), '', $this->name()); } return $this->template; } /** - * Allows a page to override the output render format, usually the extension provided - * in the URL. (e.g. `html`, `json`, `xml`, etc). + * Allows a page to override the output render format, usually the extension provided in the URL. + * (e.g. `html`, `json`, `xml`, etc). * - * @param null $var - * - * @return null + * @param string|null $var + * @return string */ public function templateFormat($var = null) { - if ($var !== null) { - $this->template_format = $var; - return $this->template_format; + if (null !== $var) { + $this->template_format = is_string($var) ? $var : null; } - if (isset($this->template_format)) { - return $this->template_format; + if (!isset($this->template_format)) { + $this->template_format = ltrim($this->header->append_url_extension ?? Utils::getPageFormat(), '.'); } - // Set from URL extension set on page - $page_extension = trim($this->header->append_url_extension ?? '' , '.'); - if (!empty($page_extension)) { - $this->template_format = $page_extension; - - return $this->template_format; - } - - // Set from uri extension - $uri_extension = Grav::instance()['uri']->extension(); - if (is_string($uri_extension)) { - $this->template_format = $uri_extension; - - return $this->template_format; - } - - // Use content negotiation via the `accept:` header - $http_accept = $_SERVER['HTTP_ACCEPT'] ?? null; - if (is_string($http_accept)) { - $negotiator = new Negotiator(); - - $supported_types = Utils::getSupportPageTypes(['html', 'json']); - $priorities = Utils::getMimeTypes($supported_types); - - $media_type = $negotiator->getBest($http_accept, $priorities); - $mimetype = $media_type instanceof Accept ? $media_type->getValue() : ''; - - $this->template_format = Utils::getExtensionByMime($mimetype); - - return $this->template_format; - } - - // Last chance set a default type - $this->template_format = 'html'; return $this->template_format; } /** * Gets and sets the extension field. * - * @param null $var - * - * @return null|string + * @param string|null $var + * @return string */ public function extension($var = null) { @@ -1468,8 +1463,7 @@ class Page implements PageInterface /** * Gets and sets the expires field. If not set will return the default * - * @param int $var The new expires value. - * + * @param int|null $var The new expires value. * @return int The expires value */ public function expires($var = null) @@ -1485,8 +1479,8 @@ class Page implements PageInterface * Gets and sets the cache-control property. If not set it will return the default value (null) * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for more details on valid options * - * @param null $var - * @return null + * @param string|null $var + * @return string|null */ public function cacheControl($var = null) { @@ -1500,8 +1494,7 @@ class Page implements PageInterface /** * Gets and sets the title for this Page. If no title is set, it will use the slug() to get a name * - * @param string $var the title of the Page - * + * @param string|null $var the title of the Page * @return string the title of the Page */ public function title($var = null) @@ -1520,8 +1513,7 @@ class Page implements PageInterface * Gets and sets the menu name for this Page. This is the text that can be used specifically for navigation. * If no menu field is set, it will use the title() * - * @param string $var the menu field for the page - * + * @param string|null $var the menu field for the page * @return string the menu field for the page */ public function menu($var = null) @@ -1539,8 +1531,7 @@ class Page implements PageInterface /** * Gets and Sets whether or not this Page is visible for navigation * - * @param bool $var true if the page is visible - * + * @param bool|null $var true if the page is visible * @return bool true if the page is visible */ public function visible($var = null) @@ -1565,8 +1556,7 @@ class Page implements PageInterface /** * Gets and Sets whether or not this Page is considered published * - * @param bool $var true if the page is published - * + * @param bool|null $var true if the page is published * @return bool true if the page is published */ public function published($var = null) @@ -1586,8 +1576,7 @@ class Page implements PageInterface /** * Gets and Sets the Page publish date * - * @param string $var string representation of a date - * + * @param string|null $var string representation of a date * @return int unix timestamp representation of the date */ public function publishDate($var = null) @@ -1602,8 +1591,7 @@ class Page implements PageInterface /** * Gets and Sets the Page unpublish date * - * @param string $var string representation of a date - * + * @param string|null $var string representation of a date * @return int|null unix timestamp representation of the date */ public function unpublishDate($var = null) @@ -1620,8 +1608,7 @@ class Page implements PageInterface * via a URL. * The page must be *routable* and *published* * - * @param bool $var true if the page is routable - * + * @param bool|null $var true if the page is routable * @return bool true if the page is routable */ public function routable($var = null) @@ -1633,6 +1620,10 @@ class Page implements PageInterface return $this->routable && $this->published(); } + /** + * @param bool|null $var + * @return bool + */ public function ssl($var = null) { if ($var !== null) { @@ -1646,8 +1637,7 @@ class Page implements PageInterface * Gets and Sets the process setup for this Page. This is multi-dimensional array that consists of * a simple array of arrays with the form array("markdown"=>true) for example * - * @param array $var an Array of name value pairs where the name is the process and value is true or false - * + * @param array|null $var an Array of name value pairs where the name is the process and value is true or false * @return array an Array of name value pairs where the name is the process and value is true or false */ public function process($var = null) @@ -1660,9 +1650,9 @@ class Page implements PageInterface } /** - * Returns the state of the debugger override etting for this page + * Returns the state of the debugger override setting for this page * - * @return mixed + * @return bool */ public function debugger() { @@ -1673,8 +1663,7 @@ class Page implements PageInterface * Function to merge page metadata tags and build an array of Metadata objects * that can then be rendered in the page. * - * @param array $var an Array of metadata values to set - * + * @param array|null $var an Array of metadata values to set * @return array an Array of metadata values for the page */ public function metadata($var = null) @@ -1685,16 +1674,21 @@ class Page implements PageInterface // if not metadata yet, process it. if (null === $this->metadata) { - $header_tag_http_equivs = ['content-type', 'default-style', 'refresh', 'x-ua-compatible']; + $header_tag_http_equivs = ['content-type', 'default-style', 'refresh', 'x-ua-compatible', 'content-security-policy']; $this->metadata = []; - $metadata = []; // Set the Generator tag - $metadata['generator'] = 'GravCMS'; + $metadata = [ + 'generator' => 'GravCMS' + ]; + + $config = Grav::instance()['config']; + + $escape = !$config->get('system.strict_mode.twig_compat', false) || $config->get('system.twig.autoescape', true); // Get initial metadata for the page - $metadata = array_merge($metadata, Grav::instance()['config']->get('site.metadata', [])); + $metadata = array_merge($metadata, $config->get('site.metadata', [])); if (isset($this->header->metadata) && is_array($this->header->metadata)) { // Merge any site.metadata settings in with page metadata @@ -1713,28 +1707,28 @@ class Page implements PageInterface $this->metadata[$prop_key] = [ 'name' => $prop_key, 'property' => $prop_key, - 'content' => htmlspecialchars($prop_value, ENT_QUOTES, 'UTF-8') + 'content' => $escape ? htmlspecialchars($prop_value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $prop_value ]; } } else { // If it this is a standard meta data type if ($value) { - if (\in_array($key, $header_tag_http_equivs, true)) { + if (in_array($key, $header_tag_http_equivs, true)) { $this->metadata[$key] = [ 'http_equiv' => $key, - 'content' => htmlspecialchars($value, ENT_QUOTES, 'UTF-8') + 'content' => $escape ? htmlspecialchars($value, ENT_COMPAT, 'UTF-8') : $value ]; } elseif ($key === 'charset') { - $this->metadata[$key] = ['charset' => htmlspecialchars($value, ENT_QUOTES, 'UTF-8')]; + $this->metadata[$key] = ['charset' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value]; } else { // if it's a social metadata with separator, render as property $separator = strpos($key, ':'); $hasSeparator = $separator && $separator < strlen($key) - 1; $entry = [ - 'content' => htmlspecialchars($value, ENT_QUOTES, 'UTF-8') + 'content' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value ]; - if ($hasSeparator && !Utils::startsWith($key, 'twitter')) { + if ($hasSeparator && !Utils::startsWith($key, ['twitter', 'flattr'])) { $entry['property'] = $key; } else { $entry['name'] = $key; @@ -1762,8 +1756,7 @@ class Page implements PageInterface * Gets and Sets the slug for the Page. The slug is used in the URL routing. If not set it uses * the parent folder from the path * - * @param string $var the slug, e.g. 'my-blog' - * + * @param string|null $var the slug, e.g. 'my-blog' * @return string the slug */ public function slug($var = null) @@ -1782,9 +1775,8 @@ class Page implements PageInterface /** * Get/set order number of this page. * - * @param int $var - * - * @return int|bool + * @param int|null $var + * @return string|bool */ public function order($var = null) { @@ -1804,7 +1796,6 @@ class Page implements PageInterface * Gets the URL for a page - alias of url(). * * @param bool $include_host - * * @return string the permalink */ public function link($include_host = false) @@ -1825,7 +1816,6 @@ class Page implements PageInterface * Returns the canonical URL for a page * * @param bool $include_lang - * * @return string */ public function canonical($include_lang = true) @@ -1840,7 +1830,6 @@ class Page implements PageInterface * @param bool $canonical True to return the canonical URL * @param bool $include_base Include base url on multisite as well as language code * @param bool $raw_route - * * @return string The url. */ public function url($include_host = false, $canonical = false, $include_base = true, $raw_route = false) @@ -1878,11 +1867,6 @@ class Page implements PageInterface $uri = $grav['uri']; $url = $uri->rootUrl($include_host) . '/' . trim($route, '/') . $this->urlExtension(); - // trim trailing / if not root - if ($url !== '/') { - $url = rtrim($url, '/'); - } - return Uri::filterPath($url); } @@ -1890,9 +1874,8 @@ class Page implements PageInterface * Gets the route for the page based on the route headers if available, else from * the parents route and the current Page's slug. * - * @param string $var Set new default route. - * - * @return string The route for the Page. + * @param string|null $var Set new default route. + * @return string|null The route for the Page. */ public function route($var = null) { @@ -1937,8 +1920,7 @@ class Page implements PageInterface /** * Gets and Sets the page raw route * - * @param null $var - * + * @param string|null $var * @return null|string */ public function rawRoute($var = null) @@ -1962,8 +1944,7 @@ class Page implements PageInterface /** * Gets the route aliases for the page based on page headers. * - * @param array $var list of route aliases - * + * @param array|null $var list of route aliases * @return array The route aliases for the Page. */ public function routeAliases($var = null) @@ -1983,8 +1964,7 @@ class Page implements PageInterface * Gets the canonical route for this page if its set. If provided it will use * that value, else if it's `true` it will use the default route. * - * @param null $var - * + * @param string|null $var * @return bool|string */ public function routeCanonical($var = null) @@ -2003,8 +1983,7 @@ class Page implements PageInterface /** * Gets and sets the identifier for this Page object. * - * @param string $var the identifier - * + * @param string|null $var the identifier * @return string the identifier */ public function id($var = null) @@ -2026,8 +2005,7 @@ class Page implements PageInterface /** * Gets and sets the modified timestamp. * - * @param int $var modified unix timestamp - * + * @param int|null $var modified unix timestamp * @return int modified unix timestamp */ public function modified($var = null) @@ -2042,9 +2020,8 @@ class Page implements PageInterface /** * Gets the redirect set in the header. * - * @param string $var redirect url - * - * @return string + * @param string|null $var redirect url + * @return string|null */ public function redirect($var = null) { @@ -2052,17 +2029,16 @@ class Page implements PageInterface $this->redirect = $var; } - return $this->redirect; + return $this->redirect ?: null; } /** * Gets and sets the option to show the etag header for the page. * - * @param bool $var show etag header - * + * @param bool|null $var show etag header * @return bool show etag header */ - public function eTag($var = null) + public function eTag($var = null): bool { if ($var !== null) { $this->etag = $var; @@ -2071,14 +2047,13 @@ class Page implements PageInterface $this->etag = (bool)Grav::instance()['config']->get('system.pages.etag'); } - return $this->etag; + return $this->etag ?? false; } /** * Gets and sets the option to show the last_modified header for the page. * - * @param bool $var show last_modified header - * + * @param bool|null $var show last_modified header * @return bool show last_modified header */ public function lastModified($var = null) @@ -2096,8 +2071,7 @@ class Page implements PageInterface /** * Gets and sets the path to the .md file for this Page object. * - * @param string $var the file path - * + * @param string|null $var the file path * @return string|null the file path */ public function filePath($var = null) @@ -2111,7 +2085,7 @@ class Page implements PageInterface $this->path = dirname($var, 2); } - return $this->path . '/' . $this->folder . '/' . ($this->name ?: ''); + return rtrim($this->path . '/' . $this->folder . '/' . ($this->name() ?: ''), '/'); } /** @@ -2121,11 +2095,13 @@ class Page implements PageInterface */ public function filePathClean() { - return str_replace(ROOT_DIR, '', $this->filePath()); + return str_replace(GRAV_ROOT . DS, '', $this->filePath()); } /** * Returns the clean path to the page file + * + * @return string */ public function relativePagePath() { @@ -2136,8 +2112,7 @@ class Page implements PageInterface * Gets and sets the path to the folder where the .md for this Page object resides. * This is equivalent to the filePath but without the filename. * - * @param string $var the path - * + * @param string|null $var the path * @return string|null the path */ public function path($var = null) @@ -2155,8 +2130,7 @@ class Page implements PageInterface /** * Get/set the folder. * - * @param string $var Optional path - * + * @param string|null $var Optional path * @return string|null */ public function folder($var = null) @@ -2171,8 +2145,7 @@ class Page implements PageInterface /** * Gets and sets the date for this Page object. This is typically passed in via the page headers * - * @param string $var string representation of a date - * + * @param string|null $var string representation of a date * @return int unix timestamp representation of the date */ public function date($var = null) @@ -2192,8 +2165,7 @@ class Page implements PageInterface * Gets and sets the date format for this Page object. This is typically passed in via the page headers * using typical PHP date string structure - http://php.net/manual/en/function.date.php * - * @param string $var string representation of a date format - * + * @param string|null $var string representation of a date format * @return string string representation of a date format */ public function dateformat($var = null) @@ -2208,8 +2180,7 @@ class Page implements PageInterface /** * Gets and sets the order by which any sub-pages should be sorted. * - * @param string $var the order, either "asc" or "desc" - * + * @param string|null $var the order, either "asc" or "desc" * @return string the order, either "asc" or "desc" * @deprecated 1.6 */ @@ -2236,8 +2207,7 @@ class Page implements PageInterface * date - is the order based on the date set in the pages * folder - is the order based on the name of the folder with any numerics omitted * - * @param string $var supported options include "default", "title", "date", and "folder" - * + * @param string|null $var supported options include "default", "title", "date", and "folder" * @return string supported options include "default", "title", "date", and "folder" * @deprecated 1.6 */ @@ -2255,8 +2225,7 @@ class Page implements PageInterface /** * Gets the manual order set in the header. * - * @param string $var supported options include "default", "title", "date", and "folder" - * + * @param string|null $var supported options include "default", "title", "date", and "folder" * @return array * @deprecated 1.6 */ @@ -2275,8 +2244,7 @@ class Page implements PageInterface * Gets and sets the maxCount field which describes how many sub-pages should be displayed if the * sub_pages header property is set for this page object. * - * @param int $var the maximum number of sub-pages - * + * @param int|null $var the maximum number of sub-pages * @return int the maximum number of sub-pages * @deprecated 1.6 */ @@ -2299,19 +2267,18 @@ class Page implements PageInterface /** * Gets and sets the taxonomy array which defines which taxonomies this page identifies itself with. * - * @param array $var an array of taxonomies - * + * @param array|null $var an array of taxonomies * @return array an array of taxonomies */ public function taxonomy($var = null) { if ($var !== null) { // make sure first level are arrays - array_walk($var, function(&$value) { + array_walk($var, function (&$value) { $value = (array) $value; }); // make sure all values are strings - array_walk_recursive($var, function(&$value) { + array_walk_recursive($var, function (&$value) { $value = (string) $value; }); $this->taxonomy = $var; @@ -2323,12 +2290,14 @@ class Page implements PageInterface /** * Gets and sets the modular var that helps identify this page is a modular child * - * @param bool $var true if modular_twig - * + * @param bool|null $var true if modular_twig * @return bool true if modular_twig + * @deprecated 1.7 Use ->isModule() or ->modularTwig() method instead. */ public function modular($var = null) { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->isModule() or ->modularTwig() method instead', E_USER_DEPRECATED); + return $this->modularTwig($var); } @@ -2336,8 +2305,7 @@ class Page implements PageInterface * Gets and sets the modular_twig var that helps identify this page as a modular child page that will need * twig processing handled differently from a regular page. * - * @param bool $var true if modular_twig - * + * @param bool|null $var true if modular_twig * @return bool true if modular_twig */ public function modularTwig($var = null) @@ -2353,14 +2321,13 @@ class Page implements PageInterface } } - return $this->modular_twig; + return $this->modular_twig ?? false; } /** * Gets the configured state of the processing method. * * @param string $process the process, eg "twig" or "markdown" - * * @return bool whether or not the processing method is enabled for this Page */ public function shouldProcess($process) @@ -2371,8 +2338,7 @@ class Page implements PageInterface /** * Gets and Sets the parent object for this page * - * @param PageInterface $var the parent page object - * + * @param PageInterface|null $var the parent page object * @return PageInterface|null the parent page object if it exists. */ public function parent(PageInterface $var = null) @@ -2390,17 +2356,13 @@ class Page implements PageInterface } /** - * Gets the top parent object for this page + * Gets the top parent object for this page. Can return page itself. * - * @return PageInterface|null the top parent page object if it exists. + * @return PageInterface The top parent page object. */ public function topParent() { - $topParent = $this->parent(); - - if (!$topParent) { - return null; - } + $topParent = $this; while (true) { $theParent = $topParent->parent(); @@ -2417,7 +2379,7 @@ class Page implements PageInterface /** * Returns children of this page. * - * @return Collection + * @return PageCollectionInterface|Collection */ public function children() { @@ -2484,8 +2446,7 @@ class Page implements PageInterface * Returns the adjacent sibling based on a direction. * * @param int $direction either -1 or +1 - * - * @return PageInterface|bool the sibling page + * @return PageInterface|false the sibling page */ public function adjacentSibling($direction = 1) { @@ -2501,7 +2462,7 @@ class Page implements PageInterface /** * Returns the item in the current position. * - * @return int the index of the current page. + * @return int|null The index of the current page. */ public function currentPosition() { @@ -2535,21 +2496,23 @@ class Page implements PageInterface */ public function activeChild() { - $uri = Grav::instance()['uri']; - $pages = Grav::instance()['pages']; + $grav = Grav::instance(); + /** @var Uri $uri */ + $uri = $grav['uri']; + /** @var Pages $pages */ + $pages = $grav['pages']; $uri_path = rtrim(urldecode($uri->path()), '/'); - $routes = Grav::instance()['pages']->routes(); + $routes = $pages->routes(); if (isset($routes[$uri_path])) { - /** @var PageInterface $child_page */ - $child_page = $pages->dispatch($uri->route())->parent(); - if ($child_page) { - while (!$child_page->root()) { - if ($this->path() === $child_page->path()) { - return true; - } - $child_page = $child_page->parent(); + $page = $pages->find($uri->route()); + /** @var PageInterface|null $child_page */ + $child_page = $page ? $page->parent() : null; + while ($child_page && !$child_page->root()) { + if ($this->path() === $child_page->path()) { + return true; } + $child_page = $child_page->parent(); } } @@ -2581,8 +2544,7 @@ class Page implements PageInterface /** * Helper method to return an ancestor page. * - * @param bool $lookup Name of the parent folder - * + * @param bool|null $lookup Name of the parent folder * @return PageInterface page you were looking for if it exists */ public function ancestor($lookup = null) @@ -2598,12 +2560,11 @@ class Page implements PageInterface * page object is returned. * * @param string $field Name of the parent folder - * * @return PageInterface */ public function inherited($field) { - list($inherited, $currentParams) = $this->getInheritedParams($field); + [$inherited, $currentParams] = $this->getInheritedParams($field); $this->modifyHeader($field, $currentParams); @@ -2620,7 +2581,7 @@ class Page implements PageInterface */ public function inheritedField($field) { - list($inherited, $currentParams) = $this->getInheritedParams($field); + [$inherited, $currentParams] = $this->getInheritedParams($field); return $currentParams; } @@ -2629,7 +2590,6 @@ class Page implements PageInterface * Method that contains shared logic for inherited() and inheritedField() * * @param string $field Name of the parent folder - * * @return array */ protected function getInheritedParams($field) @@ -2669,327 +2629,52 @@ class Page implements PageInterface * @param string|array $params * @param bool $pagination * - * @return Collection - * @throws \InvalidArgumentException + * @return PageCollectionInterface|Collection + * @throws InvalidArgumentException */ public function collection($params = 'content', $pagination = true) { if (is_string($params)) { + // Look into a page header field. $params = (array)$this->value('header.' . $params); } elseif (!is_array($params)) { - throw new \InvalidArgumentException('Argument should be either header variable name or array of parameters'); + throw new InvalidArgumentException('Argument should be either header variable name or array of parameters'); } - if (!isset($params['items'])) { - return new Collection(); - } + $params['filter'] = ($params['filter'] ?? []) + ['translated' => true]; + $context = [ + 'pagination' => $pagination, + 'self' => $this + ]; - // See if require published filter is set and use that, if assume published=true - $only_published = true; - if (isset($params['filter']['published']) && $params['filter']['published']) { - $only_published = false; - } elseif (isset($params['filter']['non-published']) && $params['filter']['non-published']) { - $only_published = false; - } + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; - $collection = $this->evaluate($params['items'], $only_published); - if (!$collection instanceof Collection) { - $collection = new Collection(); - } - $collection->setParams($params); - - /** @var Uri $uri */ - $uri = Grav::instance()['uri']; - /** @var Config $config */ - $config = Grav::instance()['config']; - - $process_taxonomy = $params['url_taxonomy_filters'] ?? $config->get('system.pages.url_taxonomy_filters'); - - if ($process_taxonomy) { - foreach ((array)$config->get('site.taxonomies') as $taxonomy) { - if ($uri->param(rawurlencode($taxonomy))) { - $items = explode(',', $uri->param($taxonomy)); - $collection->setParams(['taxonomies' => [$taxonomy => $items]]); - - foreach ($collection as $page) { - // Don't filter modular pages - if ($page->modular()) { - continue; - } - foreach ($items as $item) { - $item = rawurldecode($item); - if (empty($page->taxonomy[$taxonomy]) || !\in_array(htmlspecialchars_decode($item, ENT_QUOTES), $page->taxonomy[$taxonomy], true) - ) { - $collection->remove($page->path()); - } - } - } - } - } - } - - // If a filter or filters are set, filter the collection... - if (isset($params['filter'])) { - - // remove any inclusive sets from filer: - $sets = ['published', 'visible', 'modular', 'routable']; - foreach ($sets as $type) { - $var = "non-{$type}"; - if (isset($params['filter'][$type], $params['filter'][$var]) && $params['filter'][$type] && $params['filter'][$var]) { - unset ($params['filter'][$type], $params['filter'][$var]); - } - } - - foreach ((array)$params['filter'] as $type => $filter) { - switch ($type) { - case 'published': - if ((bool) $filter) { - $collection->published(); - } - break; - case 'non-published': - if ((bool) $filter) { - $collection->nonPublished(); - } - break; - case 'visible': - if ((bool) $filter) { - $collection->visible(); - } - break; - case 'non-visible': - if ((bool) $filter) { - $collection->nonVisible(); - } - break; - case 'modular': - if ((bool) $filter) { - $collection->modular(); - } - break; - case 'non-modular': - if ((bool) $filter) { - $collection->nonModular(); - } - break; - case 'routable': - if ((bool) $filter) { - $collection->routable(); - } - break; - case 'non-routable': - if ((bool) $filter) { - $collection->nonRoutable(); - } - break; - case 'type': - $collection->ofType($filter); - break; - case 'types': - $collection->ofOneOfTheseTypes($filter); - break; - case 'access': - $collection->ofOneOfTheseAccessLevels($filter); - break; - } - } - } - - if (isset($params['dateRange'])) { - $start = $params['dateRange']['start'] ?? 0; - $end = $params['dateRange']['end'] ?? false; - $field = $params['dateRange']['field'] ?? false; - $collection->dateRange($start, $end, $field); - } - - if (isset($params['order'])) { - $by = $params['order']['by'] ?? 'default'; - $dir = $params['order']['dir'] ?? 'asc'; - $custom = $params['order']['custom'] ?? null; - $sort_flags = $params['order']['sort_flags'] ?? null; - - if (is_array($sort_flags)) { - $sort_flags = array_map('constant', $sort_flags); //transform strings to constant value - $sort_flags = array_reduce($sort_flags, function ($a, $b) { - return $a | $b; - }, 0); //merge constant values using bit or - } - - $collection->order($by, $dir, $custom, $sort_flags); - } - - /** @var Grav $grav */ - $grav = Grav::instance(); - - // New Custom event to handle things like pagination. - $grav->fireEvent('onCollectionProcessed', new Event(['collection' => $collection])); - - // Slice and dice the collection if pagination is required - if ($pagination) { - $params = $collection->params(); - - $limit = (int)($params['limit'] ?? 0); - $start = !empty($params['pagination']) ? ($uri->currentPage() - 1) * $limit : 0; - - if ($limit && $collection->count() > $limit) { - $collection->slice($start, $limit); - } - } - - return $collection; + return $pages->getCollection($params, $context); } /** * @param string|array $value * @param bool $only_published - * @return mixed + * @return PageCollectionInterface|Collection */ public function evaluate($value, $only_published = true) { - // Parse command. - if (is_string($value)) { - // Format: @command.param - $cmd = $value; - $params = []; - } elseif (is_array($value) && count($value) == 1 && !is_int(key($value))) { - // Format: @command.param: { attr1: value1, attr2: value2 } - $cmd = (string)key($value); - $params = (array)current($value); - } else { - $result = []; - foreach ((array)$value as $key => $val) { - if (is_int($key)) { - $result = $result + $this->evaluate($val, $only_published)->toArray(); - } else { - $result = $result + $this->evaluate([$key => $val], $only_published)->toArray(); - } - - } - - return new Collection($result); - } + $params = [ + 'items' => $value, + 'published' => $only_published + ]; + $context = [ + 'event' => false, + 'pagination' => false, + 'url_taxonomy_filters' => false, + 'self' => $this + ]; /** @var Pages $pages */ $pages = Grav::instance()['pages']; - $parts = explode('.', $cmd); - $current = array_shift($parts); - - /** @var Collection $results */ - $results = new Collection(); - - switch ($current) { - case 'self@': - case '@self': - if (!empty($parts)) { - switch ($parts[0]) { - case 'modular': - // @self.modular: false (alternative to @self.children) - if (!empty($params) && $params[0] === false) { - $results = $this->children()->nonModular(); - break; - } - $results = $this->children()->modular(); - break; - case 'children': - $results = $this->children()->nonModular(); - break; - case 'all': - $results = $this->children(); - break; - case 'parent': - $collection = new Collection(); - $results = $collection->addPage($this->parent()); - break; - case 'siblings': - if (!$this->parent()) { - return new Collection(); - } - $results = $this->parent()->children()->remove($this->path()); - break; - case 'descendants': - $results = $pages->all($this)->remove($this->path())->nonModular(); - break; - } - } - - - break; - - case 'page@': - case '@page': - $page = null; - - if (!empty($params)) { - $page = $this->find($params[0]); - } - - // safety check in case page is not found - if (!isset($page)) { - return $results; - } - - // Handle a @page.descendants - if (!empty($parts)) { - switch ($parts[0]) { - case 'modular': - $results = new Collection(); - foreach ($page->children() as $child) { - $results = $results->addPage($child); - } - $results->modular(); - break; - case 'page': - case 'self': - $results = new Collection(); - $results = $results->addPage($page); - break; - - case 'descendants': - $results = $pages->all($page)->remove($page->path())->nonModular(); - break; - - case 'children': - $results = $page->children()->nonModular(); - break; - } - } else { - $results = $page->children()->nonModular(); - } - - break; - - case 'root@': - case '@root': - if (!empty($parts) && $parts[0] === 'descendants') { - $results = $pages->all($pages->root())->nonModular(); - } else { - $results = $pages->root()->children()->nonModular(); - } - break; - - case 'taxonomy@': - case '@taxonomy': - // Gets a collection of pages by using one of the following formats: - // @taxonomy.category: blog - // @taxonomy.category: [ blog, featured ] - // @taxonomy: { category: [ blog, featured ], level: 1 } - - /** @var Taxonomy $taxonomy_map */ - $taxonomy_map = Grav::instance()['taxonomy']; - - if (!empty($parts)) { - $params = [implode('.', $parts) => $params]; - } - $results = $taxonomy_map->findTaxonomy($params); - break; - } - - if ($only_published) { - $results = $results->published(); - } - - return $results; + return $pages->getCollection($params, $context); } /** @@ -3016,6 +2701,14 @@ class Page implements PageInterface return !$this->isPage(); } + /** + * @return bool + */ + public function isModule(): bool + { + return $this->modularTwig(); + } + /** * Returns whether the page exists in the filesystem. * @@ -3042,7 +2735,6 @@ class Page implements PageInterface * Cleans the path. * * @param string $path the path - * * @return string the path */ protected function cleanPath($path) @@ -3107,8 +2799,8 @@ class Page implements PageInterface * Moves or copies the page in filesystem. * * @internal - * - * @throws \Exception + * @return void + * @throws Exception */ protected function doRelocation() { @@ -3130,9 +2822,11 @@ class Page implements PageInterface rename($path . '/' . $this->_original->name(), $path . '/' . $this->name()); } } - } + /** + * @return void + */ protected function setPublishState() { // Handle publishing dates if no explicit published option set @@ -3154,6 +2848,10 @@ class Page implements PageInterface } } + /** + * @param string $route + * @return string + */ protected function adjustRouteCase($route) { $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls'); @@ -3168,16 +2866,16 @@ class Page implements PageInterface */ public function getOriginal() { - return $this->_original; + return $this->_original; } /** * Gets the action. * - * @return string The Action string. + * @return string|null The Action string. */ public function getAction() { - return $this->_action; + return $this->_action; } } diff --git a/system/src/Grav/Common/Page/Pages.php b/system/src/Grav/Common/Page/Pages.php index 8f31afb..3b3ce8e 100644 --- a/system/src/Grav/Common/Page/Pages.php +++ b/system/src/Grav/Common/Page/Pages.php @@ -3,132 +3,152 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page; +use Exception; +use FilesystemIterator; use Grav\Common\Cache; use Grav\Common\Config\Config; use Grav\Common\Data\Blueprint; use Grav\Common\Data\Blueprints; +use Grav\Common\Debugger; use Grav\Common\Filesystem\Folder; +use Grav\Common\Flex\Types\Pages\PageCollection; +use Grav\Common\Flex\Types\Pages\PageIndex; use Grav\Common\Grav; use Grav\Common\Language\Language; +use Grav\Common\Page\Interfaces\PageCollectionInterface; use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Taxonomy; use Grav\Common\Uri; use Grav\Common\Utils; +use Grav\Framework\Flex\Flex; +use Grav\Framework\Flex\FlexDirectory; +use Grav\Framework\Flex\Interfaces\FlexTranslateInterface; +use Grav\Framework\Flex\Pages\FlexPageObject; use Grav\Plugin\Admin; use RocketTheme\Toolbox\Event\Event; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use SplFileInfo; +use Symfony\Component\EventDispatcher\EventDispatcher; use Whoops\Exception\ErrorException; use Collator; +use function array_key_exists; +use function array_search; +use function count; +use function dirname; +use function extension_loaded; +use function in_array; +use function is_array; +use function is_int; +use function is_string; +/** + * Class Pages + * @package Grav\Common\Page + */ class Pages { - /** - * @var Grav - */ + /** @var FlexDirectory|null */ + private $directory; + + /** @var Grav */ protected $grav; - - /** - * @var array|PageInterface[] - */ - protected $instances; - - /** - * @var array|string[] - */ + /** @var array */ + protected $instances = []; + /** @var array */ + protected $index = []; + /** @var array */ protected $children; - - /** - * @var string - */ + /** @var string */ protected $base = ''; - - /** - * @var array|string[] - */ + /** @var string[] */ protected $baseRoute = []; - - /** - * @var array|string[] - */ + /** @var string[] */ protected $routes = []; - - /** - * @var array - */ + /** @var array */ protected $sort; - - /** - * @var Blueprints - */ + /** @var Blueprints */ protected $blueprints; - - /** - * @var int - */ + /** @var bool */ + protected $enable_pages = true; + /** @var int */ protected $last_modified; - - /** - * @var array|string[] - */ + /** @var string[] */ protected $ignore_files; - - /** - * @var array|string[] - */ + /** @var string[] */ protected $ignore_folders; - - /** - * @var bool - */ + /** @var bool */ protected $ignore_hidden; - /** @var string */ protected $check_method; - + /** @var string */ protected $pages_cache_id; - + /** @var bool */ protected $initialized = false; - + /** @var string */ protected $active_lang; - - /** - * @var Types - */ - static protected $types; - - /** - * @var string - */ - static protected $home_route; + /** @var bool */ + protected $fire_events = false; + /** @var Types|null */ + protected static $types; + /** @var string|null */ + protected static $home_route; /** * Constructor * - * @param Grav $c + * @param Grav $grav */ - public function __construct(Grav $c) + public function __construct(Grav $grav) { - $this->grav = $c; + $this->grav = $grav; + } + + /** + * @return FlexDirectory|null + */ + public function getDirectory(): ?FlexDirectory + { + return $this->directory; + } + + /** + * Method used in admin to disable frontend pages from being initialized. + */ + public function disablePages(): void + { + $this->enable_pages = false; + } + + /** + * Method used in admin to later load frontend pages. + */ + public function enablePages(): void + { + if (!$this->enable_pages) { + $this->enable_pages = true; + + $this->init(); + } } /** * Get or set base path for the pages. * - * @param string $path - * + * @param string|null $path * @return string */ public function base($path = null) { if ($path !== null) { $path = trim($path, '/'); - $this->base = $path ? '/' . $path : null; + $this->base = $path ? '/' . $path : ''; $this->baseRoute = []; } @@ -139,8 +159,7 @@ class Pages * * Get base route for Grav pages. * - * @param string $lang Optional language code for multilingual routes. - * + * @param string|null $lang Optional language code for multilingual routes. * @return string */ public function baseRoute($lang = null) @@ -165,8 +184,7 @@ class Pages * Get route for Grav site. * * @param string $route Optional route to the page. - * @param string $lang Optional language code for multilingual links. - * + * @param string|null $lang Optional language code for multilingual links. * @return string */ public function route($route = '/', $lang = null) @@ -182,9 +200,8 @@ class Pages * * Get base URL for Grav pages. * - * @param string $lang Optional language code for multilingual links. + * @param string|null $lang Optional language code for multilingual links. * @param bool|null $absolute If true, return absolute url, if false, return relative url. Otherwise return default. - * * @return string */ public function baseUrl($lang = null, $absolute = null) @@ -204,9 +221,8 @@ class Pages * * Get home URL for Grav site. * - * @param string $lang Optional language code for multilingual links. - * @param bool $absolute If true, return absolute url, if false, return relative url. Otherwise return default. - * + * @param string|null $lang Optional language code for multilingual links. + * @param bool|null $absolute If true, return absolute url, if false, return relative url. Otherwise return default. * @return string */ public function homeUrl($lang = null, $absolute = null) @@ -219,9 +235,8 @@ class Pages * Get URL for Grav site. * * @param string $route Optional route to the page. - * @param string $lang Optional language code for multilingual links. - * @param bool $absolute If true, return absolute url, if false, return relative url. Otherwise return default. - * + * @param string|null $lang Optional language code for multilingual links. + * @param bool|null $absolute If true, return absolute url, if false, return relative url. Otherwise return default. * @return string */ public function url($route = '/', $lang = null, $absolute = null) @@ -233,13 +248,31 @@ class Pages return $this->baseUrl($lang, $absolute) . Uri::filterPath($route); } - public function setCheckMethod($method) + /** + * @param string $method + * @return void + */ + public function setCheckMethod($method): void { $this->check_method = strtolower($method); } + /** + * @return void + */ + public function register(): void + { + $config = $this->grav['config']; + $type = $config->get('system.pages.type'); + if ($type === 'flex') { + $this->initFlexPages(); + } + } + /** * Reset pages (used in search indexing etc). + * + * @return void */ public function reset() { @@ -251,18 +284,20 @@ class Pages /** * Class initialization. Must be called before using this class. */ - public function init() + public function init(): void { if ($this->initialized) { return; } $config = $this->grav['config']; - $this->ignore_files = $config->get('system.pages.ignore_files'); - $this->ignore_folders = $config->get('system.pages.ignore_folders'); - $this->ignore_hidden = $config->get('system.pages.ignore_hidden'); + $this->ignore_files = (array)$config->get('system.pages.ignore_files'); + $this->ignore_folders = (array)$config->get('system.pages.ignore_folders'); + $this->ignore_hidden = (bool)$config->get('system.pages.ignore_hidden'); + $this->fire_events = (bool)$config->get('system.pages.events.page'); $this->instances = []; + $this->index = []; $this->children = []; $this->routes = []; @@ -270,14 +305,22 @@ class Pages $this->setCheckMethod($config->get('system.cache.check.method', 'file')); } + if ($this->enable_pages === false) { + $page = $this->buildRootPage(); + $this->instances[$page->path()] = $page; + + return; + } + $this->buildPages(); + + $this->initialized = true; } /** * Get or set last modification time. * - * @param int $modified - * + * @param int|null $modified * @return int|null */ public function lastModified($modified = null) @@ -292,11 +335,19 @@ class Pages /** * Returns a list of all pages. * - * @return array|PageInterface[] + * @return PageInterface[] */ public function instances() { - return $this->instances; + $instances = []; + foreach ($this->index as $path => $instance) { + $page = $this->get($path); + if ($page) { + $instances[$path] = $page; + } + } + + return $instances; } /** @@ -313,29 +364,336 @@ class Pages * Adds a page and assigns a route to it. * * @param PageInterface $page Page to be added. - * @param string $route Optional route (uses route from the object if not set). + * @param string|null $route Optional route (uses route from the object if not set). */ - public function addPage(PageInterface $page, $route = null) + public function addPage(PageInterface $page, $route = null): void { - if (!isset($this->instances[$page->path()])) { - $this->instances[$page->path()] = $page; + $path = $page->path() ?? ''; + if (!isset($this->index[$path])) { + $this->index[$path] = $page; + $this->instances[$path] = $page; } $route = $page->route($route); - if ($page->parent()) { - $this->children[$page->parent()->path()][$page->path()] = ['slug' => $page->slug()]; + $parent = $page->parent(); + if ($parent) { + $this->children[$parent->path() ?? ''][$path] = ['slug' => $page->slug()]; } - $this->routes[$route] = $page->path(); + $this->routes[$route] = $path; $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page])); } + /** + * Get a collection of pages in the given context. + * + * @param array $params + * @param array $context + * @return PageCollectionInterface|Collection + */ + public function getCollection(array $params = [], array $context = []) + { + if (!isset($params['items'])) { + return new Collection(); + } + + /** @var Config $config */ + $config = $this->grav['config']; + + $context += [ + 'event' => true, + 'pagination' => true, + 'url_taxonomy_filters' => $config->get('system.pages.url_taxonomy_filters'), + 'taxonomies' => (array)$config->get('site.taxonomies'), + 'pagination_page' => 1, + 'self' => null, + ]; + + // Include taxonomies from the URL if requested. + $process_taxonomy = $params['url_taxonomy_filters'] ?? $context['url_taxonomy_filters']; + if ($process_taxonomy) { + /** @var Uri $uri */ + $uri = $this->grav['uri']; + foreach ($context['taxonomies'] as $taxonomy) { + $param = $uri->param(rawurlencode($taxonomy)); + $items = is_string($param) ? explode(',', $param) : []; + foreach ($items as $item) { + $params['taxonomies'][$taxonomy][] = htmlspecialchars_decode(rawurldecode($item), ENT_QUOTES); + } + } + } + + $pagination = $params['pagination'] ?? $context['pagination']; + if ($pagination && !isset($params['page'], $params['start'])) { + /** @var Uri $uri */ + $uri = $this->grav['uri']; + $context['current_page'] = $uri->currentPage(); + } + + $collection = $this->evaluate($params['items'], $context['self']); + $collection->setParams($params); + + // Filter by taxonomies. + foreach ($params['taxonomies'] ?? [] as $taxonomy => $items) { + foreach ($collection as $page) { + // Don't include modules + if ($page->isModule()) { + continue; + } + + $test = $page->taxonomy()[$taxonomy] ?? []; + foreach ($items as $item) { + if (!$test || !in_array($item, $test, true)) { + $collection->remove($page->path()); + } + } + } + } + + $filters = $params['filter'] ?? []; + + // Assume published=true if not set. + if (!isset($filters['published']) && !isset($filters['non-published'])) { + $filters['published'] = true; + } + + // Remove any inclusive sets from filter. + $sets = ['published', 'visible', 'modular', 'routable']; + foreach ($sets as $type) { + $nonType = "non-{$type}"; + if (isset($filters[$type], $filters[$nonType]) && $filters[$type] === $filters[$nonType]) { + if (!$filters[$type]) { + // Both options are false, return empty collection as nothing can match the filters. + return new Collection(); + } + + // Both options are true, remove opposite filters as all pages will match the filters. + unset($filters[$type], $filters[$nonType]); + } + } + + // Filter the collection + foreach ($filters as $type => $filter) { + if (null === $filter) { + continue; + } + + // Convert non-type to type. + if (str_starts_with($type, 'non-')) { + $type = substr($type, 4); + $filter = !$filter; + } + + switch ($type) { + case 'translated': + if ($filter) { + $collection = $collection->translated(); + } else { + $collection = $collection->nonTranslated(); + } + break; + case 'published': + if ($filter) { + $collection = $collection->published(); + } else { + $collection = $collection->nonPublished(); + } + break; + case 'visible': + if ($filter) { + $collection = $collection->visible(); + } else { + $collection = $collection->nonVisible(); + } + break; + case 'page': + if ($filter) { + $collection = $collection->pages(); + } else { + $collection = $collection->modules(); + } + break; + case 'module': + case 'modular': + if ($filter) { + $collection = $collection->modules(); + } else { + $collection = $collection->pages(); + } + break; + case 'routable': + if ($filter) { + $collection = $collection->routable(); + } else { + $collection = $collection->nonRoutable(); + } + break; + case 'type': + $collection = $collection->ofType($filter); + break; + case 'types': + $collection = $collection->ofOneOfTheseTypes($filter); + break; + case 'access': + $collection = $collection->ofOneOfTheseAccessLevels($filter); + break; + } + } + + if (isset($params['dateRange'])) { + $start = $params['dateRange']['start'] ?? 0; + $end = $params['dateRange']['end'] ?? false; + $field = $params['dateRange']['field'] ?? false; + $collection = $collection->dateRange($start, $end, $field); + } + + if (isset($params['order'])) { + $by = $params['order']['by'] ?? 'default'; + $dir = $params['order']['dir'] ?? 'asc'; + $custom = $params['order']['custom'] ?? null; + $sort_flags = $params['order']['sort_flags'] ?? null; + + if (is_array($sort_flags)) { + $sort_flags = array_map('constant', $sort_flags); //transform strings to constant value + $sort_flags = array_reduce($sort_flags, function ($a, $b) { + return $a | $b; + }, 0); //merge constant values using bit or + } + + $collection = $collection->order($by, $dir, $custom, $sort_flags); + } + + // New Custom event to handle things like pagination. + if ($context['event']) { + $this->grav->fireEvent('onCollectionProcessed', new Event(['collection' => $collection, 'context' => $context])); + } + + if ($context['pagination']) { + // Slice and dice the collection if pagination is required + $params = $collection->params(); + + $limit = (int)($params['limit'] ?? 0); + $page = (int)($params['page'] ?? $context['current_page'] ?? 0); + $start = (int)($params['start'] ?? 0); + $start = $limit > 0 && $page > 0 ? ($page - 1) * $limit : max(0, $start); + + if ($start || ($limit && $collection->count() > $limit)) { + $collection->slice($start, $limit ?: null); + } + } + + return $collection; + } + + /** + * @param array|string $value + * @param PageInterface|null $self + * @return Collection + */ + protected function evaluate($value, PageInterface $self = null) + { + // Parse command. + if (is_string($value)) { + // Format: @command.param + $cmd = $value; + $params = []; + } elseif (is_array($value) && count($value) === 1 && !is_int(key($value))) { + // Format: @command.param: { attr1: value1, attr2: value2 } + $cmd = (string)key($value); + $params = (array)current($value); + } else { + $result = []; + foreach ((array)$value as $key => $val) { + if (is_int($key)) { + $result = $result + $this->evaluate($val, $self)->toArray(); + } else { + $result = $result + $this->evaluate([$key => $val], $self)->toArray(); + } + } + + return new Collection($result); + } + + $parts = explode('.', $cmd); + $scope = array_shift($parts); + $type = $parts[0] ?? null; + + /** @var PageInterface|null $page */ + $page = null; + switch ($scope) { + case 'self@': + case '@self': + $page = $self; + break; + + case 'page@': + case '@page': + $page = isset($params[0]) ? $this->find($params[0]) : null; + break; + + case 'root@': + case '@root': + $page = $this->root(); + break; + + case 'taxonomy@': + case '@taxonomy': + // Gets a collection of pages by using one of the following formats: + // @taxonomy.category: blog + // @taxonomy.category: [ blog, featured ] + // @taxonomy: { category: [ blog, featured ], level: 1 } + + /** @var Taxonomy $taxonomy_map */ + $taxonomy_map = Grav::instance()['taxonomy']; + + if (!empty($parts)) { + $params = [implode('.', $parts) => $params]; + } + + return $taxonomy_map->findTaxonomy($params); + } + + if (!$page) { + return new Collection(); + } + + // Handle '@page', '@page.modular: false', '@self' and '@self.modular: false'. + if (null === $type || (in_array($type, ['modular', 'modules']) && ($params[0] ?? null) === false)) { + $type = 'children'; + } + + switch ($type) { + case 'all': + return $page->children(); + case 'modules': + case 'modular': + return $page->children()->modules(); + case 'pages': + case 'children': + return $page->children()->pages(); + case 'page': + case 'self': + return !$page->root() ? (new Collection())->addPage($page) : new Collection(); + case 'parent': + $parent = $page->parent(); + $collection = new Collection(); + return $parent ? $collection->addPage($parent) : $collection; + case 'siblings': + $parent = $page->parent(); + return $parent ? $parent->children()->remove($page->path()) : new Collection(); + case 'descendants': + return $this->all($page)->remove($page->path())->pages(); + default: + // Unknown type; return empty collection. + return new Collection(); + } + } + /** * Sort sub-pages in a page. * * @param PageInterface $page - * @param string $order_by - * @param string $order_dir - * + * @param string|null $order_by + * @param string|null $order_dir * @return array */ public function sort(PageInterface $page, $order_by = null, $order_dir = null, $sort_flags = null) @@ -348,6 +706,10 @@ class Pages } $path = $page->path(); + if (null === $path) { + return []; + } + $children = $this->children[$path] ?? []; if (!$children) { @@ -369,11 +731,10 @@ class Pages /** * @param Collection $collection - * @param string|int $orderBy + * @param string $orderBy * @param string $orderDir * @param array|null $orderManual * @param int|null $sort_flags - * * @return array * @internal */ @@ -396,27 +757,70 @@ class Pages } return $sort; - } /** * Get a page instance. * * @param string $path The filesystem full path of the page - * - * @return PageInterface - * @throws \Exception + * @return PageInterface|null + * @throws RuntimeException */ public function get($path) { - return $this->instances[(string)$path] ?? null; + $path = (string)$path; + if ($path === '') { + return null; + } + + // Check for local instances first. + if (array_key_exists($path, $this->instances)) { + return $this->instances[$path]; + } + + $instance = $this->index[$path] ?? null; + if (is_string($instance)) { + if ($this->directory) { + /** @var Language $language */ + $language = $this->grav['language']; + $lang = $language->getActive(); + if ($lang) { + $languages = $language->getFallbackLanguages($lang, true); + $key = $instance; + $instance = null; + foreach ($languages as $code) { + $test = $code ? $key . ':' . $code : $key; + if (($instance = $this->directory->getObject($test, 'flex_key')) !== null) { + break; + } + } + } else { + $instance = $this->directory->getObject($instance, 'flex_key'); + } + } + + if ($instance instanceof PageInterface) { + if ($this->fire_events && method_exists($instance, 'initialize')) { + $instance->initialize(); + } + } else { + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addMessage(sprintf('Flex page %s is missing or broken!', $instance), 'debug'); + } + } + + if ($instance) { + $this->instances[$path] = $instance; + } + + return $instance; } /** * Get children of the path. * * @param string $path - * * @return Collection */ public function children($path) @@ -430,14 +834,13 @@ class Pages * Get a page ancestor. * * @param string $route The relative URL of the page - * @param string $path The relative path of the ancestor folder - * + * @param string|null $path The relative path of the ancestor folder * @return PageInterface|null */ public function ancestor($route, $path = null) { if ($path !== null) { - $page = $this->dispatch($route, true); + $page = $this->find($route, true); if ($page && $page->path() === $path) { return $page; @@ -456,15 +859,13 @@ class Pages * Get a page ancestor trait. * * @param string $route The relative route of the page - * @param string $field The field name of the ancestor to query for - * + * @param string|null $field The field name of the ancestor to query for * @return PageInterface|null */ public function inherited($route, $field = null) { if ($field !== null) { - - $page = $this->dispatch($route, true); + $page = $this->find($route, true); $parent = $page ? $page->parent() : null; if ($parent && $parent->value('header.' . $field) !== null) { @@ -479,97 +880,146 @@ class Pages } /** - * alias method to return find a page. - * - * @param string $route The relative URL of the page - * @param bool $all + * Find a page based on route. * + * @param string $route The route of the page + * @param bool $all If true, return also non-routable pages, otherwise return null if page isn't routable * @return PageInterface|null */ public function find($route, $all = false) { - return $this->dispatch($route, $all, false); + $route = urldecode((string)$route); + + // Fetch page if there's a defined route to it. + $path = $this->routes[$route] ?? null; + $page = null !== $path ? $this->get($path) : null; + + // Try without trailing slash + if (null === $page && Utils::endsWith($route, '/')) { + $path = $this->routes[rtrim($route, '/')] ?? null; + $page = null !== $path ? $this->get($path) : null; + } + + if (!$all && !isset($this->grav['admin'])) { + if (null === $page || !$page->routable()) { + // If the page cannot be accessed, look for the site wide routes and wildcards. + $page = $this->findSiteBasedRoute($route) ?? $page; + } + } + + return $page; + } + + /** + * Check site based routes. + * + * @param string $route + * @return PageInterface|null + */ + protected function findSiteBasedRoute($route) + { + /** @var Config $config */ + $config = $this->grav['config']; + + $site_routes = $config->get('site.routes'); + if (!is_array($site_routes)) { + return null; + } + + $page = null; + + // See if route matches one in the site configuration + $site_route = $site_routes[$route] ?? null; + if ($site_route) { + $page = $this->find($site_route); + } else { + // Use reverse order because of B/C (previously matched multiple and returned the last match). + foreach (array_reverse($site_routes, true) as $pattern => $replace) { + $pattern = '#^' . str_replace('/', '\/', ltrim($pattern, '^')) . '#'; + try { + $found = preg_replace($pattern, $replace, $route); + if ($found && $found !== $route) { + $page = $this->find($found); + if ($page) { + return $page; + } + } + } catch (ErrorException $e) { + $this->grav['log']->error('site.routes: ' . $pattern . '-> ' . $e->getMessage()); + } + } + } + + return $page; } /** * Dispatch URI to a page. * * @param string $route The relative URL of the page - * @param bool $all - * - * @param bool $redirect + * @param bool $all If true, return also non-routable pages, otherwise return null if page isn't routable + * @param bool $redirect If true, allow redirects * @return PageInterface|null - * @throws \Exception + * @throws Exception */ public function dispatch($route, $all = false, $redirect = true) { - $route = urldecode($route); + $page = $this->find($route, true); - // Fetch page if there's a defined route to it. - $page = isset($this->routes[$route]) ? $this->get($this->routes[$route]) : null; - // Try without trailing slash - if (!$page && Utils::endsWith($route, '/')) { - $page = isset($this->routes[rtrim($route, '/')]) ? $this->get($this->routes[rtrim($route, '/')]) : null; + // If we want all pages or are in admin, return what we already have. + if ($all || isset($this->grav['admin'])) { + return $page; } - // Are we in the admin? this is important! - $not_admin = !isset($this->grav['admin']); + if ($page) { + $routable = $page->routable(); + if ($redirect) { + if ($page->redirect()) { + // Follow a redirect page. + $this->grav->redirectLangSafe($page->redirect()); + } - // If the page cannot be reached, look into site wide redirects, routes + wildcards - if (!$all && $not_admin) { - - // If the page is a simple redirect, just do it. - if ($redirect && $page && $page->redirect()) { - $this->grav->redirectLangSafe($page->redirect()); + if (!$routable && ($child = $page->children()->visible()->routable()->published()->first()) !== null) { + // Redirect to the first visible child as current page isn't routable. + $this->grav->redirectLangSafe($child->route()); + } } - // fall back and check site based redirects - if (!$page || ($page && !$page->routable())) { - /** @var Config $config */ - $config = $this->grav['config']; + if ($routable) { + return $page; + } + } - // See if route matches one in the site configuration - $site_route = $config->get("site.routes.{$route}"); - if ($site_route) { - $page = $this->dispatch($site_route, $all); - } else { + $route = urldecode((string)$route); - /** @var Uri $uri */ - $uri = $this->grav['uri']; - /** @var \Grav\Framework\Uri\Uri $source_url */ - $source_url = $uri->uri(false); + // The page cannot be reached, look into site wide redirects, routes and wildcards. + $redirectedPage = $this->findSiteBasedRoute($route); + if ($redirectedPage) { + $page = $this->dispatch($redirectedPage->route(), false, $redirect); + } - // Try Regex style redirects - $site_redirects = $config->get('site.redirects'); - if (is_array($site_redirects)) { - foreach ((array)$site_redirects as $pattern => $replace) { - $pattern = '#^' . str_replace('/', '\/', ltrim($pattern, '^')) . '#'; - try { - $found = preg_replace($pattern, $replace, $source_url); - if ($found !== $source_url) { - $this->grav->redirectLangSafe($found); - } - } catch (ErrorException $e) { - $this->grav['log']->error('site.redirects: ' . $pattern . '-> ' . $e->getMessage()); - } - } - } - - // Try Regex style routes - $site_routes = $config->get('site.routes'); - if (is_array($site_routes)) { - foreach ((array)$site_routes as $pattern => $replace) { - $pattern = '#^' . str_replace('/', '\/', ltrim($pattern, '^')) . '#'; - try { - $found = preg_replace($pattern, $replace, $source_url); - if ($found !== $source_url) { - $page = $this->dispatch($found, $all); - } - } catch (ErrorException $e) { - $this->grav['log']->error('site.routes: ' . $pattern . '-> ' . $e->getMessage()); - } - } + /** @var Config $config */ + $config = $this->grav['config']; + + /** @var Uri $uri */ + $uri = $this->grav['uri']; + /** @var \Grav\Framework\Uri\Uri $source_url */ + $source_url = $uri->uri(false); + + // Try Regex style redirects + $site_redirects = $config->get('site.redirects'); + if (is_array($site_redirects)) { + foreach ((array)$site_redirects as $pattern => $replace) { + $pattern = ltrim($pattern, '^'); + $pattern = '#^' . str_replace('/', '\/', $pattern) . '#'; + try { + /** @var string $found */ + $found = preg_replace($pattern, $replace, $source_url); + if ($found && $found !== $source_url) { + $this->grav->redirectLangSafe($found); } + } catch (ErrorException $e) { + $this->grav['log']->error('site.redirects: ' . $pattern . '-> ' . $e->getMessage()); } } } @@ -581,20 +1031,26 @@ class Pages * Get root page. * * @return PageInterface + * @throws RuntimeException */ public function root() { /** @var UniformResourceLocator $locator */ $locator = $this->grav['locator']; - return $this->instances[rtrim($locator->findResource('page://'), DS)]; + $path = $locator->findResource('page://'); + $root = is_string($path) ? $this->get(rtrim($path, '/')) : null; + if (null === $root) { + throw new RuntimeException('Internal error'); + } + + return $root; } /** * Get a blueprint for a page type. * * @param string $type - * * @return Blueprint */ public function blueprints($type) @@ -605,13 +1061,13 @@ class Pages try { $blueprint = $this->blueprints->get($type); - } catch (\RuntimeException $e) { + } catch (RuntimeException $e) { $blueprint = $this->blueprints->get('default'); } if (empty($blueprint->initialized)) { - $this->grav->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $type])); $blueprint->initialized = true; + $this->grav->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $type])); } return $blueprint; @@ -620,9 +1076,8 @@ class Pages /** * Get all pages * - * @param PageInterface $current - * - * @return \Grav\Common\Page\Collection + * @param PageInterface|null $current + * @return Collection */ public function all(PageInterface $current = null) { @@ -658,7 +1113,6 @@ class Pages * Get available parents routes * * @param bool $rawRoutes get the raw route or the normal route - * * @return array */ private static function getParents($rawRoutes) @@ -681,19 +1135,17 @@ class Pages if (isset($parents[$page_route])) { unset($parents[$page_route]); } - } return $parents; } /** - * Get list of route/title of all pages. + * Get list of route/title of all pages. Title is in HTML. * - * @param PageInterface $current + * @param PageInterface|null $current * @param int $level * @param bool $rawRoutes - * * @param bool $showAll * @param bool $showFullpath * @param bool $showSlug @@ -705,7 +1157,7 @@ class Pages { if (!$current) { if ($level) { - throw new \RuntimeException('Internal error'); + throw new RuntimeException('Internal error'); } $current = $this->root(); @@ -721,22 +1173,18 @@ class Pages } if ($showFullpath) { - $option = $current->route(); + $option = htmlspecialchars($current->route()); } else { $extra = $showSlug ? '(' . $current->slug() . ') ' : ''; - $option = str_repeat('—-', $level). '▸ ' . $extra . $current->title(); - - + $option = str_repeat('—-', $level). '▸ ' . $extra . htmlspecialchars($current->title()); } $list[$route] = $option; - - } if ($limitLevels === false || ($level+1 < $limitLevels)) { foreach ($current->children() as $next) { - if ($showAll || $next->routable() || ($next->modular() && $showModular)) { + if ($showAll || $next->routable() || ($next->isModule() && $showModular)) { $list = array_merge($list, $this->getList($next, $level + 1, $rawRoutes, $showAll, $showFullpath, $showSlug, $showModular, $limitLevels)); } } @@ -752,23 +1200,38 @@ class Pages */ public static function getTypes() { - if (!self::$types) { + if (null === self::$types) { $grav = Grav::instance(); - $scanBlueprintsAndTemplates = function () use ($grav) { + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + // Prevent calls made before theme:// has been initialized (happens when upgrading old version of Admin plugin). + if (!$locator->isStream('theme://')) { + return new Types(); + } + + $scanBlueprintsAndTemplates = static function (Types $types) use ($grav) { // Scan blueprints $event = new Event(); - $event->types = self::$types; + $event->types = $types; $grav->fireEvent('onGetPageBlueprints', $event); - self::$types->scanBlueprints('theme://blueprints/'); + $types->init(); + + // Try new location first. + $lookup = 'theme://blueprints/pages/'; + if (!is_dir($lookup)) { + $lookup = 'theme://blueprints/'; + } + $types->scanBlueprints($lookup); // Scan templates $event = new Event(); - $event->types = self::$types; + $event->types = $types; $grav->fireEvent('onGetPageTemplates', $event); - self::$types->scanTemplates('theme://templates/'); + $types->scanTemplates('theme://templates/'); }; if ($grav['config']->get('system.cache.enabled')) { @@ -777,22 +1240,21 @@ class Pages // Use cached types if possible. $types_cache_id = md5('types'); - self::$types = $cache->fetch($types_cache_id); + $types = $cache->fetch($types_cache_id); - if (!self::$types) { - self::$types = new Types(); - $scanBlueprintsAndTemplates(); - $cache->save($types_cache_id, self::$types); + if (!$types instanceof Types) { + $types = new Types(); + $scanBlueprintsAndTemplates($types); + $cache->save($types_cache_id, $types); } - } else { - self::$types = new Types(); - $scanBlueprintsAndTemplates(); + $types = new Types(); + $scanBlueprintsAndTemplates($types); } // Register custom paths to the locator. $locator = $grav['locator']; - foreach (self::$types as $type => $paths) { + foreach ($types as $type => $paths) { foreach ($paths as $k => $path) { if (strpos($path, 'blueprints://') === 0) { unset($paths[$k]); @@ -802,6 +1264,8 @@ class Pages $locator->addPath('blueprints', "pages/$type.yaml", $paths); } } + + self::$types = $types; } return self::$types; @@ -834,22 +1298,26 @@ class Pages /** * Get template types based on page type (standard or modular) * + * @param string|null $type * @return array */ - public static function pageTypes() + public static function pageTypes($type = null) { - if (isset(Grav::instance()['admin'])) { + if (null === $type && isset(Grav::instance()['admin'])) { /** @var Admin $admin */ $admin = Grav::instance()['admin']; - /** @var PageInterface $page */ - $page = $admin->getPage($admin->route); + /** @var PageInterface|null $page */ + $page = $admin->page(); - if ($page && $page->modular()) { + $type = $page && $page->isModule() ? 'modular' : 'standard'; + } + + switch ($type) { + case 'standard': + return static::types(); + case 'modular': return static::modularTypes(); - } - - return static::types(); } return []; @@ -864,10 +1332,10 @@ class Pages { $accessLevels = []; foreach ($this->all() as $page) { - if (isset($page->header()->access)) { - if (\is_array($page->header()->access)) { + if ($page instanceof PageInterface && isset($page->header()->access)) { + if (is_array($page->header()->access)) { foreach ($page->header()->access as $index => $accessLevel) { - if (\is_array($accessLevel)) { + if (is_array($accessLevel)) { foreach ($accessLevel as $innerIndex => $innerAccessLevel) { $accessLevels[] = $innerIndex; } @@ -896,8 +1364,6 @@ class Pages return self::getParents($rawRoutes); } - - /** * Gets the home route * @@ -931,7 +1397,6 @@ class Pages } catch (ErrorException $e) { $home = $home_aliases[$default]; } - } } @@ -943,41 +1408,248 @@ class Pages /** * Needed for testing where we change the home route via config + * + * @return string|null */ public static function resetHomeRoute() { self::$home_route = null; + return self::getHomeRoute(); } + protected function initFlexPages(): void + { + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addMessage('Pages: Flex Directory'); + + /** @var Flex $flex */ + $flex = $this->grav['flex']; + $directory = $flex->getDirectory('pages'); + + /** @var EventDispatcher $dispatcher */ + $dispatcher = $this->grav['events']; + + // Stop /admin/pages from working, display error instead. + $dispatcher->addListener( + 'onAdminPage', + static function (Event $event) use ($directory) { + $grav = Grav::instance(); + $admin = $grav['admin']; + [$base,$location,] = $admin->getRouteDetails(); + if ($location !== 'pages' || isset($grav['flex_objects'])) { + return; + } + + /** @var PageInterface $page */ + $page = $event['page']; + $page->init(new SplFileInfo('plugin://admin/pages/admin/error.md')); + $page->routable(true); + $header = $page->header(); + $header->title = 'Please install missing plugin'; + $page->content("## Please install and enable **[Flex Objects]({$base}/plugins/flex-objects)** plugin. It is required to edit **Flex Pages**."); + + /** @var Header $header */ + $header = $page->header(); + $menu = $directory->getConfig('admin.menu.list'); + $header->access = $menu['authorize'] ?? ['admin.super']; + }, + 100000 + ); + + $this->directory = $directory; + } + /** * Builds pages. * * @internal */ - protected function buildPages() + protected function buildPages(): void { - $this->sort = []; + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->startTimer('build-pages', 'Init frontend routes'); + if ($this->directory) { + $this->buildFlexPages($this->directory); + } else { + $this->buildRegularPages(); + } + $debugger->stopTimer('build-pages'); + } + + protected function buildFlexPages(FlexDirectory $directory): void + { /** @var Config $config */ $config = $this->grav['config']; + // TODO: right now we are just emulating normal pages, it is inefficient and bad... but works! + /** @var PageCollection|PageIndex $collection */ + $collection = $directory->getIndex(null, 'storage_key'); + $cache = $directory->getCache('index'); + /** @var Language $language */ $language = $this->grav['language']; + $this->pages_cache_id = 'pages-' . md5($collection->getCacheChecksum() . $language->getActive() . $config->checksum()); + + $cached = $cache->get($this->pages_cache_id); + + if ($cached && $this->getVersion() === $cached[0]) { + [, $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort] = $cached; + + /** @var Taxonomy $taxonomy */ + $taxonomy = $this->grav['taxonomy']; + $taxonomy->taxonomy($taxonomy_map); + + return; + } + + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addMessage('Page cache missed, rebuilding Flex Pages..'); + + $root = $collection->getRoot(); + $root_path = $root->path(); + $this->routes = []; + $this->instances = [$root_path => $root]; + $this->index = [$root_path => $root]; + $this->children = []; + $this->sort = []; + + if ($this->fire_events) { + $this->grav->fireEvent('onBuildPagesInitialized'); + } + + /** @var PageInterface $page */ + foreach ($collection as $page) { + $path = $page->path(); + if (null === $path) { + throw new RuntimeException('Internal error'); + } + + if ($page instanceof FlexTranslateInterface) { + $page = $page->hasTranslation() ? $page->getTranslation() : null; + } + + if (!$page instanceof FlexPageObject || $path === $root_path) { + continue; + } + + if ($this->fire_events) { + if (method_exists($page, 'initialize')) { + $page->initialize(); + } else { + // TODO: Deprecated, only used in 1.7 betas. + $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page])); + } + } + + $parent = dirname($path); + + $route = $page->rawRoute(); + + // Skip duplicated empty folders (git revert does not remove those). + // TODO: still not perfect, will only work if the page has been translated. + if (isset($this->routes[$route])) { + $oldPath = $this->routes[$route]; + if ($page->isPage()) { + unset($this->index[$oldPath], $this->children[dirname($oldPath)][$oldPath]); + } else { + continue; + } + } + + $this->routes[$route] = $path; + $this->instances[$path] = $page; + $this->index[$path] = $page->getFlexKey(); + // FIXME: ... better... + $this->children[$parent][$path] = ['slug' => $page->slug()]; + if (!isset($this->children[$path])) { + $this->children[$path] = []; + } + } + + foreach ($this->children as $path => $list) { + $page = $this->instances[$path] ?? null; + if (null === $page) { + continue; + } + // Call onFolderProcessed event. + if ($this->fire_events) { + $this->grav->fireEvent('onFolderProcessed', new Event(['page' => $page])); + } + // Sort the children. + $this->children[$path] = $this->sort($page); + } + + $this->routes = []; + $this->buildRoutes(); + + // cache if needed + if (null !== $cache) { + /** @var Taxonomy $taxonomy */ + $taxonomy = $this->grav['taxonomy']; + $taxonomy_map = $taxonomy->taxonomy(); + + // save pages, routes, taxonomy, and sort to cache + $cache->set($this->pages_cache_id, [$this->getVersion(), $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort]); + } + } + + /** + * @return Page + */ + protected function buildRootPage() + { + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $path = $locator->findResource('page://'); + if (!is_string($path)) { + throw new RuntimeException('Internal Error'); + } + + /** @var Config $config */ + $config = $grav['config']; + + $page = new Page(); + $page->path($path); + $page->orderDir($config->get('system.pages.order.dir')); + $page->orderBy($config->get('system.pages.order.by')); + $page->modified(0); + $page->routable(false); + $page->template('default'); + $page->extension('.md'); + + return $page; + } + + protected function buildRegularPages(): void + { + /** @var Config $config */ + $config = $this->grav['config']; + /** @var UniformResourceLocator $locator */ $locator = $this->grav['locator']; + /** @var Language $language */ + $language = $this->grav['language']; + $pages_dir = $locator->findResource('page://'); + if (!is_string($pages_dir)) { + throw new RuntimeException('Internal Error'); + } // Set active language $this->active_lang = $language->getActive(); if ($config->get('system.cache.enabled')) { - /** @var Cache $cache */ - $cache = $this->grav['cache']; - /** @var Taxonomy $taxonomy */ - $taxonomy = $this->grav['taxonomy']; + /** @var Language $language */ + $language = $this->grav['language']; // how should we check for last modified? Default is by file switch ($this->check_method) { @@ -997,24 +1669,25 @@ class Pages $this->pages_cache_id = md5($pages_dir . $hash . $language->getActive() . $config->checksum()); + /** @var Cache $cache */ + $cache = $this->grav['cache']; $cached = $cache->fetch($this->pages_cache_id); - if ($cached) { - $this->grav['debugger']->addMessage('Page cache hit.'); + if ($cached && $this->getVersion() === $cached[0]) { + [, $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort] = $cached; - list($this->instances, $this->routes, $this->children, $taxonomy_map, $this->sort) = $cached; - - // If pages was found in cache, set the taxonomy + /** @var Taxonomy $taxonomy */ + $taxonomy = $this->grav['taxonomy']; $taxonomy->taxonomy($taxonomy_map); - } else { - $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..'); - // recurse pages and cache result - $this->resetPages($pages_dir); + return; } + + $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..'); } else { - $this->recurse($pages_dir); - $this->buildRoutes(); + $this->grav['debugger']->addMessage('Page cache disabled, rebuilding pages..'); } + + $this->resetPages($pages_dir); } /** @@ -1022,8 +1695,9 @@ class Pages * * @param string $pages_dir */ - public function resetPages($pages_dir) + public function resetPages($pages_dir): void { + $this->sort = []; $this->recurse($pages_dir); $this->buildRoutes(); @@ -1035,7 +1709,7 @@ class Pages $taxonomy = $this->grav['taxonomy']; // save pages, routes, taxonomy, and sort to cache - $cache->save($this->pages_cache_id, [$this->instances, $this->routes, $this->children, $taxonomy->taxonomy(), $this->sort]); + $cache->save($this->pages_cache_id, [$this->getVersion(), $this->index, $this->routes, $this->children, $taxonomy->taxonomy(), $this->sort]); } } @@ -1044,9 +1718,8 @@ class Pages * * @param string $directory * @param PageInterface|null $parent - * * @return PageInterface - * @throws \RuntimeException + * @throws RuntimeException * @internal */ protected function recurse($directory, PageInterface $parent = null) @@ -1062,7 +1735,7 @@ class Pages // Stuff to do at root page // Fire event for memory and time consuming plugins... - if ($parent === null && $config->get('system.pages.events.page')) { + if ($parent === null && $this->fire_events) { $this->grav->fireEvent('onBuildPagesInitialized'); } @@ -1075,13 +1748,14 @@ class Pages $page->orderBy($config->get('system.pages.order.by')); // Add into instances - if (!isset($this->instances[$page->path()])) { + if (!isset($this->index[$page->path()])) { + $this->index[$page->path()] = $page; $this->instances[$page->path()] = $page; if ($parent && $page->path()) { $this->children[$parent->path()][$page->path()] = ['slug' => $page->slug()]; } - } else { - throw new \RuntimeException('Fatal error when creating page instances.'); + } elseif ($parent !== null) { + throw new RuntimeException('Fatal error when creating page instances.'); } // Build regular expression for all the allowed page extensions. @@ -1098,8 +1772,7 @@ class Pages $page_extension = '.md'; $last_modified = 0; - $iterator = new \FilesystemIterator($directory); - /** @var \FilesystemIterator $file */ + $iterator = new FilesystemIterator($directory); foreach ($iterator as $file) { $filename = $file->getFilename(); @@ -1111,14 +1784,14 @@ class Pages // Handle folders later. if ($file->isDir()) { // But ignore all folders in ignore list. - if (!\in_array($filename, $this->ignore_folders, true)) { + if (!in_array($filename, $this->ignore_folders, true)) { $folders[] = $file; } continue; } // Ignore all files in ignore list. - if (\in_array($filename, $this->ignore_files, true)) { + if (in_array($filename, $this->ignore_files, true)) { continue; } @@ -1145,13 +1818,13 @@ class Pages $content_exists = true; - if ($config->get('system.pages.events.page')) { + if ($this->fire_events) { $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page])); } } // Now handle all the folders under the page. - /** @var \FilesystemIterator $file */ + /** @var FilesystemIterator $file */ foreach ($folders as $file) { $filename = $file->getFilename(); @@ -1167,20 +1840,20 @@ class Pages $path = $directory . DS . $filename; $child = $this->recurse($path, $page); - if (Utils::startsWith($filename, '_')) { + if (preg_match('/^(\d+\.)_/', $filename)) { $child->routable(false); + $child->modularTwig(true); } $this->children[$page->path()][$child->path()] = ['slug' => $child->slug()]; - if ($config->get('system.pages.events.page')) { + if ($this->fire_events) { $this->grav->fireEvent('onFolderProcessed', new Event(['page' => $page])); } } - if (!$content_exists) { - // Set routability to false if no page found + // Set routable to false if no page found $page->routable(false); // Hide empty folders if option set @@ -1202,7 +1875,7 @@ class Pages // Override the modified and ID so that it takes the latest change into account $page->modified($last_modified); - $page->id($last_modified . md5($page->filePath())); + $page->id($last_modified . md5($page->filePath() ?? '')); // Sort based on Defaults or Page Overridden sort order $this->children[$page->path()] = $this->sort($page); @@ -1213,54 +1886,68 @@ class Pages /** * @internal */ - protected function buildRoutes() + protected function buildRoutes(): void { /** @var Taxonomy $taxonomy */ $taxonomy = $this->grav['taxonomy']; // Get the home route $home = self::resetHomeRoute(); - // Build routes and taxonomy map. - /** @var PageInterface $page */ - foreach ($this->instances as $page) { - if (!$page->root()) { - // process taxonomy - $taxonomy->addTaxonomy($page); + /** @var PageInterface|string $page */ + foreach ($this->index as $path => $page) { + if (is_string($page)) { + $page = $this->get($path); + } - $route = $page->route(); - $raw_route = $page->rawRoute(); - $page_path = $page->path(); + if (!$page || $page->root()) { + continue; + } - // add regular route + // process taxonomy + $taxonomy->addTaxonomy($page); + + $page_path = $page->path(); + if (null === $page_path) { + throw new RuntimeException('Internal Error'); + } + + $route = $page->route(); + $raw_route = $page->rawRoute(); + + // add regular route + if ($route) { $this->routes[$route] = $page_path; + } - // add raw route - if ($raw_route !== $route) { - $this->routes[$raw_route] = $page_path; - } + // add raw route + if ($raw_route && $raw_route !== $route) { + $this->routes[$raw_route] = $page_path; + } - // add canonical route - $route_canonical = $page->routeCanonical(); - if ($route_canonical && ($route !== $route_canonical)) { - $this->routes[$route_canonical] = $page_path; - } + // add canonical route + $route_canonical = $page->routeCanonical(); + if ($route_canonical && $route !== $route_canonical) { + $this->routes[$route_canonical] = $page_path; + } - // add aliases to routes list if they are provided - $route_aliases = $page->routeAliases(); - if ($route_aliases) { - foreach ($route_aliases as $alias) { - $this->routes[$alias] = $page_path; - } + // add aliases to routes list if they are provided + $route_aliases = $page->routeAliases(); + if ($route_aliases) { + foreach ($route_aliases as $alias) { + $this->routes[$alias] = $page_path; } } } // Alias and set default route to home page. - $homeRoute = '/' . $home; + $homeRoute = "/{$home}"; if ($home && isset($this->routes[$homeRoute])) { - $this->routes['/'] = $this->routes[$homeRoute]; - $this->get($this->routes[$homeRoute])->route('/'); + $home = $this->get($this->routes[$homeRoute]); + if ($home) { + $this->routes['/'] = $this->routes[$homeRoute]; + $home->route('/'); + } } } @@ -1270,15 +1957,14 @@ class Pages * @param string $order_by * @param array|null $manual * @param int|null $sort_flags - * - * @throws \RuntimeException + * @throws RuntimeException * @internal */ - protected function buildSort($path, array $pages, $order_by = 'default', $manual = null, $sort_flags = null) + protected function buildSort($path, array $pages, $order_by = 'default', $manual = null, $sort_flags = null): void { $list = []; + $header_query = null; $header_default = null; - $header_query = []; // do this header query work only once if (strpos($order_by, 'header.') === 0) { @@ -1288,9 +1974,9 @@ class Pages } foreach ($pages as $key => $info) { - $child = $this->instances[$key] ?? null; + $child = $this->get($key); if (!$child) { - throw new \RuntimeException("Page does not exist: {$key}"); + throw new RuntimeException("Page does not exist: {$key}"); } switch ($order_by) { @@ -1325,24 +2011,24 @@ class Pages case 'manual': case 'default': default: - if (is_string($header_query)) { - $child_header = $child->header(); - if (!$child_header instanceof Header) { - $child_header = new Header((array)$child_header); - } - $header_value = $child_header->get($header_query); - if (is_array($header_value)) { - $list[$key] = implode(',', $header_value); - } elseif ($header_value) { - $list[$key] = $header_value; - } else { - $list[$key] = $header_default ?: $key; + if (is_string($header_query)) { + $child_header = $child->header(); + if (!$child_header instanceof Header) { + $child_header = new Header((array)$child_header); + } + $header_value = $child_header->get($header_query); + if (is_array($header_value)) { + $list[$key] = implode(',', $header_value); + } elseif ($header_value) { + $list[$key] = $header_value; + } else { + $list[$key] = $header_default ?: $key; + } + $sort_flags = $sort_flags ?: SORT_REGULAR; + break; } + $list[$key] = $key; $sort_flags = $sort_flags ?: SORT_REGULAR; - break; - } - $list[$key] = $key; - $sort_flags = $sort_flags ?: SORT_REGULAR; } } @@ -1356,13 +2042,17 @@ class Pages } else { // else just sort the list according to specified key if (extension_loaded('intl') && $this->grav['config']->get('system.intl_enabled')) { - $locale = setlocale(LC_COLLATE, 0); //`setlocale` with a 0 param returns the current locale set + $locale = setlocale(LC_COLLATE, '0'); //`setlocale` with a '0' param returns the current locale set $col = Collator::create($locale); if ($col) { + $col->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON); if (($sort_flags & SORT_NATURAL) === SORT_NATURAL) { - $list = preg_replace_callback('~([0-9]+)\.~', function($number) { + $list = preg_replace_callback('~([0-9]+)\.~', static function ($number) { return sprintf('%032d.', $number[0]); }, $list); + if (!is_array($list)) { + throw new RuntimeException('Internal Error'); + } $list_vals = array_values($list); if (is_numeric(array_shift($list_vals))) { @@ -1389,7 +2079,7 @@ class Pages foreach ($list as $key => $dummy) { $info = $pages[$key]; - $order = \array_search($info['slug'], $manual, true); + $order = array_search($info['slug'], $manual, true); if ($order === false) { $order = $i++; } @@ -1399,7 +2089,7 @@ class Pages $list = $new_list; // Apply manual ordering to the list. - asort($list); + asort($list, SORT_NUMERIC); } foreach ($list as $key => $sort) { @@ -1412,7 +2102,6 @@ class Pages * Shuffles an associative array * * @param array $list - * * @return array */ protected function arrayShuffle($list) @@ -1428,13 +2117,21 @@ class Pages return $new; } + /** + * @return string + */ + protected function getVersion() + { + return $this->directory ? 'flex' : 'regular'; + } + /** * Get the Pages cache ID * * this is particularly useful to know if pages have changed and you want * to sync another cache with pages cache - works best in `onPagesInitialized()` * - * @return mixed + * @return string */ public function getPagesCacheId() { diff --git a/system/src/Grav/Common/Page/Traits/PageFormTrait.php b/system/src/Grav/Common/Page/Traits/PageFormTrait.php new file mode 100644 index 0000000..b99e7b7 --- /dev/null +++ b/system/src/Grav/Common/Page/Traits/PageFormTrait.php @@ -0,0 +1,126 @@ + blueprint, ...], where blueprint follows the regular form blueprint format. + * + * @return array + */ + public function getForms(): array + { + if (null === $this->_forms) { + $header = $this->header(); + + // Call event to allow filling the page header form dynamically (e.g. use case: Comments plugin) + $grav = Grav::instance(); + $grav->fireEvent('onFormPageHeaderProcessed', new Event(['page' => $this, 'header' => $header])); + + $rules = $header->rules ?? null; + if (!is_array($rules)) { + $rules = []; + } + + $forms = []; + + // First grab page.header.form + $form = $this->normalizeForm($header->form ?? null, null, $rules); + if ($form) { + $forms[$form['name']] = $form; + } + + // Append page.header.forms (override singular form if it clashes) + $headerForms = $header->forms ?? null; + if (is_array($headerForms)) { + foreach ($headerForms as $name => $form) { + $form = $this->normalizeForm($form, $name, $rules); + if ($form) { + $forms[$form['name']] = $form; + } + } + } + + $this->_forms = $forms; + } + + return $this->_forms; + } + + /** + * Add forms to this page. + * + * @param array $new + * @param bool $override + * @return $this + */ + public function addForms(array $new, $override = true) + { + // Initialize forms. + $this->forms(); + + foreach ($new as $name => $form) { + $form = $this->normalizeForm($form, $name); + $name = $form['name'] ?? null; + if ($name && ($override || !isset($this->_forms[$name]))) { + $this->_forms[$name] = $form; + } + } + + return $this; + } + + /** + * Alias of $this->getForms(); + * + * @return array + */ + public function forms(): array + { + return $this->getForms(); + } + + /** + * @param array|null $form + * @param string|null $name + * @param array $rules + * @return array|null + */ + protected function normalizeForm($form, $name = null, array $rules = []): ?array + { + if (!is_array($form)) { + return null; + } + + // Ignore numeric indexes on name. + if (!$name || (string)(int)$name === (string)$name) { + $name = null; + } + + $name = $name ?? $form['name'] ?? $this->slug(); + + $formRules = $form['rules'] ?? null; + if (!is_array($formRules)) { + $formRules = []; + } + + return ['name' => $name, 'rules' => $rules + $formRules] + $form; + } + + abstract public function header($var = null); + abstract public function slug($var = null); +} diff --git a/system/src/Grav/Common/Page/Types.php b/system/src/Grav/Common/Page/Types.php index 03e3f6e..c718368 100644 --- a/system/src/Grav/Common/Page/Types.php +++ b/system/src/Grav/Common/Page/Types.php @@ -3,38 +3,52 @@ /** * @package Grav\Common\Page * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Page; +use Grav\Common\Data\Blueprint; use Grav\Common\Filesystem\Folder; use Grav\Common\Grav; +use InvalidArgumentException; use RocketTheme\Toolbox\ArrayTraits\ArrayAccess; use RocketTheme\Toolbox\ArrayTraits\Constructor; use RocketTheme\Toolbox\ArrayTraits\Countable; use RocketTheme\Toolbox\ArrayTraits\Export; use RocketTheme\Toolbox\ArrayTraits\Iterator; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use function is_string; +/** + * Class Types + * @package Grav\Common\Page + */ class Types implements \ArrayAccess, \Iterator, \Countable { use ArrayAccess, Constructor, Iterator, Countable, Export; + /** @var array */ protected $items; - protected $systemBlueprints; + /** @var array */ + protected $systemBlueprints = []; + /** + * @param string $type + * @param Blueprint|null $blueprint + * @return void + */ public function register($type, $blueprint = null) { if (!isset($this->items[$type])) { $this->items[$type] = []; - } elseif (!$blueprint) { + } elseif (null === $blueprint) { return; } - if (!$blueprint && $this->systemBlueprints) { - $blueprint = $this->systemBlueprints[$type] ?? $this->systemBlueprints['default']; + if (null === $blueprint) { + $blueprint = $this->systemBlueprints[$type] ?? $this->systemBlueprints['default'] ?? null; } if ($blueprint) { @@ -42,19 +56,28 @@ class Types implements \ArrayAccess, \Iterator, \Countable } } + /** + * @return void + */ + public function init() + { + if (empty($this->systemBlueprints)) { + // Register all blueprints from the blueprints stream. + $this->systemBlueprints = $this->findBlueprints('blueprints://pages'); + foreach ($this->systemBlueprints as $type => $blueprint) { + $this->register($type); + } + } + } + + /** + * @param string $uri + * @return void + */ public function scanBlueprints($uri) { - if (!\is_string($uri)) { - throw new \InvalidArgumentException('First parameter must be URI'); - } - - if (!$this->systemBlueprints) { - $this->systemBlueprints = $this->findBlueprints('blueprints://pages'); - - // Register default by default. - $this->register('default'); - - $this->register('external'); + if (!is_string($uri)) { + throw new InvalidArgumentException('First parameter must be URI'); } foreach ($this->findBlueprints($uri) as $type => $blueprint) { @@ -62,10 +85,14 @@ class Types implements \ArrayAccess, \Iterator, \Countable } } + /** + * @param string $uri + * @return void + */ public function scanTemplates($uri) { - if (!\is_string($uri)) { - throw new \InvalidArgumentException('First parameter must be URI'); + if (!is_string($uri)) { + throw new InvalidArgumentException('First parameter must be URI'); } $options = [ @@ -90,6 +117,9 @@ class Types implements \ArrayAccess, \Iterator, \Countable } } + /** + * @return array + */ public function pageSelect() { $list = []; @@ -104,6 +134,9 @@ class Types implements \ArrayAccess, \Iterator, \Countable return $list; } + /** + * @return array + */ public function modularSelect() { $list = []; @@ -118,6 +151,10 @@ class Types implements \ArrayAccess, \Iterator, \Countable return $list; } + /** + * @param string $uri + * @return array + */ private function findBlueprints($uri) { $options = [ diff --git a/system/src/Grav/Common/Plugin.php b/system/src/Grav/Common/Plugin.php index 727fd95..65b11db 100644 --- a/system/src/Grav/Common/Plugin.php +++ b/system/src/Grav/Common/Plugin.php @@ -3,43 +3,44 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; +use ArrayAccess; use Grav\Common\Data\Blueprint; use Grav\Common\Data\Data; use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Config\Config; -use RocketTheme\Toolbox\Event\EventDispatcher; -use RocketTheme\Toolbox\Event\EventSubscriberInterface; +use LogicException; use RocketTheme\Toolbox\File\YamlFile; +use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use function defined; +use function is_bool; +use function is_string; -class Plugin implements EventSubscriberInterface, \ArrayAccess +/** + * Class Plugin + * @package Grav\Common + */ +class Plugin implements EventSubscriberInterface, ArrayAccess { - /** - * @var string - */ + /** @var string */ public $name; - - /** - * @var array - */ + /** @var array */ public $features = []; - /** - * @var Grav - */ + /** @var Grav */ protected $grav; - - /** - * @var Config - */ + /** @var Config|null */ protected $config; - + /** @var bool */ protected $active = true; + /** @var Blueprint|null */ protected $blueprint; /** @@ -49,7 +50,7 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess */ public static function getSubscribedEvents() { - $methods = get_class_methods(get_called_class()); + $methods = get_class_methods(static::class); $list = []; foreach ($methods as $method) { @@ -66,12 +67,13 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess * * @param string $name * @param Grav $grav - * @param Config $config + * @param Config|null $config */ public function __construct($name, Grav $grav, Config $config = null) { $this->name = $name; $this->grav = $grav; + if ($config) { $this->setConfig($config); } @@ -95,7 +97,7 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess */ public function config() { - return $this->config["plugins.{$this->name}"]; + return null !== $this->config ? $this->config["plugins.{$this->name}"] : []; } /** @@ -115,7 +117,7 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess */ public function isCli() { - return \defined('GRAV_CLI'); + return defined('GRAV_CLI'); } /** @@ -126,21 +128,25 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess */ protected function isPluginActiveAdmin($plugin_route) { - $should_run = false; + $active = false; + /** @var Uri $uri */ $uri = $this->grav['uri']; + /** @var Config $config */ + $config = $this->config ?? $this->grav['config']; - if (strpos($uri->path(), $this->config->get('plugins.admin.route') . '/' . $plugin_route) === false) { - $should_run = false; + if (strpos($uri->path(), $config->get('plugins.admin.route') . '/' . $plugin_route) === false) { + $active = false; } elseif (isset($uri->paths()[1]) && $uri->paths()[1] === $plugin_route) { - $should_run = true; + $active = true; } - return $should_run; + return $active; } /** * @param array $events + * @return void */ protected function enable(array $events) { @@ -148,9 +154,9 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess $dispatcher = $this->grav['events']; foreach ($events as $eventName => $params) { - if (\is_string($params)) { + if (is_string($params)) { $dispatcher->addListener($eventName, [$this, $params]); - } elseif (\is_string($params[0])) { + } elseif (is_string($params[0])) { $dispatcher->addListener($eventName, [$this, $params[0]], $this->getPriority($params, $eventName)); } else { foreach ($params as $listener) { @@ -163,22 +169,18 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess /** * @param array $params * @param string $eventName + * @return int */ private function getPriority($params, $eventName) { - $grav = Grav::instance(); - $override = implode('.', ["priorities", $this->name, $eventName, $params[0]]); - if ($grav['config']->get($override) !== null) - { - return $grav['config']->get($override); - } elseif (isset($params[1])) { - return $params[1]; - } - return 0; + $override = implode('.', ['priorities', $this->name, $eventName, $params[0]]); + + return $this->grav['config']->get($override) ?? $params[1] ?? 0; } /** * @param array $events + * @return void */ protected function disable(array $events) { @@ -186,9 +188,9 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess $dispatcher = $this->grav['events']; foreach ($events as $eventName => $params) { - if (\is_string($params)) { + if (is_string($params)) { $dispatcher->removeListener($eventName, [$this, $params]); - } elseif (\is_string($params[0])) { + } elseif (is_string($params[0])) { $dispatcher->removeListener($eventName, [$this, $params[0]]); } else { foreach ($params as $listener) { @@ -206,12 +208,13 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess */ public function offsetExists($offset) { - $this->loadBlueprint(); - if ($offset === 'title') { $offset = 'name'; } - return isset($this->blueprint[$offset]); + + $blueprint = $this->getBlueprint(); + + return isset($blueprint[$offset]); } /** @@ -222,12 +225,13 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess */ public function offsetGet($offset) { - $this->loadBlueprint(); - if ($offset === 'title') { $offset = 'name'; } - return $this->blueprint[$offset] ?? null; + + $blueprint = $this->getBlueprint(); + + return $blueprint[$offset] ?? null; } /** @@ -235,22 +239,35 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess * * @param string $offset The offset to assign the value to. * @param mixed $value The value to set. - * @throws \LogicException + * @throws LogicException */ public function offsetSet($offset, $value) { - throw new \LogicException(__CLASS__ . ' blueprints cannot be modified.'); + throw new LogicException(__CLASS__ . ' blueprints cannot be modified.'); } /** * Unsets an offset. * * @param string $offset The offset to unset. - * @throws \LogicException + * @throws LogicException */ public function offsetUnset($offset) { - throw new \LogicException(__CLASS__ . ' blueprints cannot be modified.'); + throw new LogicException(__CLASS__ . ' blueprints cannot be modified.'); + } + + /** + * @return array + */ + public function __debugInfo(): array + { + $array = (array)$this; + + unset($array["\0*\0grav"]); + $array["\0*\0config"] = $this->config(); + + return $array; } /** @@ -263,40 +280,46 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess * @param string $content The string to perform operations upon * @param callable $function The anonymous callback function * @param string $internal_regex Optional internal regex to extra data from - * * @return string */ protected function parseLinks($content, $function, $internal_regex = '(.*)') { - $regex = '/\[plugin:(?:' . $this->name . ')\]\(' . $internal_regex . '\)/i'; + $regex = '/\[plugin:(?:' . preg_quote($this->name, '/') . ')\]\(' . $internal_regex . '\)/i'; - return preg_replace_callback($regex, $function, $content); + $result = preg_replace_callback($regex, $function, $content); + \assert($result !== null); + + return $result; } /** * Merge global and page configurations. * + * WARNING: This method modifies page header! + * * @param PageInterface $page The page to merge the configurations with the * plugin settings. * @param mixed $deep false = shallow|true = recursive|merge = recursive+unique * @param array $params Array of additional configuration options to * merge with the plugin settings. * @param string $type Is this 'plugins' or 'themes' - * * @return Data */ protected function mergeConfig(PageInterface $page, $deep = false, $params = [], $type = 'plugins') { + /** @var Config $config */ + $config = $this->config ?? $this->grav['config']; + $class_name = $this->name; $class_name_merged = $class_name . '.merged'; - $defaults = $this->config->get($type . '.' . $class_name, []); + $defaults = $config->get($type . '.' . $class_name, []); $page_header = $page->header(); $header = []; if (!isset($page_header->{$class_name_merged}) && isset($page_header->{$class_name})) { // Get default plugin configurations and retrieve page header configuration $config = $page_header->{$class_name}; - if (\is_bool($config)) { + if (is_bool($config)) { // Overwrite enabled option with boolean value in page header $config = ['enabled' => $config]; } @@ -342,23 +365,26 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess /** * Persists to disk the plugin parameters currently stored in the Grav Config object * - * @param string $plugin_name The name of the plugin whose config it should store. - * + * @param string $name The name of the plugin whose config it should store. * @return bool */ - public static function saveConfig($plugin_name) + public static function saveConfig($name) { - if (!$plugin_name) { + if (!$name) { return false; } $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ $locator = $grav['locator']; - $filename = 'config://plugins/' . $plugin_name . '.yaml'; - $file = YamlFile::instance($locator->findResource($filename, true, true)); - $content = $grav['config']->get('plugins.' . $plugin_name); + + $filename = 'config://plugins/' . $name . '.yaml'; + $file = YamlFile::instance((string)$locator->findResource($filename, true, true)); + $content = $grav['config']->get('plugins.' . $name); $file->save($content); $file->free(); + unset($file); return true; } @@ -370,21 +396,28 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess */ public function getBlueprint() { - if (!$this->blueprint) { + if (null === $this->blueprint) { $this->loadBlueprint(); + \assert($this->blueprint instanceof Blueprint); } + return $this->blueprint; } /** * Load blueprints. + * + * @return void */ protected function loadBlueprint() { - if (!$this->blueprint) { + if (null === $this->blueprint) { $grav = Grav::instance(); + /** @var Plugins $plugins */ $plugins = $grav['plugins']; - $this->blueprint = $plugins->get($this->name)->blueprints(); + $data = $plugins->get($this->name); + \assert($data !== null); + $this->blueprint = $data->blueprints(); } } } diff --git a/system/src/Grav/Common/Plugins.php b/system/src/Grav/Common/Plugins.php index 358727f..cc6f7ea 100644 --- a/system/src/Grav/Common/Plugins.php +++ b/system/src/Grav/Common/Plugins.php @@ -3,23 +3,40 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; +use Exception; use Grav\Common\Config\Config; use Grav\Common\Data\Blueprints; use Grav\Common\Data\Data; use Grav\Common\File\CompiledYamlFile; -use RocketTheme\Toolbox\Event\EventDispatcher; +use Grav\Events\PluginsLoadedEvent; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use SplFileInfo; +use Symfony\Component\EventDispatcher\EventDispatcher; +use function get_class; +use function is_object; +/** + * Class Plugins + * @package Grav\Common + */ class Plugins extends Iterator { + /** @var array|null */ public $formFieldTypes; + /** @var bool */ + private $plugins_initialized = false; + + /** + * Plugins constructor. + */ public function __construct() { parent::__construct(); @@ -30,17 +47,21 @@ class Plugins extends Iterator $iterator = $locator->getIterator('plugins://'); $plugins = []; - foreach($iterator as $directory) { + /** @var SplFileInfo $directory */ + foreach ($iterator as $directory) { if (!$directory->isDir()) { continue; } $plugins[] = $directory->getFilename(); } - natsort($plugins); + sort($plugins, SORT_NATURAL | SORT_FLAG_CASE); foreach ($plugins as $plugin) { - $this->add($this->loadPlugin($plugin)); + $object = $this->loadPlugin($plugin); + if ($object) { + $this->add($object); + } } } @@ -52,28 +73,36 @@ class Plugins extends Iterator $blueprints = []; $formFields = []; + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + /** @var Plugin $plugin */ foreach ($this->items as $plugin) { - if (isset($plugin->features['blueprints'])) { - $blueprints["plugin://{$plugin->name}/blueprints"] = $plugin->features['blueprints']; - } - if (method_exists($plugin, 'getFormFieldTypes')) { - $formFields[get_class($plugin)] = isset($plugin->features['formfields']) ? $plugin->features['formfields'] : 0; + // Setup only enabled plugins. + if ($config["plugins.{$plugin->name}.enabled"] && $plugin instanceof Plugin) { + if (isset($plugin->features['blueprints'])) { + $blueprints["plugin://{$plugin->name}/blueprints"] = $plugin->features['blueprints']; + } + if (method_exists($plugin, 'getFormFieldTypes')) { + $formFields[get_class($plugin)] = $plugin->features['formfields'] ?? 0; + } } } if ($blueprints) { // Order by priority. - arsort($blueprints); + arsort($blueprints, SORT_NUMERIC); /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - $locator->addPath('blueprints', '', array_keys($blueprints), 'system/blueprints'); + $locator = $grav['locator']; + $locator->addPath('blueprints', '', array_keys($blueprints), ['system', 'blueprints']); } if ($formFields) { // Order by priority. - arsort($formFields); + arsort($formFields, SORT_NUMERIC); $list = []; foreach ($formFields as $className => $priority) { @@ -91,10 +120,14 @@ class Plugins extends Iterator * Registers all plugins. * * @return Plugin[] array of Plugin objects - * @throws \RuntimeException + * @throws RuntimeException */ public function init() { + if ($this->plugins_initialized) { + return $this->items; + } + $grav = Grav::instance(); /** @var Config $config */ @@ -106,11 +139,23 @@ class Plugins extends Iterator foreach ($this->items as $instance) { // Register only enabled plugins. if ($config["plugins.{$instance->name}.enabled"] && $instance instanceof Plugin) { + // Set plugin configuration. $instance->setConfig($config); + // Register autoloader. + if (method_exists($instance, 'autoload')) { + $instance->autoload(); + } + // Register event listeners. $events->addSubscriber($instance); } } + // Plugins Loaded Event + $event = new PluginsLoadedEvent($grav, $this); + $grav->dispatchEvent($event); + + $this->plugins_initialized = true; + return $this->items; } @@ -118,6 +163,7 @@ class Plugins extends Iterator * Add a plugin * * @param Plugin $plugin + * @return void */ public function add($plugin) { @@ -126,14 +172,55 @@ class Plugins extends Iterator } } + /** + * @return array + */ + public function __debugInfo(): array + { + $array = (array)$this; + + unset($array["\0Grav\Common\Iterator\0iteratorUnset"]); + + return $array; + } + + /** + * @return Plugin[] Index of all plugins by plugin name. + */ + public static function getPlugins(): array + { + /** @var Plugins $plugins */ + $plugins = Grav::instance()['plugins']; + + $list = []; + foreach ($plugins as $instance) { + $list[$instance->name] = $instance; + } + + return $list; + } + + /** + * @param string $name Plugin name + * @return Plugin|null Plugin object or null if plugin cannot be found. + */ + public static function getPlugin(string $name) + { + $list = static::getPlugins(); + + return $list[$name] ?? null; + } + /** * Return list of all plugin data with their blueprints. * - * @return array + * @return Data[] */ public static function all() { $grav = Grav::instance(); + + /** @var Plugins $plugins */ $plugins = $grav['plugins']; $list = []; @@ -142,8 +229,8 @@ class Plugins extends Iterator try { $result = self::get($name); - } catch (\Exception $e) { - $exception = new \RuntimeException(sprintf('Plugin %s: %s', $name, $e->getMessage()), $e->getCode(), $e); + } catch (Exception $e) { + $exception = new RuntimeException(sprintf('Plugin %s: %s', $name, $e->getMessage()), $e->getCode(), $e); /** @var Debugger $debugger */ $debugger = $grav['debugger']; @@ -165,7 +252,6 @@ class Plugins extends Iterator * Get a plugin by name * * @param string $name - * * @return Data|null */ public static function get($name) @@ -193,36 +279,43 @@ class Plugins extends Iterator return $obj; } + /** + * @param string $name + * @return Plugin|null + */ protected function loadPlugin($name) { + // NOTE: ALL THE LOCAL VARIABLES ARE USED INSIDE INCLUDED FILE, DO NOT REMOVE THEM! $grav = Grav::instance(); $locator = $grav['locator']; - $file = $locator->findResource('plugins://' . $name . DS . $name . PLUGIN_EXT); if (is_file($file)) { - // Local variables available in the file: $grav, $config, $name, $file + // Local variables available in the file: $grav, $name, $file $class = include_once $file; - $pluginClassFormat = [ - 'Grav\\Plugin\\' . ucfirst($name). 'Plugin', - 'Grav\\Plugin\\' . Inflector::camelize($name) . 'Plugin' - ]; + if (!$class || !is_subclass_of($class, Plugin::class, true)) { + $className = Inflector::camelize($name); + $pluginClassFormat = [ + 'Grav\\Plugin\\' . ucfirst($name). 'Plugin', + 'Grav\\Plugin\\' . $className . 'Plugin', + 'Grav\\Plugin\\' . $className + ]; - foreach ($pluginClassFormat as $pluginClass) { - if (class_exists($pluginClass)) { - $class = new $pluginClass($name, $grav); - break; + foreach ($pluginClassFormat as $pluginClass) { + if (is_subclass_of($pluginClass, Plugin::class, true)) { + $class = new $pluginClass($name, $grav); + break; + } } } } else { $grav['log']->addWarning( - sprintf("Plugin '%s' enabled but not found! Try clearing cache with `bin/grav clear-cache`", $name) + sprintf("Plugin '%s' enabled but not found! Try clearing cache with `bin/grav clearcache`", $name) ); return null; } return $class; } - } diff --git a/system/src/Grav/Common/Processors/AssetsProcessor.php b/system/src/Grav/Common/Processors/AssetsProcessor.php index 8b46401..66bb5e3 100644 --- a/system/src/Grav/Common/Processors/AssetsProcessor.php +++ b/system/src/Grav/Common/Processors/AssetsProcessor.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,12 +13,23 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Class AssetsProcessor + * @package Grav\Common\Processors + */ class AssetsProcessor extends ProcessorBase { + /** @var string */ public $id = '_assets'; + /** @var string */ public $title = 'Assets'; - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->startTimer(); $this->container['assets']->init(); diff --git a/system/src/Grav/Common/Processors/BackupsProcessor.php b/system/src/Grav/Common/Processors/BackupsProcessor.php index d2a822c..6e960b4 100644 --- a/system/src/Grav/Common/Processors/BackupsProcessor.php +++ b/system/src/Grav/Common/Processors/BackupsProcessor.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,12 +13,23 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Class BackupsProcessor + * @package Grav\Common\Processors + */ class BackupsProcessor extends ProcessorBase { + /** @var string */ public $id = '_backups'; + /** @var string */ public $title = 'Backups'; - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->startTimer(); $backups = $this->container['backups']; diff --git a/system/src/Grav/Common/Processors/ConfigurationProcessor.php b/system/src/Grav/Common/Processors/ConfigurationProcessor.php deleted file mode 100644 index 83ceb84..0000000 --- a/system/src/Grav/Common/Processors/ConfigurationProcessor.php +++ /dev/null @@ -1,30 +0,0 @@ -startTimer(); - $this->container['config']->init(); - $this->container['plugins']->setup(); - $this->stopTimer(); - - return $handler->handle($request); - } -} diff --git a/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php b/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php index 11a39e8..de7cafc 100644 --- a/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php +++ b/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php @@ -3,29 +3,38 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Processors; -use Grav\Framework\Psr7\Response; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Class DebuggerAssetsProcessor + * @package Grav\Common\Processors + */ class DebuggerAssetsProcessor extends ProcessorBase { + /** @var string */ public $id = 'debugger_assets'; + /** @var string */ public $title = 'Debugger Assets'; - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->startTimer(); $this->container['debugger']->addAssets(); $this->stopTimer(); return $handler->handle($request); - } } diff --git a/system/src/Grav/Common/Processors/DebuggerProcessor.php b/system/src/Grav/Common/Processors/DebuggerProcessor.php deleted file mode 100644 index 892af55..0000000 --- a/system/src/Grav/Common/Processors/DebuggerProcessor.php +++ /dev/null @@ -1,29 +0,0 @@ -startTimer(); - $this->container['debugger']->init(); - $this->stopTimer(); - - return $handler->handle($request); - } -} diff --git a/system/src/Grav/Common/Processors/ErrorsProcessor.php b/system/src/Grav/Common/Processors/ErrorsProcessor.php deleted file mode 100644 index b0ef6ca..0000000 --- a/system/src/Grav/Common/Processors/ErrorsProcessor.php +++ /dev/null @@ -1,29 +0,0 @@ -startTimer(); - $this->container['errors']->resetHandlers(); - $this->stopTimer(); - - return $handler->handle($request); - } -} diff --git a/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php b/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php index fd97e9e..32908ba 100644 --- a/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php +++ b/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -16,6 +16,10 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use RocketTheme\Toolbox\Event\Event; +/** + * Class RequestHandlerEvent + * @package Grav\Common\Processors\Events + */ class RequestHandlerEvent extends Event { /** diff --git a/system/src/Grav/Common/Processors/InitializeProcessor.php b/system/src/Grav/Common/Processors/InitializeProcessor.php index eca7c54..cc8e4f2 100644 --- a/system/src/Grav/Common/Processors/InitializeProcessor.php +++ b/system/src/Grav/Common/Processors/InitializeProcessor.php @@ -3,24 +3,44 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Processors; use Grav\Common\Config\Config; +use Grav\Common\Debugger; +use Grav\Common\Errors\Errors; use Grav\Common\Grav; +use Grav\Common\Page\Pages; +use Grav\Common\Plugins; +use Grav\Common\Session; use Grav\Common\Uri; use Grav\Common\Utils; +use Grav\Framework\File\Formatter\YamlFormatter; +use Grav\Framework\File\YamlFile; +use Grav\Framework\Psr7\Response; use Grav\Framework\Session\Exceptions\SessionException; +use Monolog\Formatter\LineFormatter; +use Monolog\Handler\SyslogHandler; +use Monolog\Logger; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use function defined; +use function in_array; +/** + * Class InitializeProcessor + * @package Grav\Common\Processors + */ class InitializeProcessor extends ProcessorBase { - public $id = 'init'; + /** @var string */ + public $id = '_init'; + /** @var string */ public $title = 'Initialize'; /** @var bool */ @@ -28,6 +48,7 @@ class InitializeProcessor extends ProcessorBase /** * @param Grav $grav + * @return void */ public static function initializeCli(Grav $grav) { @@ -39,13 +60,283 @@ class InitializeProcessor extends ProcessorBase } } - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $this->startTimer(); + $this->startTimer('_init', 'Initialize'); + + // Load configuration. + $config = $this->initializeConfig(); + + // Initialize logger. + $this->initializeLogger($config); + + // Initialize error handlers. + $this->initializeErrors(); + + // Initialize debugger. + $debugger = $this->initializeDebugger(); + + // Debugger can return response right away. + $response = $this->handleDebuggerRequest($debugger, $request); + if ($response) { + $this->stopTimer('_init'); + + return $response; + } + + // Initialize output buffering. + $this->initializeOutputBuffering($config); + + // Set timezone, locale. + $this->initializeLocale($config); + + // Load plugins. + $this->initializePlugins(); + + // Load pages. + $this->initializePages($config); + + // Load accounts (decides class to be used). + // TODO: remove in 2.0. + $this->container['accounts']; + + // Initialize session. + $this->initializeSession($config); + + // Initialize URI (uses session, see issue #3269). + $this->initializeUri($config); + + // Grav may return redirect response right away. + $redirectCode = (int)$config->get('system.pages.redirect_trailing_slash', 1); + if ($redirectCode) { + $response = $this->handleRedirectRequest($request, $redirectCode > 300 ? $redirectCode : null); + if ($response) { + $this->stopTimer('_init'); + + return $response; + } + } + + $this->stopTimer('_init'); + + // Wrap call to next handler so that debugger can profile it. + /** @var Response $response */ + $response = $debugger->profile(static function () use ($handler, $request) { + return $handler->handle($request); + }); + + // Log both request and response and return the response. + return $debugger->logRequest($request, $response); + } + + public function processCli(): void + { + // Load configuration. + $config = $this->initializeConfig(); + + // Initialize logger. + $this->initializeLogger($config); + + // Disable debugger. + $this->container['debugger']->enabled(false); + + // Set timezone, locale. + $this->initializeLocale($config); + + // Load plugins. + $this->initializePlugins(); + + // Load pages. + $this->initializePages($config); + + // Initialize URI. + $this->initializeUri($config); + + // Load accounts (decides class to be used). + // TODO: remove in 2.0. + $this->container['accounts']; + } + + /** + * @return Config + */ + protected function initializeConfig(): Config + { + $this->startTimer('_init_config', 'Configuration'); + + // Initialize Configuration + $grav = $this->container; /** @var Config $config */ - $config = $this->container['config']; - $config->debug(); + $config = $grav['config']; + $config->init(); + $grav['plugins']->setup(); + + if (defined('GRAV_SCHEMA') && $config->get('versions') === null) { + $filename = USER_DIR . 'config/versions.yaml'; + if (!is_file($filename)) { + $versions = [ + 'core' => [ + 'grav' => [ + 'version' => GRAV_VERSION, + 'schema' => GRAV_SCHEMA + ] + ] + ]; + $config->set('versions', $versions); + + $file = new YamlFile($filename, new YamlFormatter(['inline' => 4])); + $file->save($versions); + } + } + + // Override configuration using the environment. + $prefix = 'GRAV_CONFIG'; + $env = getenv($prefix); + if ($env) { + $cPrefix = $prefix . '__'; + $aPrefix = $prefix . '_ALIAS__'; + $cLen = strlen($cPrefix); + $aLen = strlen($aPrefix); + + $keys = $aliases = []; + $env = $_ENV + $_SERVER; + foreach ($env as $key => $value) { + if (!str_starts_with($key, $prefix)) { + continue; + } + if (str_starts_with($key, $cPrefix)) { + $key = str_replace('__', '.', substr($key, $cLen)); + $keys[$key] = $value; + } elseif (str_starts_with($key, $aPrefix)) { + $key = substr($key, $aLen); + $aliases[$key] = $value; + } + } + $list = []; + foreach ($keys as $key => $value) { + foreach ($aliases as $alias => $real) { + $key = str_replace($alias, $real, $key); + } + $list[$key] = $value; + $config->set($key, $value); + } + } + + $this->stopTimer('_init_config'); + + return $config; + } + + /** + * @param Config $config + * @return Logger + */ + protected function initializeLogger(Config $config): Logger + { + $this->startTimer('_init_logger', 'Logger'); + + $grav = $this->container; + + // Initialize Logging + /** @var Logger $log */ + $log = $grav['log']; + + if ($config->get('system.log.handler', 'file') === 'syslog') { + $log->popHandler(); + + $facility = $config->get('system.log.syslog.facility', 'local6'); + $logHandler = new SyslogHandler('grav', $facility); + $formatter = new LineFormatter("%channel%.%level_name%: %message% %extra%"); + $logHandler->setFormatter($formatter); + + $log->pushHandler($logHandler); + } + + $this->stopTimer('_init_logger'); + + return $log; + } + + /** + * @return Errors + */ + protected function initializeErrors(): Errors + { + $this->startTimer('_init_errors', 'Error Handlers Reset'); + + $grav = $this->container; + + // Initialize Error Handlers + /** @var Errors $errors */ + $errors = $grav['errors']; + $errors->resetHandlers(); + + $this->stopTimer('_init_errors'); + + return $errors; + } + + /** + * @return Debugger + */ + protected function initializeDebugger(): Debugger + { + $this->startTimer('_init_debugger', 'Init Debugger'); + + $grav = $this->container; + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->init(); + + $this->stopTimer('_init_debugger'); + + return $debugger; + } + + /** + * @param Debugger $debugger + * @param ServerRequestInterface $request + * @return ResponseInterface|null + */ + protected function handleDebuggerRequest(Debugger $debugger, ServerRequestInterface $request): ?ResponseInterface + { + // Clockwork integration. + $clockwork = $debugger->getClockwork(); + if ($clockwork) { + $server = $request->getServerParams(); +// $baseUri = str_replace('\\', '/', dirname(parse_url($server['SCRIPT_NAME'], PHP_URL_PATH))); +// if ($baseUri === '/') { +// $baseUri = ''; +// } + $requestTime = $server['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME; + + $request = $request->withAttribute('request_time', $requestTime); + + // Handle clockwork API calls. + $uri = $request->getUri(); + if (Utils::contains($uri->getPath(), '/__clockwork/')) { + return $debugger->debuggerRequest($request); + } + + $this->container['clockwork'] = $clockwork; + } + + return null; + } + + /** + * @param Config $config + */ + protected function initializeOutputBuffering(Config $config): void + { + $this->startTimer('_init_ob', 'Initialize Output Buffering'); // Use output buffering to prevent headers from being sent too early. ob_start(); @@ -54,75 +345,116 @@ class InitializeProcessor extends ProcessorBase ob_start(); } + $this->stopTimer('_init_ob'); + } + + /** + * @param Config $config + */ + protected function initializeLocale(Config $config): void + { + $this->startTimer('_init_locale', 'Initialize Locale'); + // Initialize the timezone. $timezone = $config->get('system.timezone'); if ($timezone) { date_default_timezone_set($timezone); } + $grav = $this->container; + $grav->setLocale(); + + $this->stopTimer('_init_locale'); + } + + protected function initializePlugins(): Plugins + { + $this->startTimer('_init_plugins_load', 'Load Plugins'); + + $grav = $this->container; + + /** @var Plugins $plugins */ + $plugins = $grav['plugins']; + $plugins->init(); + + $this->stopTimer('_init_plugins_load'); + + return $plugins; + } + + protected function initializePages(Config $config): Pages + { + $this->startTimer('_init_pages_register', 'Load Pages'); + + $grav = $this->container; + + /** @var Pages $pages */ + $pages = $grav['pages']; + // Upgrading from older Grav versions won't work without checking if the method exists. + if (method_exists($pages, 'register')) { + $pages->register(); + } + + $this->stopTimer('_init_pages_register'); + + return $pages; + } + + + protected function initializeUri(Config $config): void + { + $this->startTimer('_init_uri', 'Initialize URI'); + + $grav = $this->container; + + /** @var Uri $uri */ + $uri = $grav['uri']; + $uri->init(); + + $this->stopTimer('_init_uri'); + } + + protected function handleRedirectRequest(RequestInterface $request, int $code = null): ?ResponseInterface + { + if (!in_array($request->getMethod(), ['GET', 'HEAD'])) { + return null; + } + + // Redirect pages with trailing slash if configured to do so. + $uri = $request->getUri(); + $path = $uri->getPath() ?: '/'; + $root = $this->container['uri']->rootUrl(); + + if ($path !== $root && $path !== $root . '/' && Utils::endsWith($path, '/')) { + // Use permanent redirect for SEO reasons. + return $this->container->getRedirectResponse((string)$uri->withPath(rtrim($path, '/')), $code); + } + + return null; + } + + /** + * @param Config $config + */ + protected function initializeSession(Config $config): void + { // FIXME: Initialize session should happen later after plugins have been loaded. This is a workaround to fix session issues in AWS. if (isset($this->container['session']) && $config->get('system.session.initialize', true)) { - // TODO: remove in 2.0. - $this->container['accounts']; + $this->startTimer('_init_session', 'Start Session'); + + /** @var Session $session */ + $session = $this->container['session']; try { - $this->container['session']->init(); + $session->init(); } catch (SessionException $e) { - $this->container['session']->init(); + $session->init(); $message = 'Session corruption detected, restarting session...'; $this->addMessage($message); $this->container['messages']->add($message, 'error'); } + + $this->stopTimer('_init_session'); } - - /** @var Uri $uri */ - $uri = $this->container['uri']; - $uri->init(); - - // Redirect pages with trailing slash if configured to do so. - $path = $uri->path() ?: '/'; - if ($path !== '/' - && $config->get('system.pages.redirect_trailing_slash', false) - && Utils::endsWith($path, '/')) { - - $redirect = (string) $uri::getCurrentRoute()->toString(); - $this->container->redirect($redirect); - } - - $this->container->setLocale(); - $this->stopTimer(); - - return $handler->handle($request); - } - - public function processCli(): void - { - // Load configuration. - $this->container['config']->init(); - $this->container['plugins']->setup(); - - // Disable debugger. - $this->container['debugger']->enabled(false); - - // Set timezone, locale. - /** @var Config $config */ - $config = $this->container['config']; - $timezone = $config->get('system.timezone'); - if ($timezone) { - date_default_timezone_set($timezone); - } - $this->container->setLocale(); - - // Load plugins. - $this->container['plugins']->init(); - - // Initialize URI. - /** @var Uri $uri */ - $uri = $this->container['uri']; - $uri->init(); - - // Load accounts. - // TODO: remove in 2.0. - $this->container['accounts']; } } diff --git a/system/src/Grav/Common/Processors/LoggerProcessor.php b/system/src/Grav/Common/Processors/LoggerProcessor.php deleted file mode 100644 index af3c169..0000000 --- a/system/src/Grav/Common/Processors/LoggerProcessor.php +++ /dev/null @@ -1,50 +0,0 @@ -startTimer(); - - $grav = $this->container; - - /** @var Config $config */ - $config = $grav['config']; - - switch ($config->get('system.log.handler', 'file')) { - case 'syslog': - $log = $grav['log']; - $log->popHandler(); - - $facility = $config->get('system.log.syslog.facility', 'local6'); - $logHandler = new SyslogHandler('grav', $facility); - $formatter = new LineFormatter("%channel%.%level_name%: %message% %extra%"); - $logHandler->setFormatter($formatter); - - $log->pushHandler($logHandler); - break; - } - $this->stopTimer(); - - return $handler->handle($request); - } -} diff --git a/system/src/Grav/Common/Processors/PagesProcessor.php b/system/src/Grav/Common/Processors/PagesProcessor.php index ede22e5..33b483f 100644 --- a/system/src/Grav/Common/Processors/PagesProcessor.php +++ b/system/src/Grav/Common/Processors/PagesProcessor.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,13 +14,25 @@ use RocketTheme\Toolbox\Event\Event; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use RuntimeException; +/** + * Class PagesProcessor + * @package Grav\Common\Processors + */ class PagesProcessor extends ProcessorBase { + /** @var string */ public $id = 'pages'; + /** @var string */ public $title = 'Pages'; - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->startTimer(); @@ -41,10 +53,10 @@ class PagesProcessor extends ProcessorBase $event = $this->container->fireEvent('onPageNotFound', $event); if (isset($event->page)) { - unset ($this->container['page']); + unset($this->container['page']); $this->container['page'] = $page = $event->page; } else { - throw new \RuntimeException('Page Not Found', 404); + throw new RuntimeException('Page Not Found', 404); } $this->addMessage("Routed to page {$page->rawRoute()} (type: {$page->template()}) [Not Found fallback]"); diff --git a/system/src/Grav/Common/Processors/PluginsProcessor.php b/system/src/Grav/Common/Processors/PluginsProcessor.php index 9d2943b..485578e 100644 --- a/system/src/Grav/Common/Processors/PluginsProcessor.php +++ b/system/src/Grav/Common/Processors/PluginsProcessor.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,18 +13,27 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Class PluginsProcessor + * @package Grav\Common\Processors + */ class PluginsProcessor extends ProcessorBase { + /** @var string */ public $id = 'plugins'; - public $title = 'Plugins'; + /** @var string */ + public $title = 'Initialize Plugins'; - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->startTimer(); - // TODO: remove in 2.0. - $this->container['accounts']; - $this->container['plugins']->init(); - $this->container->fireEvent('onPluginsInitialized'); + $grav = $this->container; + $grav->fireEvent('onPluginsInitialized'); $this->stopTimer(); return $handler->handle($request); diff --git a/system/src/Grav/Common/Processors/ProcessorBase.php b/system/src/Grav/Common/Processors/ProcessorBase.php index 5ce7bfd..a3506f5 100644 --- a/system/src/Grav/Common/Processors/ProcessorBase.php +++ b/system/src/Grav/Common/Processors/ProcessorBase.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -12,34 +12,56 @@ namespace Grav\Common\Processors; use Grav\Common\Debugger; use Grav\Common\Grav; +/** + * Class ProcessorBase + * @package Grav\Common\Processors + */ abstract class ProcessorBase implements ProcessorInterface { /** @var Grav */ protected $container; + /** @var string */ public $id = 'processorbase'; + /** @var string */ public $title = 'ProcessorBase'; + /** + * ProcessorBase constructor. + * @param Grav $container + */ public function __construct(Grav $container) { $this->container = $container; } - protected function startTimer($id = null, $title = null) + /** + * @param string|null $id + * @param string|null $title + */ + protected function startTimer($id = null, $title = null): void { /** @var Debugger $debugger */ $debugger = $this->container['debugger']; $debugger->startTimer($id ?? $this->id, $title ?? $this->title); } - protected function stopTimer($id = null) + /** + * @param string|null $id + */ + protected function stopTimer($id = null): void { /** @var Debugger $debugger */ $debugger = $this->container['debugger']; $debugger->stopTimer($id ?? $this->id); } - protected function addMessage($message, $label = 'info', $isString = true) + /** + * @param string $message + * @param string $label + * @param bool $isString + */ + protected function addMessage($message, $label = 'info', $isString = true): void { /** @var Debugger $debugger */ $debugger = $this->container['debugger']; diff --git a/system/src/Grav/Common/Processors/ProcessorInterface.php b/system/src/Grav/Common/Processors/ProcessorInterface.php index a8e4aaa..8a6edbe 100644 --- a/system/src/Grav/Common/Processors/ProcessorInterface.php +++ b/system/src/Grav/Common/Processors/ProcessorInterface.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,6 +11,10 @@ namespace Grav\Common\Processors; use Psr\Http\Server\MiddlewareInterface; +/** + * Interface ProcessorInterface + * @package Grav\Common\Processors + */ interface ProcessorInterface extends MiddlewareInterface { } diff --git a/system/src/Grav/Common/Processors/RenderProcessor.php b/system/src/Grav/Common/Processors/RenderProcessor.php index 06a6c79..32f48e7 100644 --- a/system/src/Grav/Common/Processors/RenderProcessor.php +++ b/system/src/Grav/Common/Processors/RenderProcessor.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,12 +15,23 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Class RenderProcessor + * @package Grav\Common\Processors + */ class RenderProcessor extends ProcessorBase { + /** @var string */ public $id = 'render'; + /** @var string */ public $title = 'Render'; - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->startTimer(); diff --git a/system/src/Grav/Common/Processors/RequestProcessor.php b/system/src/Grav/Common/Processors/RequestProcessor.php index 97564e6..971fb67 100644 --- a/system/src/Grav/Common/Processors/RequestProcessor.php +++ b/system/src/Grav/Common/Processors/RequestProcessor.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,12 +15,23 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Class RequestProcessor + * @package Grav\Common\Processors + */ class RequestProcessor extends ProcessorBase { + /** @var string */ public $id = 'request'; + /** @var string */ public $title = 'Request'; - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->startTimer(); diff --git a/system/src/Grav/Common/Processors/SchedulerProcessor.php b/system/src/Grav/Common/Processors/SchedulerProcessor.php index 64f3fba..69cc163 100644 --- a/system/src/Grav/Common/Processors/SchedulerProcessor.php +++ b/system/src/Grav/Common/Processors/SchedulerProcessor.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,12 +14,23 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Class SchedulerProcessor + * @package Grav\Common\Processors + */ class SchedulerProcessor extends ProcessorBase { + /** @var string */ public $id = '_scheduler'; + /** @var string */ public $title = 'Scheduler'; - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->startTimer(); $scheduler = $this->container['scheduler']; diff --git a/system/src/Grav/Common/Processors/TasksProcessor.php b/system/src/Grav/Common/Processors/TasksProcessor.php index aaf5cdf..07e0934 100644 --- a/system/src/Grav/Common/Processors/TasksProcessor.php +++ b/system/src/Grav/Common/Processors/TasksProcessor.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,12 +14,23 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Class TasksProcessor + * @package Grav\Common\Processors + */ class TasksProcessor extends ProcessorBase { + /** @var string */ public $id = 'tasks'; + /** @var string */ public $title = 'Tasks'; - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->startTimer(); @@ -42,7 +53,6 @@ class TasksProcessor extends ProcessorBase $this->stopTimer(); return $response; - } catch (NotFoundException $e) { // Task not found: Let it pass through. } diff --git a/system/src/Grav/Common/Processors/ThemesProcessor.php b/system/src/Grav/Common/Processors/ThemesProcessor.php index d1c6ae5..951dc79 100644 --- a/system/src/Grav/Common/Processors/ThemesProcessor.php +++ b/system/src/Grav/Common/Processors/ThemesProcessor.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,12 +13,23 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Class ThemesProcessor + * @package Grav\Common\Processors + */ class ThemesProcessor extends ProcessorBase { + /** @var string */ public $id = 'themes'; + /** @var string */ public $title = 'Themes'; - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->startTimer(); $this->container['themes']->init(); diff --git a/system/src/Grav/Common/Processors/TwigProcessor.php b/system/src/Grav/Common/Processors/TwigProcessor.php index 4ed247d..6604b5c 100644 --- a/system/src/Grav/Common/Processors/TwigProcessor.php +++ b/system/src/Grav/Common/Processors/TwigProcessor.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Processors * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,12 +13,23 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Class TwigProcessor + * @package Grav\Common\Processors + */ class TwigProcessor extends ProcessorBase { + /** @var string */ public $id = 'twig'; + /** @var string */ public $title = 'Twig'; - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->startTimer(); $this->container['twig']->init(); diff --git a/system/src/Grav/Common/Scheduler/Cron.php b/system/src/Grav/Common/Scheduler/Cron.php index 1838b07..5127a99 100644 --- a/system/src/Grav/Common/Scheduler/Cron.php +++ b/system/src/Grav/Common/Scheduler/Cron.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Scheduler * @author Originally based on jqCron by Arnaud Buathier modified for Grav integration - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -45,6 +45,15 @@ namespace Grav\Common\Scheduler; * var_dump($cron->matchWithMargin(new \DateTime('2012-07-01 12:32:50'), -3, 5)); * // bool(true) */ + +use DateInterval; +use DateTime; +use RuntimeException; +use function count; +use function in_array; +use function is_array; +use function is_string; + class Cron { public const TYPE_UNDEFINED = ''; @@ -127,7 +136,6 @@ class Cron protected $dom = []; /** - * * @param string|null $cron */ public function __construct($cron = null) @@ -138,7 +146,6 @@ class Cron } /** - * * @return string */ public function getCron() @@ -153,7 +160,6 @@ class Cron } /** - * * @param string $lang 'fr' or 'en' * @return string */ @@ -191,7 +197,7 @@ class Cron } // month + year - if (\in_array($type, [self::TYPE_MONTH, self::TYPE_YEAR], true)) { + if (in_array($type, [self::TYPE_MONTH, self::TYPE_YEAR], true)) { $elements[] = sprintf($texts['text_dom'], $this->getCronDaysOfMonth()); } @@ -205,7 +211,7 @@ class Cron } // day + week + month + year - if (\in_array($type, [self::TYPE_DAY, self::TYPE_WEEK, self::TYPE_MONTH, self::TYPE_YEAR], true)) { + if (in_array($type, [self::TYPE_DAY, self::TYPE_WEEK, self::TYPE_MONTH, self::TYPE_YEAR], true)) { $elements[] = sprintf($texts['text_time'], $this->getCronHours(), $this->getCronMinutes()); } @@ -213,7 +219,6 @@ class Cron } /** - * * @return string */ public function getType() @@ -250,9 +255,8 @@ class Cron } /** - * * @param string $cron - * @return Cron + * @return $this */ public function setCron($cron) { @@ -261,8 +265,8 @@ class Cron $cron = preg_replace('/\s+/', ' ', $cron); // explode $elements = explode(' ', $cron); - if (\count($elements) !== 5) { - throw new \RuntimeException('Bad number of elements'); + if (count($elements) !== 5) { + throw new RuntimeException('Bad number of elements'); } $this->cron = $cron; @@ -276,7 +280,6 @@ class Cron } /** - * * @return string */ public function getCronMinutes() @@ -285,7 +288,6 @@ class Cron } /** - * * @return string */ public function getCronHours() @@ -294,7 +296,6 @@ class Cron } /** - * * @return string */ public function getCronDaysOfMonth() @@ -303,7 +304,6 @@ class Cron } /** - * * @return string */ public function getCronMonths() @@ -312,7 +312,6 @@ class Cron } /** - * * @return string */ public function getCronDaysOfWeek() @@ -321,7 +320,6 @@ class Cron } /** - * * @return array */ public function getMinutes() @@ -330,7 +328,6 @@ class Cron } /** - * * @return array */ public function getHours() @@ -339,7 +336,6 @@ class Cron } /** - * * @return array */ public function getDaysOfMonth() @@ -348,7 +344,6 @@ class Cron } /** - * * @return array */ public function getMonths() @@ -357,7 +352,6 @@ class Cron } /** - * * @return array */ public function getDaysOfWeek() @@ -366,9 +360,8 @@ class Cron } /** - * - * @param string|array $minutes - * @return Cron + * @param string|string[] $minutes + * @return $this */ public function setMinutes($minutes) { @@ -378,9 +371,8 @@ class Cron } /** - * - * @param string|array $hours - * @return Cron + * @param string|string[] $hours + * @return $this */ public function setHours($hours) { @@ -390,9 +382,8 @@ class Cron } /** - * - * @param string|array $months - * @return Cron + * @param string|string[] $months + * @return $this */ public function setMonths($months) { @@ -402,9 +393,8 @@ class Cron } /** - * - * @param string|array $dow - * @return Cron + * @param string|string[] $dow + * @return $this */ public function setDaysOfWeek($dow) { @@ -414,9 +404,8 @@ class Cron } /** - * - * @param string|array $dom - * @return Cron + * @param string|string[] $dom + * @return $this */ public function setDaysOfMonth($dom) { @@ -426,73 +415,68 @@ class Cron } /** - * * @param mixed $date * @param int $min * @param int $hour * @param int $day * @param int $month * @param int $weekday - * @return \DateTime + * @return DateTime */ protected function parseDate($date, &$min, &$hour, &$day, &$month, &$weekday) { if (is_numeric($date) && (int)$date == $date) { - $date = new \DateTime('@' . $date); + $date = new DateTime('@' . $date); + } elseif (is_string($date)) { + $date = new DateTime('@' . strtotime($date)); } - elseif (is_string($date)) { - $date = new \DateTime('@' . strtotime($date)); - } - if ($date instanceof \DateTime) { + if ($date instanceof DateTime) { $min = (int)$date->format('i'); $hour = (int)$date->format('H'); $day = (int)$date->format('d'); $month = (int)$date->format('m'); $weekday = (int)$date->format('w'); // 0-6 - } - else { - throw new \RuntimeException('Date format not supported'); + } else { + throw new RuntimeException('Date format not supported'); } - return new \DateTime($date->format('Y-m-d H:i:sP')); + return new DateTime($date->format('Y-m-d H:i:sP')); } /** - * - * @param int|string|\DateTime $date + * @param int|string|DateTime $date */ public function matchExact($date) { $date = $this->parseDate($date, $min, $hour, $day, $month, $weekday); return - (empty($this->minutes) || \in_array($min, $this->minutes, true)) && - (empty($this->hours) || \in_array($hour, $this->hours, true)) && - (empty($this->dom) || \in_array($day, $this->dom, true)) && - (empty($this->months) || \in_array($month, $this->months, true)) && - (empty($this->dow) || \in_array($weekday, $this->dow, true) || ($weekday == 0 && \in_array(7, $this->dow, true)) || ($weekday == 7 && \in_array(0, $this->dow, true)) + (empty($this->minutes) || in_array($min, $this->minutes, true)) && + (empty($this->hours) || in_array($hour, $this->hours, true)) && + (empty($this->dom) || in_array($day, $this->dom, true)) && + (empty($this->months) || in_array($month, $this->months, true)) && + (empty($this->dow) || in_array($weekday, $this->dow, true) || ($weekday == 0 && in_array(7, $this->dow, true)) || ($weekday == 7 && in_array(0, $this->dow, true)) ); } /** - * - * @param int|string|\DateTime $date + * @param int|string|DateTime $date * @param int $minuteBefore * @param int $minuteAfter */ public function matchWithMargin($date, $minuteBefore = 0, $minuteAfter = 0) { if ($minuteBefore > 0) { - throw new \RuntimeException('MinuteBefore parameter cannot be positive !'); + throw new RuntimeException('MinuteBefore parameter cannot be positive !'); } if ($minuteAfter < 0) { - throw new \RuntimeException('MinuteAfter parameter cannot be negative !'); + throw new RuntimeException('MinuteAfter parameter cannot be negative !'); } $date = $this->parseDate($date, $min, $hour, $day, $month, $weekday); - $interval = new \DateInterval('PT1M'); // 1 min + $interval = new DateInterval('PT1M'); // 1 min if ($minuteBefore !== 0) { - $date->sub(new \DateInterval('PT' . abs($minuteBefore) . 'M')); + $date->sub(new DateInterval('PT' . abs($minuteBefore) . 'M')); } $n = $minuteAfter - $minuteBefore + 1; for ($i = 0; $i < $n; $i++) { @@ -506,14 +490,13 @@ class Cron } /** - * * @param array $array * @return string */ protected function arrayToCron($array) { - $n = \count($array); - if (!\is_array($array) || $n === 0) { + $n = count($array); + if (!is_array($array) || $n === 0) { return '*'; } @@ -522,9 +505,8 @@ class Cron for ($i = 1; $i < $n; $i++) { if ($array[$i] == $c + 1) { $c = $array[$i]; - $cron[\count($cron) - 1] = $s . '-' . $c; - } - else { + $cron[count($cron) - 1] = $s . '-' . $c; + } else { $s = $c = $array[$i]; $cron[] = $c; } @@ -543,7 +525,7 @@ class Cron protected function cronToArray($string, $min, $max) { $array = []; - if (\is_array($string)) { + if (is_array($string)) { foreach ($string as $val) { if (is_numeric($val) && (int)$val == $val && $val >= $min && $val <= $max) { $array[] = (int)$val; @@ -588,7 +570,7 @@ class Cron return []; } } - sort($array); + sort($array, SORT_NUMERIC); return $array; } diff --git a/system/src/Grav/Common/Scheduler/IntervalTrait.php b/system/src/Grav/Common/Scheduler/IntervalTrait.php index b382c9d..cc5c165 100644 --- a/system/src/Grav/Common/Scheduler/IntervalTrait.php +++ b/system/src/Grav/Common/Scheduler/IntervalTrait.php @@ -3,14 +3,20 @@ /** * @package Grav\Common\Scheduler * @author Originally based on peppeocchi/php-cron-scheduler modified for Grav integration - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Scheduler; use Cron\CronExpression; +use InvalidArgumentException; +use function is_string; +/** + * Trait IntervalTrait + * @package Grav\Common\Scheduler + */ trait IntervalTrait { /** @@ -59,7 +65,7 @@ trait IntervalTrait */ public function daily($hour = 0, $minute = 0) { - if (\is_string($hour)) { + if (is_string($hour)) { $parts = explode(':', $hour); $hour = $parts[0]; $minute = $parts[1] ?? '0'; @@ -79,7 +85,7 @@ trait IntervalTrait */ public function weekly($weekday = 0, $hour = 0, $minute = 0) { - if (\is_string($hour)) { + if (is_string($hour)) { $parts = explode(':', $hour); $hour = $parts[0]; $minute = $parts[1] ?? '0'; @@ -100,7 +106,7 @@ trait IntervalTrait */ public function monthly($month = '*', $day = 1, $hour = 0, $minute = 0) { - if (\is_string($hour)) { + if (is_string($hour)) { $parts = explode(':', $hour); $hour = $parts[0]; $minute = $parts[1] ?? '0'; @@ -353,11 +359,11 @@ trait IntervalTrait /** * Validate sequence of cron expression. * - * @param int|string $minute - * @param int|string $hour - * @param int|string $day - * @param int|string $month - * @param int|string $weekday + * @param int|string|null $minute + * @param int|string|null $hour + * @param int|string|null $day + * @param int|string|null $month + * @param int|string|null $weekday * @return array */ private function validateCronSequence($minute = null, $hour = null, $day = null, $month = null, $weekday = null) @@ -374,7 +380,7 @@ trait IntervalTrait /** * Validate sequence of cron expression. * - * @param int|string $value + * @param int|string|null $value * @param int $min * @param int $max * @return mixed @@ -388,7 +394,7 @@ trait IntervalTrait if (! is_numeric($value) || ! ($value >= $min && $value <= $max) ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( "Invalid value: it should be '*' or between {$min} and {$max}." ); } @@ -396,4 +402,3 @@ trait IntervalTrait return $value; } } - diff --git a/system/src/Grav/Common/Scheduler/Job.php b/system/src/Grav/Common/Scheduler/Job.php index 6250f1d..f21c26d 100644 --- a/system/src/Grav/Common/Scheduler/Job.php +++ b/system/src/Grav/Common/Scheduler/Job.php @@ -3,42 +3,79 @@ /** * @package Grav\Common\Scheduler * @author Originally based on peppeocchi/php-cron-scheduler modified for Grav integration - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Scheduler; +use Closure; use Cron\CronExpression; +use DateTime; use Grav\Common\Grav; +use InvalidArgumentException; +use RuntimeException; use Symfony\Component\Process\Process; +use function call_user_func; +use function call_user_func_array; +use function count; +use function is_array; +use function is_callable; +use function is_string; +/** + * Class Job + * @package Grav\Common\Scheduler + */ class Job { use IntervalTrait; + /** @var string */ private $id; - private $enabled = true; + /** @var bool */ + private $enabled; + /** @var callable|string */ private $command; + /** @var string */ private $at; + /** @var array */ private $args = []; + /** @var bool */ private $runInBackground = true; + /** @var DateTime */ private $creationTime; + /** @var CronExpression */ private $executionTime; + /** @var string */ private $tempDir; + /** @var string */ private $lockFile; + /** @var bool */ private $truthTest = true; + /** @var string */ private $output; + /** @var int */ private $returnCode = 0; + /** @var array */ private $outputTo = []; + /** @var array */ private $emailTo = []; + /** @var array */ private $emailConfig = []; + /** @var callable|null */ private $before; + /** @var callable|null */ private $after; + /** @var callable */ private $whenOverlapping; + /** @var string */ private $outputMode; + /** @var Process|null $process */ private $process; + /** @var bool */ private $successful = false; + /** @var string|null */ private $backlink; /** @@ -46,7 +83,7 @@ class Job * * @param string|callable $command * @param array $args - * @param string $id + * @param string|null $id */ public function __construct($command, $args = [], $id = null) { @@ -60,7 +97,7 @@ class Job $this->id = spl_object_hash($command); } } - $this->creationTime = new \DateTime('now'); + $this->creationTime = new DateTime('now'); // initialize the directory path for lock files $this->tempDir = sys_get_temp_dir(); $this->command = $command; @@ -73,7 +110,7 @@ class Job /** * Get the command * - * @return string + * @return Closure|string */ public function getCommand() { @@ -107,13 +144,16 @@ class Job */ public function getArguments() { - if (\is_string($this->args)) { + if (is_string($this->args)) { return $this->args; } return null; } + /** + * @return CronExpression + */ public function getCronExpression() { return CronExpression::factory($this->at); @@ -145,10 +185,10 @@ class Job * the job is due. Defaults to job creation time. * It also default the execution time if not previously defined. * - * @param \DateTime $date + * @param DateTime|null $date * @return bool */ - public function isDue(\DateTime $date = null) + public function isDue(DateTime $date = null) { // The execution time is being defaulted if not defined if (!$this->executionTime) { @@ -187,9 +227,8 @@ class Job /** * Sets/Gets an option backlink * - * @param string $link - * - * @return null|string + * @param string|null $link + * @return string|null */ public function backlink($link = null) { @@ -216,8 +255,8 @@ class Job * being executed if the previous is still running. * The job id is used as a filename for the lock file. * - * @param string $tempDir The directory path for the lock files - * @param callable $whenOverlapping A callback to ignore job overlapping + * @param string|null $tempDir The directory path for the lock files + * @param callable|null $whenOverlapping A callback to ignore job overlapping * @return self */ public function onlyOne($tempDir = null, callable $whenOverlapping = null) @@ -298,8 +337,8 @@ class Job if (is_callable($this->command)) { $this->output = $this->exec(); } else { - $args = \is_string($this->args) ? $this->args : implode(' ', $this->args); - $command = $this->command . ' ' . $args; + $args = is_string($this->args) ? explode(' ', $this->args) : $this->args; + $command = array_merge([$this->command], $args); $process = new Process($command); $this->process = $process; @@ -322,7 +361,6 @@ class Job */ public function finalize() { - /** @var Process $process */ $process = $this->process; if ($process) { @@ -344,6 +382,8 @@ class Job /** * Things to run after job has run + * + * @return void */ private function postRun() { @@ -374,7 +414,7 @@ class Job private function createLockFile($content = null) { if ($this->lockFile) { - if ($content === null || !\is_string($content)) { + if ($content === null || !is_string($content)) { $content = $this->getId(); } file_put_contents($this->lockFile, $content); @@ -396,8 +436,8 @@ class Job /** * Execute a callable job. * - * @throws \RuntimeException * @return string + * @throws RuntimeException */ private function exec() { @@ -406,12 +446,15 @@ class Job try { $return_data = call_user_func_array($this->command, $this->args); $this->successful = true; - } catch (\RuntimeException $e) { + } catch (RuntimeException $e) { + $return_data = $e->getMessage(); $this->successful = false; } $this->output = ob_get_clean() . (is_string($return_data) ? $return_data : ''); $this->postRun(); + + return $this->output; } /** @@ -450,7 +493,7 @@ class Job public function email($email) { if (!is_string($email) && !is_array($email)) { - throw new \InvalidArgumentException('The email can be only string or array'); + throw new InvalidArgumentException('The email can be only string or array'); } $this->emailTo = is_array($email) ? $email : [$email]; @@ -519,4 +562,3 @@ class Job return $this; } } - diff --git a/system/src/Grav/Common/Scheduler/Scheduler.php b/system/src/Grav/Common/Scheduler/Scheduler.php index df5602e..73a0712 100644 --- a/system/src/Grav/Common/Scheduler/Scheduler.php +++ b/system/src/Grav/Common/Scheduler/Scheduler.php @@ -3,33 +3,51 @@ /** * @package Grav\Common\Scheduler * @author Originally based on peppeocchi/php-cron-scheduler modified for Grav integration - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Scheduler; +use DateTime; use Grav\Common\Filesystem\Folder; use Grav\Common\Grav; use Grav\Common\Utils; +use InvalidArgumentException; use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; use RocketTheme\Toolbox\File\YamlFile; +use function is_callable; +use function is_string; +/** + * Class Scheduler + * @package Grav\Common\Scheduler + */ class Scheduler { - /** - * The queued jobs. - * - * @var array - */ + /** @var Job[] The queued jobs. */ private $jobs = []; + + /** @var Job[] */ private $saved_jobs = []; + + /** @var Job[] */ private $executed_jobs = []; + + /** @var Job[] */ private $failed_jobs = []; + + /** @var Job[] */ private $jobs_run = []; + + /** @var array */ private $output_schedule = []; + + /** @var array */ private $config; + + /** @var string */ private $status_path; /** @@ -44,11 +62,12 @@ class Scheduler if (!file_exists($this->status_path)) { Folder::create($this->status_path); } - } /** * Load saved jobs from config/scheduler.yaml file + * + * @return $this */ public function loadSavedJobs() { @@ -65,7 +84,7 @@ class Scheduler } if (isset($j['output'])) { - $mode = isset($j['output_mode']) && $j['output_mode'] === 'append' ? true : false; + $mode = isset($j['output_mode']) && $j['output_mode'] === 'append'; $job->output($j['output'], $mode); } @@ -98,7 +117,6 @@ class Scheduler $foreground[] = $job; } } - } return [$background, $foreground]; } @@ -106,21 +124,38 @@ class Scheduler /** * Get all jobs if they are disabled or not as one array * - * @return array + * @return Job[] */ public function getAllJobs() { - list($background, $foreground) = $this->loadSavedJobs()->getQueuedJobs(true); + [$background, $foreground] = $this->loadSavedJobs()->getQueuedJobs(true); return array_merge($background, $foreground); } + /** + * Get a specific Job based on id + * + * @param string $jobid + * @return Job|null + */ + public function getJob($jobid) + { + $all = $this->getAllJobs(); + foreach ($all as $job) { + if ($jobid == $job->getId()) { + return $job; + } + } + return null; + } + /** * Queues a PHP function execution. * * @param callable $fn The function to execute * @param array $args Optional arguments to pass to the php script - * @param string $id Optional custom identifier + * @param string|null $id Optional custom identifier * @return Job */ public function addFunction(callable $fn, $args = [], $id = null) @@ -136,7 +171,7 @@ class Scheduler * * @param string $command The command to execute * @param array $args Optional arguments to pass to the command - * @param string $id Optional custom identifier + * @param string|null $id Optional custom identifier * @return Job */ public function addCommand($command, $args = [], $id = null) @@ -150,29 +185,30 @@ class Scheduler /** * Run the scheduler. * - * @param \DateTime|null $runTime Optional, run at specific moment + * @param DateTime|null $runTime Optional, run at specific moment + * @param bool $force force run even if not due */ - public function run(\DateTime $runTime = null) + public function run(DateTime $runTime = null, $force = false) { $this->loadSavedJobs(); - list($background, $foreground) = $this->getQueuedJobs(false); + [$background, $foreground] = $this->getQueuedJobs(false); $alljobs = array_merge($background, $foreground); if (null === $runTime) { - $runTime = new \DateTime('now'); + $runTime = new DateTime('now'); } // Star processing jobs foreach ($alljobs as $job) { - if ($job->isDue($runTime)) { + if ($job->isDue($runTime) || $force) { $job->run(); $this->jobs_run[] = $job; } } // Finish handling any background jobs - foreach($background as $job) { + foreach ($background as $job) { $job->finalize(); } @@ -184,6 +220,8 @@ class Scheduler * Reset all collected data of last run. * * Call before run() if you call run() multiple times. + * + * @return $this */ public function resetRun() { @@ -199,7 +237,7 @@ class Scheduler * Get the scheduler verbose output. * * @param string $type Allowed: text, html, array - * @return mixed The return depends on the requested $type + * @return string|array The return depends on the requested $type */ public function getVerboseOutput($type = 'text') { @@ -211,12 +249,14 @@ class Scheduler case 'array': return $this->output_schedule; default: - throw new \InvalidArgumentException('Invalid output type'); + throw new InvalidArgumentException('Invalid output type'); } } /** * Remove all queued Jobs. + * + * @return $this */ public function clearJobs() { @@ -232,27 +272,43 @@ class Scheduler */ public function getCronCommand() { - $phpBinaryFinder = new PhpExecutableFinder(); - $php = $phpBinaryFinder->find(); - $command = 'cd ' . str_replace(' ', '\ ', GRAV_ROOT) . ';' . $php . ' bin/grav scheduler'; + $command = $this->getSchedulerCommand(); return "(crontab -l; echo \"* * * * * {$command} 1>> /dev/null 2>&1\") | crontab -"; } + /** + * @param string|null $php + * @return string + */ + public function getSchedulerCommand($php = null) + { + $phpBinaryFinder = new PhpExecutableFinder(); + $php = $php ?? $phpBinaryFinder->find(); + $command = 'cd ' . str_replace(' ', '\ ', GRAV_ROOT) . ';' . $php . ' bin/grav scheduler'; + + return $command; + } + /** * Helper to determine if cron job is setup + * 0 - Crontab Not found + * 1 - Crontab Found + * 2 - Error * * @return int */ public function isCrontabSetup() { - $process = new Process('crontab -l'); + $process = new Process(['crontab', '-l']); $process->run(); if ($process->isSuccessful()) { $output = $process->getOutput(); + $command = str_replace('/', '\/', $this->getSchedulerCommand('.*')); + $full_command = '/^(?!#).* .* .* .* .* ' . $command . '/m'; - return preg_match('$bin\/grav schedule$', $output) ? 1 : 0; + return preg_match($full_command, $output) ? 1 : 0; } $error = $process->getErrorOutput(); @@ -263,7 +319,7 @@ class Scheduler /** * Get the Job states file * - * @return \RocketTheme\Toolbox\File\FileInterface|YamlFile + * @return YamlFile */ public function getJobStates() { @@ -272,6 +328,8 @@ class Scheduler /** * Save job states to statys file + * + * @return void */ private function saveJobStates() { @@ -292,6 +350,24 @@ class Scheduler $saved_states->save(array_merge($saved_states->content(), $new_states)); } + /** + * Try to determine who's running the process + * + * @return false|string + */ + public function whoami() + { + $process = new Process('whoami'); + $process->run(); + + if ($process->isSuccessful()) { + return trim($process->getOutput()); + } + + return $process->getErrorOutput(); + } + + /** * Queue a job for execution in the correct queue. * @@ -313,7 +389,7 @@ class Scheduler */ private function addSchedulerVerboseOutput($string) { - $now = '[' . (new \DateTime('now'))->format('c') . '] '; + $now = '[' . (new DateTime('now'))->format('c') . '] '; $this->output_schedule[] = $now . $string; // Print to stdoutput in light gray // echo "\033[37m{$string}\033[0m\n"; @@ -332,7 +408,7 @@ class Scheduler $args = $job->getArguments(); // If callable, log the string Closure if (is_callable($command)) { - $command = \is_string($command) ? $command : 'Closure'; + $command = is_string($command) ? $command : 'Closure'; } $this->addSchedulerVerboseOutput("Success: {$command} {$args}"); @@ -351,7 +427,7 @@ class Scheduler $command = $job->getCommand(); // If callable, log the string Closure if (is_callable($command)) { - $command = \is_string($command) ? $command : 'Closure'; + $command = is_string($command) ? $command : 'Closure'; } $output = trim($job->getOutput()); $this->addSchedulerVerboseOutput("Error: {$command}{$output}"); diff --git a/system/src/Grav/Common/Security.php b/system/src/Grav/Common/Security.php index 0f61097..fe33d79 100644 --- a/system/src/Grav/Common/Security.php +++ b/system/src/Grav/Common/Security.php @@ -3,17 +3,74 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; +use enshrined\svgSanitize\Sanitizer; +use Exception; +use Grav\Common\Config\Config; use Grav\Common\Page\Pages; +use function chr; +use function count; +use function is_array; +use function is_string; +/** + * Class Security + * @package Grav\Common + */ class Security { + /** + * Sanitize SVG string for XSS code + * + * @param string $svg + * @return string + */ + public static function sanitizeSvgString(string $svg): string + { + if (Grav::instance()['config']->get('security.sanitize_svg')) { + $sanitizer = new Sanitizer(); + $sanitized = $sanitizer->sanitize($svg); + if (is_string($sanitized)) { + $svg = $sanitized; + } + } + return $svg; + } + + /** + * Sanitize SVG for XSS code + * + * @param string $file + * @return void + */ + public static function sanitizeSVG(string $file): void + { + if (file_exists($file) && Grav::instance()['config']->get('security.sanitize_svg')) { + $sanitizer = new Sanitizer(); + $original_svg = file_get_contents($file); + $clean_svg = $sanitizer->sanitize($original_svg); + + // TODO: what to do with bad SVG files which return false? + if ($clean_svg !== false && $clean_svg !== $original_svg) { + file_put_contents($file, $clean_svg); + } + } + } + + /** + * Detect XSS code in Grav pages + * + * @param Pages $pages + * @param bool $route + * @param callable|null $status + * @return array + */ public static function detectXssFromPages(Pages $pages, $route = true, callable $status = null) { $routes = $pages->routes(); @@ -30,7 +87,6 @@ class Security ]); foreach ($routes as $path) { - $status && $status([ 'type' => 'progress', ]); @@ -51,10 +107,8 @@ class Security } else { $list[$page->filePathClean()] = $results; } - } - - } catch (\Exception $e) { + } catch (Exception $e) { continue; } } @@ -63,19 +117,25 @@ class Security } /** + * Detect XSS in an array or strings such as $_POST or $_GET + * * @param array $array Array such as $_POST or $_GET + * @param array|null $options Extra options to be passed. * @param string $prefix Prefix for returned values. * @return array Returns flatten list of potentially dangerous input values, such as 'data.content'. */ - public static function detectXssFromArray(array $array, $prefix = '') + public static function detectXssFromArray(array $array, string $prefix = '', array $options = null) { - $list = []; + if (null === $options) { + $options = static::getXssDefaults(); + } + $list = []; foreach ($array as $key => $value) { - if (\is_array($value)) { - $list[] = static::detectXssFromArray($value, $prefix . $key . '.'); + if (is_array($value)) { + $list[] = static::detectXssFromArray($value, $prefix . $key . '.', $options); } - if ($result = static::detectXss($value)) { + if ($result = static::detectXss($value, $options)) { $list[] = [$prefix . $key => $result]; } } @@ -89,19 +149,39 @@ class Security /** * Determine if string potentially has a XSS attack. This simple function does not catch all XSS and it is likely to + * * return false positives because of it tags all potentially dangerous HTML tags and attributes without looking into * their content. * - * @param string $string The string to run XSS detection logic on - * @return bool|string Type of XSS vector if the given `$string` may contain XSS, false otherwise. + * @param string|null $string The string to run XSS detection logic on + * @param array|null $options + * @return string|null Type of XSS vector if the given `$string` may contain XSS, false otherwise. * * Copies the code from: https://github.com/symphonycms/xssfilter/blob/master/extension.driver.php#L138 */ - public static function detectXss($string) + public static function detectXss($string, array $options = null): ?string { // Skip any null or non string values - if (null === $string || !\is_string($string) || empty($string)) { - return false; + if (null === $string || !is_string($string) || empty($string)) { + return null; + } + + if (null === $options) { + $options = static::getXssDefaults(); + } + + $enabled_rules = (array)($options['enabled_rules'] ?? null); + $dangerous_tags = (array)($options['dangerous_tags'] ?? null); + if (!$dangerous_tags) { + $enabled_rules['dangerous_tags'] = false; + } + $invalid_protocols = (array)($options['invalid_protocols'] ?? null); + if (!$invalid_protocols) { + $enabled_rules['invalid_protocols'] = false; + } + $enabled_rules = array_filter($enabled_rules, static function ($val) { return !empty($val); }); + if (!$enabled_rules) { + return null; } // Keep a copy of the original string before cleaning up @@ -111,32 +191,26 @@ class Security $string = urldecode($string); // Convert Hexadecimals - $string = (string)preg_replace_callback('!(&#|\\\)[xX]([0-9a-fA-F]+);?!u', function($m) { - return \chr(hexdec($m[2])); + $string = (string)preg_replace_callback('!(&#|\\\)[xX]([0-9a-fA-F]+);?!u', function ($m) { + return chr(hexdec($m[2])); }, $string); // Clean up entities - $string = preg_replace('!(�+[0-9]+)!u','$1;', $string); + $string = preg_replace('!(�+[0-9]+)!u', '$1;', $string); // Decode entities $string = html_entity_decode($string, ENT_NOQUOTES, 'UTF-8'); // Strip whitespace characters - $string = preg_replace('!\s!u','', $string); - - $config = Grav::instance()['config']; - - $dangerous_tags = array_map('preg_quote', array_map("trim", $config->get('security.xss_dangerous_tags'))); - $invalid_protocols = array_map('preg_quote', array_map("trim", $config->get('security.xss_invalid_protocols'))); - $enabled_rules = $config->get('security.xss_enabled'); + $string = preg_replace('!\s!u', '', $string); // Set the patterns we'll test against $patterns = [ // Match any attribute starting with "on" or xmlns - 'on_events' => '#(<[^>]+[[a-z\x00-\x20\"\'\/])(\son|\sxmlns)[a-z].*=>?#iUu', + 'on_events' => '#(<[^>]+[[a-z\x00-\x20\"\'\/])([\s\/]on|\sxmlns)[a-z].*=>?#iUu', // Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols - 'invalid_protocols' => '#(' . implode('|', $invalid_protocols) . '):.*?#iUu', + 'invalid_protocols' => '#(' . implode('|', array_map('preg_quote', $invalid_protocols, ['#'])) . '):\S.*?#iUu', // Match -moz-bindings 'moz_binding' => '#-moz-binding[a-z\x00-\x20]*:#u', @@ -145,21 +219,30 @@ class Security 'html_inline_styles' => '#(<[^>]+[a-z\x00-\x20\"\'\/])(style=[^>]*(url\:|x\:expression).*)>?#iUu', // Match potentially dangerous tags - 'dangerous_tags' => '#]*>?#ui' + 'dangerous_tags' => '#]*>?#ui' ]; - // Iterate over rules and return label if fail - foreach ((array) $patterns as $name => $regex) { - if ($enabled_rules[$name] === true) { - + foreach ($patterns as $name => $regex) { + if (!empty($enabled_rules[$name])) { if (preg_match($regex, $string) || preg_match($regex, $orig)) { return $name; } - } } return false; } + + public static function getXssDefaults(): array + { + /** @var Config $config */ + $config = Grav::instance()['config']; + + return [ + 'enabled_rules' => $config->get('security.xss_enabled'), + 'dangerous_tags' => array_map('trim', $config->get('security.xss_dangerous_tags')), + 'invalid_protocols' => array_map('trim', $config->get('security.xss_invalid_protocols')), + ]; + } } diff --git a/system/src/Grav/Common/Service/AccountsServiceProvider.php b/system/src/Grav/Common/Service/AccountsServiceProvider.php index e0778be..8b158c9 100644 --- a/system/src/Grav/Common/Service/AccountsServiceProvider.php +++ b/system/src/Grav/Common/Service/AccountsServiceProvider.php @@ -3,127 +3,155 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Service; use Grav\Common\Config\Config; -use Grav\Common\Debugger; +use Grav\Common\Grav; +use Grav\Common\Page\Header; +use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\User\DataUser; -use Grav\Common\User\FlexUser; use Grav\Common\User\User; -use Grav\Framework\File\Formatter\YamlFormatter; +use Grav\Events\PermissionsRegisterEvent; +use Grav\Framework\Acl\Permissions; +use Grav\Framework\Acl\PermissionsReader; use Grav\Framework\Flex\Flex; -use Grav\Framework\Flex\FlexDirectory; +use Grav\Framework\Flex\Interfaces\FlexIndexInterface; use Pimple\Container; use Pimple\ServiceProviderInterface; use RocketTheme\Toolbox\Event\Event; -use RocketTheme\Toolbox\Event\EventDispatcher; +use SplFileInfo; +use Symfony\Component\EventDispatcher\EventDispatcher; +use function define; +use function defined; +use function is_array; +/** + * Class AccountsServiceProvider + * @package Grav\Common\Service + */ class AccountsServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { - $container['accounts'] = function (Container $container) { - $type = strtolower(defined('GRAV_USER_INSTANCE') ? GRAV_USER_INSTANCE : $container['config']->get('system.accounts.type', 'data')); - if ($type === 'flex') { - /** @var Debugger $debugger */ - $debugger = $container['debugger']; - $debugger->addMessage('User Accounts: Flex Directory'); - return $this->flexAccounts($container); + $container['permissions'] = static function (Grav $container) { + /** @var Config $config */ + $config = $container['config']; + + $permissions = new Permissions(); + $permissions->addTypes($config->get('permissions.types', [])); + + $array = $config->get('permissions.actions'); + if (is_array($array)) { + $actions = PermissionsReader::fromArray($array, $permissions->getTypes()); + $permissions->addActions($actions); } - return $this->dataAccounts($container); + $event = new PermissionsRegisterEvent($permissions); + $container->dispatchEvent($event); + + return $permissions; }; - $container['users'] = $container->factory(function (Container $container) { + $container['accounts'] = function (Container $container) { + $type = $this->initialize($container); + + return $type === 'flex' ? $this->flexAccounts($container) : $this->regularAccounts($container); + }; + + $container['user_groups'] = static function (Container $container) { + /** @var Flex $flex */ + $flex = $container['flex']; + $directory = $flex->getDirectory('user-groups'); + + return $directory ? $directory->getIndex() : null; + }; + + $container['users'] = $container->factory(static function (Container $container) { user_error('Grav::instance()[\'users\'] is deprecated since Grav 1.6, use Grav::instance()[\'accounts\'] instead', E_USER_DEPRECATED); return $container['accounts']; }); } - protected function dataAccounts(Container $container) + /** + * @param Container $container + * @return string + */ + protected function initialize(Container $container): string { - if (!defined('GRAV_USER_INSTANCE')) { - define('GRAV_USER_INSTANCE', 'DATA'); + $isDefined = defined('GRAV_USER_INSTANCE'); + $type = strtolower($isDefined ? GRAV_USER_INSTANCE : $container['config']->get('system.accounts.type', 'regular')); + + if ($type === 'flex') { + if (!$isDefined) { + define('GRAV_USER_INSTANCE', 'FLEX'); + } + + /** @var EventDispatcher $dispatcher */ + $dispatcher = $container['events']; + + // Stop /admin/user from working, display error instead. + $dispatcher->addListener( + 'onAdminPage', + static function (Event $event) { + $grav = Grav::instance(); + $admin = $grav['admin']; + [$base,$location,] = $admin->getRouteDetails(); + if ($location !== 'user' || isset($grav['flex_objects'])) { + return; + } + + /** @var PageInterface $page */ + $page = $event['page']; + $page->init(new SplFileInfo('plugin://admin/pages/admin/error.md')); + $page->routable(true); + $header = $page->header(); + $header->title = 'Please install missing plugin'; + $page->content("## Please install and enable **[Flex Objects]({$base}/plugins/flex-objects)** plugin. It is required to edit **Flex User Accounts**."); + + /** @var Header $header */ + $header = $page->header(); + $directory = $grav['accounts']->getFlexDirectory(); + $menu = $directory->getConfig('admin.menu.list'); + $header->access = $menu['authorize'] ?? ['admin.super']; + }, + 100000 + ); + } elseif (!$isDefined) { + define('GRAV_USER_INSTANCE', 'REGULAR'); } + return $type; + } + + /** + * @param Container $container + * @return DataUser\UserCollection + */ + protected function regularAccounts(Container $container) + { // Use User class for backwards compatibility. return new DataUser\UserCollection(User::class); } + /** + * @param Container $container + * @return FlexIndexInterface|null + */ protected function flexAccounts(Container $container) { - if (!defined('GRAV_USER_INSTANCE')) { - define('GRAV_USER_INSTANCE', 'FLEX'); - } + /** @var Flex $flex */ + $flex = $container['flex']; + $directory = $flex->getDirectory('user-accounts'); - /** @var Config $config */ - $config = $container['config']; - - $options = [ - 'enabled' => true, - 'data' => [ - 'object' => User::class, // Use User class for backwards compatibility. - 'collection' => FlexUser\UserCollection::class, - 'index' => FlexUser\UserIndex::class, - 'storage' => $this->getFlexStorage($config->get('system.accounts.storage', 'file')), - 'search' => [ - 'options' => [ - 'contains' => 1 - ], - 'fields' => [ - 'key', - 'email' - ] - ] - ] - ] + ($config->get('plugins.flex-objects.object') ?: []); - - $directory = new FlexDirectory('accounts', 'blueprints://user/accounts.yaml', $options); - - /** @var EventDispatcher $dispatcher */ - $dispatcher = $container['events']; - $dispatcher->addListener('onFlexInit', function (Event $event) use ($directory) { - /** @var Flex $flex */ - $flex = $event['flex']; - $flex->addDirectory($directory); - }); - - return $directory->getIndex(); - } - - protected function getFlexStorage($config) - { - if (\is_array($config)) { - return $config; - } - - if ($config === 'folder') { - return [ - 'class' => FlexUser\Storage\UserFolderStorage::class, - 'options' => [ - 'formatter' => ['class' => YamlFormatter::class], - 'folder' => 'account://', - 'pattern' => '{FOLDER}/{KEY:2}/{KEY}/user.yaml', - 'key' => 'username', - 'indexed' => true - ], - ]; - } - - return [ - 'class' => FlexUser\Storage\UserFileStorage::class, - 'options' => [ - 'formatter' => ['class' => YamlFormatter::class], - 'folder' => 'account://', - 'pattern' => '{FOLDER}/{KEY}.yaml', - 'key' => 'storage_key', - 'indexed' => true - ], - ]; + return $directory ? $directory->getIndex() : null; } } diff --git a/system/src/Grav/Common/Service/AssetsServiceProvider.php b/system/src/Grav/Common/Service/AssetsServiceProvider.php index 004da9c..1e4e647 100644 --- a/system/src/Grav/Common/Service/AssetsServiceProvider.php +++ b/system/src/Grav/Common/Service/AssetsServiceProvider.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,8 +13,16 @@ use Pimple\Container; use Pimple\ServiceProviderInterface; use Grav\Common\Assets; +/** + * Class AssetsServiceProvider + * @package Grav\Common\Service + */ class AssetsServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { $container['assets'] = function () { diff --git a/system/src/Grav/Common/Service/BackupsServiceProvider.php b/system/src/Grav/Common/Service/BackupsServiceProvider.php index 2c01036..00fa963 100644 --- a/system/src/Grav/Common/Service/BackupsServiceProvider.php +++ b/system/src/Grav/Common/Service/BackupsServiceProvider.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,8 +13,16 @@ use Grav\Common\Backup\Backups; use Pimple\Container; use Pimple\ServiceProviderInterface; +/** + * Class BackupsServiceProvider + * @package Grav\Common\Service + */ class BackupsServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { $container['backups'] = function () { diff --git a/system/src/Grav/Common/Service/ConfigServiceProvider.php b/system/src/Grav/Common/Service/ConfigServiceProvider.php index c874441..a423f6e 100644 --- a/system/src/Grav/Common/Service/ConfigServiceProvider.php +++ b/system/src/Grav/Common/Service/ConfigServiceProvider.php @@ -3,12 +3,13 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Service; +use DirectoryIterator; use Grav\Common\Config\CompiledBlueprints; use Grav\Common\Config\CompiledConfig; use Grav\Common\Config\CompiledLanguages; @@ -21,8 +22,16 @@ use Pimple\ServiceProviderInterface; use RocketTheme\Toolbox\File\YamlFile; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +/** + * Class ConfigServiceProvider + * @package Grav\Common\Service + */ class ConfigServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { $container['setup'] = function ($c) { @@ -56,6 +65,10 @@ class ConfigServiceProvider implements ServiceProviderInterface }; } + /** + * @param Container $container + * @return mixed + */ public static function blueprints(Container $container) { /** Setup $setup */ @@ -71,6 +84,8 @@ class ConfigServiceProvider implements ServiceProviderInterface $files += (new ConfigFileFinder)->locateFiles($paths); $paths = $locator->findResources('plugins://'); $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'blueprints'); + $paths = $locator->findResources('themes://'); + $files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths, 'blueprints'); $blueprints = new CompiledBlueprints($cache, $files, GRAV_ROOT); @@ -96,9 +111,11 @@ class ConfigServiceProvider implements ServiceProviderInterface $files += (new ConfigFileFinder)->locateFiles($paths); $paths = $locator->findResources('plugins://'); $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths); + $paths = $locator->findResources('themes://'); + $files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths); $compiled = new CompiledConfig($cache, $files, GRAV_ROOT); - $compiled->setBlueprints(function() use ($container) { + $compiled->setBlueprints(function () use ($container) { return $container['blueprints']; }); @@ -108,6 +125,10 @@ class ConfigServiceProvider implements ServiceProviderInterface return $config; } + /** + * @param Container $container + * @return mixed + */ public static function languages(Container $container) { /** @var Setup $setup */ @@ -149,9 +170,9 @@ class ConfigServiceProvider implements ServiceProviderInterface $paths = []; foreach ($plugins as $path) { - $iterator = new \DirectoryIterator($path); + $iterator = new DirectoryIterator($path); - /** @var \DirectoryIterator $directory */ + /** @var DirectoryIterator $directory */ foreach ($iterator as $directory) { if (!$directory->isDir() || $directory->isDot()) { continue; @@ -168,5 +189,4 @@ class ConfigServiceProvider implements ServiceProviderInterface } return $paths; } - } diff --git a/system/src/Grav/Common/Service/ErrorServiceProvider.php b/system/src/Grav/Common/Service/ErrorServiceProvider.php index fce69ff..3227365 100644 --- a/system/src/Grav/Common/Service/ErrorServiceProvider.php +++ b/system/src/Grav/Common/Service/ErrorServiceProvider.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,8 +13,16 @@ use Grav\Common\Errors\Errors; use Pimple\Container; use Pimple\ServiceProviderInterface; +/** + * Class ErrorServiceProvider + * @package Grav\Common\Service + */ class ErrorServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { $container['errors'] = new Errors; diff --git a/system/src/Grav/Common/Service/FilesystemServiceProvider.php b/system/src/Grav/Common/Service/FilesystemServiceProvider.php index f5547cb..62dad5d 100644 --- a/system/src/Grav/Common/Service/FilesystemServiceProvider.php +++ b/system/src/Grav/Common/Service/FilesystemServiceProvider.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,8 +13,16 @@ use Grav\Framework\Filesystem\Filesystem; use Pimple\Container; use Pimple\ServiceProviderInterface; +/** + * Class FilesystemServiceProvider + * @package Grav\Common\Service + */ class FilesystemServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { $container['filesystem'] = function () { diff --git a/system/src/Grav/Common/Service/FlexServiceProvider.php b/system/src/Grav/Common/Service/FlexServiceProvider.php new file mode 100644 index 0000000..9c8cd49 --- /dev/null +++ b/system/src/Grav/Common/Service/FlexServiceProvider.php @@ -0,0 +1,117 @@ + $config->get('system.flex', [])]); + FlexFormFlash::setFlex($flex); + + $accountsEnabled = $config->get('system.accounts.type', 'regular') === 'flex'; + $pagesEnabled = $config->get('system.pages.type', 'regular') === 'flex'; + + // Add built-in types from Grav. + if ($pagesEnabled) { + $flex->addDirectoryType( + 'pages', + 'blueprints://flex/pages.yaml', + [ + 'enabled' => $pagesEnabled + ] + ); + } + if ($accountsEnabled) { + $flex->addDirectoryType( + 'user-accounts', + 'blueprints://flex/user-accounts.yaml', + [ + 'enabled' => $accountsEnabled, + 'data' => [ + 'storage' => $this->getFlexAccountsStorage($config), + ] + ] + ); + $flex->addDirectoryType( + 'user-groups', + 'blueprints://flex/user-groups.yaml', + [ + 'enabled' => $accountsEnabled + ] + ); + } + + // Call event to register Flex Directories. + $event = new FlexRegisterEvent($flex); + $container->dispatchEvent($event); + + return $flex; + }; + } + + /** + * @param Config $config + * @return array + */ + private function getFlexAccountsStorage(Config $config): array + { + $value = $config->get('system.accounts.storage', 'file'); + if (is_array($value)) { + return $value; + } + + if ($value === 'folder') { + return [ + 'class' => UserFolderStorage::class, + 'options' => [ + 'file' => 'user', + 'pattern' => '{FOLDER}/{KEY:2}/{KEY}/{FILE}{EXT}', + 'key' => 'storage_key' + ], + ]; + } + + if ($value === 'file') { + return [ + 'class' => UserFileStorage::class, + 'options' => [ + 'pattern' => '{FOLDER}/{KEY}{EXT}', + 'key' => 'username' + ], + ]; + } + + return []; + } +} diff --git a/system/src/Grav/Common/Service/InflectorServiceProvider.php b/system/src/Grav/Common/Service/InflectorServiceProvider.php index fd69547..861a69e 100644 --- a/system/src/Grav/Common/Service/InflectorServiceProvider.php +++ b/system/src/Grav/Common/Service/InflectorServiceProvider.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,8 +13,16 @@ use Grav\Common\Inflector; use Pimple\Container; use Pimple\ServiceProviderInterface; +/** + * Class InflectorServiceProvider + * @package Grav\Common\Service + */ class InflectorServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { $container['inflector'] = function () { diff --git a/system/src/Grav/Common/Service/LoggerServiceProvider.php b/system/src/Grav/Common/Service/LoggerServiceProvider.php index 85830de..5043cfb 100644 --- a/system/src/Grav/Common/Service/LoggerServiceProvider.php +++ b/system/src/Grav/Common/Service/LoggerServiceProvider.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,8 +15,16 @@ use Pimple\Container; use Pimple\ServiceProviderInterface; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +/** + * Class LoggerServiceProvider + * @package Grav\Common\Service + */ class LoggerServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { $container['log'] = function ($c) { diff --git a/system/src/Grav/Common/Service/OutputServiceProvider.php b/system/src/Grav/Common/Service/OutputServiceProvider.php index a9b2877..9a49185 100644 --- a/system/src/Grav/Common/Service/OutputServiceProvider.php +++ b/system/src/Grav/Common/Service/OutputServiceProvider.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,8 +14,16 @@ use Grav\Common\Twig\Twig; use Pimple\Container; use Pimple\ServiceProviderInterface; +/** + * Class OutputServiceProvider + * @package Grav\Common\Service + */ class OutputServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { $container['output'] = function ($c) { diff --git a/system/src/Grav/Common/Service/PagesServiceProvider.php b/system/src/Grav/Common/Service/PagesServiceProvider.php index f425b6d..55f6a45 100644 --- a/system/src/Grav/Common/Service/PagesServiceProvider.php +++ b/system/src/Grav/Common/Service/PagesServiceProvider.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -17,20 +17,30 @@ use Grav\Common\Page\Pages; use Grav\Common\Uri; use Pimple\Container; use Pimple\ServiceProviderInterface; +use SplFileInfo; +use function defined; +/** + * Class PagesServiceProvider + * @package Grav\Common\Service + */ class PagesServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { - $container['pages'] = function ($c) { - return new Pages($c); + $container['pages'] = function (Grav $grav) { + return new Pages($grav); }; - if (\defined('GRAV_CLI')) { - $container['page'] = static function ($c) { - $path = $c['locator']->findResource('system://pages/notfound.md'); + if (defined('GRAV_CLI')) { + $container['page'] = static function (Grav $grav) { + $path = $grav['locator']->findResource('system://pages/notfound.md'); $page = new Page(); - $page->init(new \SplFileInfo($path)); + $page->init(new SplFileInfo($path)); $page->routable(false); return $page; @@ -39,17 +49,15 @@ class PagesServiceProvider implements ServiceProviderInterface return; } - $container['page'] = function ($c) { - /** @var Grav $c */ - + $container['page'] = static function (Grav $grav) { /** @var Pages $pages */ - $pages = $c['pages']; + $pages = $grav['pages']; /** @var Config $config */ - $config = $c['config']; + $config = $grav['config']; /** @var Uri $uri */ - $uri = $c['uri']; + $uri = $grav['uri']; $path = $uri->path() ?: '/'; // Don't trim to support trailing slash default routes $page = $pages->dispatch($path); @@ -58,55 +66,69 @@ class PagesServiceProvider implements ServiceProviderInterface if ($page) { // some debugger override logic if ($page->debugger() === false) { - $c['debugger']->enabled(false); + $grav['debugger']->enabled(false); } if ($config->get('system.force_ssl')) { - if (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') { + $scheme = $uri->scheme(true); + if ($scheme !== 'https') { $url = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; - $c->redirect($url); + $grav->redirect($url); } } - $url = $pages->route($page->route()); + $route = $page->route(); + if ($route && \in_array($uri->method(), ['GET', 'HEAD'], true)) { + $pageExtension = $page->urlExtension(); + $url = $pages->route($route) . $pageExtension; - if ($uri->params()) { - if ($url === '/') { //Avoid double slash - $url = $uri->params(); - } else { - $url .= $uri->params(); + if ($uri->params()) { + if ($url === '/') { //Avoid double slash + $url = $uri->params(); + } else { + $url .= $uri->params(); + } + } + if ($uri->query()) { + $url .= '?' . $uri->query(); + } + if ($uri->fragment()) { + $url .= '#' . $uri->fragment(); } - } - if ($uri->query()) { - $url .= '?' . $uri->query(); - } - if ($uri->fragment()) { - $url .= '#' . $uri->fragment(); - } - /** @var Language $language */ - $language = $c['language']; + /** @var Language $language */ + $language = $grav['language']; - // Language-specific redirection scenarios - if ($language->enabled() && ($language->isLanguageInUrl() xor $language->isIncludeDefaultLanguage())) { - $c->redirect($url); - } - // Default route test and redirect - if ($config->get('system.pages.redirect_default_route') && $page->route() !== $path) { - $c->redirect($url); + $redirectCode = (int)$config->get('system.pages.redirect_default_route', 0); + + // Language-specific redirection scenarios + if ($language->enabled() && ($language->isLanguageInUrl() xor $language->isIncludeDefaultLanguage())) { + $grav->redirect($url, $redirectCode); + } + + // Default route test and redirect + if ($redirectCode) { + $uriExtension = $uri->extension(); + $uriExtension = null !== $uriExtension ? '.' . $uriExtension : ''; + + if ($route !== $path || ($pageExtension !== $uriExtension + && \in_array($pageExtension, ['', '.htm', '.html'], true) + && \in_array($uriExtension, ['', '.htm', '.html'], true))) { + $grav->redirect($url, $redirectCode); + } + } } } // if page is not found, try some fallback stuff if (!$page || !$page->routable()) { - // Try fallback URL stuff... - $page = $c->fallbackUrl($path); + $page = $grav->fallbackUrl($path); if (!$page) { - $path = $c['locator']->findResource('system://pages/notfound.md'); + $path = $grav['locator']->findResource('system://pages/notfound.md'); $page = new Page(); - $page->init(new \SplFileInfo($path)); + $page->init(new SplFileInfo($path)); $page->routable(false); } } diff --git a/system/src/Grav/Common/Service/RequestServiceProvider.php b/system/src/Grav/Common/Service/RequestServiceProvider.php index 6410ef7..17b3149 100644 --- a/system/src/Grav/Common/Service/RequestServiceProvider.php +++ b/system/src/Grav/Common/Service/RequestServiceProvider.php @@ -3,20 +3,36 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Service; use Grav\Common\Uri; +use JsonException; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7Server\ServerRequestCreator; use Pimple\Container; use Pimple\ServiceProviderInterface; +use function explode; +use function fopen; +use function function_exists; +use function in_array; +use function is_array; +use function strtolower; +use function trim; +/** + * Class RequestServiceProvider + * @package Grav\Common\Service + */ class RequestServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { $container['request'] = function () { @@ -28,10 +44,59 @@ class RequestServiceProvider implements ServiceProviderInterface $psr17Factory // StreamFactory ); - return $creator->fromGlobals(); + $server = $_SERVER; + if (false === isset($server['REQUEST_METHOD'])) { + $server['REQUEST_METHOD'] = 'GET'; + } + $method = $server['REQUEST_METHOD']; + + $headers = function_exists('getallheaders') ? getallheaders() : $creator::getHeadersFromServer($_SERVER); + + $post = null; + if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) { + foreach ($headers as $headerName => $headerValue) { + if ('content-type' !== strtolower($headerName)) { + continue; + } + + $contentType = strtolower(trim(explode(';', $headerValue, 2)[0])); + switch ($contentType) { + case 'application/x-www-form-urlencoded': + case 'multipart/form-data': + $post = $_POST; + break 2; + case 'application/json': + case 'application/vnd.api+json': + try { + $json = file_get_contents('php://input'); + $post = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($post)) { + $post = null; + } + } catch (JsonException $e) { + $post = null; + } + break 2; + } + } + } + + // Remove _url from ngnix routes. + $get = $_GET; + unset($get['_url']); + if (isset($server['QUERY_STRING'])) { + $query = $server['QUERY_STRING']; + if (strpos($query, '_url=') !== false) { + parse_str($query, $query); + unset($query['_url']); + $server['QUERY_STRING'] = http_build_query($query); + } + } + + return $creator->fromArrays($server, $headers, $_COOKIE, $get, $post, $_FILES, fopen('php://input', 'rb') ?: null); }; - $container['route'] = $container->factory(function() { + $container['route'] = $container->factory(function () { return clone Uri::getCurrentRoute(); }); } diff --git a/system/src/Grav/Common/Service/SchedulerServiceProvider.php b/system/src/Grav/Common/Service/SchedulerServiceProvider.php index 1df4bbc..a952725 100644 --- a/system/src/Grav/Common/Service/SchedulerServiceProvider.php +++ b/system/src/Grav/Common/Service/SchedulerServiceProvider.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,8 +13,16 @@ use Grav\Common\Scheduler\Scheduler; use Pimple\Container; use Pimple\ServiceProviderInterface; +/** + * Class SchedulerServiceProvider + * @package Grav\Common\Service + */ class SchedulerServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { $container['scheduler'] = function () { diff --git a/system/src/Grav/Common/Service/SessionServiceProvider.php b/system/src/Grav/Common/Service/SessionServiceProvider.php index 84d23d3..88c833f 100644 --- a/system/src/Grav/Common/Service/SessionServiceProvider.php +++ b/system/src/Grav/Common/Service/SessionServiceProvider.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,16 +14,24 @@ use Grav\Common\Debugger; use Grav\Common\Session; use Grav\Common\Uri; use Grav\Common\Utils; +use Grav\Framework\Session\Messages; use Pimple\Container; use Pimple\ServiceProviderInterface; -use RocketTheme\Toolbox\Session\Message; +/** + * Class SessionServiceProvider + * @package Grav\Common\Service + */ class SessionServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { // Define session service. - $container['session'] = function ($c) { + $container['session'] = static function ($c) { /** @var Config $config */ $config = $c['config']; @@ -35,18 +43,23 @@ class SessionServiceProvider implements ServiceProviderInterface $cookie_secure = (bool)$config->get('system.session.secure', false); $cookie_httponly = (bool)$config->get('system.session.httponly', true); $cookie_lifetime = (int)$config->get('system.session.timeout', 1800); + $cookie_domain = $config->get('system.session.domain'); $cookie_path = $config->get('system.session.path'); + $cookie_samesite = $config->get('system.session.samesite', 'Lax'); + + if (null === $cookie_domain) { + $cookie_domain = $uri->host(); + if ($cookie_domain === 'localhost') { + $cookie_domain = ''; + } + } + if (null === $cookie_path) { $cookie_path = '/' . trim(Uri::filterPath($uri->rootUrl(false)), '/'); } // Session cookie path requires trailing slash. $cookie_path = rtrim($cookie_path, '/') . '/'; - $cookie_domain = $uri->host(); - if ($cookie_domain === 'localhost') { - $cookie_domain = ''; - } - // Activate admin if we're inside the admin path. $is_admin = false; if ($config->get('plugins.admin.enabled')) { @@ -87,7 +100,8 @@ class SessionServiceProvider implements ServiceProviderInterface 'cookie_path' => $cookie_path, 'cookie_domain' => $cookie_domain, 'cookie_secure' => $cookie_secure, - 'cookie_httponly' => $cookie_httponly + 'cookie_httponly' => $cookie_httponly, + 'cookie_samesite' => $cookie_samesite ] + (array) $config->get('system.session.options'); $session = new Session($options); @@ -103,14 +117,14 @@ class SessionServiceProvider implements ServiceProviderInterface $debugger = $c['debugger']; $debugger->addMessage('Inactive session: session messages may disappear', 'warming'); - return new Message; + return new Messages(); } /** @var Session $session */ $session = $c['session']; - if (!isset($session->messages)) { - $session->messages = new Message; + if (!$session->messages instanceof Messages) { + $session->messages = new Messages(); } return $session->messages; diff --git a/system/src/Grav/Common/Service/StreamsServiceProvider.php b/system/src/Grav/Common/Service/StreamsServiceProvider.php index 3bbbea1..edde09a 100644 --- a/system/src/Grav/Common/Service/StreamsServiceProvider.php +++ b/system/src/Grav/Common/Service/StreamsServiceProvider.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -17,12 +17,20 @@ use RocketTheme\Toolbox\StreamWrapper\ReadOnlyStream; use RocketTheme\Toolbox\StreamWrapper\Stream; use RocketTheme\Toolbox\StreamWrapper\StreamBuilder; +/** + * Class StreamsServiceProvider + * @package Grav\Common\Service + */ class StreamsServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { - $container['locator'] = function(Container $container) { - $locator = new UniformResourceLocator(GRAV_ROOT); + $container['locator'] = function (Container $container) { + $locator = new UniformResourceLocator(GRAV_WEBROOT); /** @var Setup $setup */ $setup = $container['setup']; @@ -31,7 +39,7 @@ class StreamsServiceProvider implements ServiceProviderInterface return $locator; }; - $container['streams'] = function(Container $container) { + $container['streams'] = function (Container $container) { /** @var Setup $setup */ $setup = $container['setup']; diff --git a/system/src/Grav/Common/Service/TaskServiceProvider.php b/system/src/Grav/Common/Service/TaskServiceProvider.php index c22121a..9afab59 100644 --- a/system/src/Grav/Common/Service/TaskServiceProvider.php +++ b/system/src/Grav/Common/Service/TaskServiceProvider.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Service * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,8 +13,16 @@ use Grav\Common\Grav; use Pimple\Container; use Pimple\ServiceProviderInterface; +/** + * Class TaskServiceProvider + * @package Grav\Common\Service + */ class TaskServiceProvider implements ServiceProviderInterface { + /** + * @param Container $container + * @return void + */ public function register(Container $container) { $container['task'] = function (Grav $c) { diff --git a/system/src/Grav/Common/Session.php b/system/src/Grav/Common/Session.php index cd9b2c1..8856e7a 100644 --- a/system/src/Grav/Common/Session.php +++ b/system/src/Grav/Common/Session.php @@ -3,14 +3,21 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; use Grav\Common\Form\FormFlash; +use Grav\Events\SessionStartEvent; +use Grav\Plugin\Form\Forms; +use function is_string; +/** + * Class Session + * @package Grav\Common + */ class Session extends \Grav\Framework\Session\Session { /** @var bool */ @@ -31,6 +38,8 @@ class Session extends \Grav\Framework\Session\Session * Initialize session. * * Code in this function has been moved into SessionServiceProvider class. + * + * @return void */ public function init() { @@ -68,7 +77,7 @@ class Session extends \Grav\Framework\Session\Session /** * Checks if the session was started. * - * @return Boolean + * @return bool * @deprecated 1.5 Use ->isStarted() method instead. */ public function started() @@ -102,7 +111,7 @@ class Session extends \Grav\Framework\Session\Session { $serialized = $this->__get($name); - $object = \is_string($serialized) ? unserialize($serialized, ['allowed_classes' => true]) : $serialized; + $object = is_string($serialized) ? unserialize($serialized, ['allowed_classes' => true]) : $serialized; $this->__unset($name); @@ -118,14 +127,14 @@ class Session extends \Grav\Framework\Session\Session /** @var Uri $uri */ $uri = $grav['uri']; - /** @var Grav\Plugin\Form\Forms $form */ - $form = $grav['forms']->getActiveForm(); + /** @var Forms|null $form */ + $form = $grav['forms']->getActiveForm(); // @phpstan-ignore-line $sessionField = base64_encode($uri->url); - /** @var FormFlash $flash */ - $flash = $form ? $form->getFlash() : null; - $object = $flash ? [$sessionField => $flash->getLegacyFiles()] : null; + /** @var FormFlash|null $flash */ + $flash = $form ? $form->getFlash() : null; // @phpstan-ignore-line + $object = $flash && method_exists($flash, 'getLegacyFiles') ? [$sessionField => $flash->getLegacyFiles()] : null; } } @@ -156,11 +165,22 @@ class Session extends \Grav\Framework\Session\Session public function getFlashCookieObject($name) { if (isset($_COOKIE[$name])) { - $object = json_decode($_COOKIE[$name]); + $object = json_decode($_COOKIE[$name], false); setcookie($name, '', time() - 3600, '/'); return $object; } return null; } + + /** + * @return void + */ + protected function onSessionStart(): void + { + $event = new SessionStartEvent($this); + + $grav = Grav::instance(); + $grav->dispatchEvent($event); + } } diff --git a/system/src/Grav/Common/Taxonomy.php b/system/src/Grav/Common/Taxonomy.php index e323647..de3c452 100644 --- a/system/src/Grav/Common/Taxonomy.php +++ b/system/src/Grav/Common/Taxonomy.php @@ -3,7 +3,7 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -12,6 +12,7 @@ namespace Grav\Common; use Grav\Common\Config\Config; use Grav\Common\Page\Collection; use Grav\Common\Page\Interfaces\PageInterface; +use function is_string; /** * The Taxonomy object is a singleton that holds a reference to a 'taxonomy map'. This map is @@ -32,7 +33,9 @@ use Grav\Common\Page\Interfaces\PageInterface; */ class Taxonomy { + /** @var array */ protected $taxonomy_map; + /** @var Grav */ protected $grav; /** @@ -51,28 +54,60 @@ class Taxonomy * then adds those taxonomies to the map * * @param PageInterface $page the page to process - * @param array $page_taxonomy + * @param array|null $page_taxonomy */ public function addTaxonomy(PageInterface $page, $page_taxonomy = null) { + if (!$page->published()) { + return; + } + if (!$page_taxonomy) { $page_taxonomy = $page->taxonomy(); } - if (empty($page_taxonomy) || !$page->published()) { + if (empty($page_taxonomy)) { return; } /** @var Config $config */ $config = $this->grav['config']; - if ($config->get('site.taxonomies')) { - foreach ((array)$config->get('site.taxonomies') as $taxonomy) { - if (isset($page_taxonomy[$taxonomy])) { - foreach ((array)$page_taxonomy[$taxonomy] as $item) { - $this->taxonomy_map[$taxonomy][(string)$item][$page->path()] = ['slug' => $page->slug()]; - } - } + $taxonomies = (array)$config->get('site.taxonomies'); + foreach ($taxonomies as $taxonomy) { + // Skip invalid taxonomies. + if (!\is_string($taxonomy)) { + continue; } + $current = $page_taxonomy[$taxonomy] ?? null; + foreach ((array)$current as $item) { + $this->iterateTaxonomy($page, $taxonomy, '', $item); + } + } + } + + /** + * Iterate through taxonomy fields + * + * Reduces [taxonomy_type] to dot-notation where necessary + * + * @param PageInterface $page The Page to process + * @param string $taxonomy Taxonomy type to add + * @param string $key Taxonomy type to concatenate + * @param iterable|string $value Taxonomy value to add or iterate + * @return void + */ + public function iterateTaxonomy(PageInterface $page, string $taxonomy, string $key, $value) + { + if (is_iterable($value)) { + foreach ($value as $identifier => $item) { + $identifier = "{$key}.{$identifier}"; + $this->iterateTaxonomy($page, $taxonomy, $identifier, $item); + } + } elseif (is_string($value)) { + if (!empty($key)) { + $taxonomy = $taxonomy . $key; + } + $this->taxonomy_map[$taxonomy][(string) $value][$page->path()] = ['slug' => $page->slug()]; } } @@ -82,7 +117,6 @@ class Taxonomy * * @param array $taxonomies taxonomies to search, eg ['tag'=>['animal','cat']] * @param string $operator can be 'or' or 'and' (defaults to 'and') - * * @return Collection Collection object set to contain matches found in the taxonomy map */ public function findTaxonomy($taxonomies, $operator = 'and') @@ -117,8 +151,7 @@ class Taxonomy /** * Gets and Sets the taxonomy map * - * @param array $var the taxonomy map - * + * @param array|null $var the taxonomy map * @return array the taxonomy map */ public function taxonomy($var = null) @@ -134,7 +167,6 @@ class Taxonomy * Gets item keys per taxonomy * * @param string $taxonomy taxonomy name - * * @return array keys of this taxonomy */ public function getTaxonomyItemKeys($taxonomy) diff --git a/system/src/Grav/Common/Theme.php b/system/src/Grav/Common/Theme.php index dbc589f..a5006a2 100644 --- a/system/src/Grav/Common/Theme.php +++ b/system/src/Grav/Common/Theme.php @@ -3,16 +3,20 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; -use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Config\Config; use RocketTheme\Toolbox\File\YamlFile; +use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +/** + * Class Theme + * @package Grav\Common + */ class Theme extends Plugin { /** @@ -30,67 +34,54 @@ class Theme extends Plugin /** * Get configuration of the plugin. * - * @return Config + * @return array */ public function config() { - return $this->config["themes.{$this->name}"]; + return $this->config["themes.{$this->name}"] ?? []; } /** * Persists to disk the theme parameters currently stored in the Grav Config object * - * @param string $theme_name The name of the theme whose config it should store. - * - * @return true + * @param string $name The name of the theme whose config it should store. + * @return bool */ - public static function saveConfig($theme_name) + public static function saveConfig($name) { - if (!$theme_name) { + if (!$name) { return false; } $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ $locator = $grav['locator']; - $filename = 'config://themes/' . $theme_name . '.yaml'; - $file = YamlFile::instance($locator->findResource($filename, true, true)); - $content = $grav['config']->get('themes.' . $theme_name); + + $filename = 'config://themes/' . $name . '.yaml'; + $file = YamlFile::instance((string)$locator->findResource($filename, true, true)); + $content = $grav['config']->get('themes.' . $name); $file->save($content); $file->free(); + unset($file); return true; } - /** - * Override the mergeConfig method to work for themes - */ - protected function mergeConfig(PageInterface $page, $deep = 'merge', $params = [], $type = 'themes') - { - return parent::mergeConfig($page, $deep, $params, $type); - } - - /** - * Simpler getter for the theme blueprint - * - * @return mixed - */ - public function getBlueprint() - { - if (!$this->blueprint) { - $this->loadBlueprint(); - } - return $this->blueprint; - } - /** * Load blueprints. + * + * @return void */ protected function loadBlueprint() { if (!$this->blueprint) { $grav = Grav::instance(); + /** @var Themes $themes */ $themes = $grav['themes']; - $this->blueprint = $themes->get($this->name)->blueprints(); + $data = $themes->get($this->name); + \assert($data !== null); + $this->blueprint = $data->blueprints(); } } } diff --git a/system/src/Grav/Common/Themes.php b/system/src/Grav/Common/Themes.php index 6720d46..bf155ed 100644 --- a/system/src/Grav/Common/Themes.php +++ b/system/src/Grav/Common/Themes.php @@ -3,28 +3,39 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; +use DirectoryIterator; +use Exception; use Grav\Common\Config\Config; use Grav\Common\File\CompiledYamlFile; use Grav\Common\Data\Blueprints; use Grav\Common\Data\Data; -use RocketTheme\Toolbox\Event\EventDispatcher; -use RocketTheme\Toolbox\Event\EventSubscriberInterface; +use Grav\Framework\Psr7\Response; +use InvalidArgumentException; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use function defined; +use function in_array; +use function strlen; +/** + * Class Themes + * @package Grav\Common + */ class Themes extends Iterator { /** @var Grav */ protected $grav; - /** @var Config */ protected $config; - + /** @var bool */ protected $inited = false; /** @@ -43,6 +54,9 @@ class Themes extends Iterator spl_autoload_register([$this, 'autoloadTheme']); } + /** + * @return void + */ public function init() { /** @var Themes $themes */ @@ -52,6 +66,9 @@ class Themes extends Iterator $this->initTheme(); } + /** + * @return void + */ public function initTheme() { if ($this->inited === false) { @@ -60,17 +77,36 @@ class Themes extends Iterator try { $instance = $themes->load(); - } catch (\InvalidArgumentException $e) { - throw new \RuntimeException($this->current() . ' theme could not be found'); + } catch (InvalidArgumentException $e) { + throw new RuntimeException($this->current() . ' theme could not be found'); } + // Register autoloader. + if (method_exists($instance, 'autoload')) { + $instance->autoload(); + } + + // Register event listeners. if ($instance instanceof EventSubscriberInterface) { /** @var EventDispatcher $events */ $events = $this->grav['events']; - $events->addSubscriber($instance); } + // Register blueprints. + if (is_dir('theme://blueprints/pages')) { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + $locator->addPath('blueprints', '', ['theme://blueprints'], ['user', 'blueprints']); + } + + // Register form fields. + if (method_exists($instance, 'getFormFieldTypes')) { + /** @var Plugins $plugins */ + $plugins = $this->grav['plugins']; + $plugins->formFieldTypes = $instance->getFormFieldTypes() + $plugins->formFieldTypes; + } + $this->grav['theme'] = $instance; $this->grav->fireEvent('onThemeInitialized'); @@ -93,7 +129,7 @@ class Themes extends Iterator $iterator = $locator->getIterator('themes://'); - /** @var \DirectoryIterator $directory */ + /** @var DirectoryIterator $directory */ foreach ($iterator as $directory) { if (!$directory->isDir() || $directory->isDot()) { continue; @@ -103,8 +139,8 @@ class Themes extends Iterator try { $result = $this->get($theme); - } catch (\Exception $e) { - $exception = new \RuntimeException(sprintf('Theme %s: %s', $theme, $e->getMessage()), $e->getCode(), $e); + } catch (Exception $e) { + $exception = new RuntimeException(sprintf('Theme %s: %s', $theme, $e->getMessage()), $e->getCode(), $e); /** @var Debugger $debugger */ $debugger = $this->grav['debugger']; @@ -118,7 +154,7 @@ class Themes extends Iterator $list[$theme] = $result; } } - ksort($list); + ksort($list, SORT_NATURAL | SORT_FLAG_CASE); return $list; } @@ -127,14 +163,13 @@ class Themes extends Iterator * Get theme configuration or throw exception if it cannot be found. * * @param string $name - * - * @return Data - * @throws \RuntimeException + * @return Data|null + * @throws RuntimeException */ public function get($name) { if (!$name) { - throw new \RuntimeException('Theme name not provided.'); + throw new RuntimeException('Theme name not provided.'); } $blueprints = new Blueprints('themes://'); @@ -194,27 +229,28 @@ class Themes extends Iterator $locator = $grav['locator']; $file = $locator('theme://theme.php') ?: $locator("theme://{$name}.php"); - $inflector = $grav['inflector']; - if ($file) { // Local variables available in the file: $grav, $config, $name, $file $class = include $file; - if (!is_object($class)) { + if (!$class || !is_subclass_of($class, Plugin::class, true)) { + $className = Inflector::camelize($name); $themeClassFormat = [ - 'Grav\\Theme\\' . ucfirst($name), - 'Grav\\Theme\\' . $inflector->camelize($name) + 'Grav\\Theme\\' . $className, + 'Grav\\Theme\\' . ucfirst($name) ]; foreach ($themeClassFormat as $themeClass) { - if (class_exists($themeClass)) { + if (is_subclass_of($themeClass, Theme::class, true)) { $class = new $themeClass($grav, $config, $name); break; } } } } elseif (!$locator('theme://') && !defined('GRAV_CLI')) { - exit("Theme '$name' does not exist, unable to display page."); + $response = new Response(500, [], "Theme '$name' does not exist, unable to display page."); + + $grav->close($response); } $this->config->set('theme', $config->get('themes.' . $name)); @@ -229,7 +265,8 @@ class Themes extends Iterator /** * Configure and prepare streams for current template. * - * @throws \InvalidArgumentException + * @return void + * @throws InvalidArgumentException */ public function configure() { @@ -261,7 +298,7 @@ class Themes extends Iterator } } - if (\in_array($scheme, $registered, true)) { + if (in_array($scheme, $registered, true)) { stream_wrapper_unregister($scheme); } $type = !empty($config['type']) ? $config['type'] : 'ReadOnlyStream'; @@ -270,7 +307,7 @@ class Themes extends Iterator } if (!stream_wrapper_register($scheme, $type)) { - throw new \InvalidArgumentException("Stream '{$type}' could not be initialized."); + throw new InvalidArgumentException("Stream '{$type}' could not be initialized."); } } @@ -283,6 +320,7 @@ class Themes extends Iterator * * @param string $name Theme name * @param Config $config Configuration class + * @return void */ protected function loadConfiguration($name, Config $config) { @@ -292,8 +330,10 @@ class Themes extends Iterator /** * Load theme languages. + * Reads ALL language files from theme stream and merges them. * * @param Config $config Configuration class + * @return void */ protected function loadLanguages(Config $config) { @@ -301,17 +341,15 @@ class Themes extends Iterator $locator = $this->grav['locator']; if ($config->get('system.languages.translations', true)) { - $language_file = $locator->findResource('theme://languages' . YAML_EXT); - if ($language_file) { + $language_files = array_reverse($locator->findResources('theme://languages' . YAML_EXT)); + foreach ($language_files as $language_file) { $language = CompiledYamlFile::instance($language_file)->content(); $this->grav['languages']->mergeRecursive($language); } - $languages_folder = $locator->findResource('theme://languages'); - if (file_exists($languages_folder)) { + $languages_folders = array_reverse($locator->findResources('theme://languages')); + foreach ($languages_folders as $languages_folder) { $languages = []; - $iterator = new \DirectoryIterator($languages_folder); - - /** @var \DirectoryIterator $directory */ + $iterator = new DirectoryIterator($languages_folder); foreach ($iterator as $file) { if ($file->getExtension() !== 'yaml') { continue; @@ -327,8 +365,7 @@ class Themes extends Iterator * Autoload theme classes for inheritance * * @param string $class Class name - * - * @return mixed false FALSE if unable to load $class; Class name if + * @return mixed|false FALSE if unable to load $class; Class name if * $class is successfully loaded */ protected function autoloadTheme($class) @@ -357,7 +394,10 @@ class Themes extends Iterator } // Try Old style theme classes - $path = strtolower(preg_replace('#\\\|_(?!.+\\\)#', '/', $class)); + $path = preg_replace('#\\\|_(?!.+\\\)#', '/', $class); + \assert(null !== $path); + + $path = strtolower($path); $file = $locator("themes://{$path}/theme.php") ?: $locator("themes://{$path}/{$path}.php"); // Load class diff --git a/system/src/Grav/Common/Twig/Extension/FilesystemExtension.php b/system/src/Grav/Common/Twig/Extension/FilesystemExtension.php new file mode 100644 index 0000000..e31b5fb --- /dev/null +++ b/system/src/Grav/Common/Twig/Extension/FilesystemExtension.php @@ -0,0 +1,351 @@ +locator = Grav::instance()['locator']; + } + + /** + * @return TwigFunction[] + */ + public function getFilters() + { + return $this->getFunctions(); + } + + /** + * Return a list of all functions. + * + * @return TwigFunction[] + */ + public function getFunctions() + { + return [ + new TwigFunction('file_exists', [$this, 'file_exists']), + new TwigFunction('fileatime', [$this, 'fileatime']), + new TwigFunction('filectime', [$this, 'filectime']), + new TwigFunction('filemtime', [$this, 'filemtime']), + new TwigFunction('filesize', [$this, 'filesize']), + new TwigFunction('filetype', [$this, 'filetype']), + new TwigFunction('is_dir', [$this, 'is_dir']), + new TwigFunction('is_file', [$this, 'is_file']), + new TwigFunction('is_link', [$this, 'is_link']), + new TwigFunction('is_readable', [$this, 'is_readable']), + new TwigFunction('is_writable', [$this, 'is_writable']), + new TwigFunction('is_writeable', [$this, 'is_writable']), + new TwigFunction('lstat', [$this, 'lstat']), + new TwigFunction('getimagesize', [$this, 'getimagesize']), + new TwigFunction('exif_read_data', [$this, 'exif_read_data']), + new TwigFunction('read_exif_data', [$this, 'exif_read_data']), + new TwigFunction('exif_imagetype', [$this, 'exif_imagetype']), + new TwigFunction('hash_file', [$this, 'hash_file']), + new TwigFunction('hash_hmac_file', [$this, 'hash_hmac_file']), + new TwigFunction('md5_file', [$this, 'md5_file']), + new TwigFunction('sha1_file', [$this, 'sha1_file']), + new TwigFunction('get_meta_tags', [$this, 'get_meta_tags']), + ]; + } + + /** + * @param string $filename + * @return bool + */ + public function file_exists($filename): bool + { + if (!$this->checkFilename($filename)) { + return false; + } + + return file_exists($filename); + } + + /** + * @param string $filename + * @return int|false + */ + public function fileatime($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return fileatime($filename); + } + + /** + * @param string $filename + * @return int|false + */ + public function filectime($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return filectime($filename); + } + + /** + * @param string $filename + * @return int|false + */ + public function filemtime($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return filemtime($filename); + } + + /** + * @param string $filename + * @return int|false + */ + public function filesize($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return filesize($filename); + } + + /** + * @param string $filename + * @return string|false + */ + public function filetype($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return filetype($filename); + } + + /** + * @param string $filename + * @return bool + */ + public function is_dir($filename): bool + { + if (!$this->checkFilename($filename)) { + return false; + } + + return is_dir($filename); + } + + /** + * @param string $filename + * @return bool + */ + public function is_file($filename): bool + { + if (!$this->checkFilename($filename)) { + return false; + } + + return is_file($filename); + } + + /** + * @param string $filename + * @return bool + */ + public function is_link($filename): bool + { + if (!$this->checkFilename($filename)) { + return false; + } + + return is_link($filename); + } + + /** + * @param string $filename + * @return bool + */ + public function is_readable($filename): bool + { + if (!$this->checkFilename($filename)) { + return false; + } + + return is_readable($filename); + } + + /** + * @param string $filename + * @return bool + */ + public function is_writable($filename): bool + { + if (!$this->checkFilename($filename)) { + return false; + } + + return is_writable($filename); + } + + /** + * @param string $filename + * @return array|false + */ + public function lstat($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return lstat($filename); + } + + /** + * @param string $filename + * @return array|false + */ + public function getimagesize($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return getimagesize($filename); + } + + /** + * @param string $file + * @param string|null $required_sections + * @param bool $as_arrays + * @param bool $read_thumbnail + * @return array|false + */ + public function exif_read_data($file, ?string $required_sections, bool $as_arrays = false, bool $read_thumbnail = false) + { + if (!Utils::functionExists('exif_read_data') || !$this->checkFilename($file)) { + return false; + } + + return exif_read_data($file, $required_sections, $as_arrays, $read_thumbnail); + } + + /** + * @param string $filename + * @return string|false + */ + public function exif_imagetype($filename) + { + if (!Utils::functionExists('exif_imagetype') || !$this->checkFilename($filename)) { + return false; + } + + return @exif_imagetype($filename); + } + + /** + * @param string $algo + * @param string $filename + * @param bool $binary + * @return string|false + */ + public function hash_file(string $algo, string $filename, bool $binary = false) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return hash_file($algo, $filename, $binary); + } + + /** + * @param string $algo + * @param string $data + * @param string $key + * @param bool $binary + * @return string|false + */ + public function hash_hmac_file(string $algo, string $data, string $key, bool $binary = false) + { + if (!$this->checkFilename($data)) { + return false; + } + + return hash_hmac_file($algo, $data, $key, $binary); + } + + /** + * @param string $filename + * @param bool $binary + * @return string|false + */ + public function md5_file($filename, bool $binary = false) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return md5_file($filename, $binary); + } + + /** + * @param string $filename + * @param bool $binary + * @return string|false + */ + public function sha1_file($filename, bool $binary = false) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return sha1_file($filename, $binary); + } + + /** + * @param string $filename + * @return array|false + */ + public function get_meta_tags($filename) + { + if (!$this->checkFilename($filename)) { + return false; + } + + return get_meta_tags($filename); + } + + /** + * @param string $filename + * @return bool + */ + private function checkFilename($filename): bool + { + return is_string($filename) && (!str_contains($filename, '://') || $this->locator->isStream($filename)); + } +} diff --git a/system/src/Grav/Common/Twig/Extension/GravExtension.php b/system/src/Grav/Common/Twig/Extension/GravExtension.php new file mode 100644 index 0000000..d076973 --- /dev/null +++ b/system/src/Grav/Common/Twig/Extension/GravExtension.php @@ -0,0 +1,1597 @@ +grav = Grav::instance(); + $this->debugger = $this->grav['debugger'] ?? null; + $this->config = $this->grav['config']; + } + + /** + * Register some standard globals + * + * @return array + */ + public function getGlobals() + { + return [ + 'grav' => $this->grav, + ]; + } + + /** + * Return a list of all filters. + * + * @return array + */ + public function getFilters() + { + return [ + new TwigFilter('*ize', [$this, 'inflectorFilter']), + new TwigFilter('absolute_url', [$this, 'absoluteUrlFilter']), + new TwigFilter('contains', [$this, 'containsFilter']), + new TwigFilter('chunk_split', [$this, 'chunkSplitFilter']), + new TwigFilter('nicenumber', [$this, 'niceNumberFunc']), + new TwigFilter('nicefilesize', [$this, 'niceFilesizeFunc']), + new TwigFilter('nicetime', [$this, 'nicetimeFunc']), + new TwigFilter('defined', [$this, 'definedDefaultFilter']), + new TwigFilter('ends_with', [$this, 'endsWithFilter']), + new TwigFilter('fieldName', [$this, 'fieldNameFilter']), + new TwigFilter('ksort', [$this, 'ksortFilter']), + new TwigFilter('ltrim', [$this, 'ltrimFilter']), + new TwigFilter('markdown', [$this, 'markdownFunction'], ['needs_context' => true, 'is_safe' => ['html']]), + new TwigFilter('md5', [$this, 'md5Filter']), + new TwigFilter('base32_encode', [$this, 'base32EncodeFilter']), + new TwigFilter('base32_decode', [$this, 'base32DecodeFilter']), + new TwigFilter('base64_encode', [$this, 'base64EncodeFilter']), + new TwigFilter('base64_decode', [$this, 'base64DecodeFilter']), + new TwigFilter('randomize', [$this, 'randomizeFilter']), + new TwigFilter('modulus', [$this, 'modulusFilter']), + new TwigFilter('rtrim', [$this, 'rtrimFilter']), + new TwigFilter('pad', [$this, 'padFilter']), + new TwigFilter('regex_replace', [$this, 'regexReplace']), + new TwigFilter('safe_email', [$this, 'safeEmailFilter'], ['is_safe' => ['html']]), + new TwigFilter('safe_truncate', [Utils::class, 'safeTruncate']), + new TwigFilter('safe_truncate_html', [Utils::class, 'safeTruncateHTML']), + new TwigFilter('sort_by_key', [$this, 'sortByKeyFilter']), + new TwigFilter('starts_with', [$this, 'startsWithFilter']), + new TwigFilter('truncate', [Utils::class, 'truncate']), + new TwigFilter('truncate_html', [Utils::class, 'truncateHTML']), + new TwigFilter('json_decode', [$this, 'jsonDecodeFilter']), + new TwigFilter('array_unique', 'array_unique'), + new TwigFilter('basename', 'basename'), + new TwigFilter('dirname', 'dirname'), + new TwigFilter('print_r', [$this, 'print_r']), + new TwigFilter('yaml_encode', [$this, 'yamlEncodeFilter']), + new TwigFilter('yaml_decode', [$this, 'yamlDecodeFilter']), + new TwigFilter('nicecron', [$this, 'niceCronFilter']), + + // Translations + new TwigFilter('t', [$this, 'translate'], ['needs_environment' => true]), + new TwigFilter('tl', [$this, 'translateLanguage']), + new TwigFilter('ta', [$this, 'translateArray']), + + // Casting values + new TwigFilter('string', [$this, 'stringFilter']), + new TwigFilter('int', [$this, 'intFilter'], ['is_safe' => ['all']]), + new TwigFilter('bool', [$this, 'boolFilter']), + new TwigFilter('float', [$this, 'floatFilter'], ['is_safe' => ['all']]), + new TwigFilter('array', [$this, 'arrayFilter']), + + // Object Types + new TwigFilter('get_type', [$this, 'getTypeFunc']), + new TwigFilter('of_type', [$this, 'ofTypeFunc']) + ]; + } + + /** + * Return a list of all functions. + * + * @return array + */ + public function getFunctions() + { + return [ + new TwigFunction('array', [$this, 'arrayFilter']), + new TwigFunction('array_key_value', [$this, 'arrayKeyValueFunc']), + new TwigFunction('array_key_exists', 'array_key_exists'), + new TwigFunction('array_unique', 'array_unique'), + new TwigFunction('array_intersect', [$this, 'arrayIntersectFunc']), + new TwigFunction('array_diff', 'array_diff'), + new TwigFunction('authorize', [$this, 'authorize']), + new TwigFunction('debug', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]), + new TwigFunction('dump', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]), + new TwigFunction('vardump', [$this, 'vardumpFunc']), + new TwigFunction('print_r', [$this, 'print_r']), + new TwigFunction('http_response_code', 'http_response_code'), + new TwigFunction('evaluate', [$this, 'evaluateStringFunc'], ['needs_context' => true]), + new TwigFunction('evaluate_twig', [$this, 'evaluateTwigFunc'], ['needs_context' => true]), + new TwigFunction('gist', [$this, 'gistFunc']), + new TwigFunction('nonce_field', [$this, 'nonceFieldFunc']), + new TwigFunction('pathinfo', 'pathinfo'), + new TwigFunction('random_string', [$this, 'randomStringFunc']), + new TwigFunction('repeat', [$this, 'repeatFunc']), + new TwigFunction('regex_replace', [$this, 'regexReplace']), + new TwigFunction('regex_filter', [$this, 'regexFilter']), + new TwigFunction('regex_match', [$this, 'regexMatch']), + new TwigFunction('regex_split', [$this, 'regexSplit']), + new TwigFunction('string', [$this, 'stringFilter']), + new TwigFunction('url', [$this, 'urlFunc']), + new TwigFunction('json_decode', [$this, 'jsonDecodeFilter']), + new TwigFunction('get_cookie', [$this, 'getCookie']), + new TwigFunction('redirect_me', [$this, 'redirectFunc']), + new TwigFunction('range', [$this, 'rangeFunc']), + new TwigFunction('isajaxrequest', [$this, 'isAjaxFunc']), + new TwigFunction('exif', [$this, 'exifFunc']), + new TwigFunction('media_directory', [$this, 'mediaDirFunc']), + new TwigFunction('body_class', [$this, 'bodyClassFunc'], ['needs_context' => true]), + new TwigFunction('theme_var', [$this, 'themeVarFunc'], ['needs_context' => true]), + new TwigFunction('header_var', [$this, 'pageHeaderVarFunc'], ['needs_context' => true]), + new TwigFunction('read_file', [$this, 'readFileFunc']), + new TwigFunction('nicenumber', [$this, 'niceNumberFunc']), + new TwigFunction('nicefilesize', [$this, 'niceFilesizeFunc']), + new TwigFunction('nicetime', [$this, 'nicetimeFunc']), + new TwigFunction('cron', [$this, 'cronFunc']), + new TwigFunction('svg_image', [$this, 'svgImageFunction']), + new TwigFunction('xss', [$this, 'xssFunc']), + + // Translations + new TwigFunction('t', [$this, 'translate'], ['needs_environment' => true]), + new TwigFunction('tl', [$this, 'translateLanguage']), + new TwigFunction('ta', [$this, 'translateArray']), + + // Object Types + new TwigFunction('get_type', [$this, 'getTypeFunc']), + new TwigFunction('of_type', [$this, 'ofTypeFunc']) + ]; + } + + /** + * @return array + */ + public function getTokenParsers() + { + return [ + new TwigTokenParserRender(), + new TwigTokenParserThrow(), + new TwigTokenParserTryCatch(), + new TwigTokenParserScript(), + new TwigTokenParserStyle(), + new TwigTokenParserMarkdown(), + new TwigTokenParserSwitch(), + new TwigTokenParserCache(), + ]; + } + + public function print_r($var) + { + return print_r($var, true); + } + + /** + * Filters field name by changing dot notation into array notation. + * + * @param string $str + * @return string + */ + public function fieldNameFilter($str) + { + $path = explode('.', rtrim($str, '.')); + + return array_shift($path) . ($path ? '[' . implode('][', $path) . ']' : ''); + } + + /** + * Protects email address. + * + * @param string $str + * @return string + */ + public function safeEmailFilter($str) + { + static $list = [ + '"' => '"', + "'" => ''', + '&' => '&', + '<' => '<', + '>' => '>', + '@' => '@' + ]; + + $characters = mb_str_split($str, 1, 'UTF-8'); + + $encoded = ''; + foreach ($characters as $chr) { + $encoded .= $list[$chr] ?? (random_int(0, 1) ? '&#' . mb_ord($chr) . ';' : $chr); + } + + return $encoded; + } + + /** + * Returns array in a random order. + * + * @param array|Traversable $original + * @param int $offset Can be used to return only slice of the array. + * @return array + */ + public function randomizeFilter($original, $offset = 0) + { + if ($original instanceof Traversable) { + $original = iterator_to_array($original, false); + } + + if (!is_array($original)) { + return $original; + } + + $sorted = []; + $random = array_slice($original, $offset); + shuffle($random); + + $sizeOf = count($original); + for ($x = 0; $x < $sizeOf; $x++) { + if ($x < $offset) { + $sorted[] = $original[$x]; + } else { + $sorted[] = array_shift($random); + } + } + + return $sorted; + } + + /** + * Returns the modulus of an integer + * + * @param string|int $number + * @param int $divider + * @param array|null $items array of items to select from to return + * @return int + */ + public function modulusFilter($number, $divider, $items = null) + { + if (is_string($number)) { + $number = strlen($number); + } + + $remainder = $number % $divider; + + if (is_array($items)) { + return $items[$remainder] ?? $items[0]; + } + + return $remainder; + } + + /** + * Inflector supports following notations: + * + * `{{ 'person'|pluralize }} => people` + * `{{ 'shoes'|singularize }} => shoe` + * `{{ 'welcome page'|titleize }} => "Welcome Page"` + * `{{ 'send_email'|camelize }} => SendEmail` + * `{{ 'CamelCased'|underscorize }} => camel_cased` + * `{{ 'Something Text'|hyphenize }} => something-text` + * `{{ 'something_text_to_read'|humanize }} => "Something text to read"` + * `{{ '181'|monthize }} => 5` + * `{{ '10'|ordinalize }} => 10th` + * + * @param string $action + * @param string $data + * @param int|null $count + * @return string + */ + public function inflectorFilter($action, $data, $count = null) + { + $action .= 'ize'; + + /** @var Inflector $inflector */ + $inflector = $this->grav['inflector']; + + if (in_array( + $action, + ['titleize', 'camelize', 'underscorize', 'hyphenize', 'humanize', 'ordinalize', 'monthize'], + true + )) { + return $inflector->{$action}($data); + } + + if (in_array($action, ['pluralize', 'singularize'], true)) { + return $count ? $inflector->{$action}($data, $count) : $inflector->{$action}($data); + } + + return $data; + } + + /** + * Return MD5 hash from the input. + * + * @param string $str + * @return string + */ + public function md5Filter($str) + { + return md5($str); + } + + /** + * Return Base32 encoded string + * + * @param string $str + * @return string + */ + public function base32EncodeFilter($str) + { + return Base32::encode($str); + } + + /** + * Return Base32 decoded string + * + * @param string $str + * @return string + */ + public function base32DecodeFilter($str) + { + return Base32::decode($str); + } + + /** + * Return Base64 encoded string + * + * @param string $str + * @return string + */ + public function base64EncodeFilter($str) + { + return base64_encode($str); + } + + /** + * Return Base64 decoded string + * + * @param string $str + * @return string|false + */ + public function base64DecodeFilter($str) + { + return base64_decode($str); + } + + /** + * Sorts a collection by key + * + * @param array $input + * @param string $filter + * @param int $direction + * @param int $sort_flags + * @return array + */ + public function sortByKeyFilter($input, $filter, $direction = SORT_ASC, $sort_flags = SORT_REGULAR) + { + return Utils::sortArrayByKey($input, $filter, $direction, $sort_flags); + } + + /** + * Return ksorted collection. + * + * @param array|null $array + * @return array + */ + public function ksortFilter($array) + { + if (null === $array) { + $array = []; + } + ksort($array); + + return $array; + } + + /** + * Wrapper for chunk_split() function + * + * @param string $value + * @param int $chars + * @param string $split + * @return string + */ + public function chunkSplitFilter($value, $chars, $split = '-') + { + return chunk_split($value, $chars, $split); + } + + /** + * determine if a string contains another + * + * @param string $haystack + * @param string $needle + * @return string|bool + * @todo returning $haystack here doesn't make much sense + */ + public function containsFilter($haystack, $needle) + { + if (empty($needle)) { + return $haystack; + } + + return (strpos($haystack, (string) $needle) !== false); + } + + /** + * Gets a human readable output for cron syntax + * + * @param string $at + * @return string + */ + public function niceCronFilter($at) + { + $cron = new Cron($at); + return $cron->getText('en'); + } + + /** + * Get Cron object for a crontab 'at' format + * + * @param string $at + * @return CronExpression + */ + public function cronFunc($at) + { + return CronExpression::factory($at); + } + + /** + * displays a facebook style 'time ago' formatted date/time + * + * @param string $date + * @param bool $long_strings + * @param bool $show_tense + * @return string + */ + public function nicetimeFunc($date, $long_strings = true, $show_tense = true) + { + if (empty($date)) { + return $this->grav['language']->translate('GRAV.NICETIME.NO_DATE_PROVIDED'); + } + + if ($long_strings) { + $periods = [ + 'NICETIME.SECOND', + 'NICETIME.MINUTE', + 'NICETIME.HOUR', + 'NICETIME.DAY', + 'NICETIME.WEEK', + 'NICETIME.MONTH', + 'NICETIME.YEAR', + 'NICETIME.DECADE' + ]; + } else { + $periods = [ + 'NICETIME.SEC', + 'NICETIME.MIN', + 'NICETIME.HR', + 'NICETIME.DAY', + 'NICETIME.WK', + 'NICETIME.MO', + 'NICETIME.YR', + 'NICETIME.DEC' + ]; + } + + $lengths = ['60', '60', '24', '7', '4.35', '12', '10']; + + $now = time(); + + // check if unix timestamp + if ((string)(int)$date === (string)$date) { + $unix_date = $date; + } else { + $unix_date = strtotime($date); + } + + // check validity of date + if (empty($unix_date)) { + return $this->grav['language']->translate('GRAV.NICETIME.BAD_DATE'); + } + + // is it future date or past date + if ($now > $unix_date) { + $difference = $now - $unix_date; + $tense = $this->grav['language']->translate('GRAV.NICETIME.AGO'); + } elseif ($now == $unix_date) { + $difference = $now - $unix_date; + $tense = $this->grav['language']->translate('GRAV.NICETIME.JUST_NOW'); + } else { + $difference = $unix_date - $now; + $tense = $this->grav['language']->translate('GRAV.NICETIME.FROM_NOW'); + } + + for ($j = 0; $difference >= $lengths[$j] && $j < count($lengths) - 1; $j++) { + $difference /= $lengths[$j]; + } + + $difference = round($difference); + + if ($difference != 1) { + $periods[$j] .= '_PLURAL'; + } + + if ($this->grav['language']->getTranslation( + $this->grav['language']->getLanguage(), + $periods[$j] . '_MORE_THAN_TWO' + ) + ) { + if ($difference > 2) { + $periods[$j] .= '_MORE_THAN_TWO'; + } + } + + $periods[$j] = $this->grav['language']->translate('GRAV.'.$periods[$j]); + + if ($now == $unix_date) { + return $tense; + } + + $time = "{$difference} {$periods[$j]}"; + $time .= $show_tense ? " {$tense}" : ''; + + return $time; + } + + /** + * Allow quick check of a string for XSS Vulnerabilities + * + * @param string|array $data + * @return bool|string|array + */ + public function xssFunc($data) + { + if (!is_array($data)) { + return Security::detectXss($data); + } + + $results = Security::detectXssFromArray($data); + $results_parts = array_map(static function ($value, $key) { + return $key.': \''.$value . '\''; + }, array_values($results), array_keys($results)); + + return implode(', ', $results_parts); + } + + /** + * @param string $string + * @return string + */ + public function absoluteUrlFilter($string) + { + $url = $this->grav['uri']->base(); + $string = preg_replace('/((?:href|src) *= *[\'"](?!(http|ftp)))/i', "$1$url", $string); + + return $string; + } + + /** + * @param array $context + * @param string $string + * @param bool $block Block or Line processing + * @return string + */ + public function markdownFunction($context, $string, $block = true) + { + $page = $context['page'] ?? null; + return Utils::processMarkdown($string, $block, $page); + } + + /** + * @param string $haystack + * @param string $needle + * @return bool + */ + public function startsWithFilter($haystack, $needle) + { + return Utils::startsWith($haystack, $needle); + } + + /** + * @param string $haystack + * @param string $needle + * @return bool + */ + public function endsWithFilter($haystack, $needle) + { + return Utils::endsWith($haystack, $needle); + } + + /** + * @param mixed $value + * @param null $default + * @return mixed|null + */ + public function definedDefaultFilter($value, $default = null) + { + return $value ?? $default; + } + + /** + * @param string $value + * @param string|null $chars + * @return string + */ + public function rtrimFilter($value, $chars = null) + { + return null !== $chars ? rtrim($value, $chars) : rtrim($value); + } + + /** + * @param string $value + * @param string|null $chars + * @return string + */ + public function ltrimFilter($value, $chars = null) + { + return null !== $chars ? ltrim($value, $chars) : ltrim($value); + } + + /** + * Returns a string from a value. If the value is array, return it json encoded + * + * @param mixed $value + * @return string + */ + public function stringFilter($value) + { + // Format the array as a string + if (is_array($value)) { + return json_encode($value); + } + + // Boolean becomes '1' or '0' + if (is_bool($value)) { + $value = (int)$value; + } + + // Cast the other values to string. + return (string)$value; + } + + /** + * Casts input to int. + * + * @param mixed $input + * @return int + */ + public function intFilter($input) + { + return (int) $input; + } + + /** + * Casts input to bool. + * + * @param mixed $input + * @return bool + */ + public function boolFilter($input) + { + return (bool) $input; + } + + /** + * Casts input to float. + * + * @param mixed $input + * @return float + */ + public function floatFilter($input) + { + return (float) $input; + } + + /** + * Casts input to array. + * + * @param mixed $input + * @return array + */ + public function arrayFilter($input) + { + if (is_array($input)) { + return $input; + } + + if (is_object($input)) { + if (method_exists($input, 'toArray')) { + return $input->toArray(); + } + + if ($input instanceof Iterator) { + return iterator_to_array($input); + } + } + + return (array)$input; + } + + /** + * @param Environment $twig + * @return string + */ + public function translate(Environment $twig) + { + // shift off the environment + $args = func_get_args(); + array_shift($args); + + // If admin and tu filter provided, use it + if (isset($this->grav['admin'])) { + $numargs = count($args); + $lang = null; + + if (($numargs === 3 && is_array($args[1])) || ($numargs === 2 && !is_array($args[1]))) { + $lang = array_pop($args); + } elseif ($numargs === 2 && is_array($args[1])) { + $subs = array_pop($args); + $args = array_merge($args, $subs); + } + + return $this->grav['admin']->translate($args, $lang); + } + + // else use the default grav translate functionality + return $this->grav['language']->translate($args); + } + + /** + * Translate Strings + * + * @param string|array $args + * @param array|null $languages + * @param bool $array_support + * @param bool $html_out + * @return string + */ + public function translateLanguage($args, array $languages = null, $array_support = false, $html_out = false) + { + /** @var Language $language */ + $language = $this->grav['language']; + + return $language->translate($args, $languages, $array_support, $html_out); + } + + /** + * @param string $key + * @param string $index + * @param array|null $lang + * @return string + */ + public function translateArray($key, $index, $lang = null) + { + /** @var Language $language */ + $language = $this->grav['language']; + + return $language->translateArray($key, $index, $lang); + } + + /** + * Repeat given string x times. + * + * @param string $input + * @param int $multiplier + * + * @return string + */ + public function repeatFunc($input, $multiplier) + { + return str_repeat($input, $multiplier); + } + + /** + * Return URL to the resource. + * + * @example {{ url('theme://images/logo.png')|default('http://www.placehold.it/150x100/f4f4f4') }} + * + * @param string $input Resource to be located. + * @param bool $domain True to include domain name. + * @param bool $failGracefully If true, return URL even if the file does not exist. + * @return string|false Returns url to the resource or null if resource was not found. + */ + public function urlFunc($input, $domain = false, $failGracefully = false) + { + return Utils::url($input, $domain, $failGracefully); + } + + /** + * This function will evaluate Twig $twig through the $environment, and return its results. + * + * @param array $context + * @param string $twig + * @return mixed + */ + public function evaluateTwigFunc($context, $twig) + { + + $loader = new FilesystemLoader('.'); + $env = new Environment($loader); + $env->addExtension($this); + + $template = $env->createTemplate($twig); + + return $template->render($context); + } + + /** + * This function will evaluate a $string through the $environment, and return its results. + * + * @param array $context + * @param string $string + * @return mixed + */ + public function evaluateStringFunc($context, $string) + { + return $this->evaluateTwigFunc($context, "{{ $string }}"); + } + + /** + * Based on Twig\Extension\Debug / twig_var_dump + * (c) 2011 Fabien Potencier + * + * @param Environment $env + * @param array $context + */ + public function dump(Environment $env, $context) + { + if (!$env->isDebug() || !$this->debugger) { + return; + } + + $count = func_num_args(); + if (2 === $count) { + $data = []; + foreach ($context as $key => $value) { + if (is_object($value)) { + if (method_exists($value, 'toArray')) { + $data[$key] = $value->toArray(); + } else { + $data[$key] = 'Object (' . get_class($value) . ')'; + } + } else { + $data[$key] = $value; + } + } + $this->debugger->addMessage($data, 'debug'); + } else { + for ($i = 2; $i < $count; $i++) { + $var = func_get_arg($i); + $this->debugger->addMessage($var, 'debug'); + } + } + } + + /** + * Output a Gist + * + * @param string $id + * @param string|false $file + * @return string + */ + public function gistFunc($id, $file = false) + { + $url = 'https://gist.github.com/' . $id . '.js'; + if ($file) { + $url .= '?file=' . $file; + } + return ''; + } + + /** + * Generate a random string + * + * @param int $count + * @return string + */ + public function randomStringFunc($count = 5) + { + return Utils::generateRandomString($count); + } + + /** + * Pad a string to a certain length with another string + * + * @param string $input + * @param int $pad_length + * @param string $pad_string + * @param int $pad_type + * @return string + */ + public static function padFilter($input, $pad_length, $pad_string = ' ', $pad_type = STR_PAD_RIGHT) + { + return str_pad($input, (int)$pad_length, $pad_string, $pad_type); + } + + /** + * Workaround for twig associative array initialization + * Returns a key => val array + * + * @param string $key key of item + * @param string $val value of item + * @param array|null $current_array optional array to add to + * @return array + */ + public function arrayKeyValueFunc($key, $val, $current_array = null) + { + if (empty($current_array)) { + return array($key => $val); + } + + $current_array[$key] = $val; + + return $current_array; + } + + /** + * Wrapper for array_intersect() method + * + * @param array|Collection $array1 + * @param array|Collection $array2 + * @return array|Collection + */ + public function arrayIntersectFunc($array1, $array2) + { + if ($array1 instanceof Collection && $array2 instanceof Collection) { + return $array1->intersect($array2)->toArray(); + } + + return array_intersect($array1, $array2); + } + + /** + * Translate a string + * + * @return string + */ + public function translateFunc() + { + return $this->grav['language']->translate(func_get_args()); + } + + /** + * Authorize an action. Returns true if the user is logged in and + * has the right to execute $action. + * + * @param string|array $action An action or a list of actions. Each + * entry can be a string like 'group.action' + * or without dot notation an associative + * array. + * @return bool Returns TRUE if the user is authorized to + * perform the action, FALSE otherwise. + */ + public function authorize($action) + { + // Admin can use Flex users even if the site does not; make sure we use the right version of the user. + $admin = $this->grav['admin'] ?? null; + if ($admin) { + $user = $admin->user; + } else { + /** @var UserInterface|null $user */ + $user = $this->grav['user'] ?? null; + } + + if (!$user) { + return false; + } + + if (is_array($action)) { + if (Utils::isAssoc($action)) { + // Handle nested access structure. + $actions = Utils::arrayFlattenDotNotation($action); + } else { + // Handle simple access list. + $actions = array_combine($action, array_fill(0, count($action), true)); + } + } else { + // Handle single action. + $actions = [(string)$action => true]; + } + + $count = count($actions); + foreach ($actions as $act => $authenticated) { + // Ignore 'admin.super' if it's not the only value to be checked. + if ($act === 'admin.super' && $count > 1 && $user instanceof FlexObjectInterface) { + continue; + } + + $auth = $user->authorize($act) ?? false; + if (is_bool($auth) && $auth === Utils::isPositive($authenticated)) { + return true; + } + } + + return false; + } + + /** + * Used to add a nonce to a form. Call {{ nonce_field('action') }} specifying a string representing the action. + * + * For maximum protection, ensure that the string representing the action is as specific as possible + * + * @param string $action the action + * @param string $nonceParamName a custom nonce param name + * @return string the nonce input field + */ + public function nonceFieldFunc($action, $nonceParamName = 'nonce') + { + $string = ''; + + return $string; + } + + /** + * Decodes string from JSON. + * + * @param string $str + * @param bool $assoc + * @param int $depth + * @param int $options + * @return array + */ + public function jsonDecodeFilter($str, $assoc = false, $depth = 512, $options = 0) + { + return json_decode(html_entity_decode($str, ENT_COMPAT | ENT_HTML401, 'UTF-8'), $assoc, $depth, $options); + } + + /** + * Used to retrieve a cookie value + * + * @param string $key The cookie name to retrieve + * @return string + */ + public function getCookie($key) + { + return filter_input(INPUT_COOKIE, $key, FILTER_SANITIZE_STRING); + } + + /** + * Twig wrapper for PHP's preg_replace method + * + * @param string|string[] $subject the content to perform the replacement on + * @param string|string[] $pattern the regex pattern to use for matches + * @param string|string[] $replace the replacement value either as a string or an array of replacements + * @param int $limit the maximum possible replacements for each pattern in each subject + * @return string|string[]|null the resulting content + */ + public function regexReplace($subject, $pattern, $replace, $limit = -1) + { + return preg_replace($pattern, $replace, $subject, $limit); + } + + /** + * Twig wrapper for PHP's preg_grep method + * + * @param array $array + * @param string $regex + * @param int $flags + * @return array + */ + public function regexFilter($array, $regex, $flags = 0) + { + return preg_grep($regex, $array, $flags); + } + + /** + * Twig wrapper for PHP's preg_match method + * + * @param string $subject the content to perform the match on + * @param string $pattern the regex pattern to use for match + * @param int $flags + * @param int $offset + * @return array|false returns the matches if there is at least one match in the subject for a given pattern or null if not. + */ + public function regexMatch($subject, $pattern, $flags = 0, $offset = 0) + { + if (preg_match($pattern, $subject, $matches, $flags, $offset) === false) { + return false; + } + + return $matches; + } + + /** + * Twig wrapper for PHP's preg_split method + * + * @param string $subject the content to perform the split on + * @param string $pattern the regex pattern to use for split + * @param int $limit the maximum possible splits for the given pattern + * @param int $flags + * @return array|false the resulting array after performing the split operation + */ + public function regexSplit($subject, $pattern, $limit = -1, $flags = 0) + { + return preg_split($pattern, $subject, $limit, $flags); + } + + /** + * redirect browser from twig + * + * @param string $url the url to redirect to + * @param int $statusCode statusCode, default 303 + * @return void + */ + public function redirectFunc($url, $statusCode = 303) + { + $response = new Response($statusCode, ['location' => $url]); + + $this->grav->close($response); + } + + /** + * Generates an array containing a range of elements, optionally stepped + * + * @param int $start Minimum number, default 0 + * @param int $end Maximum number, default `getrandmax()` + * @param int $step Increment between elements in the sequence, default 1 + * @return array + */ + public function rangeFunc($start = 0, $end = 100, $step = 1) + { + return range($start, $end, $step); + } + + /** + * Check if HTTP_X_REQUESTED_WITH has been set to xmlhttprequest, + * in which case we may unsafely assume ajax. Non critical use only. + * + * @return bool True if HTTP_X_REQUESTED_WITH exists and has been set to xmlhttprequest + */ + public function isAjaxFunc() + { + return ( + !empty($_SERVER['HTTP_X_REQUESTED_WITH']) + && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'); + } + + /** + * Get the Exif data for a file + * + * @param string $image + * @param bool $raw + * @return mixed + */ + public function exifFunc($image, $raw = false) + { + if (isset($this->grav['exif'])) { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + if ($locator->isStream($image)) { + $image = $locator->findResource($image); + } + + $exif_reader = $this->grav['exif']->getReader(); + + if ($image && file_exists($image) && $this->config->get('system.media.auto_metadata_exif') && $exif_reader) { + $exif_data = $exif_reader->read($image); + + if ($exif_data) { + if ($raw) { + return $exif_data->getRawData(); + } + + return $exif_data->getData(); + } + } + } + + return null; + } + + /** + * Simple function to read a file based on a filepath and output it + * + * @param string $filepath + * @return bool|string + */ + public function readFileFunc($filepath) + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + if ($locator->isStream($filepath)) { + $filepath = $locator->findResource($filepath); + } + + if ($filepath && file_exists($filepath)) { + return file_get_contents($filepath); + } + + return false; + } + + /** + * Process a folder as Media and return a media object + * + * @param string $media_dir + * @return Media|null + */ + public function mediaDirFunc($media_dir) + { + /** @var UniformResourceLocator $locator */ + $locator = $this->grav['locator']; + + if ($locator->isStream($media_dir)) { + $media_dir = $locator->findResource($media_dir); + } + + if ($media_dir && file_exists($media_dir)) { + return new Media($media_dir); + } + + return null; + } + + /** + * Dump a variable to the browser + * + * @param mixed $var + * @return void + */ + public function vardumpFunc($var) + { + var_dump($var); + } + + /** + * Returns a nicer more readable filesize based on bytes + * + * @param int $bytes + * @return string + */ + public function niceFilesizeFunc($bytes) + { + return Utils::prettySize($bytes); + } + + /** + * Returns a nicer more readable number + * + * @param int|float|string $n + * @return string|bool + */ + public function niceNumberFunc($n) + { + if (!is_float($n) && !is_int($n)) { + if (!is_string($n) || $n === '') { + return false; + } + + // Strip any thousand formatting and find the first number. + $list = array_filter(preg_split("/\D+/", str_replace(',', '', $n))); + $n = reset($list); + + if (!is_numeric($n)) { + return false; + } + + $n = (float)$n; + } + + // now filter it; + if ($n > 1000000000000) { + return round($n/1000000000000, 2).' t'; + } + if ($n > 1000000000) { + return round($n/1000000000, 2).' b'; + } + if ($n > 1000000) { + return round($n/1000000, 2).' m'; + } + if ($n > 1000) { + return round($n/1000, 2).' k'; + } + + return number_format($n); + } + + /** + * Get a theme variable + * Will try to get the variable for the current page, if not found, it tries it's parent page on up to root. + * If still not found, will use the theme's configuration value, + * If still not found, will use the $default value passed in + * + * @param array $context Twig Context + * @param string $var variable to be found (using dot notation) + * @param null $default the default value to be used as last resort + * @param null $page an optional page to use for the current page + * @param bool $exists toggle to simply return the page where the variable is set, else null + * @return mixed + */ + public function themeVarFunc($context, $var, $default = null, $page = null, $exists = false) + { + $page = $page ?? $context['page'] ?? Grav::instance()['page'] ?? null; + + // Try to find var in the page headers + if ($page instanceof PageInterface && $page->exists()) { + // Loop over pages and look for header vars + while ($page && !$page->root()) { + $header = new Data((array)$page->header()); + $value = $header->get($var); + if (isset($value)) { + if ($exists) { + return $page; + } + + return $value; + } + $page = $page->parent(); + } + } + + if ($exists) { + return false; + } + + return Grav::instance()['config']->get('theme.' . $var, $default); + } + + /** + * Look for a page header variable in an array of pages working its way through until a value is found + * + * @param array $context + * @param string $var the variable to look for in the page header + * @param string|string[]|null $pages array of pages to check (current page upwards if not null) + * @return mixed + * @deprecated 1.7 Use themeVarFunc() instead + */ + public function pageHeaderVarFunc($context, $var, $pages = null) + { + if (is_array($pages)) { + $page = array_shift($pages); + } else { + $page = null; + } + return $this->themeVarFunc($context, $var, null, $page); + } + + /** + * takes an array of classes, and if they are not set on body_classes + * look to see if they are set in theme config + * + * @param array $context + * @param string|string[] $classes + * @return string + */ + public function bodyClassFunc($context, $classes) + { + + $header = $context['page']->header(); + $body_classes = $header->body_classes ?? ''; + + foreach ((array)$classes as $class) { + if (!empty($body_classes) && Utils::contains($body_classes, $class)) { + continue; + } + + $val = $this->config->get('theme.' . $class, false) ? $class : false; + $body_classes .= $val ? ' ' . $val : ''; + } + + return $body_classes; + } + + /** + * Returns the content of an SVG image and adds extra classes as needed + * + * @param string $path + * @param string|null $classes + * @return string|string[]|null + */ + public static function svgImageFunction($path, $classes = null, $strip_style = false) + { + $path = Utils::fullPath($path); + + $classes = $classes ?: ''; + + if (file_exists($path) && !is_dir($path)) { + $svg = file_get_contents($path); + $classes = " inline-block $classes"; + $matched = false; + + //Remove xml tag if it exists + $svg = preg_replace('/^<\?xml.*\?>/','', $svg); + + //Strip style if needed + if ($strip_style) { + $svg = preg_replace('//s', '', $svg); + } + + //Look for existing class + $svg = preg_replace_callback('/^]*(class=\")([^"]*)(\")[^>]*>/', function($matches) use ($classes, &$matched) { + if (isset($matches[2])) { + $new_classes = $matches[2] . $classes; + $matched = true; + return str_replace($matches[1], "class=\"$new_classes\"", $matches[0]); + } + return $matches[0]; + }, $svg + ); + + // no matches found just add the class + if (!$matched) { + $classes = trim($classes); + $svg = str_replace('jsonSerialize(); + } elseif (method_exists($data, 'toArray')) { + $data = $data->toArray(); + } else { + $data = json_decode(json_encode($data), true); + } + } + + return Yaml::dump($data, $inline); + } + + /** + * Decode/Parse data from YAML format + * + * @param string $data + * @return array + */ + public function yamlDecodeFilter($data) + { + return Yaml::parse($data); + } + + /** + * Function/Filter to return the type of variable + * + * @param mixed $var + * @return string + */ + public function getTypeFunc($var) + { + return gettype($var); + } + + /** + * Function/Filter to test type of variable + * + * @param mixed $var + * @param string|null $typeTest + * @param string|null $className + * @return bool + */ + public function ofTypeFunc($var, $typeTest = null, $className = null) + { + + switch ($typeTest) { + default: + return false; + + case 'array': + return is_array($var); + + case 'bool': + return is_bool($var); + + case 'class': + return is_object($var) === true && get_class($var) === $className; + + case 'float': + return is_float($var); + + case 'int': + return is_int($var); + + case 'numeric': + return is_numeric($var); + + case 'object': + return is_object($var); + + case 'scalar': + return is_scalar($var); + + case 'string': + return is_string($var); + } + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeCache.php b/system/src/Grav/Common/Twig/Node/TwigNodeCache.php new file mode 100644 index 0000000..0ed1fff --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeCache.php @@ -0,0 +1,58 @@ + $body), array( 'key' => $key, 'lifetime' => $lifetime), $lineno, $tag); + } + + /** + * {@inheritDoc} + */ + public function compile(Compiler $compiler): void + { + $boo = $this->getAttribute('key'); + $compiler + ->addDebugInfo($this) + ->write("\$cache = \\Grav\\Common\\Grav::instance()['cache'];\n") + ->write("\$key = \"twigcache-\" . \"" . $this->getAttribute('key') . "\";\n") + ->write("\$lifetime = " . $this->getAttribute('lifetime') . ";\n") + ->write("\$cache_body = \$cache->fetch(\$key);\n") + ->write("if (\$cache_body === false) {\n") + ->indent() + ->write("ob_start();\n") + ->indent() + ->subcompile($this->getNode('body')) + ->outdent() + ->write("\n") + ->write("\$cache_body = ob_get_clean();\n") + ->write("\$cache->save(\$key, \$cache_body, \$lifetime);\n") + ->outdent() + ->write("}\n") + ->write("echo \$cache_body;\n"); + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php b/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php index 7bb2bfc..81cecae 100644 --- a/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php +++ b/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,6 +13,10 @@ use Twig\Compiler; use Twig\Node\Node; use Twig\Node\NodeOutputInterface; +/** + * Class TwigNodeMarkdown + * @package Grav\Common\Twig\Node + */ class TwigNodeMarkdown extends Node implements NodeOutputInterface { /** @@ -25,12 +29,14 @@ class TwigNodeMarkdown extends Node implements NodeOutputInterface { parent::__construct(['body' => $body], [], $lineno, $tag); } + /** * Compiles the node to PHP. * - * @param Compiler $compiler A Twig_Compiler instance + * @param Compiler $compiler A Twig Compiler instance + * @return void */ - public function compile(Compiler $compiler) + public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) @@ -41,6 +47,6 @@ class TwigNodeMarkdown extends Node implements NodeOutputInterface ->write('$lines = explode("\n", $content);' . PHP_EOL) ->write('$content = preg_replace(\'/^\' . $matches[0]. \'/\', "", $lines);' . PHP_EOL) ->write('$content = join("\n", $content);' . PHP_EOL) - ->write('echo $this->env->getExtension(\'Grav\Common\Twig\TwigExtension\')->markdownFunction($context, $content);' . PHP_EOL); + ->write('echo $this->env->getExtension(\'Grav\Common\Twig\Extension\GravExtension\')->markdownFunction($context, $content);' . PHP_EOL); } } diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeRender.php b/system/src/Grav/Common/Twig/Node/TwigNodeRender.php index 753adf0..798c6ba 100644 --- a/system/src/Grav/Common/Twig/Node/TwigNodeRender.php +++ b/system/src/Grav/Common/Twig/Node/TwigNodeRender.php @@ -3,19 +3,25 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig\Node; +use LogicException; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Node; use Twig\Node\NodeCaptureInterface; +/** + * Class TwigNodeRender + * @package Grav\Common\Twig\Node + */ class TwigNodeRender extends Node implements NodeCaptureInterface { + /** @var string */ protected $tagName = 'render'; /** @@ -25,36 +31,35 @@ class TwigNodeRender extends Node implements NodeCaptureInterface * @param int $lineno * @param string|null $tag */ - public function __construct( - AbstractExpression $object, - ?AbstractExpression $layout, - ?AbstractExpression $context, - $lineno, - $tag = null - ) + public function __construct(AbstractExpression $object, ?AbstractExpression $layout, ?AbstractExpression $context, $lineno, $tag = null) { - parent::__construct(['object' => $object, 'layout' => $layout, 'context' => $context], [], $lineno, $tag); + $nodes = ['object' => $object, 'layout' => $layout, 'context' => $context]; + $nodes = array_filter($nodes); + + parent::__construct($nodes, [], $lineno, $tag); } + /** * Compiles the node to PHP. * - * @param Compiler $compiler A Twig_Compiler instance - * @throws \LogicException + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException */ - public function compile(Compiler $compiler) + public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); $compiler->write('$object = ')->subcompile($this->getNode('object'))->raw(';' . PHP_EOL); - $layout = $this->getNode('layout'); - if ($layout) { + if ($this->hasNode('layout')) { + $layout = $this->getNode('layout'); $compiler->write('$layout = ')->subcompile($layout)->raw(';' . PHP_EOL); } else { $compiler->write('$layout = null;' . PHP_EOL); } - $context = $this->getNode('context'); - if ($context) { + if ($this->hasNode('context')) { + $context = $this->getNode('context'); $compiler->write('$attributes = ')->subcompile($context)->raw(';' . PHP_EOL); } else { $compiler->write('$attributes = null;' . PHP_EOL); diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeScript.php b/system/src/Grav/Common/Twig/Node/TwigNodeScript.php index 4f6f776..46a0870 100644 --- a/system/src/Grav/Common/Twig/Node/TwigNodeScript.php +++ b/system/src/Grav/Common/Twig/Node/TwigNodeScript.php @@ -3,19 +3,25 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig\Node; +use LogicException; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Node; use Twig\Node\NodeCaptureInterface; +/** + * Class TwigNodeScript + * @package Grav\Common\Twig\Node + */ class TwigNodeScript extends Node implements NodeCaptureInterface { + /** @var string */ protected $tagName = 'script'; /** @@ -28,31 +34,28 @@ class TwigNodeScript extends Node implements NodeCaptureInterface * @param int $lineno * @param string|null $tag */ - public function __construct( - Node $body = null, - AbstractExpression $file = null, - AbstractExpression $group = null, - AbstractExpression $priority = null, - AbstractExpression $attributes = null, - $lineno = 0, - $tag = null - ) + public function __construct(?Node $body, ?AbstractExpression $file, ?AbstractExpression $group, ?AbstractExpression $priority, ?AbstractExpression $attributes, $lineno = 0, $tag = null) { - parent::__construct(['body' => $body, 'file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes], [], $lineno, $tag); + $nodes = ['body' => $body, 'file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes]; + $nodes = array_filter($nodes); + + parent::__construct($nodes, [], $lineno, $tag); } + /** * Compiles the node to PHP. * - * @param Compiler $compiler A Twig_Compiler instance - * @throws \LogicException + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException */ - public function compile(Compiler $compiler) + public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];\n"); - if ($this->getNode('attributes') !== null) { + if ($this->hasNode('attributes')) { $compiler ->write('$attributes = ') ->subcompile($this->getNode('attributes')) @@ -66,26 +69,26 @@ class TwigNodeScript extends Node implements NodeCaptureInterface $compiler->write('$attributes = [];' . "\n"); } - if ($this->getNode('group') !== null) { - $compiler - ->write("\$attributes['group'] = ") - ->subcompile($this->getNode('group')) - ->raw(";\n") - ->write("if (!is_string(\$attributes['group'])) {\n") - ->indent() - ->write("throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');\n") - ->outdent() - ->write("}\n"); - } + if ($this->hasNode('group')) { + $compiler + ->write("\$attributes['group'] = ") + ->subcompile($this->getNode('group')) + ->raw(";\n") + ->write("if (!is_string(\$attributes['group'])) {\n") + ->indent() + ->write("throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');\n") + ->outdent() + ->write("}\n"); + } - if ($this->getNode('priority') !== null) { + if ($this->hasNode('priority')) { $compiler ->write("\$attributes['priority'] = (int)(") ->subcompile($this->getNode('priority')) ->raw(");\n"); } - if ($this->getNode('file') !== null) { + if ($this->hasNode('file')) { $compiler ->write('$assets->addJs(') ->subcompile($this->getNode('file')) diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php b/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php index 22f3302..05355f9 100644 --- a/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php +++ b/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php @@ -3,19 +3,25 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig\Node; +use LogicException; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Node; use Twig\Node\NodeCaptureInterface; +/** + * Class TwigNodeStyle + * @package Grav\Common\Twig\Node + */ class TwigNodeStyle extends Node implements NodeCaptureInterface { + /** @var string */ protected $tagName = 'style'; /** @@ -28,31 +34,27 @@ class TwigNodeStyle extends Node implements NodeCaptureInterface * @param int $lineno * @param string|null $tag */ - public function __construct( - Node $body = null, - AbstractExpression $file = null, - AbstractExpression $group = null, - AbstractExpression $priority = null, - AbstractExpression $attributes = null, - $lineno = 0, - $tag = null - ) + public function __construct(?Node $body, ?AbstractExpression $file, ?AbstractExpression $group, ?AbstractExpression $priority, ?AbstractExpression $attributes, $lineno = 0, $tag = null) { - parent::__construct(['body' => $body, 'file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes], [], $lineno, $tag); + $nodes = ['body' => $body, 'file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes]; + $nodes = array_filter($nodes); + + parent::__construct($nodes, [], $lineno, $tag); } /** * Compiles the node to PHP. * - * @param Compiler $compiler A Twig_Compiler instance - * @throws \LogicException + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException */ - public function compile(Compiler $compiler) + public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];\n"); - if ($this->getNode('attributes') !== null) { + if ($this->hasNode('attributes')) { $compiler ->write('$attributes = ') ->subcompile($this->getNode('attributes')) @@ -66,7 +68,7 @@ class TwigNodeStyle extends Node implements NodeCaptureInterface $compiler->write('$attributes = [];' . "\n"); } - if ($this->getNode('group') !== null) { + if ($this->hasNode('group')) { $compiler ->write("\$attributes['group'] = ") ->subcompile($this->getNode('group')) @@ -78,14 +80,14 @@ class TwigNodeStyle extends Node implements NodeCaptureInterface ->write("}\n"); } - if ($this->getNode('priority') !== null) { + if ($this->hasNode('priority')) { $compiler ->write("\$attributes['priority'] = (int)(") ->subcompile($this->getNode('priority')) ->raw(");\n"); } - if ($this->getNode('file') !== null) { + if ($this->hasNode('file')) { $compiler ->write('$assets->addCss(') ->subcompile($this->getNode('file')) diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php b/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php index f29ed6d..43ae56f 100644 --- a/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php +++ b/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -12,6 +12,10 @@ namespace Grav\Common\Twig\Node; use Twig\Compiler; use Twig\Node\Node; +/** + * Class TwigNodeSwitch + * @package Grav\Common\Twig\Node + */ class TwigNodeSwitch extends Node { /** @@ -22,23 +26,21 @@ class TwigNodeSwitch extends Node * @param int $lineno * @param string|null $tag */ - public function __construct( - Node $value, - Node $cases, - Node $default = null, - $lineno = 0, - $tag = null - ) + public function __construct(Node $value, Node $cases, Node $default = null, $lineno = 0, $tag = null) { - parent::__construct(array('value' => $value, 'cases' => $cases, 'default' => $default), array(), $lineno, $tag); + $nodes = ['value' => $value, 'cases' => $cases, 'default' => $default]; + $nodes = array_filter($nodes); + + parent::__construct($nodes, [], $lineno, $tag); } /** * Compiles the node to PHP. * - * @param Compiler $compiler A Twig_Compiler instance + * @param Compiler $compiler A Twig Compiler instance + * @return void */ - public function compile(Compiler $compiler) + public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) @@ -47,6 +49,7 @@ class TwigNodeSwitch extends Node ->raw(") {\n") ->indent(); + /** @var Node $case */ foreach ($this->getNode('cases') as $case) { if (!$case->hasNode('body')) { continue; @@ -68,7 +71,7 @@ class TwigNodeSwitch extends Node ->write("}\n"); } - if ($this->hasNode('default') && $this->getNode('default') !== null) { + if ($this->hasNode('default')) { $compiler ->write("default:\n") ->write("{\n") diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeThrow.php b/system/src/Grav/Common/Twig/Node/TwigNodeThrow.php index 5bb5899..1a32a91 100644 --- a/system/src/Grav/Common/Twig/Node/TwigNodeThrow.php +++ b/system/src/Grav/Common/Twig/Node/TwigNodeThrow.php @@ -3,15 +3,20 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig\Node; +use LogicException; use Twig\Compiler; use Twig\Node\Node; +/** + * Class TwigNodeThrow + * @package Grav\Common\Twig\Node + */ class TwigNodeThrow extends Node { /** @@ -21,12 +26,7 @@ class TwigNodeThrow extends Node * @param int $lineno * @param string|null $tag */ - public function __construct( - $code, - Node $message, - $lineno = 0, - $tag = null - ) + public function __construct($code, Node $message, $lineno = 0, $tag = null) { parent::__construct(['message' => $message], ['code' => $code], $lineno, $tag); } @@ -34,10 +34,11 @@ class TwigNodeThrow extends Node /** * Compiles the node to PHP. * - * @param Compiler $compiler A Twig_Compiler instance - * @throws \LogicException + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException */ - public function compile(Compiler $compiler) + public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php b/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php index 4028e74..da95a1d 100644 --- a/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php +++ b/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php @@ -3,15 +3,20 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig\Node; +use LogicException; use Twig\Compiler; use Twig\Node\Node; +/** + * Class TwigNodeTryCatch + * @package Grav\Common\Twig\Node + */ class TwigNodeTryCatch extends Node { /** @@ -21,44 +26,39 @@ class TwigNodeTryCatch extends Node * @param int $lineno * @param string|null $tag */ - public function __construct( - Node $try, - Node $catch = null, - $lineno = 0, - $tag = null - ) + public function __construct(Node $try, Node $catch = null, $lineno = 0, $tag = null) { - parent::__construct(['try' => $try, 'catch' => $catch], [], $lineno, $tag); + $nodes = ['try' => $try, 'catch' => $catch]; + $nodes = array_filter($nodes); + + parent::__construct($nodes, [], $lineno, $tag); } /** * Compiles the node to PHP. * - * @param Compiler $compiler A Twig_Compiler instance - * @throws \LogicException + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException */ - public function compile(Compiler $compiler) + public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); - $compiler - ->write('try {') - ; + $compiler->write('try {'); $compiler ->indent() - ->subcompile($this->getNode('try')) - ; + ->subcompile($this->getNode('try')); - if ($this->hasNode('catch') && null !== $this->getNode('catch')) { + if ($this->hasNode('catch')) { $compiler ->outdent() ->write('} catch (\Exception $e) {' . "\n") ->indent() ->write('if (isset($context[\'grav\'][\'debugger\'])) $context[\'grav\'][\'debugger\']->addException($e);' . "\n") ->write('$context[\'e\'] = $e;' . "\n") - ->subcompile($this->getNode('catch')) - ; + ->subcompile($this->getNode('catch')); } $compiler diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserCache.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserCache.php new file mode 100644 index 0000000..371e89a --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserCache.php @@ -0,0 +1,71 @@ +getLine(); + $stream = $this->parser->getStream(); + $key = $this->parser->getVarName() . $lineno; + $lifetime = Grav::instance()['cache']->getLifetime(); + + // Check for optional lifetime override + if (!$stream->test(Token::BLOCK_END_TYPE)) { + $lifetime_expr = $this->parser->getExpressionParser()->parseExpression(); + $lifetime = $lifetime_expr->getAttribute('value'); + } + + $stream->expect(Token::BLOCK_END_TYPE); + $body = $this->parser->subparse(array($this, 'decideCacheEnd'), true); + $stream->expect(Token::BLOCK_END_TYPE); + + return new TwigNodeCache($key, $lifetime, $body, $lineno, $this->getTag()); + } + + /** + * Decide if current token marks end of cache block. + * + * @param Token $token + * @return bool + */ + public function decideCacheEnd(Token $token): bool + { + return $token->test('endcache'); + } + /** + * {@inheritDoc} + */ + public function getTag(): string + { + return 'cache'; + } +} diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php index 7a1d139..9dab4ae 100644 --- a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php @@ -3,13 +3,14 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig\TokenParser; use Grav\Common\Twig\Node\TwigNodeMarkdown; +use Twig\Error\SyntaxError; use Twig\Token; use Twig\TokenParser\AbstractTokenParser; @@ -26,13 +27,15 @@ use Twig\TokenParser\AbstractTokenParser; class TwigTokenParserMarkdown extends AbstractTokenParser { /** - * {@inheritdoc} + * @param Token $token + * @return TwigNodeMarkdown + * @throws SyntaxError */ public function parse(Token $token) { $lineno = $token->getLine(); $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); - $body = $this->parser->subparse(array($this, 'decideMarkdownEnd'), true); + $body = $this->parser->subparse([$this, 'decideMarkdownEnd'], true); $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); return new TwigNodeMarkdown($body, $lineno, $this->getTag()); } @@ -42,14 +45,14 @@ class TwigTokenParserMarkdown extends AbstractTokenParser * @param Token $token * @return bool */ - public function decideMarkdownEnd(Token $token) + public function decideMarkdownEnd(Token $token): bool { return $token->test('endmarkdown'); } /** * {@inheritdoc} */ - public function getTag() + public function getTag(): string { return 'markdown'; } diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php index 811705d..ab2bdde 100644 --- a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -24,9 +24,8 @@ class TwigTokenParserRender extends AbstractTokenParser /** * Parses a token and returns a node. * - * @param Token $token A Twig_Token instance - * - * @return Node A Twig_Node instance + * @param Token $token + * @return TwigNodeRender */ public function parse(Token $token) { @@ -41,7 +40,7 @@ class TwigTokenParserRender extends AbstractTokenParser * @param Token $token * @return array */ - protected function parseArguments(Token $token) + protected function parseArguments(Token $token): array { $stream = $this->parser->getStream(); @@ -68,7 +67,7 @@ class TwigTokenParserRender extends AbstractTokenParser * * @return string The tag name */ - public function getTag() + public function getTag(): string { return 'render'; } diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php index bb04707..b860631 100644 --- a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php @@ -3,23 +3,23 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig\TokenParser; use Grav\Common\Twig\Node\TwigNodeScript; -use Twig\Node\Node; +use Twig\Error\SyntaxError; use Twig\Token; use Twig\TokenParser\AbstractTokenParser; /** * Adds a script to head/bottom/custom location in the document. * - * {% script 'theme://js/something.js' in 'bottom' priority: 20 with { defer: true, async: true } %} + * {% script 'theme://js/something.js' at 'bottom' priority: 20 with { defer: true, async: true } %} * - * {% script in 'bottom' priority: 20 %} + * {% script at 'bottom' priority: 20 %} * alert('Warning!'); * {% endscript %} @@ -29,16 +29,16 @@ class TwigTokenParserScript extends AbstractTokenParser /** * Parses a token and returns a node. * - * @param Token $token A Twig_Token instance - * - * @return Node A Twig_Node instance + * @param Token $token + * @return TwigNodeScript + * @throws SyntaxError */ public function parse(Token $token) { $lineno = $token->getLine(); $stream = $this->parser->getStream(); - list($file, $group, $priority, $attributes) = $this->parseArguments($token); + [$file, $group, $priority, $attributes] = $this->parseArguments($token); $content = null; if ($file === null) { @@ -53,17 +53,33 @@ class TwigTokenParserScript extends AbstractTokenParser * @param Token $token * @return array */ - protected function parseArguments(Token $token) + protected function parseArguments(Token $token): array { $stream = $this->parser->getStream(); + // Look for deprecated {% script ... in ... %} + if (!$stream->test(Token::BLOCK_END_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in')) { + $i = 0; + do { + $token = $stream->look(++$i); + if ($token->test(Token::BLOCK_END_TYPE)) { + break; + } + if ($token->test(Token::OPERATOR_TYPE, 'in') && $stream->look($i+1)->test(Token::STRING_TYPE)) { + user_error("Twig: Using {% script ... in ... %} is deprecated, use {% script ... at ... %} instead", E_USER_DEPRECATED); + + break; + } + } while (true); + } + $file = null; - if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::OPERATOR_TYPE) && !$stream->test(Token::BLOCK_END_TYPE)) { + if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in') && !$stream->test(Token::BLOCK_END_TYPE)) { $file = $this->parser->getExpressionParser()->parseExpression(); } $group = null; - if ($stream->nextIf(Token::OPERATOR_TYPE, 'in')) { + if ($stream->nextIf(Token::NAME_TYPE, 'at') || $stream->nextIf(Token::OPERATOR_TYPE, 'in')) { $group = $this->parser->getExpressionParser()->parseExpression(); } @@ -87,7 +103,7 @@ class TwigTokenParserScript extends AbstractTokenParser * @param Token $token * @return bool */ - public function decideBlockEnd(Token $token) + public function decideBlockEnd(Token $token): bool { return $token->test('endscript'); } @@ -97,7 +113,7 @@ class TwigTokenParserScript extends AbstractTokenParser * * @return string The tag name */ - public function getTag() + public function getTag(): string { return 'script'; } diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php index 5a5beb6..c8d9544 100644 --- a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php @@ -3,14 +3,14 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig\TokenParser; use Grav\Common\Twig\Node\TwigNodeStyle; -use Twig\Node\Node; +use Twig\Error\SyntaxError; use Twig\Token; use Twig\TokenParser\AbstractTokenParser; @@ -28,16 +28,16 @@ class TwigTokenParserStyle extends AbstractTokenParser /** * Parses a token and returns a node. * - * @param Token $token A Twig_Token instance - * - * @return Node A Twig_Node instance + * @param Token $token + * @return TwigNodeStyle + * @throws SyntaxError */ public function parse(Token $token) { $lineno = $token->getLine(); $stream = $this->parser->getStream(); - list ($file, $group, $priority, $attributes) = $this->parseArguments($token); + [$file, $group, $priority, $attributes] = $this->parseArguments($token); $content = null; if (!$file) { @@ -52,17 +52,33 @@ class TwigTokenParserStyle extends AbstractTokenParser * @param Token $token * @return array */ - protected function parseArguments(Token $token) + protected function parseArguments(Token $token): array { $stream = $this->parser->getStream(); + // Look for deprecated {% style ... in ... %} + if (!$stream->test(Token::BLOCK_END_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in')) { + $i = 0; + do { + $token = $stream->look(++$i); + if ($token->test(Token::BLOCK_END_TYPE)) { + break; + } + if ($token->test(Token::OPERATOR_TYPE, 'in') && $stream->look($i+1)->test(Token::STRING_TYPE)) { + user_error("Twig: Using {% style ... in ... %} is deprecated, use {% style ... at ... %} instead", E_USER_DEPRECATED); + + break; + } + } while (true); + } + $file = null; - if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::OPERATOR_TYPE) && !$stream->test(Token::BLOCK_END_TYPE)) { + if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in') && !$stream->test(Token::BLOCK_END_TYPE)) { $file = $this->parser->getExpressionParser()->parseExpression(); } $group = null; - if ($stream->nextIf(Token::OPERATOR_TYPE, 'in')) { + if ($stream->nextIf(Token::NAME_TYPE, 'at') || $stream->nextIf(Token::OPERATOR_TYPE, 'in')) { $group = $this->parser->getExpressionParser()->parseExpression(); } @@ -86,7 +102,7 @@ class TwigTokenParserStyle extends AbstractTokenParser * @param Token $token * @return bool */ - public function decideBlockEnd(Token $token) + public function decideBlockEnd(Token $token): bool { return $token->test('endstyle'); } @@ -96,7 +112,7 @@ class TwigTokenParserStyle extends AbstractTokenParser * * @return string The tag name */ - public function getTag() + public function getTag(): string { return 'style'; } diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php index 7bd3ba9..4540bbf 100644 --- a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. * @origin https://gist.github.com/maxgalbu/9409182 */ @@ -31,7 +31,9 @@ use Twig\TokenParser\AbstractTokenParser; class TwigTokenParserSwitch extends AbstractTokenParser { /** - * {@inheritdoc} + * @param Token $token + * @return TwigNodeSwitch + * @throws SyntaxError */ public function parse(Token $token) { @@ -72,7 +74,7 @@ class TwigTokenParserSwitch extends AbstractTokenParser } $stream->expect(Token::BLOCK_END_TYPE); - $body = $this->parser->subparse(array($this, 'decideIfFork')); + $body = $this->parser->subparse([$this, 'decideIfFork']); $cases[] = new Node([ 'values' => new Node($values), 'body' => $body @@ -81,7 +83,7 @@ class TwigTokenParserSwitch extends AbstractTokenParser case 'default': $stream->expect(Token::BLOCK_END_TYPE); - $default = $this->parser->subparse(array($this, 'decideIfEnd')); + $default = $this->parser->subparse([$this, 'decideIfEnd']); break; case 'endswitch': @@ -104,9 +106,9 @@ class TwigTokenParserSwitch extends AbstractTokenParser * @param Token $token * @return bool */ - public function decideIfFork(Token $token) + public function decideIfFork(Token $token): bool { - return $token->test(array('case', 'default', 'endswitch')); + return $token->test(['case', 'default', 'endswitch']); } /** @@ -115,15 +117,15 @@ class TwigTokenParserSwitch extends AbstractTokenParser * @param Token $token * @return bool */ - public function decideIfEnd(Token $token) + public function decideIfEnd(Token $token): bool { - return $token->test(array('endswitch')); + return $token->test(['endswitch']); } /** * {@inheritdoc} */ - public function getTag() + public function getTag(): string { return 'switch'; } diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php index c0d6de9..bd4adab 100644 --- a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php @@ -3,13 +3,14 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig\TokenParser; use Grav\Common\Twig\Node\TwigNodeThrow; +use Twig\Error\SyntaxError; use Twig\Node\Node; use Twig\Token; use Twig\TokenParser\AbstractTokenParser; @@ -26,9 +27,9 @@ class TwigTokenParserThrow extends AbstractTokenParser /** * Parses a token and returns a node. * - * @param Token $token A Twig_Token instance - * - * @return Node A Twig_Node instance + * @param Token $token + * @return TwigNodeThrow + * @throws SyntaxError */ public function parse(Token $token) { @@ -39,7 +40,7 @@ class TwigTokenParserThrow extends AbstractTokenParser $message = $this->parser->getExpressionParser()->parseExpression(); $stream->expect(Token::BLOCK_END_TYPE); - return new TwigNodeThrow($code, $message, $lineno, $this->getTag()); + return new TwigNodeThrow((int)$code, $message, $lineno, $this->getTag()); } /** @@ -47,7 +48,7 @@ class TwigTokenParserThrow extends AbstractTokenParser * * @return string The tag name */ - public function getTag() + public function getTag(): string { return 'throw'; } diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php index 95adef3..46af176 100644 --- a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php @@ -3,13 +3,14 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig\TokenParser; use Grav\Common\Twig\Node\TwigNodeTryCatch; +use Twig\Error\SyntaxError; use Twig\Node\Node; use Twig\Token; use Twig\TokenParser\AbstractTokenParser; @@ -30,9 +31,9 @@ class TwigTokenParserTryCatch extends AbstractTokenParser /** * Parses a token and returns a node. * - * @param Token $token A Twig_Token instance - * - * @return Node A Twig_Node instance + * @param Token $token + * @return TwigNodeTryCatch + * @throws SyntaxError */ public function parse(Token $token) { @@ -50,14 +51,22 @@ class TwigTokenParserTryCatch extends AbstractTokenParser return new TwigNodeTryCatch($try, $catch, $lineno, $this->getTag()); } - public function decideCatch(Token $token) + /** + * @param Token $token + * @return bool + */ + public function decideCatch(Token $token): bool { - return $token->test(array('catch')); + return $token->test(['catch']); } - public function decideEnd(Token $token) + /** + * @param Token $token + * @return bool + */ + public function decideEnd(Token $token): bool { - return $token->test(array('endtry')) || $token->test(array('endcatch')); + return $token->test(['endtry']) || $token->test(['endcatch']); } /** @@ -65,7 +74,7 @@ class TwigTokenParserTryCatch extends AbstractTokenParser * * @return string The tag name */ - public function getTag() + public function getTag(): string { return 'try'; } diff --git a/system/src/Grav/Common/Twig/Twig.php b/system/src/Grav/Common/Twig/Twig.php index 5c9008f..2796c0d 100644 --- a/system/src/Grav/Common/Twig/Twig.php +++ b/system/src/Grav/Common/Twig/Twig.php @@ -3,61 +3,68 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig; +use Grav\Common\Debugger; use Grav\Common\Grav; use Grav\Common\Config\Config; use Grav\Common\Language\Language; use Grav\Common\Language\LanguageCodes; use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Page\Pages; +use Grav\Common\Twig\Extension\FilesystemExtension; +use Grav\Common\Twig\Extension\GravExtension; +use Grav\Common\Utils; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; use RocketTheme\Toolbox\Event\Event; use Phive\Twig\Extensions\Deferred\DeferredExtension; +use RuntimeException; +use Twig\Cache\FilesystemCache; +use Twig\Environment; +use Twig\Error\LoaderError; +use Twig\Extension\CoreExtension; +use Twig\Extension\DebugExtension; +use Twig\Extension\StringLoaderExtension; +use Twig\Loader\ArrayLoader; +use Twig\Loader\ChainLoader; +use Twig\Loader\ExistsLoaderInterface; +use Twig\Loader\FilesystemLoader; +use Twig\Profiler\Profile; +use Twig\TwigFilter; +use Twig\TwigFunction; +use function function_exists; +use function in_array; +use function is_array; +/** + * Class Twig + * @package Grav\Common\Twig + */ class Twig { - /** - * @var \Twig_Environment - */ + /** @var Environment */ public $twig; - - /** - * @var array - */ + /** @var array */ public $twig_vars = []; - - /** - * @var array - */ + /** @var array */ public $twig_paths; - - /** - * @var string - */ + /** @var string */ public $template; - /** - * @var Grav - */ + /** @var Grav */ protected $grav; - - /** - * @var \Twig_Loader_Filesystem - */ + /** @var FilesystemLoader */ protected $loader; - - /** - * @var \Twig_Loader_Array - */ + /** @var ArrayLoader */ protected $loaderArray; - - + /** @var bool */ protected $autoescape; + /** @var Profile */ + protected $profile; /** * Constructor @@ -73,6 +80,8 @@ class Twig /** * Twig initialization that sets the twig loader chain, then the environment, then extensions * and also the base set of twig vars + * + * @return $this */ public function init() { @@ -88,7 +97,7 @@ class Twig // handle language templates if available if ($language->enabled()) { - $lang_templates = $locator->findResource('theme://templates/' . ($active_language ? $active_language : $language->getDefault())); + $lang_templates = $locator->findResource('theme://templates/' . ($active_language ?: $language->getDefault())); if ($lang_templates) { $this->twig_paths[] = $lang_templates; } @@ -102,7 +111,7 @@ class Twig $core_templates = array_merge($locator->findResources('system://templates'), $locator->findResources('system://templates/testing')); $this->twig_paths = array_merge($this->twig_paths, $core_templates); - $this->loader = new \Twig_Loader_Filesystem($this->twig_paths); + $this->loader = new FilesystemLoader($this->twig_paths); // Register all other prefixes as namespaces in twig foreach ($locator->getPaths('theme') as $prefix => $_) { @@ -114,7 +123,7 @@ class Twig // handle language templates if available if ($language->enabled()) { - $lang_templates = $locator->findResource('theme://'.$prefix.'templates/' . ($active_language ? $active_language : $language->getDefault())); + $lang_templates = $locator->findResource('theme://'.$prefix.'templates/' . ($active_language ?: $language->getDefault())); if ($lang_templates) { $twig_paths[] = $lang_templates; } @@ -128,16 +137,16 @@ class Twig $this->grav->fireEvent('onTwigLoader'); - $this->loaderArray = new \Twig_Loader_Array([]); - $loader_chain = new \Twig_Loader_Chain([$this->loaderArray, $this->loader]); + $this->loaderArray = new ArrayLoader([]); + $loader_chain = new ChainLoader([$this->loaderArray, $this->loader]); $params = $config->get('system.twig'); if (!empty($params['cache'])) { $cachePath = $locator->findResource('cache://twig', true, true); - $params['cache'] = new \Twig_Cache_Filesystem($cachePath, \Twig_Cache_Filesystem::FORCE_BYTECODE_INVALIDATION); + $params['cache'] = new FilesystemCache($cachePath, FilesystemCache::FORCE_BYTECODE_INVALIDATION); } - if (!$config->get('system.strict_mode.twig_compat', true)) { + if (!$config->get('system.strict_mode.twig_compat', false)) { // Force autoescape on for all files if in strict mode. $params['autoescape'] = 'html'; } elseif (!empty($this->autoescape)) { @@ -150,42 +159,74 @@ class Twig $this->twig = new TwigEnvironment($loader_chain, $params); - if ($config->get('system.twig.undefined_functions')) { - $this->twig->registerUndefinedFunctionCallback(function ($name) { + $this->twig->registerUndefinedFunctionCallback(function ($name) use ($config) { + $allowed = $config->get('system.twig.safe_functions'); + if (is_array($allowed) && in_array($name, $allowed, true) && function_exists($name)) { + return new TwigFunction($name, $name); + } + if ($config->get('system.twig.undefined_functions')) { if (function_exists($name)) { - return new \Twig_SimpleFunction($name, $name); + if (!Utils::isDangerousFunction($name)) { + user_error("PHP function {$name}() was used as Twig function. This is deprecated in Grav 1.7. Please add it to system configuration: `system.twig.safe_functions`", E_USER_DEPRECATED); + + return new TwigFunction($name, $name); + } + + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig function. If you really want to use it, please add it to system configuration: `system.twig.safe_functions`")); } - return new \Twig_SimpleFunction($name, function () { - }); - }); - } + return new TwigFunction($name, static function () {}); + } - if ($config->get('system.twig.undefined_filters')) { - $this->twig->registerUndefinedFilterCallback(function ($name) { + return false; + }); + + $this->twig->registerUndefinedFilterCallback(function ($name) use ($config) { + $allowed = $config->get('system.twig.safe_filters'); + if (is_array($allowed) && in_array($name, $allowed, true) && function_exists($name)) { + return new TwigFilter($name, $name); + } + if ($config->get('system.twig.undefined_filters')) { if (function_exists($name)) { - return new \Twig_SimpleFilter($name, $name); + if (!Utils::isDangerousFunction($name)) { + user_error("PHP function {$name}() used as Twig filter. This is deprecated in Grav 1.7. Please add it to system configuration: `system.twig.safe_filters`", E_USER_DEPRECATED); + + return new TwigFilter($name, $name); + } + + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig filter. If you really want to use it, please add it to system configuration: `system.twig.safe_filters`")); } - return new \Twig_SimpleFilter($name, function () { - }); - }); - } + return new TwigFilter($name, static function () {}); + } + + return false; + }); $this->grav->fireEvent('onTwigInitialized'); // set default date format if set in config if ($config->get('system.pages.dateformat.long')) { - /** @var \Twig_Extension_Core $extension */ - $extension = $this->twig->getExtension('Twig_Extension_Core'); + /** @var CoreExtension $extension */ + $extension = $this->twig->getExtension(CoreExtension::class); $extension->setDateFormat($config->get('system.pages.dateformat.long')); } // enable the debug extension if required if ($config->get('system.twig.debug')) { - $this->twig->addExtension(new \Twig_Extension_Debug()); + $this->twig->addExtension(new DebugExtension()); } - $this->twig->addExtension(new TwigExtension()); + $this->twig->addExtension(new GravExtension()); + $this->twig->addExtension(new FilesystemExtension()); $this->twig->addExtension(new DeferredExtension()); + $this->twig->addExtension(new StringLoaderExtension()); + + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addTwigProfiler($this->twig); $this->grav->fireEvent('onTwigExtensions'); @@ -202,7 +243,7 @@ class Twig 'assets' => $this->grav['assets'], 'taxonomy' => $this->grav['taxonomy'], 'browser' => $this->grav['browser'], - 'base_dir' => rtrim(ROOT_DIR, '/'), + 'base_dir' => GRAV_ROOT, 'home_url' => $pages->homeUrl($active_language), 'base_url' => $pages->baseUrl($active_language), 'base_url_absolute' => $pages->baseUrl($active_language, true), @@ -219,7 +260,7 @@ class Twig } /** - * @return \Twig_Environment + * @return Environment */ public function twig() { @@ -227,13 +268,22 @@ class Twig } /** - * @return \Twig_Loader_Filesystem + * @return FilesystemLoader */ public function loader() { return $this->loader; } + /** + * @return Profile + */ + public function profile() + { + return $this->profile; + } + + /** * Adds or overrides a template. * @@ -251,10 +301,9 @@ class Twig * 2) Renders individual page items for twig processing before the site rendering * * @param PageInterface $item The page item to render - * @param string $content Optional content override + * @param string|null $content Optional content override * * @return string The rendered output - * @throws \Twig_Error_Loader */ public function processPage(PageInterface $item, $content = null) { @@ -267,17 +316,14 @@ class Twig $twig_vars['page'] = $item; $twig_vars['media'] = $item->media(); $twig_vars['header'] = $item->header(); - $local_twig = clone $this->twig; $output = ''; + try { - // Process Modular Twig - if ($item->modularTwig()) { + if ($item->isModule()) { $twig_vars['content'] = $content; - $extension = $item->templateFormat(); - $extension = $extension ? ".{$extension}.twig" : TEMPLATE_EXT; - $template = $item->template() . $extension; + $template = $this->getPageTwigTemplate($item); $output = $content = $local_twig->render($template, $twig_vars); } @@ -288,8 +334,8 @@ class Twig $output = $local_twig->render($name, $twig_vars); } - } catch (\Twig_Error_Loader $e) { - throw new \RuntimeException($e->getRawMessage(), 404, $e); + } catch (LoaderError $e) { + throw new RuntimeException($e->getRawMessage(), 400, $e); } return $output; @@ -312,12 +358,11 @@ class Twig try { $output = $this->twig->render($template, $vars); - } catch (\Twig_Error_Loader $e) { - throw new \RuntimeException($e->getRawMessage(), 404, $e); + } catch (LoaderError $e) { + throw new RuntimeException($e->getRawMessage(), 404, $e); } return $output; - } @@ -341,8 +386,8 @@ class Twig try { $output = $this->twig->render($name, $vars); - } catch (\Twig_Error_Loader $e) { - throw new \RuntimeException($e->getRawMessage(), 404, $e); + } catch (LoaderError $e) { + throw new RuntimeException($e->getRawMessage(), 404, $e); } return $output; @@ -352,16 +397,18 @@ class Twig * Twig process that renders the site layout. This is the main twig process that renders the overall * page and handles all the layout for the site display. * - * @param string $format Output format (defaults to HTML). - * + * @param string|null $format Output format (defaults to HTML). + * @param array $vars * @return string the rendered output - * @throws \RuntimeException + * @throws RuntimeException */ public function processSite($format = null, array $vars = []) { // set the page now its been processed $this->grav->fireEvent('onTwigSiteVariables'); + /** @var Pages $pages */ $pages = $this->grav['pages']; + /** @var PageInterface $page */ $page = $this->grav['page']; $content = $page->content(); @@ -373,7 +420,6 @@ class Twig $twig_vars['header'] = $page->header(); $twig_vars['media'] = $page->media(); $twig_vars['content'] = $content; - $ext = '.' . ($format ?: 'html') . TWIG_EXT; // determine if params are set, if so disable twig cache $params = $this->grav['uri']->params(null, true); @@ -382,32 +428,24 @@ class Twig } // Get Twig template layout - $template = $this->template($page->template() . $ext); + $template = $this->getPageTwigTemplate($page, $format); + $page->templateFormat($format); try { $output = $this->twig->render($template, $vars + $twig_vars); - } catch (\Twig_Error_Loader $e) { + } catch (LoaderError $e) { $error_msg = $e->getMessage(); - // Try html version of this template if initial template was NOT html - if ($ext !== '.html' . TWIG_EXT) { - try { - $page->templateFormat('html'); - $output = $this->twig->render($page->template() . '.html' . TWIG_EXT, $vars + $twig_vars); - } catch (\Twig_Error_Loader $e) { - throw new \RuntimeException($error_msg, 400, $e); - } - } else { - throw new \RuntimeException($error_msg, 400, $e); - } + throw new RuntimeException($error_msg, 400, $e); } return $output; } /** - * Wraps the Twig_Loader_Filesystem addPath method (should be used only in `onTwigLoader()` event + * Wraps the FilesystemLoader addPath method (should be used only in `onTwigLoader()` event * @param string $template_path * @param string $namespace + * @throws LoaderError */ public function addPath($template_path, $namespace = '__main__') { @@ -415,9 +453,10 @@ class Twig } /** - * Wraps the Twig_Loader_Filesystem prependPath method (should be used only in `onTwigLoader()` event + * Wraps the FilesystemLoader prependPath method (should be used only in `onTwigLoader()` event * @param string $template_path * @param string $namespace + * @throws LoaderError */ public function prependPath($template_path, $namespace = '__main__') { @@ -429,7 +468,6 @@ class Twig * the one being passed in * * @param string $template the template name - * * @return string the template name */ public function template($template) @@ -437,10 +475,49 @@ class Twig return $this->template ?? $template; } + /** + * @param PageInterface $page + * @param string|null $format + * @return string + */ + public function getPageTwigTemplate($page, &$format = null) + { + $template = $page->template(); + $default = $page->isModule() ? 'modular/default' : 'default'; + $extension = $format ?: $page->templateFormat(); + $twig_extension = $extension ? '.'. $extension .TWIG_EXT : TEMPLATE_EXT; + $template_file = $this->template($page->template() . $twig_extension); + + $page_template = null; + + $loader = $this->twig->getLoader(); + if ($loader instanceof ExistsLoaderInterface) { + if ($loader->exists($template_file)) { + // template.xxx.twig + $page_template = $template_file; + } elseif ($twig_extension !== TEMPLATE_EXT && $loader->exists($template . TEMPLATE_EXT)) { + // template.html.twig + $page_template = $template . TEMPLATE_EXT; + $format = 'html'; + } elseif ($loader->exists($default . $twig_extension)) { + // default.xxx.twig + $page_template = $default . $twig_extension; + } else { + // default.html.twig + $page_template = $default . TEMPLATE_EXT; + $format = 'html'; + } + } + + return $page_template; + + } + /** * Overrides the autoescape setting * * @param bool $state + * @return void * @deprecated 1.5 Auto-escape should always be turned on to protect against XSS issues (can be disabled per template file). */ public function setAutoescape($state) diff --git a/system/src/Grav/Common/Twig/TwigClockworkDataSource.php b/system/src/Grav/Common/Twig/TwigClockworkDataSource.php new file mode 100644 index 0000000..11127b8 --- /dev/null +++ b/system/src/Grav/Common/Twig/TwigClockworkDataSource.php @@ -0,0 +1,58 @@ +twig = $twig; + } + + /** + * Register the Twig profiler extension + */ + public function listenToEvents(): void + { + $this->twig->addExtension(new ProfilerExtension($this->profile = new Profile())); + } + + /** + * Adds rendered views to the request + * + * @param Request $request + * @return Request + */ + public function resolve(Request $request) + { + $timeline = (new TwigClockworkDumper())->dump($this->profile); + + $request->viewsData = array_merge($request->viewsData, $timeline->finalize()); + + return $request; + } +} diff --git a/system/src/Grav/Common/Twig/TwigClockworkDumper.php b/system/src/Grav/Common/Twig/TwigClockworkDumper.php new file mode 100644 index 0000000..2c1f4be --- /dev/null +++ b/system/src/Grav/Common/Twig/TwigClockworkDumper.php @@ -0,0 +1,72 @@ +dumpProfile($profile, $timeline); + + return $timeline; + } + + /** + * @param Profile $profile + * @param Timeline $timeline + * @param null $parent + */ + public function dumpProfile(Profile $profile, Timeline $timeline, $parent = null) + { + $id = $this->lastId++; + + if ($profile->isRoot()) { + $name = $profile->getName(); + } elseif ($profile->isTemplate()) { + $name = $profile->getTemplate(); + } else { + $name = $profile->getTemplate() . '::' . $profile->getType() . '(' . $profile->getName() . ')'; + } + + foreach ($profile as $p) { + $this->dumpProfile($p, $timeline, $id); + } + + $data = $profile->__serialize(); + + $timeline->event($name, [ + 'name' => $id, + 'start' => $data[3]['wt'] ?? null, + 'end' => $data[4]['wt'] ?? null, + 'data' => [ + 'data' => [], + 'memoryUsage' => $data[4]['mu'] ?? null, + 'parent' => $parent + ] + ]); + } +} diff --git a/system/src/Grav/Common/Twig/TwigEnvironment.php b/system/src/Grav/Common/Twig/TwigEnvironment.php index 54ef24f..bebbdf1 100644 --- a/system/src/Grav/Common/Twig/TwigEnvironment.php +++ b/system/src/Grav/Common/Twig/TwigEnvironment.php @@ -3,13 +3,19 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig; -class TwigEnvironment extends \Twig_Environment +use Twig\Environment; + +/** + * Class TwigEnvironment + * @package Grav\Common\Twig + */ +class TwigEnvironment extends Environment { use WriteCacheFileTrait; } diff --git a/system/src/Grav/Common/Twig/TwigExtension.php b/system/src/Grav/Common/Twig/TwigExtension.php index 58b1d6d..698858b 100644 --- a/system/src/Grav/Common/Twig/TwigExtension.php +++ b/system/src/Grav/Common/Twig/TwigExtension.php @@ -3,1443 +3,19 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\Twig; -use Cron\CronExpression; -use Grav\Common\Config\Config; -use Grav\Common\Debugger; -use Grav\Common\Grav; -use Grav\Common\Language\Language; -use Grav\Common\Page\Collection; -use Grav\Common\Page\Media; -use Grav\Common\Scheduler\Cron; -use Grav\Common\Security; -use Grav\Common\Twig\TokenParser\TwigTokenParserRender; -use Grav\Common\Twig\TokenParser\TwigTokenParserScript; -use Grav\Common\Twig\TokenParser\TwigTokenParserStyle; -use Grav\Common\Twig\TokenParser\TwigTokenParserSwitch; -use Grav\Common\Twig\TokenParser\TwigTokenParserThrow; -use Grav\Common\Twig\TokenParser\TwigTokenParserTryCatch; -use Grav\Common\Twig\TokenParser\TwigTokenParserMarkdown; -use Grav\Common\User\Interfaces\UserInterface; -use Grav\Common\Utils; -use Grav\Common\Yaml; -use Grav\Common\Helpers\Base32; -use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use Grav\Common\Twig\Extension\GravExtension; -class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsInterface +/** + * Class TwigExtension + * @package Grav\Common\Twig + * @deprecated 1.7 Use GravExtension instead + */ +class TwigExtension extends GravExtension { - /** @var Grav */ - protected $grav; - - /** @var Debugger */ - protected $debugger; - - /** @var Config */ - protected $config; - - /** - * TwigExtension constructor. - */ - public function __construct() - { - $this->grav = Grav::instance(); - $this->debugger = $this->grav['debugger'] ?? null; - $this->config = $this->grav['config']; - } - - /** - * Register some standard globals - * - * @return array - */ - public function getGlobals() - { - return [ - 'grav' => $this->grav, - ]; - } - - /** - * Return a list of all filters. - * - * @return array - */ - public function getFilters() - { - return [ - new \Twig_SimpleFilter('*ize', [$this, 'inflectorFilter']), - new \Twig_SimpleFilter('absolute_url', [$this, 'absoluteUrlFilter']), - new \Twig_SimpleFilter('contains', [$this, 'containsFilter']), - new \Twig_SimpleFilter('chunk_split', [$this, 'chunkSplitFilter']), - new \Twig_SimpleFilter('nicenumber', [$this, 'niceNumberFunc']), - new \Twig_SimpleFilter('nicefilesize', [$this, 'niceFilesizeFunc']), - new \Twig_SimpleFilter('nicetime', [$this, 'nicetimeFunc']), - new \Twig_SimpleFilter('defined', [$this, 'definedDefaultFilter']), - new \Twig_SimpleFilter('ends_with', [$this, 'endsWithFilter']), - new \Twig_SimpleFilter('fieldName', [$this, 'fieldNameFilter']), - new \Twig_SimpleFilter('ksort', [$this, 'ksortFilter']), - new \Twig_SimpleFilter('ltrim', [$this, 'ltrimFilter']), - new \Twig_SimpleFilter('markdown', [$this, 'markdownFunction'], ['needs_context' => true, 'is_safe' => ['html']]), - new \Twig_SimpleFilter('md5', [$this, 'md5Filter']), - new \Twig_SimpleFilter('base32_encode', [$this, 'base32EncodeFilter']), - new \Twig_SimpleFilter('base32_decode', [$this, 'base32DecodeFilter']), - new \Twig_SimpleFilter('base64_encode', [$this, 'base64EncodeFilter']), - new \Twig_SimpleFilter('base64_decode', [$this, 'base64DecodeFilter']), - new \Twig_SimpleFilter('randomize', [$this, 'randomizeFilter']), - new \Twig_SimpleFilter('modulus', [$this, 'modulusFilter']), - new \Twig_SimpleFilter('rtrim', [$this, 'rtrimFilter']), - new \Twig_SimpleFilter('pad', [$this, 'padFilter']), - new \Twig_SimpleFilter('regex_replace', [$this, 'regexReplace']), - new \Twig_SimpleFilter('safe_email', [$this, 'safeEmailFilter']), - new \Twig_SimpleFilter('safe_truncate', ['\Grav\Common\Utils', 'safeTruncate']), - new \Twig_SimpleFilter('safe_truncate_html', ['\Grav\Common\Utils', 'safeTruncateHTML']), - new \Twig_SimpleFilter('sort_by_key', [$this, 'sortByKeyFilter']), - new \Twig_SimpleFilter('starts_with', [$this, 'startsWithFilter']), - new \Twig_SimpleFilter('truncate', ['\Grav\Common\Utils', 'truncate']), - new \Twig_SimpleFilter('truncate_html', ['\Grav\Common\Utils', 'truncateHTML']), - new \Twig_SimpleFilter('json_decode', [$this, 'jsonDecodeFilter']), - new \Twig_SimpleFilter('array_unique', 'array_unique'), - new \Twig_SimpleFilter('basename', 'basename'), - new \Twig_SimpleFilter('dirname', 'dirname'), - new \Twig_SimpleFilter('print_r', 'print_r'), - new \Twig_SimpleFilter('yaml_encode', [$this, 'yamlEncodeFilter']), - new \Twig_SimpleFilter('yaml_decode', [$this, 'yamlDecodeFilter']), - new \Twig_SimpleFilter('nicecron', [$this, 'niceCronFilter']), - - // Translations - new \Twig_SimpleFilter('t', [$this, 'translate'], ['needs_environment' => true]), - new \Twig_SimpleFilter('tl', [$this, 'translateLanguage']), - new \Twig_SimpleFilter('ta', [$this, 'translateArray']), - - // Casting values - new \Twig_SimpleFilter('string', [$this, 'stringFilter']), - new \Twig_SimpleFilter('int', [$this, 'intFilter'], ['is_safe' => ['all']]), - new \Twig_SimpleFilter('bool', [$this, 'boolFilter']), - new \Twig_SimpleFilter('float', [$this, 'floatFilter'], ['is_safe' => ['all']]), - new \Twig_SimpleFilter('array', [$this, 'arrayFilter']), - - // Object Types - new \Twig_SimpleFilter('get_type', [$this, 'getTypeFunc']), - new \Twig_SimpleFilter('of_type', [$this, 'ofTypeFunc']) - ]; - } - - /** - * Return a list of all functions. - * - * @return array - */ - public function getFunctions() - { - return [ - new \Twig_SimpleFunction('array', [$this, 'arrayFilter']), - new \Twig_SimpleFunction('array_key_value', [$this, 'arrayKeyValueFunc']), - new \Twig_SimpleFunction('array_key_exists', 'array_key_exists'), - new \Twig_SimpleFunction('array_unique', 'array_unique'), - new \Twig_SimpleFunction('array_intersect', [$this, 'arrayIntersectFunc']), - new \Twig_SimpleFunction('authorize', [$this, 'authorize']), - new \Twig_SimpleFunction('debug', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]), - new \Twig_SimpleFunction('dump', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]), - new \Twig_SimpleFunction('vardump', [$this, 'vardumpFunc']), - new \Twig_SimpleFunction('print_r', 'print_r'), - new \Twig_SimpleFunction('http_response_code', 'http_response_code'), - new \Twig_SimpleFunction('evaluate', [$this, 'evaluateStringFunc'], ['needs_context' => true]), - new \Twig_SimpleFunction('evaluate_twig', [$this, 'evaluateTwigFunc'], ['needs_context' => true]), - new \Twig_SimpleFunction('gist', [$this, 'gistFunc']), - new \Twig_SimpleFunction('nonce_field', [$this, 'nonceFieldFunc']), - new \Twig_SimpleFunction('pathinfo', 'pathinfo'), - new \Twig_SimpleFunction('random_string', [$this, 'randomStringFunc']), - new \Twig_SimpleFunction('repeat', [$this, 'repeatFunc']), - new \Twig_SimpleFunction('regex_replace', [$this, 'regexReplace']), - new \Twig_SimpleFunction('regex_filter', [$this, 'regexFilter']), - new \Twig_SimpleFunction('string', [$this, 'stringFunc']), - new \Twig_SimpleFunction('url', [$this, 'urlFunc']), - new \Twig_SimpleFunction('json_decode', [$this, 'jsonDecodeFilter']), - new \Twig_SimpleFunction('get_cookie', [$this, 'getCookie']), - new \Twig_SimpleFunction('redirect_me', [$this, 'redirectFunc']), - new \Twig_SimpleFunction('range', [$this, 'rangeFunc']), - new \Twig_SimpleFunction('isajaxrequest', [$this, 'isAjaxFunc']), - new \Twig_SimpleFunction('exif', [$this, 'exifFunc']), - new \Twig_SimpleFunction('media_directory', [$this, 'mediaDirFunc']), - new \Twig_SimpleFunction('body_class', [$this, 'bodyClassFunc']), - new \Twig_SimpleFunction('theme_var', [$this, 'themeVarFunc']), - new \Twig_SimpleFunction('header_var', [$this, 'pageHeaderVarFunc']), - new \Twig_SimpleFunction('read_file', [$this, 'readFileFunc']), - new \Twig_SimpleFunction('nicenumber', [$this, 'niceNumberFunc']), - new \Twig_SimpleFunction('nicefilesize', [$this, 'niceFilesizeFunc']), - new \Twig_SimpleFunction('nicetime', [$this, 'nicetimeFunc']), - new \Twig_SimpleFunction('cron', [$this, 'cronFunc']), - new \Twig_SimpleFunction('xss', [$this, 'xssFunc']), - - - // Translations - new \Twig_SimpleFunction('t', [$this, 'translate'], ['needs_environment' => true]), - new \Twig_SimpleFunction('tl', [$this, 'translateLanguage']), - new \Twig_SimpleFunction('ta', [$this, 'translateArray']), - - // Object Types - new \Twig_SimpleFunction('get_type', [$this, 'getTypeFunc']), - new \Twig_SimpleFunction('of_type', [$this, 'ofTypeFunc']) - ]; - } - - /** - * @return array - */ - public function getTokenParsers() - { - return [ - new TwigTokenParserRender(), - new TwigTokenParserThrow(), - new TwigTokenParserTryCatch(), - new TwigTokenParserScript(), - new TwigTokenParserStyle(), - new TwigTokenParserMarkdown(), - new TwigTokenParserSwitch(), - ]; - } - - /** - * Filters field name by changing dot notation into array notation. - * - * @param string $str - * - * @return string - */ - public function fieldNameFilter($str) - { - $path = explode('.', rtrim($str, '.')); - - return array_shift($path) . ($path ? '[' . implode('][', $path) . ']' : ''); - } - - /** - * Protects email address. - * - * @param string $str - * - * @return string - */ - public function safeEmailFilter($str) - { - $email = ''; - for ($i = 0, $len = strlen($str); $i < $len; $i++) { - $j = random_int(0, 1); - - $email .= $j === 0 ? '&#' . ord($str[$i]) . ';' : $str[$i]; - } - - return str_replace('@', '@', $email); - } - - /** - * Returns array in a random order. - * - * @param array $original - * @param int $offset Can be used to return only slice of the array. - * - * @return array - */ - public function randomizeFilter($original, $offset = 0) - { - if (!\is_array($original)) { - return $original; - } - - if ($original instanceof \Traversable) { - $original = iterator_to_array($original, false); - } - - $sorted = []; - $random = array_slice($original, $offset); - shuffle($random); - - $sizeOf = \count($original); - for ($x = 0; $x < $sizeOf; $x++) { - if ($x < $offset) { - $sorted[] = $original[$x]; - } else { - $sorted[] = array_shift($random); - } - } - - return $sorted; - } - - /** - * Returns the modulus of an integer - * - * @param string|int $number - * @param int $divider - * @param array $items array of items to select from to return - * - * @return int - */ - public function modulusFilter($number, $divider, $items = null) - { - if (\is_string($number)) { - $number = strlen($number); - } - - $remainder = $number % $divider; - - if (\is_array($items)) { - return $items[$remainder] ?? $items[0]; - } - - return $remainder; - } - - /** - * Inflector supports following notations: - * - * `{{ 'person'|pluralize }} => people` - * `{{ 'shoes'|singularize }} => shoe` - * `{{ 'welcome page'|titleize }} => "Welcome Page"` - * `{{ 'send_email'|camelize }} => SendEmail` - * `{{ 'CamelCased'|underscorize }} => camel_cased` - * `{{ 'Something Text'|hyphenize }} => something-text` - * `{{ 'something_text_to_read'|humanize }} => "Something text to read"` - * `{{ '181'|monthize }} => 5` - * `{{ '10'|ordinalize }} => 10th` - * - * @param string $action - * @param string $data - * @param int $count - * - * @return string - */ - public function inflectorFilter($action, $data, $count = null) - { - $action .= 'ize'; - - $inflector = $this->grav['inflector']; - - if (\in_array( - $action, - ['titleize', 'camelize', 'underscorize', 'hyphenize', 'humanize', 'ordinalize', 'monthize'], - true - )) { - return $inflector->{$action}($data); - } - - if (\in_array($action, ['pluralize', 'singularize'], true)) { - return $count ? $inflector->{$action}($data, $count) : $inflector->{$action}($data); - } - - return $data; - } - - /** - * Return MD5 hash from the input. - * - * @param string $str - * - * @return string - */ - public function md5Filter($str) - { - return md5($str); - } - - /** - * Return Base32 encoded string - * - * @param string $str - * @return string - */ - public function base32EncodeFilter($str) - { - return Base32::encode($str); - } - - /** - * Return Base32 decoded string - * - * @param string $str - * @return bool|string - */ - public function base32DecodeFilter($str) - { - return Base32::decode($str); - } - - /** - * Return Base64 encoded string - * - * @param string $str - * @return string - */ - public function base64EncodeFilter($str) - { - return base64_encode($str); - } - - /** - * Return Base64 decoded string - * - * @param string $str - * @return bool|string - */ - public function base64DecodeFilter($str) - { - return base64_decode($str); - } - - - /** - * Sorts a collection by key - * - * @param array $input - * @param string $filter - * @param int $direction - * @param int $sort_flags - * - * @return array - */ - public function sortByKeyFilter($input, $filter, $direction = SORT_ASC, $sort_flags = SORT_REGULAR) - { - return Utils::sortArrayByKey($input, $filter, $direction, $sort_flags); - } - - /** - * Return ksorted collection. - * - * @param array $array - * - * @return array - */ - public function ksortFilter($array) - { - if (null === $array) { - $array = []; - } - ksort($array); - - return $array; - } - - /** - * Wrapper for chunk_split() function - * - * @param string $value - * @param int $chars - * @param string $split - * @return string - */ - public function chunkSplitFilter($value, $chars, $split = '-') - { - return chunk_split($value, $chars, $split); - } - - /** - * determine if a string contains another - * - * @param string $haystack - * @param string $needle - * - * @return bool - */ - public function containsFilter($haystack, $needle) - { - if (empty($needle)) { - return $haystack; - } - - return (strpos($haystack, (string) $needle) !== false); - } - - /** - * Gets a human readable output for cron syntax - * - * @param $at - * @return string - */ - public function niceCronFilter($at) - { - $cron = new Cron($at); - return $cron->getText('en'); - } - - /** - * Get Cron object for a crontab 'at' format - * - * @param string $at - * @return CronExpression - */ - public function cronFunc($at) - { - return CronExpression::factory($at); - } - - /** - * displays a facebook style 'time ago' formatted date/time - * - * @param string $date - * @param bool $long_strings - * - * @param bool $show_tense - * @return bool - */ - public function nicetimeFunc($date, $long_strings = true, $show_tense = true) - { - if (empty($date)) { - return $this->grav['language']->translate('GRAV.NICETIME.NO_DATE_PROVIDED', null, true); - } - - if ($long_strings) { - $periods = [ - 'NICETIME.SECOND', - 'NICETIME.MINUTE', - 'NICETIME.HOUR', - 'NICETIME.DAY', - 'NICETIME.WEEK', - 'NICETIME.MONTH', - 'NICETIME.YEAR', - 'NICETIME.DECADE' - ]; - } else { - $periods = [ - 'NICETIME.SEC', - 'NICETIME.MIN', - 'NICETIME.HR', - 'NICETIME.DAY', - 'NICETIME.WK', - 'NICETIME.MO', - 'NICETIME.YR', - 'NICETIME.DEC' - ]; - } - - $lengths = ['60', '60', '24', '7', '4.35', '12', '10']; - - $now = time(); - - // check if unix timestamp - if ((string)(int)$date === (string)$date) { - $unix_date = $date; - } else { - $unix_date = strtotime($date); - } - - // check validity of date - if (empty($unix_date)) { - return $this->grav['language']->translate('GRAV.NICETIME.BAD_DATE', null, true); - } - - // is it future date or past date - if ($now > $unix_date) { - $difference = $now - $unix_date; - $tense = $this->grav['language']->translate('GRAV.NICETIME.AGO', null, true); - - } elseif ($now == $unix_date) { - $difference = $now - $unix_date; - $tense = $this->grav['language']->translate('GRAV.NICETIME.JUST_NOW', null, false); - - } else { - $difference = $unix_date - $now; - $tense = $this->grav['language']->translate('GRAV.NICETIME.FROM_NOW', null, true); - } - - for ($j = 0; $difference >= $lengths[$j] && $j < count($lengths) - 1; $j++) { - $difference /= $lengths[$j]; - } - - $difference = round($difference); - - if ($difference != 1) { - $periods[$j] .= '_PLURAL'; - } - - if ($this->grav['language']->getTranslation($this->grav['language']->getLanguage(), - $periods[$j] . '_MORE_THAN_TWO') - ) { - if ($difference > 2) { - $periods[$j] .= '_MORE_THAN_TWO'; - } - } - - $periods[$j] = $this->grav['language']->translate('GRAV.'.$periods[$j], null, true); - - if ($now == $unix_date) { - return $tense; - } - - $time = "{$difference} {$periods[$j]}"; - $time .= $show_tense ? " {$tense}" : ''; - - return $time; - } - - /** - * Allow quick check of a string for XSS Vulnerabilities - * - * @param string|array $data - * @return bool|string|array - */ - public function xssFunc($data) - { - if (!\is_array($data)) { - return Security::detectXss($data); - } - - $results = Security::detectXssFromArray($data); - $results_parts = array_map(function($value, $key) { - return $key.': \''.$value . '\''; - }, array_values($results), array_keys($results)); - - return implode(', ', $results_parts); - } - - /** - * @param string $string - * - * @return mixed - */ - public function absoluteUrlFilter($string) - { - $url = $this->grav['uri']->base(); - $string = preg_replace('/((?:href|src) *= *[\'"](?!(http|ftp)))/i', "$1$url", $string); - - return $string; - - } - - /** - * @param string $string - * - * @param array $context - * @param bool $block Block or Line processing - * @return mixed|string - */ - public function markdownFunction($context, $string, $block = true) - { - $page = $context['page'] ?? null; - return Utils::processMarkdown($string, $block, $page); - } - - /** - * @param string $haystack - * @param string $needle - * - * @return bool - */ - public function startsWithFilter($haystack, $needle) - { - return Utils::startsWith($haystack, $needle); - } - - /** - * @param string $haystack - * @param string $needle - * - * @return bool - */ - public function endsWithFilter($haystack, $needle) - { - return Utils::endsWith($haystack, $needle); - } - - /** - * @param mixed $value - * @param null $default - * - * @return null - */ - public function definedDefaultFilter($value, $default = null) - { - return null !== $value ? $value : $default; - } - - /** - * @param string $value - * @param null $chars - * - * @return string - */ - public function rtrimFilter($value, $chars = null) - { - return rtrim($value, $chars); - } - - /** - * @param string $value - * @param null $chars - * - * @return string - */ - public function ltrimFilter($value, $chars = null) - { - return ltrim($value, $chars); - } - - /** - * Casts input to string. - * - * @param mixed $input - * @return string - */ - public function stringFilter($input) - { - return (string) $input; - } - - - /** - * Casts input to int. - * - * @param mixed $input - * @return int - */ - public function intFilter($input) - { - return (int) $input; - } - - /** - * Casts input to bool. - * - * @param mixed $input - * @return bool - */ - public function boolFilter($input) - { - return (bool) $input; - } - - /** - * Casts input to float. - * - * @param mixed $input - * @return float - */ - public function floatFilter($input) - { - return (float) $input; - } - - /** - * Casts input to array. - * - * @param mixed $input - * @return array - */ - public function arrayFilter($input) - { - return (array) $input; - } - - /** - * @return string - */ - public function translate(\Twig_Environment $twig) - { - // shift off the environment - $args = func_get_args(); - array_shift($args); - - // If admin and tu filter provided, use it - if (isset($this->grav['admin'])) { - $numargs = count($args); - $lang = null; - - if (($numargs === 3 && is_array($args[1])) || ($numargs === 2 && !is_array($args[1]))) { - $lang = array_pop($args); - } elseif ($numargs === 2 && is_array($args[1])) { - $subs = array_pop($args); - $args = array_merge($args, $subs); - } - - return $this->grav['admin']->translate($args, $lang); - } - - // else use the default grav translate functionality - return $this->grav['language']->translate($args); - } - - /** - * Translate Strings - * - * @param string|array $args - * @param array|null $languages - * @param bool $array_support - * @param bool $html_out - * @return string - */ - public function translateLanguage($args, array $languages = null, $array_support = false, $html_out = false) - { - /** @var Language $language */ - $language = $this->grav['language']; - - return $language->translate($args, $languages, $array_support, $html_out); - } - - /** - * @param string $key - * @param string $index - * @param array|null $lang - * @return string - */ - public function translateArray($key, $index, $lang = null) - { - /** @var Language $language */ - $language = $this->grav['language']; - - return $language->translateArray($key, $index, $lang); - } - - /** - * Repeat given string x times. - * - * @param string $input - * @param int $multiplier - * - * @return string - */ - public function repeatFunc($input, $multiplier) - { - return str_repeat($input, $multiplier); - } - - /** - * Return URL to the resource. - * - * @example {{ url('theme://images/logo.png')|default('http://www.placehold.it/150x100/f4f4f4') }} - * - * @param string $input Resource to be located. - * @param bool $domain True to include domain name. - * - * @return string|null Returns url to the resource or null if resource was not found. - */ - public function urlFunc($input, $domain = false) - { - return Utils::url($input, $domain); - } - - /** - * This function will evaluate Twig $twig through the $environment, and return its results. - * - * @param array $context - * @param string $twig - * @return mixed - */ - public function evaluateTwigFunc($context, $twig ) { - - $loader = new \Twig_Loader_Filesystem('.'); - $env = new \Twig_Environment($loader); - - $template = $env->createTemplate($twig); - - return $template->render($context); - } - - /** - * This function will evaluate a $string through the $environment, and return its results. - * - * @param array $context - * @param string $string - * @return mixed - */ - public function evaluateStringFunc($context, $string ) - { - return $this->evaluateTwigFunc($context, "{{ $string }}"); - } - - - /** - * Based on Twig_Extension_Debug / twig_var_dump - * (c) 2011 Fabien Potencier - * - * @param \Twig_Environment $env - * @param string $context - */ - public function dump(\Twig_Environment $env, $context) - { - if (!$env->isDebug() || !$this->debugger) { - return; - } - - $count = func_num_args(); - if (2 === $count) { - $data = []; - foreach ($context as $key => $value) { - if (is_object($value)) { - if (method_exists($value, 'toArray')) { - $data[$key] = $value->toArray(); - } else { - $data[$key] = "Object (" . get_class($value) . ")"; - } - } else { - $data[$key] = $value; - } - } - $this->debugger->addMessage($data, 'debug'); - } else { - for ($i = 2; $i < $count; $i++) { - $this->debugger->addMessage(func_get_arg($i), 'debug'); - } - } - } - - /** - * Output a Gist - * - * @param string $id - * @param string|bool $file - * - * @return string - */ - public function gistFunc($id, $file = false) - { - $url = 'https://gist.github.com/' . $id . '.js'; - if ($file) { - $url .= '?file=' . $file; - } - return ''; - } - - /** - * Generate a random string - * - * @param int $count - * - * @return string - */ - public function randomStringFunc($count = 5) - { - return Utils::generateRandomString($count); - } - - /** - * Pad a string to a certain length with another string - * - * @param string $input - * @param int $pad_length - * @param string $pad_string - * @param int $pad_type - * - * @return string - */ - public static function padFilter($input, $pad_length, $pad_string = ' ', $pad_type = STR_PAD_RIGHT) - { - return str_pad($input, (int)$pad_length, $pad_string, $pad_type); - } - - /** - * Workaround for twig associative array initialization - * Returns a key => val array - * - * @param string $key key of item - * @param string $val value of item - * @param array $current_array optional array to add to - * - * @return array - */ - public function arrayKeyValueFunc($key, $val, $current_array = null) - { - if (empty($current_array)) { - return array($key => $val); - } - - $current_array[$key] = $val; - - return $current_array; - } - - /** - * Wrapper for array_intersect() method - * - * @param array $array1 - * @param array $array2 - * @return array - */ - public function arrayIntersectFunc($array1, $array2) - { - if ($array1 instanceof Collection && $array2 instanceof Collection) { - return $array1->intersect($array2); - } - - return array_intersect($array1, $array2); - } - - /** - * Returns a string from a value. If the value is array, return it json encoded - * - * @param array|string $value - * - * @return string - */ - public function stringFunc($value) - { - if (is_array($value)) { //format the array as a string - return json_encode($value); - } - - return $value; - } - - /** - * Translate a string - * - * @return string - */ - public function translateFunc() - { - return $this->grav['language']->translate(func_get_args()); - } - - /** - * Authorize an action. Returns true if the user is logged in and - * has the right to execute $action. - * - * @param string|array $action An action or a list of actions. Each - * entry can be a string like 'group.action' - * or without dot notation an associative - * array. - * @return bool Returns TRUE if the user is authorized to - * perform the action, FALSE otherwise. - */ - public function authorize($action) - { - /** @var UserInterface|null $user */ - $user = $this->grav['user'] ?? null; - - if (!$user || !$user->authenticated || (isset($user->authorized) && !$user->authorized)) { - return false; - } - - $action = (array) $action; - foreach ($action as $key => $perms) { - $prefix = is_int($key) ? '' : $key . '.'; - $perms = $prefix ? (array) $perms : [$perms => true]; - foreach ($perms as $action2 => $authenticated) { - if ($user->authorize($prefix . $action2)) { - return $authenticated; - } - } - } - - return false; - } - - /** - * Used to add a nonce to a form. Call {{ nonce_field('action') }} specifying a string representing the action. - * - * For maximum protection, ensure that the string representing the action is as specific as possible - * - * @param string $action the action - * @param string $nonceParamName a custom nonce param name - * - * @return string the nonce input field - */ - public function nonceFieldFunc($action, $nonceParamName = 'nonce') - { - $string = ''; - - return $string; - } - - /** - * Decodes string from JSON. - * - * @param string $str - * @param bool $assoc - * @param int $depth - * @param int $options - * @return array - */ - public function jsonDecodeFilter($str, $assoc = false, $depth = 512, $options = 0) - { - return json_decode(html_entity_decode($str, ENT_COMPAT | ENT_HTML401, 'UTF-8'), $assoc, $depth, $options); - } - - /** - * Used to retrieve a cookie value - * - * @param string $key The cookie name to retrieve - * - * @return mixed - */ - public function getCookie($key) - { - return filter_input(INPUT_COOKIE, $key, FILTER_SANITIZE_STRING); - } - - /** - * Twig wrapper for PHP's preg_replace method - * - * @param mixed $subject the content to perform the replacement on - * @param mixed $pattern the regex pattern to use for matches - * @param mixed $replace the replacement value either as a string or an array of replacements - * @param int $limit the maximum possible replacements for each pattern in each subject - * - * @return string|string[]|null the resulting content - */ - public function regexReplace($subject, $pattern, $replace, $limit = -1) - { - return preg_replace($pattern, $replace, $subject, $limit); - } - - /** - * Twig wrapper for PHP's preg_grep method - * - * @param array $array - * @param string $regex - * @param int $flags - * @return array - */ - public function regexFilter($array, $regex, $flags = 0) - { - return preg_grep($regex, $array, $flags); - } - - /** - * redirect browser from twig - * - * @param string $url the url to redirect to - * @param int $statusCode statusCode, default 303 - */ - public function redirectFunc($url, $statusCode = 303) - { - header('Location: ' . $url, true, $statusCode); - exit(); - } - - /** - * Generates an array containing a range of elements, optionally stepped - * - * @param int $start Minimum number, default 0 - * @param int $end Maximum number, default `getrandmax()` - * @param int $step Increment between elements in the sequence, default 1 - * - * @return array - */ - public function rangeFunc($start = 0, $end = 100, $step = 1) - { - return range($start, $end, $step); - } - - /** - * Check if HTTP_X_REQUESTED_WITH has been set to xmlhttprequest, - * in which case we may unsafely assume ajax. Non critical use only. - * - * @return bool True if HTTP_X_REQUESTED_WITH exists and has been set to xmlhttprequest - */ - public function isAjaxFunc() - { - return ( - !empty($_SERVER['HTTP_X_REQUESTED_WITH']) - && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'); - } - - /** - * Get the Exif data for a file - * - * @param string $image - * @param bool $raw - * @return mixed - */ - public function exifFunc($image, $raw = false) - { - if (isset($this->grav['exif'])) { - /** @var UniformResourceLocator $locator */ - $locator = $this->grav['locator']; - - if ($locator->isStream($image)) { - $image = $locator->findResource($image); - } - - $exif_reader = $this->grav['exif']->getReader(); - - if ($image && file_exists($image) && $this->config->get('system.media.auto_metadata_exif') && $exif_reader) { - - $exif_data = $exif_reader->read($image); - - if ($exif_data) { - if ($raw) { - return $exif_data->getRawData(); - } - - return $exif_data->getData(); - } - } - } - - return null; - } - - /** - * Simple function to read a file based on a filepath and output it - * - * @param string $filepath - * @return bool|string - */ - public function readFileFunc($filepath) - { - /** @var UniformResourceLocator $locator */ - $locator = $this->grav['locator']; - - if ($locator->isStream($filepath)) { - $filepath = $locator->findResource($filepath); - } - - if ($filepath && file_exists($filepath)) { - return file_get_contents($filepath); - } - - return false; - } - - /** - * Process a folder as Media and return a media object - * - * @param string $media_dir - * @return Media|null - */ - public function mediaDirFunc($media_dir) - { - /** @var UniformResourceLocator $locator */ - $locator = $this->grav['locator']; - - if ($locator->isStream($media_dir)) { - $media_dir = $locator->findResource($media_dir); - } - - if ($media_dir && file_exists($media_dir)) { - return new Media($media_dir); - } - - return null; - } - - /** - * Dump a variable to the browser - * - * @param mixed $var - */ - public function vardumpFunc($var) - { - var_dump($var); - } - - /** - * Returns a nicer more readable filesize based on bytes - * - * @param int $bytes - * @return string - */ - public function niceFilesizeFunc($bytes) - { - return Utils::prettySize($bytes); - } - - /** - * Returns a nicer more readable number - * - * @param int|float|string $n - * @return string|bool - */ - public function niceNumberFunc($n) - { - if (!\is_float($n) && !\is_int($n)) { - if (!\is_string($n) || $n === '') { - return false; - } - - // Strip any thousand formatting and find the first number. - $list = array_filter(preg_split("/\D+/", str_replace(',', '', $n))); - $n = reset($list); - - if (!\is_numeric($n)) { - return false; - } - - $n = (float)$n; - } - - // now filter it; - if ($n > 1000000000000) { - return round($n/1000000000000, 2).' t'; - } - if ($n > 1000000000) { - return round($n/1000000000, 2).' b'; - } - if ($n > 1000000) { - return round($n/1000000, 2).' m'; - } - if ($n > 1000) { - return round($n/1000, 2).' k'; - } - - return number_format($n); - } - - /** - * Get a theme variable - * - * @param string $var - * @param bool $default - * @return string - */ - public function themeVarFunc($var, $default = null) - { - $header = $this->grav['page']->header(); - $header_classes = $header->{$var} ?? null; - - return $header_classes ?: $this->config->get('theme.' . $var, $default); - } - - /** - * takes an array of classes, and if they are not set on body_classes - * look to see if they are set in theme config - * - * @param string|string[] $classes - * @return string - */ - public function bodyClassFunc($classes) - { - - $header = $this->grav['page']->header(); - $body_classes = $header->body_classes ?? ''; - - foreach ((array)$classes as $class) { - if (!empty($body_classes) && Utils::contains($body_classes, $class)) { - continue; - } - - $val = $this->config->get('theme.' . $class, false) ? $class : false; - $body_classes .= $val ? ' ' . $val : ''; - } - - return $body_classes; - } - - /** - * Look for a page header variable in an array of pages working its way through until a value is found - * - * @param string $var - * @param string|string[]|null $pages - * @return mixed - */ - public function pageHeaderVarFunc($var, $pages = null) - { - if ($pages === null) { - $pages = $this->grav['page']; - } - - // Make sure pages are an array - if (!\is_array($pages)) { - $pages = [$pages]; - } - - // Loop over pages and look for header vars - foreach ($pages as $page) { - if (\is_string($page)) { - $page = $this->grav['pages']->find($page); - } - - if ($page) { - $header = $page->header(); - if (isset($header->{$var})) { - return $header->{$var}; - } - } - } - - return null; - } - - /** - * Dump/Encode data into YAML format - * - * @param array $data - * @param int $inline integer number of levels of inline syntax - * @return string - */ - public function yamlEncodeFilter($data, $inline = 10) - { - return Yaml::dump($data, $inline); - } - - /** - * Decode/Parse data from YAML format - * - * @param string $data - * @return array - */ - public function yamlDecodeFilter($data) - { - return Yaml::parse($data); - } - - /** - * Function/Filter to return the type of variable - * - * @param mixed $var - * @return string - */ - public function getTypeFunc($var) - { - return gettype($var); - } - - /** - * Function/Filter to test type of variable - * - * @param mixed $var - * @param string|null $typeTest - * @param string|null $className - * @return bool - */ - public function ofTypeFunc($var, $typeTest=null, $className=null) - { - - switch ($typeTest) - { - default: - return false; - break; - - case 'array': - return is_array($var); - break; - - case 'bool': - return is_bool($var); - break; - - case 'class': - return is_object($var) === true && get_class($var) === $className; - break; - - case 'float': - return is_float($var); - break; - - case 'int': - return is_int($var); - break; - - case 'numeric': - return is_numeric($var); - break; - - case 'object': - return is_object($var); - break; - - case 'scalar': - return is_scalar($var); - break; - - case 'string': - return is_string($var); - break; - } - } } diff --git a/system/src/Grav/Common/Twig/WriteCacheFileTrait.php b/system/src/Grav/Common/Twig/WriteCacheFileTrait.php index f87ff67..edab8e5 100644 --- a/system/src/Grav/Common/Twig/WriteCacheFileTrait.php +++ b/system/src/Grav/Common/Twig/WriteCacheFileTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\Twig * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,9 +11,15 @@ namespace Grav\Common\Twig; use Grav\Common\Filesystem\Folder; use Grav\Common\Grav; +use function dirname; +/** + * Trait WriteCacheFileTrait + * @package Grav\Common\Twig + */ trait WriteCacheFileTrait { + /** @var bool */ protected static $umask; /** @@ -22,6 +28,7 @@ trait WriteCacheFileTrait * * @param string $file * @param string $content + * @return void */ protected function writeCacheFile($file, $content) { diff --git a/system/src/Grav/Common/Uri.php b/system/src/Grav/Common/Uri.php index 32c6e41..d0d6ad5 100644 --- a/system/src/Grav/Common/Uri.php +++ b/system/src/Grav/Common/Uri.php @@ -3,7 +3,7 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,58 +13,92 @@ use Grav\Common\Config\Config; use Grav\Common\Language\Language; use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Page\Pages; +use Grav\Framework\Route\Route; use Grav\Framework\Route\RouteFactory; use Grav\Framework\Uri\UriFactory; use Grav\Framework\Uri\UriPartsFilter; use RocketTheme\Toolbox\Event\Event; +use RuntimeException; +use function array_key_exists; +use function count; +use function in_array; +use function is_array; +use function is_string; +use function strlen; +/** + * Class Uri + * @package Grav\Common + */ class Uri { const HOSTNAME_REGEX = '/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/'; - /** @var \Grav\Framework\Uri\Uri */ + /** @var \Grav\Framework\Uri\Uri|null */ protected static $currentUri; - /** @var \Grav\Framework\Route\Route */ + /** @var Route|null */ protected static $currentRoute; + /** @var string */ public $url; // Uri parts. + /** @var string|null */ protected $scheme; + /** @var string|null */ protected $user; + /** @var string|null */ protected $password; + /** @var string|null */ protected $host; + /** @var int|null */ protected $port; + /** @var string */ protected $path; + /** @var string */ protected $query; + /** @var string|null */ protected $fragment; // Internal stuff. + /** @var string */ protected $base; + /** @var string|null */ protected $basename; + /** @var string */ protected $content_path; + /** @var string|null */ protected $extension; + /** @var string */ protected $env; + /** @var array */ protected $paths; + /** @var array */ protected $queries; + /** @var array */ protected $params; + /** @var string */ protected $root; + /** @var string */ + protected $setup_base; + /** @var string */ protected $root_path; + /** @var string */ protected $uri; - protected $content_type; + /** @var array */ protected $post; /** * Uri constructor. - * @param string|array $env + * @param string|array|null $env */ public function __construct($env = null) { if (is_string($env)) { $this->createFromString($env); } else { - $this->createFromEnvironment(\is_array($env) ? $env : $_SERVER); + $this->createFromEnvironment(is_array($env) ? $env : $_SERVER); } } @@ -73,7 +107,6 @@ class Uri * Used for testing purposes. * * @param string $url the URL to use in the class - * * @return $this */ public function initializeWithUrl($url = '') @@ -90,7 +123,6 @@ class Uri * * @param string $url * @param string $root_path - * * @return $this */ public function initializeWithUrlAndRootPath($url, $root_path) @@ -105,8 +137,7 @@ class Uri * Validate a hostname * * @param string $hostname The hostname - * - * @return boolean + * @return bool */ public function validateHostname($hostname) { @@ -115,6 +146,8 @@ class Uri /** * Initializes the URI object based on the url set on the object + * + * @return void */ public function init() { @@ -133,13 +166,19 @@ class Uri // Handle custom base $custom_base = rtrim($grav['config']->get('system.custom_base_url'), '/'); - if ($custom_base) { $custom_parts = parse_url($custom_base); + if ($custom_parts === false) { + throw new RuntimeException('Bad configuration: system.custom_base_url'); + } $orig_root_path = $this->root_path; $this->root_path = isset($custom_parts['path']) ? rtrim($custom_parts['path'], '/') : ''; if (isset($custom_parts['scheme'])) { $this->base = $custom_parts['scheme'] . '://' . $custom_parts['host']; + $this->port = $custom_parts['port'] ?? null; + if ($this->port !== null && $config->get('system.reverse_proxy_setup') === false) { + $this->base .= ':' . (string)$this->port; + } $this->root = $custom_base; } else { $this->root = $this->base . $this->root_path; @@ -158,6 +197,7 @@ class Uri if ($setup_base) { $uri = preg_replace('|^' . preg_quote($setup_base, '|') . '|', '', $uri); } + $this->setup_base = $setup_base; // process params $uri = $this->processParams($uri, $config->get('system.param_sep')); @@ -165,8 +205,8 @@ class Uri // set active language $uri = $language->setActiveFromUri($uri); - // split the URL and params - $bits = parse_url($uri); + // split the URL and params (and make sure that the path isn't seen as domain) + $bits = parse_url('http://domain.com' . $uri); //process fragment if (isset($bits['fragment'])) { @@ -205,15 +245,15 @@ class Uri $grav['base_url_relative'] = $this->rootUrl(false); $grav['base_url'] = $config->get('system.absolute_urls') ? $grav['base_url_absolute'] : $grav['base_url_relative']; - RouteFactory::setRoot($this->root_path); + RouteFactory::setRoot($this->root_path . $setup_base); RouteFactory::setLanguage($language->getLanguageURLPrefix()); + RouteFactory::setParamValueDelimiter($config->get('system.param_sep')); } /** * Return URI path. * - * @param string $id - * + * @param int|null $id * @return string|string[] */ public function paths($id = null) @@ -230,7 +270,6 @@ class Uri * * @param bool $absolute True to include full path. * @param bool $domain True to include domain. Works only if first parameter is also true. - * * @return string */ public function route($absolute = false, $domain = false) @@ -241,7 +280,7 @@ class Uri /** * Return full query string or a single query attribute. * - * @param string $id Optional attribute. Get a single query attribute if set + * @param string|null $id Optional attribute. Get a single query attribute if set * @param bool $raw If true and $id is not set, return the full query array. Otherwise return the query string * * @return string|array Returns an array if $id = null and $raw = true @@ -266,9 +305,8 @@ class Uri /** * Return all or a single query parameter as a URI compatible string. * - * @param string $id Optional parameter name. + * @param string|null $id Optional parameter name. * @param boolean $array return the array format or not - * * @return null|string|array */ public function params($id = null, $array = false) @@ -299,24 +337,23 @@ class Uri /** * Get URI parameter. * - * @param string $id - * + * @param string|null $id + * @param string|bool|null $default * @return bool|string */ - public function param($id) + public function param($id, $default = false) { if (isset($this->params[$id])) { return html_entity_decode(rawurldecode($this->params[$id]), ENT_COMPAT | ENT_HTML401, 'UTF-8'); } - return false; + return $default; } /** * Gets the Fragment portion of a URI (eg #target) * - * @param string $fragment - * + * @param string|null $fragment * @return string|null */ public function fragment($fragment = null) @@ -331,7 +368,6 @@ class Uri * Return URL. * * @param bool $include_host Include hostname. - * * @return string */ public function url($include_host = false) @@ -348,7 +384,7 @@ class Uri /** * Return the Path * - * @return String The path of the URI + * @return string The path of the URI */ public function path() { @@ -359,8 +395,7 @@ class Uri * Return the Extension of the URI * * @param string|null $default - * - * @return string The extension of the URI + * @return string|null The extension of the URI */ public function extension($default = null) { @@ -371,12 +406,15 @@ class Uri return $this->extension; } + /** + * @return string + */ public function method() { $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET'; - if ($method === 'POST' && isset($_SERVER['X-HTTP-METHOD-OVERRIDE'])) { - $method = strtoupper($_SERVER['X-HTTP-METHOD-OVERRIDE']); + if ($method === 'POST' && isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) { + $method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']); } return $method; @@ -385,7 +423,7 @@ class Uri /** * Return the scheme of the URI * - * @param bool $raw + * @param bool|null $raw * @return string The scheme of the URI */ public function scheme($raw = false) @@ -459,7 +497,7 @@ class Uri /** * Gets the environment name * - * @return String + * @return string */ public function environment() { @@ -470,7 +508,7 @@ class Uri /** * Return the basename of the URI * - * @return String The basename of the URI + * @return string The basename of the URI */ public function basename() { @@ -481,7 +519,7 @@ class Uri * Return the full uri * * @param bool $include_root - * @return mixed + * @return string */ public function uri($include_root = true) { @@ -495,7 +533,7 @@ class Uri /** * Return the base of the URI * - * @return String The base of the URI + * @return string The base of the URI */ public function base() { @@ -506,7 +544,7 @@ class Uri * Return the base relative URL including the language prefix * or the base relative url if multi-language is not enabled * - * @return String The base of the URI + * @return string The base of the URI */ public function baseIncludingLanguage() { @@ -522,8 +560,7 @@ class Uri * Return root URL to the site. * * @param bool $include_host Include hostname. - * - * @return mixed + * @return string */ public function rootUrl($include_host = false) { @@ -549,9 +586,8 @@ class Uri /** * Return relative path to the referrer defaulting to current or given page. * - * @param string $default - * @param string $attributes - * + * @param string|null $default + * @param string|null $attributes * @return string */ public function referrer($default = null, $attributes = null) @@ -579,16 +615,26 @@ class Uri return substr($referrer, strlen($root)); } + /** + * @return string + */ public function __toString() { return static::buildUrl($this->toArray()); } + /** + * @return string + */ public function toOriginalString() { return static::buildUrl($this->toArray(true)); } + /** + * @param bool $full + * @return array + */ public function toArray($full = false) { if ($full === true) { @@ -639,7 +685,7 @@ class Uri $ip = getenv('HTTP_FORWARDED_FOR'); } elseif (getenv('HTTP_FORWARDED')) { $ip = getenv('HTTP_FORWARDED'); - } elseif (getenv('REMOTE_ADDR')){ + } elseif (getenv('REMOTE_ADDR')) { $ip = getenv('REMOTE_ADDR'); } else { $ip = 'UNKNOWN'; @@ -665,13 +711,15 @@ class Uri /** * Returns current route. * - * @return \Grav\Framework\Route\Route + * @return Route */ public static function getCurrentRoute() { if (!static::$currentRoute) { + /** @var Uri $uri */ $uri = Grav::instance()['uri']; - static::$currentRoute = RouteFactory::createFromParts($uri->toArray()); + + static::$currentRoute = RouteFactory::createFromLegacyUri($uri); } return static::$currentRoute; @@ -681,8 +729,7 @@ class Uri * Is this an external URL? if it starts with `http` then yes, else false * * @param string $url the URL in question - * - * @return boolean is eternal state + * @return bool is eternal state */ public static function isExternal($url) { @@ -693,7 +740,6 @@ class Uri * The opposite of built-in PHP method parse_url() * * @param array $parsed_url - * * @return string */ public static function buildUrl($parsed_url) @@ -773,7 +819,6 @@ class Uri } elseif ($url_path === '/' || ($base_url !== '' && Utils::startsWith($url_path, $base_url))) { $url_path = $base_url . $url_path; } else { - // see if page is relative to this or absolute if (Utils::startsWith($url_path, '/')) { $normalized_url = Utils::normalizePath($base_url . $url_path); @@ -835,8 +880,7 @@ class Uri } // handle absolute URLs - if (\is_array($url) && !$external && ($absolute === true || $grav['config']->get('system.absolute_urls', false))) { - + if (is_array($url) && !$external && ($absolute === true || $grav['config']->get('system.absolute_urls', false))) { $url['scheme'] = $uri->scheme(true); $url['host'] = $uri->host(); $url['port'] = $uri->port(true); @@ -892,13 +936,24 @@ class Uri return $url; } + /** + * @param string $url + * @return array|false + */ public static function parseUrl($url) { $grav = Grav::instance(); + // Remove extra slash from streams, parse_url() doesn't like it. + if ($pos = strpos($url, ':///')) { + $url = substr_replace($url, '://', $pos, 4); + } + $encodedUrl = preg_replace_callback( '%[^:/@?&=#]+%usD', - function ($matches) { return rawurlencode($matches[0]); }, + static function ($matches) { + return rawurlencode($matches[0]); + }, $url ); @@ -908,7 +963,7 @@ class Uri return false; } - foreach($parts as $name => $value) { + foreach ($parts as $name => $value) { $parts[$name] = rawurldecode($value); } @@ -916,7 +971,7 @@ class Uri $parts['path'] = ''; } - list($stripped_path, $params) = static::extractParams($parts['path'], $grav['config']->get('system.param_sep')); + [$stripped_path, $params] = static::extractParams($parts['path'], $grav['config']->get('system.param_sep')); if (!empty($params)) { $parts['path'] = $stripped_path; @@ -926,6 +981,11 @@ class Uri return $parts; } + /** + * @param string $uri + * @param string $delimiter + * @return array + */ public static function extractParams($uri, $delimiter) { $params = []; @@ -935,7 +995,7 @@ class Uri foreach ($matches as $match) { $param = explode($delimiter, $match[1]); - if (\count($param) === 2) { + if (count($param) === 2) { $plain_var = filter_var(rawurldecode($param[1]), FILTER_SANITIZE_STRING); $params[$param[0]] = $plain_var; $uri = str_replace($match[0], '', $uri); @@ -952,7 +1012,7 @@ class Uri * @param PageInterface $page the current page to use as reference * @param string $markdown_url the URL as it was written in the markdown * @param string $type the type of URL, image | link - * @param null $relative if null, will use system default, if true will use relative links internally + * @param bool|null $relative if null, will use system default, if true will use relative links internally * * @return string the more friendly formatted url */ @@ -1094,13 +1154,13 @@ class Uri * Removes extra double slashes and fixes back-slashes * * @param string $path - * @return mixed|string + * @return string */ public static function cleanPath($path) { $regex = '/(\/)\/+/'; $path = str_replace(['\\', '/ /'], '/', $path); - $path = preg_replace($regex,'$1',$path); + $path = preg_replace($regex, '$1', $path); return $path; } @@ -1108,7 +1168,7 @@ class Uri /** * Filters the user info string. * - * @param string $info The raw user or password. + * @param string|null $info The raw user or password. * @return string The percent-encoded user or password string. */ public static function filterUserInfo($info) @@ -1124,7 +1184,7 @@ class Uri * will NOT double-encode characters that are already * percent-encoded. * - * @param string $path The raw uri path. + * @param string|null $path The raw uri path. * @return string The RFC 3986 percent-encoded uri path. * @link http://www.faqs.org/rfcs/rfc3986.html */ @@ -1136,7 +1196,7 @@ class Uri /** * Filters the query string or fragment of a URI. * - * @param string $query The raw uri query string. + * @param string|null $query The raw uri query string. * @return string The percent-encoded query string. */ public static function filterQuery($query) @@ -1146,6 +1206,7 @@ class Uri /** * @param array $env + * @return void */ protected function createFromEnvironment(array $env) { @@ -1157,7 +1218,7 @@ class Uri } elseif (isset($env['HTTP_CLOUDFRONT_FORWARDED_PROTO'])) { $this->scheme = $env['HTTP_CLOUDFRONT_FORWARDED_PROTO']; } elseif (isset($env['REQUEST_SCHEME']) && empty($env['HTTPS'])) { - $this->scheme = $env['REQUEST_SCHEME']; + $this->scheme = $env['REQUEST_SCHEME']; } else { $https = $env['HTTPS'] ?? ''; $this->scheme = (empty($https) || strtolower($https) === 'off') ? 'http' : 'https'; @@ -1184,17 +1245,17 @@ class Uri // Build port. if (isset($env['HTTP_X_FORWARDED_PORT']) && Grav::instance()['config']->get('system.http_x_forwarded.port')) { - $this->port = (int)$env['HTTP_X_FORWARDED_PORT']; + $this->port = (int)$env['HTTP_X_FORWARDED_PORT']; } elseif (isset($env['X-FORWARDED-PORT'])) { - $this->port = (int)$env['X-FORWARDED-PORT']; + $this->port = (int)$env['X-FORWARDED-PORT']; } elseif (isset($env['HTTP_CLOUDFRONT_FORWARDED_PROTO'])) { // Since AWS Cloudfront does not provide a forwarded port header, // we have to build the port using the scheme. - $this->port = $this->port(); + $this->port = $this->port(); } elseif (isset($env['SERVER_PORT'])) { - $this->port = (int)$env['SERVER_PORT']; + $this->port = (int)$env['SERVER_PORT']; } else { - $this->port = null; + $this->port = null; } if ($this->hasStandardPort()) { @@ -1248,7 +1309,7 @@ class Uri // Set Uri parts. $parts = parse_url($url); if ($parts === false) { - throw new \RuntimeException('Malformed URL: ' . $url); + throw new RuntimeException('Malformed URL: ' . $url); } $this->scheme = $parts['scheme'] ?? null; $this->user = $parts['user'] ?? null; @@ -1273,6 +1334,9 @@ class Uri $this->reset(); } + /** + * @return void + */ protected function reset() { // resets @@ -1294,9 +1358,9 @@ class Uri * Get post from either $_POST or JSON response object * By default returns all data, or can return a single item * - * @param string $element - * @param string $filter_type - * @return array|mixed|null + * @param string|null $element + * @param string|null $filter_type + * @return array|null */ public function post($element = null, $filter_type = null) { @@ -1335,7 +1399,7 @@ class Uri if (isset($_SERVER['CONTENT_TYPE'])) { $content_type = $_SERVER['CONTENT_TYPE']; if ($short) { - return Utils::substrToString($content_type,';'); + return Utils::substrToString($content_type, ';'); } return $content_type; } @@ -1345,35 +1409,51 @@ class Uri /** * Check if this is a valid Grav extension * - * @param $extension + * @param string|null $extension * @return bool */ - public function isValidExtension($extension) + public function isValidExtension($extension): bool { - $valid_page_types = implode('|', Utils::getSupportPageTypes()); + $extension = (string)$extension; - // Strip the file extension for valid page types - if (preg_match('/(' . $valid_page_types . ')/', $extension)) { - return true; - } - return false; + return $extension !== '' && in_array($extension, Utils::getSupportPageTypes(), true); } /** * Allow overriding of any element (be careful!) * - * @param $data + * @param array $data * @return Uri */ public function setUriProperties($data) { foreach (get_object_vars($this) as $property => $default) { - if (!array_key_exists($property, $data)) continue; + if (!array_key_exists($property, $data)) { + continue; + } $this->{$property} = $data[$property]; // assign value to object } return $this; } + + /** + * Compatibility in case getallheaders() is not available on platform + */ + public static function getAllHeaders() + { + if (!function_exists('getallheaders')) { + $headers = []; + foreach ($_SERVER as $name => $value) { + if (substr($name, 0, 5) == 'HTTP_') { + $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value; + } + } + return $headers; + } + return getallheaders(); + } + /** * Get the base URI with port if needed * @@ -1398,6 +1478,9 @@ class Uri return $rootPath; } + /** + * @return string + */ private function buildEnvironment() { // check for localhost variations @@ -1413,7 +1496,6 @@ class Uri * * @param string $uri * @param string $delimiter - * * @return string */ private function processParams($uri, $delimiter = ':') diff --git a/system/src/Grav/Common/User/Access.php b/system/src/Grav/Common/User/Access.php new file mode 100644 index 0000000..6503c89 --- /dev/null +++ b/system/src/Grav/Common/User/Access.php @@ -0,0 +1,52 @@ + ['admin.configuration_system'], + 'admin.configuration.site' => ['admin.configuration_site', 'admin.settings'], + 'admin.configuration.media' => ['admin.configuration_media'], + 'admin.configuration.info' => ['admin.configuration_info'], + ]; + + /** + * @param string $action + * @return bool|null + */ + public function get(string $action) + { + $result = parent::get($action); + if (is_bool($result)) { + return $result; + } + + // Get access value. + if (isset($this->aliases[$action])) { + $aliases = $this->aliases[$action]; + foreach ($aliases as $alias) { + $result = parent::get($alias); + if (is_bool($result)) { + return $result; + } + } + } + + return null; + } +} diff --git a/system/src/Grav/Common/User/Authentication.php b/system/src/Grav/Common/User/Authentication.php index 2447732..6ff95e7 100644 --- a/system/src/Grav/Common/User/Authentication.php +++ b/system/src/Grav/Common/User/Authentication.php @@ -3,12 +3,18 @@ /** * @package Grav\Common\User * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common\User; +use RuntimeException; + +/** + * Class Authentication + * @package Grav\Common\User + */ abstract class Authentication { /** @@ -16,19 +22,19 @@ abstract class Authentication * * @param string $password Plaintext password. * - * @throws \RuntimeException + * @throws RuntimeException * @return string */ public static function create($password): string { if (!$password) { - throw new \RuntimeException('Password hashing failed: no password provided.'); + throw new RuntimeException('Password hashing failed: no password provided.'); } $hash = password_hash($password, PASSWORD_DEFAULT); if (!$hash) { - throw new \RuntimeException('Password hashing failed: internal error.'); + throw new RuntimeException('Password hashing failed: internal error.'); } return $hash; diff --git a/system/src/Grav/Common/User/DataUser/User.php b/system/src/Grav/Common/User/DataUser/User.php index 9a06aee..4735342 100644 --- a/system/src/Grav/Common/User/DataUser/User.php +++ b/system/src/Grav/Common/User/DataUser/User.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\User * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,23 +14,31 @@ use Grav\Common\Data\Blueprints; use Grav\Common\Data\Data; use Grav\Common\File\CompiledYamlFile; use Grav\Common\Grav; +use Grav\Common\Media\Interfaces\MediaCollectionInterface; use Grav\Common\Page\Media; -use Grav\Common\Page\Medium\ImageMedium; +use Grav\Common\Page\Medium\Medium; use Grav\Common\Page\Medium\MediumFactory; use Grav\Common\User\Authentication; use Grav\Common\User\Interfaces\UserInterface; use Grav\Common\User\Traits\UserTrait; +use Grav\Framework\Flex\Flex; +use function is_array; +/** + * Class User + * @package Grav\Common\User\DataUser + */ class User extends Data implements UserInterface { use UserTrait; + /** @var MediaCollectionInterface */ protected $_media; /** * User constructor. * @param array $items - * @param Blueprint $blueprints + * @param Blueprint|null $blueprints */ public function __construct(array $items = [], $blueprints = null) { @@ -78,6 +86,9 @@ class User extends Data implements UserInterface return $value; } + /** + * @return bool + */ public function isValid(): bool { return $this->items !== null; @@ -99,43 +110,66 @@ class User extends Data implements UserInterface } /** - * Save user without the username + * Save user + * + * @return void */ public function save() { - /** @var CompiledYamlFile $file */ + /** @var CompiledYamlFile|null $file */ $file = $this->file(); if (!$file || !$file->filename()) { user_error(__CLASS__ . ': calling \$user = new ' . __CLASS__ . "() is deprecated since Grav 1.6, use \$grav['accounts']->load(\$username) or \$grav['accounts']->load('') instead", E_USER_DEPRECATED); } if ($file) { - $username = $this->get('username'); + $username = $this->filterUsername((string)$this->get('username')); if (!$file->filename()) { $locator = Grav::instance()['locator']; - $file->filename($locator->findResource('account://' . mb_strtolower($username) . YAML_EXT, true, true)); + $file->filename($locator->findResource('account://' . $username . YAML_EXT, true, true)); } // if plain text password, hash it and remove plain text - $password = $this->get('password'); - if ($password) { + $password = $this->get('password') ?? $this->get('password1'); + if (null !== $password && '' !== $password) { + $password2 = $this->get('password2'); + if (!\is_string($password) || ($password2 && $password !== $password2)) { + throw new \RuntimeException('Passwords did not match.'); + } + $this->set('hashed_password', Authentication::create($password)); - $this->undef('password'); } + $this->undef('password'); + $this->undef('password1'); + $this->undef('password2'); $data = $this->items; - unset($data['username'], $data['authenticated'], $data['authorized']); + if ($username === $data['username']) { + unset($data['username']); + } + unset($data['authenticated'], $data['authorized']); $file->save($data); + + // We need to signal Flex Users about the change. + /** @var Flex|null $flex */ + $flex = Grav::instance()['flex'] ?? null; + $users = $flex ? $flex->getDirectory('user-accounts') : null; + if (null !== $users) { + $users->clearCache(); + } } } + /** + * @return MediaCollectionInterface|Media + */ public function getMedia() { if (null === $this->_media) { // Media object should only contain avatar, nothing else. - $media = new Media($this->getMediaFolder(), $this->getMediaOrder(), false); + $media = new Media($this->getMediaFolder() ?? '', $this->getMediaOrder(), false); $path = $this->getAvatarFile(); if ($path && is_file($path)) { @@ -151,11 +185,17 @@ class User extends Data implements UserInterface return $this->_media; } + /** + * @return string + */ public function getMediaFolder() { return $this->blueprints()->fields()['avatar']['destination'] ?? 'user://accounts/avatars'; } + /** + * @return array + */ public function getMediaOrder() { return []; @@ -163,6 +203,8 @@ class User extends Data implements UserInterface /** * Serialize user. + * + * @return string[] */ public function __sleep() { @@ -207,7 +249,7 @@ class User extends Data implements UserInterface /** * Return media object for the User's avatar. * - * @return ImageMedium|null + * @return Medium|null * @deprecated 1.6 Use ->getAvatarImage() method instead. */ public function getAvatarMedia() @@ -235,7 +277,6 @@ class User extends Data implements UserInterface * Ensures backwards compatibility * * @param string $action - * * @return bool * @deprecated 1.5 Use ->authorize() method instead. */ @@ -243,7 +284,7 @@ class User extends Data implements UserInterface { user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use authorize() method instead', E_USER_DEPRECATED); - return $this->authorize($action); + return $this->authorize($action) ?? false; } /** @@ -259,10 +300,22 @@ class User extends Data implements UserInterface return parent::count(); } + /** + * @param string $username + * @return string + */ + protected function filterUsername(string $username): string + { + return mb_strtolower($username); + } + + /** + * @return string|null + */ protected function getAvatarFile(): ?string { $avatars = $this->get('avatar'); - if (\is_array($avatars) && $avatars) { + if (is_array($avatars) && $avatars) { $avatar = array_shift($avatars); return $avatar['path'] ?? null; } diff --git a/system/src/Grav/Common/User/DataUser/UserCollection.php b/system/src/Grav/Common/User/DataUser/UserCollection.php index 800322a..3da7e2d 100644 --- a/system/src/Grav/Common/User/DataUser/UserCollection.php +++ b/system/src/Grav/Common/User/DataUser/UserCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\User * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -16,7 +16,15 @@ use Grav\Common\User\Interfaces\UserCollectionInterface; use Grav\Common\User\Interfaces\UserInterface; use Grav\Common\Utils; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use function count; +use function in_array; +use function is_string; +/** + * Class UserCollection + * @package Grav\Common\User\DataUser + */ class UserCollection implements UserCollectionInterface { /** @var string */ @@ -41,20 +49,25 @@ class UserCollection implements UserCollectionInterface */ public function load($username): UserInterface { + $username = (string)$username; + $grav = Grav::instance(); /** @var UniformResourceLocator $locator */ $locator = $grav['locator']; - // force lowercase of username - $username = mb_strtolower($username); + // Filter username. + $username = $this->filterUsername($username); $filename = 'account://' . $username . YAML_EXT; $path = $locator->findResource($filename) ?: $locator->findResource($filename, true, true); + if (!is_string($path)) { + throw new RuntimeException('Internal Error'); + } $file = CompiledYamlFile::instance($path); $content = (array)$file->content() + ['username' => $username, 'state' => 'enabled']; $userClass = $this->className; - $callable = function() { + $callable = static function () { $blueprints = new Blueprints; return $blueprints->get('user/account'); @@ -78,8 +91,16 @@ class UserCollection implements UserCollectionInterface { $fields = (array)$fields; - $account_dir = Grav::instance()['locator']->findResource('account://'); - $files = $account_dir ? array_diff(scandir($account_dir), ['.', '..']) : []; + $grav = Grav::instance(); + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $account_dir = $locator->findResource('account://'); + if (!is_string($account_dir)) { + return $this->load(''); + } + + $files = array_diff(scandir($account_dir) ?: [], ['.', '..']); // Try with username first, you never know! if (in_array('username', $fields, true)) { @@ -95,7 +116,7 @@ class UserCollection implements UserCollectionInterface if (Utils::endsWith($file, YAML_EXT)) { $find_user = $this->load(trim(pathinfo($file, PATHINFO_FILENAME))); foreach ($fields as $field) { - if ($find_user[$field] === $query) { + if (isset($find_user[$field]) && $find_user[$field] === $query) { return $find_user; } } @@ -109,7 +130,6 @@ class UserCollection implements UserCollectionInterface * Remove user account. * * @param string $username - * * @return bool True if the action was performed */ public function delete($username): bool @@ -119,6 +139,9 @@ class UserCollection implements UserCollectionInterface return $file_path && unlink($file_path); } + /** + * @return int + */ public function count(): int { // check for existence of a user account @@ -127,4 +150,13 @@ class UserCollection implements UserCollectionInterface return count($accounts); } + + /** + * @param string $username + * @return string + */ + protected function filterUsername(string $username): string + { + return mb_strtolower($username); + } } diff --git a/system/src/Grav/Common/User/FlexUser/Storage/UserFileStorage.php b/system/src/Grav/Common/User/FlexUser/Storage/UserFileStorage.php deleted file mode 100644 index 3156fc4..0000000 --- a/system/src/Grav/Common/User/FlexUser/Storage/UserFileStorage.php +++ /dev/null @@ -1,16 +0,0 @@ - false, - 'find' => false, - 'remove' => false, - 'get' => true, - 'set' => false, - 'undef' => false, - 'def' => false, - ] + parent::getCachedMethods(); - } - - public function __construct(array $elements, $key, FlexDirectory $directory, bool $validate = false) - { - // User can only be authenticated via login. - unset($elements['authenticated'], $elements['authorized']); - - parent::__construct($elements, $key, $directory, $validate); - - // Define username and state if they aren't set. - $this->defProperty('username', $key); - $this->defProperty('state', 'enabled'); - } - - /** - * Get value by using dot notation for nested arrays/objects. - * - * @example $value = $this->get('this.is.my.nested.variable'); - * - * @param string $name Dot separated path to the requested value. - * @param mixed $default Default value (or null). - * @param string $separator Separator, defaults to '.' - * @return mixed Value. - */ - public function get($name, $default = null, $separator = null) - { - return $this->getNestedProperty($name, $default, $separator); - } - - /** - * Set value by using dot notation for nested arrays/objects. - * - * @example $data->set('this.is.my.nested.variable', $value); - * - * @param string $name Dot separated path to the requested value. - * @param mixed $value New value. - * @param string $separator Separator, defaults to '.' - * @return $this - */ - public function set($name, $value, $separator = null) - { - $this->setNestedProperty($name, $value, $separator); - - return $this; - } - - /** - * Unset value by using dot notation for nested arrays/objects. - * - * @example $data->undef('this.is.my.nested.variable'); - * - * @param string $name Dot separated path to the requested value. - * @param string $separator Separator, defaults to '.' - * @return $this - */ - public function undef($name, $separator = null) - { - $this->unsetNestedProperty($name, $separator); - - return $this; - } - - /** - * Set default value by using dot notation for nested arrays/objects. - * - * @example $data->def('this.is.my.nested.variable', 'default'); - * - * @param string $name Dot separated path to the requested value. - * @param mixed $default Default value (or null). - * @param string $separator Separator, defaults to '.' - * @return $this - */ - public function def($name, $default = null, $separator = null) - { - $this->defNestedProperty($name, $default, $separator); - - return $this; - } - - /** - * Get value from a page variable (used mostly for creating edit forms). - * - * @param string $name Variable name. - * @param mixed $default - * @param string|null $separator - * @return mixed - */ - public function getFormValue(string $name, $default = null, string $separator = null) - { - $value = parent::getFormValue($name, null, $separator); - - if ($name === 'avatar') { - return $this->parseFileProperty($value); - } - - if (null === $value) { - if ($name === 'media_order') { - return implode(',', $this->getMediaOrder()); - } - } - - return $value ?? $default; - } - - /** - * @param string $property - * @param mixed $default - * @return mixed - */ - public function getProperty($property, $default = null) - { - $value = parent::getProperty($property, $default); - - if ($property === 'avatar') { - $value = $this->parseFileProperty($value); - } - - return $value; - } - - /** - * Convert object into an array. - * - * @return array - */ - public function toArray() - { - $array = $this->jsonSerialize(); - $array['avatar'] = $this->parseFileProperty($array['avatar'] ?? null); - - return $array; - } - - /** - * Convert object into YAML string. - * - * @param int $inline The level where you switch to inline YAML. - * @param int $indent The amount of spaces to use for indentation of nested nodes. - * - * @return string A YAML string representing the object. - */ - public function toYaml($inline = 5, $indent = 2) - { - $yaml = new YamlFormatter(['inline' => $inline, 'indent' => $indent]); - - return $yaml->encode($this->toArray()); - } - - /** - * Convert object into JSON string. - * - * @return string - */ - public function toJson() - { - $json = new JsonFormatter(); - - return $json->encode($this->toArray()); - } - - /** - * Join nested values together by using blueprints. - * - * @param string $name Dot separated path to the requested value. - * @param mixed $value Value to be joined. - * @param string $separator Separator, defaults to '.' - * @return $this - * @throws \RuntimeException - */ - public function join($name, $value, $separator = null) - { - $separator = $separator ?? '.'; - $old = $this->get($name, null, $separator); - if ($old !== null) { - if (!\is_array($old)) { - throw new \RuntimeException('Value ' . $old); - } - - if (\is_object($value)) { - $value = (array) $value; - } elseif (!\is_array($value)) { - throw new \RuntimeException('Value ' . $value); - } - - $value = $this->getBlueprint()->mergeData($old, $value, $name, $separator); - } - - $this->set($name, $value, $separator); - - return $this; - } - - /** - * Get nested structure containing default values defined in the blueprints. - * - * Fields without default value are ignored in the list. - - * @return array - */ - public function getDefaults() - { - return $this->getBlueprint()->getDefaults(); - } - - /** - * Set default values by using blueprints. - * - * @param string $name Dot separated path to the requested value. - * @param mixed $value Value to be joined. - * @param string $separator Separator, defaults to '.' - * @return $this - */ - public function joinDefaults($name, $value, $separator = null) - { - if (\is_object($value)) { - $value = (array) $value; - } - - $old = $this->get($name, null, $separator); - if ($old !== null) { - $value = $this->getBlueprint()->mergeData($value, $old, $name, $separator); - } - - $this->setNestedProperty($name, $value, $separator); - - return $this; - } - - /** - * Get value from the configuration and join it with given data. - * - * @param string $name Dot separated path to the requested value. - * @param array|object $value Value to be joined. - * @param string $separator Separator, defaults to '.' - * @return array - * @throws \RuntimeException - */ - public function getJoined($name, $value, $separator = null) - { - if (\is_object($value)) { - $value = (array) $value; - } elseif (!\is_array($value)) { - throw new \RuntimeException('Value ' . $value); - } - - $old = $this->get($name, null, $separator); - - if ($old === null) { - // No value set; no need to join data. - return $value; - } - - if (!\is_array($old)) { - throw new \RuntimeException('Value ' . $old); - } - - // Return joined data. - return $this->getBlueprint()->mergeData($old, $value, $name, $separator); - } - - /** - * Set default values to the configuration if variables were not set. - * - * @param array $data - * @return $this - */ - public function setDefaults(array $data) - { - $this->setElements($this->getBlueprint()->mergeData($data, $this->toArray())); - - return $this; - } - - /** - * Validate by blueprints. - * - * @return $this - * @throws \Exception - */ - public function validate() - { - $this->getBlueprint()->validate($this->toArray()); - - return $this; - } - - /** - * Filter all items by using blueprints. - * @return $this - */ - public function filter() - { - $this->setElements($this->getBlueprint()->filter($this->toArray())); - - return $this; - } - - /** - * Get extra items which haven't been defined in blueprints. - * - * @return array - */ - public function extra() - { - return $this->getBlueprint()->extra($this->toArray()); - } - - /** - * @param string $name - * @return Blueprint - */ - public function getBlueprint(string $name = '') - { - $blueprint = clone parent::getBlueprint($name); - - $blueprint->addDynamicHandler('flex', function (array &$field, $property, array &$call) { - $params = (array)$call['params']; - $method = array_shift($params); - - if (method_exists($this, $method)) { - $value = $this->{$method}(...$params); - if (\is_array($value) && isset($field[$property]) && \is_array($field[$property])) { - $field[$property] = array_merge_recursive($field[$property], $value); - } else { - $field[$property] = $value; - } - } - }); - - return $blueprint->init(); - } - - /** - * Return unmodified data as raw string. - * - * NOTE: This function only returns data which has been saved to the storage. - * - * @return string - */ - public function raw() - { - $file = $this->file(); - - return $file ? $file->raw() : ''; - } - - /** - * Set or get the data storage. - * - * @param FileInterface $storage Optionally enter a new storage. - * @return FileInterface - */ - public function file(FileInterface $storage = null) - { - if (null !== $storage) { - $this->_storage = $storage; - } - - return $this->_storage; - } - - public function isValid(): bool - { - return $this->getProperty('state') !== null; - } - - /** - * Save user without the username - */ - public function save() - { - // TODO: We may want to handle this in the storage layer in the future. - $key = $this->getStorageKey(); - if (!$key || strpos($key, '@@')) { - $storage = $this->getFlexDirectory()->getStorage(); - if ($storage instanceof FileStorage) { - $this->setStorageKey($this->getKey()); - } - } - - $password = $this->getProperty('password'); - if (null !== $password) { - $this->unsetProperty('password'); - $this->unsetProperty('password1'); - $this->unsetProperty('password2'); - $this->setProperty('hashed_password', Authentication::create($password)); - } - - return parent::save(); - } - - public function isAuthorized(string $action, string $scope = null, UserInterface $user = null): bool - { - if (null === $user) { - /** @var UserInterface $user */ - $user = Grav::instance()['user'] ?? null; - } - - if ($user instanceof User && $user->getStorageKey() === $this->getStorageKey()) { - return true; - } - - return parent::isAuthorized($action, $scope, $user); - } - - /** - * @return array - */ - public function prepareStorage(): array - { - $elements = parent::prepareStorage(); - - // Do not save authorization information. - unset($elements['authenticated'], $elements['authorized']); - - return $elements; - } - - /** - * Merge two configurations together. - * - * @param array $data - * @return $this - * @deprecated 1.6 Use `->update($data)` instead (same but with data validation & filtering, file upload support). - */ - public function merge(array $data) - { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->update($data) method instead', E_USER_DEPRECATED); - - $this->setElements($this->getBlueprint()->mergeData($this->toArray(), $data)); - - return $this; - } - - /** - * Return media object for the User's avatar. - * - * @return ImageMedium|null - * @deprecated 1.6 Use ->getAvatarImage() method instead. - */ - public function getAvatarMedia() - { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getAvatarImage() method instead', E_USER_DEPRECATED); - - return $this->getAvatarImage(); - } - - /** - * Return the User's avatar URL - * - * @return string - * @deprecated 1.6 Use ->getAvatarUrl() method instead. - */ - public function avatarUrl() - { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getAvatarUrl() method instead', E_USER_DEPRECATED); - - return $this->getAvatarUrl(); - } - - /** - * Checks user authorization to the action. - * Ensures backwards compatibility - * - * @param string $action - * @return bool - * @deprecated 1.5 Use ->authorize() method instead. - */ - public function authorise($action) - { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->authorize() method instead', E_USER_DEPRECATED); - - return $this->authorize($action); - } - - /** - * Implements Countable interface. - * - * @return int - * @deprecated 1.6 Method makes no sense for user account. - */ - public function count() - { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); - - return \count($this->jsonSerialize()); - } - - /** - * Gets the associated media collection (original images). - * - * @return MediaCollectionInterface Representation of associated media. - */ - protected function getOriginalMedia() - { - return (new Media($this->getMediaFolder() . '/original', $this->getMediaOrder()))->setTimestamps(); - } - - /** - * @param array $files - */ - protected function setUpdatedMedia(array $files): void - { - $list = []; - $list_original = []; - foreach ($files as $field => $group) { - foreach ($group as $filename => $file) { - if (strpos($field, '/original')) { - // Special handling for original images. - $list_original[$filename] = $file; - continue; - } - - $list[$filename] = $file; - - if ($file) { - /** @var FormFlashFile $file */ - $data = $file->jsonSerialize(); - $path = $file->getClientFilename(); - unset($data['tmp_name'], $data['path']); - - $this->setNestedProperty("{$field}\n{$path}", $data, "\n"); - } else { - $this->unsetNestedProperty("{$field}\n{$filename}", "\n"); - } - } - } - - $this->_uploads = $list; - $this->_uploads_original = $list_original; - } - - protected function saveUpdatedMedia(): void - { - // Upload/delete original sized images. - /** @var FormFlashFile $file */ - foreach ($this->_uploads_original ?? [] as $name => $file) { - $name = 'original/' . $name; - if ($file) { - $this->uploadMediaFile($file, $name); - } else { - $this->deleteMediaFile($name); - } - } - - /** - * @var string $filename - * @var UploadedFileInterface $file - */ - foreach ($this->getUpdatedMedia() as $filename => $file) { - if ($file) { - $this->uploadMediaFile($file, $filename); - } else { - $this->deleteMediaFile($filename); - } - } - - $this->setUpdatedMedia([]); - } - - /** - * @param array $value - * @return array - */ - protected function parseFileProperty($value) - { - if (!\is_array($value)) { - return $value; - } - - $originalMedia = $this->getOriginalMedia(); - $resizedMedia = $this->getMedia(); - - $list = []; - foreach ($value as $filename => $info) { - if (!\is_array($info)) { - continue; - } - - /** @var Medium $thumbFile */ - $thumbFile = $resizedMedia[$filename]; - /** @var Medium $imageFile */ - $imageFile = $originalMedia[$filename] ?? $thumbFile; - if ($thumbFile) { - $list[$filename] = [ - 'name' => $filename, - 'type' => $info['type'], - 'size' => $info['size'], - 'image_url' => $imageFile->url(), - 'thumb_url' => $thumbFile->url(), - 'cropData' => (object)($imageFile->metadata()['upload']['crop'] ?? []) - ]; - } - } - - return $list; - } - - /** - * @return array - */ - protected function doSerialize(): array - { - return [ - 'type' => 'accounts', - 'key' => $this->getKey(), - 'elements' => $this->jsonSerialize(), - 'storage' => $this->getStorage() - ]; - } - - /** - * @param array $serialized - */ - protected function doUnserialize(array $serialized): void - { - $grav = Grav::instance(); - - /** @var UserCollection $accounts */ - $accounts = $grav['accounts']; - - $directory = $accounts->getFlexDirectory(); - if (!$directory) { - throw new \InvalidArgumentException('Internal error'); - } - - $this->setFlexDirectory($directory); - $this->setStorage($serialized['storage']); - $this->setKey($serialized['key']); - $this->setElements($serialized['elements']); - } -} diff --git a/system/src/Grav/Common/User/FlexUser/UserCollection.php b/system/src/Grav/Common/User/FlexUser/UserCollection.php deleted file mode 100644 index 0f7a1d1..0000000 --- a/system/src/Grav/Common/User/FlexUser/UserCollection.php +++ /dev/null @@ -1,100 +0,0 @@ -exists(). - * - * @param string $username - * @return User - */ - public function load($username): UserInterface - { - if ($username !== '') { - $key = mb_strtolower($username); - $user = $this->get($key); - if ($user) { - return $user; - } - } else { - $key = ''; - } - - $directory = $this->getFlexDirectory(); - - /** @var User $object */ - $object = $directory->createObject( - [ - 'username' => $username, - 'state' => 'enabled' - ], - $key - ); - - return $object; - } - - /** - * Find a user by username, email, etc - * - * @param string $query the query to search for - * @param array $fields the fields to search - * @return User - */ - public function find($query, $fields = ['username', 'email']): UserInterface - { - foreach ((array)$fields as $field) { - if ($field === 'key') { - $user = $this->get($query); - } elseif ($field === 'storage_key') { - $user = $this->withKeyField('storage_key')->get($query); - } elseif ($field === 'flex_key') { - $user = $this->withKeyField('flex_key')->get($query); - } elseif ($field === 'username') { - $user = $this->get(mb_strtolower($query)); - } else { - $user = parent::find($query, $field); - } - if ($user) { - return $user; - } - } - - return $this->load(''); - } - - /** - * Delete user account. - * - * @param string $username - * - * @return bool True if user account was found and was deleted. - */ - public function delete($username): bool - { - $user = $this->load($username); - - $exists = $user->exists(); - if ($exists) { - $user->delete(); - } - - return $exists; - } -} diff --git a/system/src/Grav/Common/User/FlexUser/UserIndex.php b/system/src/Grav/Common/User/FlexUser/UserIndex.php deleted file mode 100644 index f95f51b..0000000 --- a/system/src/Grav/Common/User/FlexUser/UserIndex.php +++ /dev/null @@ -1,133 +0,0 @@ -exists(). - * - * @param string $username - * - * @return User - */ - public function load($username): UserInterface - { - if ($username !== '') { - $key = mb_strtolower($username); - $user = $this->get($key); - if ($user) { - return $user; - } - } else { - $key = ''; - } - - $directory = $this->getFlexDirectory(); - - /** @var User $object */ - $object = $directory->createObject( - [ - 'username' => $username, - 'state' => 'enabled' - ], - $key - ); - - return $object; - } - - /** - * Find a user by username, email, etc - * - * @param string $query the query to search for - * @param array $fields the fields to search - * @return User - */ - public function find($query, $fields = ['username', 'email']): UserInterface - { - foreach ((array)$fields as $field) { - if ($field === 'key') { - $user = $this->get($query); - } elseif ($field === 'storage_key') { - $user = $this->withKeyField('storage_key')->get($query); - } elseif ($field === 'flex_key') { - $user = $this->withKeyField('flex_key')->get($query); - } elseif ($field === 'email') { - $user = $this->withKeyField('email')->get($query); - } elseif ($field === 'username') { - $user = $this->get(mb_strtolower($query)); - } else { - $user = $this->__call('find', [$query, $field]); - } - if ($user) { - return $user; - } - } - - return $this->load(''); - } - - protected static function updateIndexData(array &$entry, array $data) - { - $entry['key'] = mb_strtolower($entry['key']); - $entry['email'] = isset($data['email']) ? mb_strtolower($data['email']) : null; - } - - protected static function getIndexFile(FlexStorageInterface $storage) - { - // Load saved index file. - $grav = Grav::instance(); - $locator = $grav['locator']; - $filename = $locator->findResource('user-data://flex/indexes/accounts.yaml', true, true); - - return CompiledYamlFile::instance($filename); - } - - protected static function onChanges(array $entries, array $added, array $updated, array $removed) - { - $message = sprintf('Flex: User index updated, %d objects (%d added, %d updated, %d removed).', \count($entries), \count($added), \count($updated), \count($removed)); - - $grav = Grav::instance(); - - /** @var Logger $logger */ - $logger = $grav['log']; - $logger->addDebug($message); - - /** @var Debugger $debugger */ - $debugger = $grav['debugger']; - $debugger->addMessage($message, 'debug'); - } -} diff --git a/system/src/Grav/Common/User/Group.php b/system/src/Grav/Common/User/Group.php index 9815c0e..5be5386 100644 --- a/system/src/Grav/Common/User/Group.php +++ b/system/src/Grav/Common/User/Group.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\User * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -16,15 +16,21 @@ use Grav\Common\File\CompiledYamlFile; use Grav\Common\Grav; use Grav\Common\Utils; +/** + * @deprecated 1.7 Use $grav['user_groups'] instead of this class. In type hints, please use UserGroupInterface. + */ class Group extends Data { /** * Get the groups list * * @return array + * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead */ private static function groups() { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\'user_groups\'] Flex UserGroupCollection instead', E_USER_DEPRECATED); + return Grav::instance()['config']->get('groups', []); } @@ -32,12 +38,15 @@ class Group extends Data * Get the groups list * * @return array + * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead */ public static function groupNames() { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\'user_groups\'] Flex UserGroupCollection instead', E_USER_DEPRECATED); + $groups = []; - foreach(static::groups() as $groupname => $group) { + foreach (static::groups() as $groupname => $group) { $groups[$groupname] = $group['readableName'] ?? $groupname; } @@ -48,11 +57,13 @@ class Group extends Data * Checks if a group exists * * @param string $groupname - * * @return bool + * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead */ public static function groupExists($groupname) { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\'user_groups\'] Flex UserGroupCollection instead', E_USER_DEPRECATED); + return isset(self::groups()[$groupname]); } @@ -60,11 +71,13 @@ class Group extends Data * Get a group by name * * @param string $groupname - * * @return object + * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead */ public static function load($groupname) { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\'user_groups\'] Flex UserGroupCollection instead', E_USER_DEPRECATED); + $groups = self::groups(); $content = $groups[$groupname] ?? []; @@ -78,6 +91,8 @@ class Group extends Data /** * Save a group + * + * @return void */ public function save() { @@ -125,11 +140,13 @@ class Group extends Data * Remove a group * * @param string $groupname - * * @return bool True if the action was performed + * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead */ public static function remove($groupname) { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\'user_groups\'] Flex UserGroupCollection instead', E_USER_DEPRECATED); + $grav = Grav::instance(); /** @var Config $config */ diff --git a/system/src/Grav/Common/User/Interfaces/AuthorizeInterface.php b/system/src/Grav/Common/User/Interfaces/AuthorizeInterface.php new file mode 100644 index 0000000..3ad8d2c --- /dev/null +++ b/system/src/Grav/Common/User/Interfaces/AuthorizeInterface.php @@ -0,0 +1,26 @@ +undef('this.is.my.nested.variable'); * * @param string $name Dot separated path to the requested value. - * @param string $separator Separator, defaults to '.' + * @param string|null $separator Separator, defaults to '.' * @return $this */ public function undef($name, $separator = null); @@ -79,7 +80,7 @@ interface UserInterface extends DataInterface, MediaInterface, \ArrayAccess, \Js * * @param string $name Dot separated path to the requested value. * @param mixed $default Default value (or null). - * @param string $separator Separator, defaults to '.' + * @param string|null $separator Separator, defaults to '.' * @return $this */ public function def($name, $default = null, $separator = null); @@ -91,7 +92,7 @@ interface UserInterface extends DataInterface, MediaInterface, \ArrayAccess, \Js * @param mixed $value Value to be joined. * @param string $separator Separator, defaults to '.' * @return $this - * @throws \RuntimeException + * @throws RuntimeException */ public function join($name, $value, $separator = '.'); @@ -121,7 +122,7 @@ interface UserInterface extends DataInterface, MediaInterface, \ArrayAccess, \Js * @param array|object $value Value to be joined. * @param string $separator Separator, defaults to '.' * @return array - * @throws \RuntimeException + * @throws RuntimeException */ public function getJoined($name, $value, $separator = '.'); @@ -166,28 +167,18 @@ interface UserInterface extends DataInterface, MediaInterface, \ArrayAccess, \Js * If user password needs to be updated, new information will be saved. * * @param string $password Plaintext password. - * * @return bool */ public function authenticate(string $password): bool; - /** - * Checks user authorization to the action. - * - * @param string $action - * @param string|null $scope - * @return bool - */ - public function authorize(string $action, string $scope = null): bool; - /** * Return media object for the User's avatar. * * Note: if there's no local avatar image for the user, you should call getAvatarUrl() to get the external avatar URL. * - * @return ImageMedium|null + * @return Medium|null */ - public function getAvatarImage(): ?ImageMedium; + public function getAvatarImage(): ?Medium; /** * Return the User's avatar URL. diff --git a/system/src/Grav/Common/User/Traits/UserTrait.php b/system/src/Grav/Common/User/Traits/UserTrait.php index f711cc9..5a6b749 100644 --- a/system/src/Grav/Common/User/Traits/UserTrait.php +++ b/system/src/Grav/Common/User/Traits/UserTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\User * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,9 +11,17 @@ namespace Grav\Common\User\Traits; use Grav\Common\Grav; use Grav\Common\Page\Medium\ImageMedium; +use Grav\Common\Page\Medium\Medium; +use Grav\Common\Page\Medium\StaticImageMedium; use Grav\Common\User\Authentication; use Grav\Common\Utils; +use function is_array; +use function is_string; +/** + * Trait UserTrait + * @package Grav\Common\User\Traits + */ trait UserTrait { /** @@ -63,15 +71,22 @@ trait UserTrait * * @param string $action * @param string|null $scope - * @return bool + * @return bool|null */ - public function authorize(string $action, string $scope = null): bool + public function authorize(string $action, string $scope = null): ?bool { + // User needs to be enabled. + if ($this->get('state', 'enabled') !== 'enabled') { + return false; + } + + // User needs to be logged in. if (!$this->get('authenticated')) { return false; } - if ($this->get('state', 'enabled') !== 'enabled') { + // User needs to be authorized (2FA). + if (strpos($action, 'login') === false && !$this->get('authorized', true)) { return false; } @@ -107,19 +122,20 @@ trait UserTrait * * Note: if there's no local avatar image for the user, you should call getAvatarUrl() to get the external avatar URL. * - * @return ImageMedium|null + * @return ImageMedium|StaticImageMedium|null */ - public function getAvatarImage(): ?ImageMedium + public function getAvatarImage(): ?Medium { $avatars = $this->get('avatar'); - if (\is_array($avatars) && $avatars) { + if (is_array($avatars) && $avatars) { $avatar = array_shift($avatars); $media = $this->getMedia(); $name = $avatar['name'] ?? null; $image = $name ? $media[$name] : null; - if ($image instanceof ImageMedium) { + if ($image instanceof ImageMedium || + $image instanceof StaticImageMedium) { return $image; } } @@ -142,18 +158,18 @@ trait UserTrait // Try if avatar is a sting (URL). $avatar = $this->get('avatar'); - if (\is_string($avatar)) { + if (is_string($avatar)) { return $avatar; } // Try looking for provider. $provider = $this->get('provider'); $provider_options = $this->get($provider); - if (\is_array($provider_options)) { - if (isset($provider_options['avatar_url']) && \is_string($provider_options['avatar_url'])) { + if (is_array($provider_options)) { + if (isset($provider_options['avatar_url']) && is_string($provider_options['avatar_url'])) { return $provider_options['avatar_url']; } - if (isset($provider_options['avatar']) && \is_string($provider_options['avatar'])) { + if (isset($provider_options['avatar']) && is_string($provider_options['avatar'])) { return $provider_options['avatar']; } } diff --git a/system/src/Grav/Common/User/User.php b/system/src/Grav/Common/User/User.php index ba1dbdc..4b2319f 100644 --- a/system/src/Grav/Common/User/User.php +++ b/system/src/Grav/Common/User/User.php @@ -3,7 +3,7 @@ /** * @package Grav\Common\User * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,7 +11,7 @@ namespace Grav\Common\User; use Grav\Common\Grav; use Grav\Common\User\DataUser; -use Grav\Common\User\FlexUser; +use Grav\Common\Flex; use Grav\Common\User\Interfaces\UserCollectionInterface; use Grav\Common\User\Interfaces\UserInterface; @@ -23,7 +23,7 @@ if (defined('GRAV_USER_INSTANCE') && GRAV_USER_INSTANCE === 'FLEX') { /** * @deprecated 1.6 Use $grav['accounts'] instead of static calls. In type hints, please use UserInterface. */ - class User extends FlexUser\User + class User extends Flex\Types\Users\UserObject { /** * Load user account. @@ -31,7 +31,6 @@ if (defined('GRAV_USER_INSTANCE') && GRAV_USER_INSTANCE === 'FLEX') { * Always creates user object. To check if user exists, use $this->exists(). * * @param string $username - * * @return UserInterface * @deprecated 1.6 Use $grav['accounts']->load(...) instead. */ @@ -93,7 +92,6 @@ if (defined('GRAV_USER_INSTANCE') && GRAV_USER_INSTANCE === 'FLEX') { * Always creates user object. To check if user exists, use $this->exists(). * * @param string $username - * * @return UserInterface * @deprecated 1.6 Use $grav['accounts']->load(...) instead. */ diff --git a/system/src/Grav/Common/Utils.php b/system/src/Grav/Common/Utils.php index 56b2d89..edc093e 100644 --- a/system/src/Grav/Common/Utils.php +++ b/system/src/Grav/Common/Utils.php @@ -3,22 +3,48 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Common; +use DateTime; +use DateTimeZone; +use Exception; +use Grav\Common\Flex\Types\Pages\PageObject; use Grav\Common\Helpers\Truncator; use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Markdown\Parsedown; use Grav\Common\Markdown\ParsedownExtra; use Grav\Common\Page\Markdown\Excerpts; +use Grav\Common\Page\Pages; +use Grav\Framework\Flex\Flex; +use Grav\Framework\Flex\Interfaces\FlexObjectInterface; +use InvalidArgumentException; +use Negotiation\Accept; +use Negotiation\Negotiator; use RocketTheme\Toolbox\Event\Event; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use function array_key_exists; +use function array_slice; +use function count; +use function extension_loaded; +use function function_exists; +use function in_array; +use function is_array; +use function is_callable; +use function is_string; +use function strlen; +/** + * Class Utils + * @package Grav\Common + */ abstract class Utils { + /** @var array */ protected static $nonces = []; protected const ROOTURL_REGEX = '{^((?:http[s]?:\/\/[^\/]+)|(?:\/\/[^\/]+))(.*)}'; @@ -31,11 +57,11 @@ abstract class Utils * @param string|object $input * @param bool $domain * @param bool $fail_gracefully - * @return bool|null|string + * @return string|false */ public static function url($input, $domain = false, $fail_gracefully = false) { - if ((!is_string($input) && !method_exists($input, '__toString')) || !trim($input)) { + if ((!is_string($input) && !is_callable([$input, '__toString'])) || !trim($input)) { if ($fail_gracefully) { $input = '/'; } else { @@ -54,6 +80,7 @@ abstract class Utils /** @var Uri $uri */ $uri = $grav['uri']; + $resource = false; if (static::contains((string)$input, '://')) { /** @var UniformResourceLocator $locator */ $locator = $grav['locator']; @@ -85,7 +112,6 @@ abstract class Utils // Return location where the file would be if it was saved. $resource = $locator->findResource("{$scheme}://{$host}{$path}", false, true); } - } elseif ($host || $port) { // If URL doesn't have scheme but has host or port, it is external. return str_replace(' ', '%20', $input); @@ -102,12 +128,10 @@ abstract class Utils $resource .= '#' . $parts['fragment']; } } - } else { // Not a valid URL (can still be a stream). $resource = $locator->findResource($input, false); } - } else { $root = $uri->rootUrl(); @@ -129,13 +153,34 @@ abstract class Utils return rtrim($uri->rootUrl($domain), '/') . '/' . ($resource ?? ''); } + /** + * Helper method to find the full path to a file, be it a stream, a relative path, or + * already a full path + * + * @param string $path + * @return string + */ + public static function fullPath($path) + { + $locator = Grav::instance()['locator']; + + if ($locator->isStream($path)) { + $path = $locator->findResource($path, true); + } elseif (!Utils::startsWith($path, GRAV_ROOT)) { + $base_url = Grav::instance()['base_url']; + $path = GRAV_ROOT . '/' . ltrim(Utils::replaceFirstOccurrence($base_url, '', $path), '/'); + } + + return $path; + } + + /** * Check if the $haystack string starts with the substring $needle * - * @param string $haystack - * @param string|string[] $needle + * @param string $haystack + * @param string|string[] $needle * @param bool $case_sensitive - * * @return bool */ public static function startsWith($haystack, $needle, $case_sensitive = true) @@ -157,10 +202,9 @@ abstract class Utils /** * Check if the $haystack string ends with the substring $needle * - * @param string $haystack - * @param string|string[] $needle + * @param string $haystack + * @param string|string[] $needle * @param bool $case_sensitive - * * @return bool */ public static function endsWith($haystack, $needle, $case_sensitive = true) @@ -183,10 +227,9 @@ abstract class Utils /** * Check if the $haystack string contains the substring $needle * - * @param string $haystack - * @param string|string[] $needle - * @param bool $case_sensitive - * + * @param string $haystack + * @param string|string[] $needle + * @param bool $case_sensitive * @return bool */ public static function contains($haystack, $needle, $case_sensitive = true) @@ -219,14 +262,35 @@ abstract class Utils * @param string $haystack * @return false|int */ - public static function matchWildcard($wildcard_pattern, $haystack) { + public static function matchWildcard($wildcard_pattern, $haystack) + { $regex = str_replace( array("\*", "\?"), // wildcard chars - array('.*','.'), // regexp chars + array('.*', '.'), // regexp chars preg_quote($wildcard_pattern, '/') ); - return preg_match('/^'.$regex.'$/is', $haystack); + return preg_match('/^' . $regex . '$/is', $haystack); + } + + /** + * Render simple template filling up the variables in it. If value is not defined, leave it as it was. + * + * @param string $template Template string + * @param array $variables Variables with values + * @param array $brackets Optional array of opening and closing brackets or symbols + * @return string Final string filled with values + */ + public static function simpleTemplate(string $template, array $variables, array $brackets = ['{', '}']): string + { + $opening = $brackets[0] ?? '{'; + $closing = $brackets[1] ?? '}'; + $expression = '/' . preg_quote($opening, '/') . '(.*?)' . preg_quote($closing, '/') . '/'; + $callback = static function ($match) use ($variables) { + return $variables[$match[1]] ?? $match[0]; + }; + + return preg_replace_callback($expression, $callback, $template); } /** @@ -285,8 +349,7 @@ abstract class Utils { $pos = strrpos($subject, $search); - if($pos !== false) - { + if ($pos !== false) { $subject = static::mb_substr_replace($subject, $replace, $pos, mb_strlen($search)); } @@ -304,8 +367,8 @@ abstract class Utils */ public static function mb_substr_replace($original, $replacement, $position, $length) { - $startString = mb_substr($original, 0, $position, "UTF-8"); - $endString = mb_substr($original, $position + $length, mb_strlen($original), "UTF-8"); + $startString = mb_substr($original, 0, $position, 'UTF-8'); + $endString = mb_substr($original, $position + $length, mb_strlen($original), 'UTF-8'); return $startString . $replacement . $endString; } @@ -313,8 +376,8 @@ abstract class Utils /** * Merge two objects into one. * - * @param object $obj1 - * @param object $obj2 + * @param object $obj1 + * @param object $obj2 * * @return object */ @@ -323,13 +386,22 @@ abstract class Utils return (object)array_merge((array)$obj1, (array)$obj2); } + /** + * @param array $array + * @return bool + */ + public static function isAssoc(array $array) + { + return (array_values($array) !== $array); + } + /** * Lowercase an entire array. Useful when combined with `in_array()` * * @param array $a * @return array|false */ - public static function arrayLower(Array $a) + public static function arrayLower(array $a) { return array_map('mb_strtolower', $a); } @@ -337,13 +409,13 @@ abstract class Utils /** * Simple function to remove item/s in an array by value * - * @param $search array - * @param $value string|array + * @param array $search + * @param string|array $value * @return array */ - public static function arrayRemoveValue(Array $search, $value) + public static function arrayRemoveValue(array $search, $value) { - foreach ((array) $value as $val) { + foreach ((array)$value as $val) { $key = array_search($val, $search); if ($key !== false) { unset($search[$key]); @@ -409,8 +481,8 @@ abstract class Utils /** * Array combine but supports different array lengths * - * @param array $arr1 - * @param array $arr2 + * @param array $arr1 + * @param array $arr2 * @return array|false */ public static function arrayCombine($arr1, $arr2) @@ -423,7 +495,7 @@ abstract class Utils /** * Array is associative or not * - * @param array $arr + * @param array $arr * @return bool */ public static function arrayIsAssociative($arr) @@ -442,18 +514,18 @@ abstract class Utils */ public static function dateFormats() { - $now = new \DateTime(); + $now = new DateTime(); $date_formats = [ - 'd-m-Y H:i' => 'd-m-Y H:i (e.g. '.$now->format('d-m-Y H:i').')', - 'Y-m-d H:i' => 'Y-m-d H:i (e.g. '.$now->format('Y-m-d H:i').')', - 'm/d/Y h:i a' => 'm/d/Y h:i a (e.g. '.$now->format('m/d/Y h:i a').')', - 'H:i d-m-Y' => 'H:i d-m-Y (e.g. '.$now->format('H:i d-m-Y').')', - 'h:i a m/d/Y' => 'h:i a m/d/Y (e.g. '.$now->format('h:i a m/d/Y').')', - ]; + 'd-m-Y H:i' => 'd-m-Y H:i (e.g. ' . $now->format('d-m-Y H:i') . ')', + 'Y-m-d H:i' => 'Y-m-d H:i (e.g. ' . $now->format('Y-m-d H:i') . ')', + 'm/d/Y h:i a' => 'm/d/Y h:i a (e.g. ' . $now->format('m/d/Y h:i a') . ')', + 'H:i d-m-Y' => 'H:i d-m-Y (e.g. ' . $now->format('H:i d-m-Y') . ')', + 'h:i a m/d/Y' => 'h:i a m/d/Y (e.g. ' . $now->format('h:i a m/d/Y') . ')', + ]; $default_format = Grav::instance()['config']->get('system.pages.dateformat.default'); if ($default_format) { - $date_formats = array_merge([$default_format => $default_format.' (e.g. '.$now->format($default_format).')'], $date_formats); + $date_formats = array_merge([$default_format => $default_format . ' (e.g. ' . $now->format($default_format) . ')'], $date_formats); } return $date_formats; @@ -464,13 +536,13 @@ abstract class Utils * * @param string|null $default_format * @return string - * @throws \Exception + * @throws Exception */ public static function dateNow($default_format = null) { - $now = new \DateTime(); + $now = new DateTime(); - if (is_null($default_format)) { + if (null === $default_format) { $default_format = Grav::instance()['config']->get('system.pages.dateformat.default'); } @@ -480,12 +552,11 @@ abstract class Utils /** * Truncate text by number of characters but can cut off words. * - * @param string $string - * @param int $limit Max number of characters. - * @param bool $up_to_break truncate up to breakpoint after char count - * @param string $break Break point. - * @param string $pad Appended padding to the end of the string. - * + * @param string $string + * @param int $limit Max number of characters. + * @param bool $up_to_break truncate up to breakpoint after char count + * @param string $break Break point. + * @param string $pad Appended padding to the end of the string. * @return string */ public static function truncate($string, $limit = 150, $up_to_break = false, $break = ' ', $pad = '…') @@ -511,8 +582,7 @@ abstract class Utils * Truncate text by number of characters in a "word-safe" manor. * * @param string $string - * @param int $limit - * + * @param int $limit * @return string */ public static function safeTruncate($string, $limit = 150) @@ -524,10 +594,9 @@ abstract class Utils /** * Truncate HTML by number of characters. not "word-safe"! * - * @param string $text - * @param int $length in characters - * @param string $ellipsis - * + * @param string $text + * @param int $length in characters + * @param string $ellipsis * @return string */ public static function truncateHtml($text, $length = 100, $ellipsis = '...') @@ -538,10 +607,9 @@ abstract class Utils /** * Truncate HTML by number of characters in a "word-safe" manor. * - * @param string $text - * @param int $length in words - * @param string $ellipsis - * + * @param string $text + * @param int $length in words + * @param string $ellipsis * @return string */ public static function safeTruncateHtml($text, $length = 25, $ellipsis = '...') @@ -553,7 +621,6 @@ abstract class Utils * Generate a random string of a given length * * @param int $length - * * @return string */ public static function generateRandomString($length = 5) @@ -566,9 +633,9 @@ abstract class Utils * * @param string $file the full path to the file to be downloaded * @param bool $force_download as opposed to letting browser choose if to download or render - * @param int $sec Throttling, try 0.1 for some speed throttling of downloads - * @param int $bytes Size of chunks to send in bytes. Default is 1024 - * @throws \Exception + * @param int $sec Throttling, try 0.1 for some speed throttling of downloads + * @param int $bytes Size of chunks to send in bytes. Default is 1024 + * @throws Exception */ public static function download($file, $force_download = true, $sec = 0, $bytes = 1024) { @@ -578,7 +645,7 @@ abstract class Utils $file_parts = pathinfo($file); $mimetype = static::getMimeByExtension($file_parts['extension']); - $size = filesize($file); // File size + $size = filesize($file); // File size // clean all buffers while (ob_get_level()) { @@ -600,9 +667,9 @@ abstract class Utils // multipart-download and download resuming support if (isset($_SERVER['HTTP_RANGE'])) { - list($a, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2); - list($range) = explode(',', $range, 2); - list($range, $range_end) = explode('-', $range); + [$a, $range] = explode('=', $_SERVER['HTTP_RANGE'], 2); + [$range] = explode(',', $range, 2); + [$range, $range_end] = explode('-', $range); $range = (int)$range; if (!$range_end) { $range_end = $size - 1; @@ -630,8 +697,7 @@ abstract class Utils // Return 304 Not Modified if the file is already cached in the browser if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && - strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file)) - { + strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file)) { header('HTTP/1.1 304 Not Modified'); exit(); } @@ -647,7 +713,7 @@ abstract class Utils if ($range) { fseek($fp, $range); } - while (!feof($fp) && (!connection_aborted()) && ($bytes_send < $new_length) ) { + while (!feof($fp) && (!connection_aborted()) && ($bytes_send < $new_length)) { $buffer = fread($fp, $chunksize); echo($buffer); //echo($buffer); // is also possible flush(); @@ -656,19 +722,51 @@ abstract class Utils } fclose($fp); } else { - throw new \RuntimeException('Error - can not open file.'); + throw new RuntimeException('Error - can not open file.'); } exit; } } + /** + * Returns the output render format, usually the extension provided in the URL. (e.g. `html`, `json`, `xml`, etc). + * + * @return string + */ + public static function getPageFormat(): string + { + /** @var Uri $uri */ + $uri = Grav::instance()['uri']; + + // Set from uri extension + $uri_extension = $uri->extension(); + if (is_string($uri_extension) && $uri->isValidExtension($uri_extension)) { + return ($uri_extension); + } + + // Use content negotiation via the `accept:` header + $http_accept = $_SERVER['HTTP_ACCEPT'] ?? null; + if (is_string($http_accept)) { + $negotiator = new Negotiator(); + + $supported_types = Utils::getSupportPageTypes(['html', 'json']); + $priorities = Utils::getMimeTypes($supported_types); + + $media_type = $negotiator->getBest($http_accept, $priorities); + $mimetype = $media_type instanceof Accept ? $media_type->getValue() : ''; + + return Utils::getExtensionByMime($mimetype); + } + + return 'html'; + } + /** * Return the mimetype based on filename extension * * @param string $extension Extension of file (eg "txt") * @param string $default - * * @return string */ public static function getMimeByExtension($extension, $default = 'application/octet-stream') @@ -726,7 +824,6 @@ abstract class Utils * * @param string $mime mime type (eg "text/html") * @param string $default default value - * * @return string */ public static function getExtensionByMime($mime, $default = 'html') @@ -774,7 +871,7 @@ abstract class Utils $extensions = []; foreach ($mimetypes as $mimetype) { $extension = static::getExtensionByMime($mimetype, false); - if ($extension && !\in_array($extension, $extensions, true)) { + if ($extension && !in_array($extension, $extensions, true)) { $extensions[] = $extension; } } @@ -787,7 +884,6 @@ abstract class Utils * * @param string $filename Filename or path to file * @param string $default default value - * * @return string */ public static function getMimeByFilename($filename, $default = 'application/octet-stream') @@ -799,7 +895,7 @@ abstract class Utils * Return the mimetype based on existing local file * * @param string $filename Path to the file - * + * @param string $default * @return string|bool */ public static function getMimeByLocalFile($filename, $default = 'application/octet-stream') @@ -812,7 +908,7 @@ abstract class Utils } // Prefer using finfo if it exists. - if (\extension_loaded('fileinfo')) { + if (extension_loaded('fileinfo')) { $finfo = finfo_open(FILEINFO_SYMLINK | FILEINFO_MIME_TYPE); $type = finfo_file($finfo, $filename); finfo_close($finfo); @@ -837,11 +933,7 @@ abstract class Utils public static function checkFilename($filename) { $dangerous_extensions = Grav::instance()['config']->get('security.uploads_dangerous_extensions', []); - array_walk($dangerous_extensions, function(&$val) { - $val = '.' . $val; - }); - - $extension = '.' . pathinfo($filename, PATHINFO_EXTENSION); + $extension = pathinfo($filename, PATHINFO_EXTENSION); return !( // Empty filenames are not allowed. @@ -850,8 +942,8 @@ abstract class Utils || strtr($filename, "\t\v\n\r\0\\/", '_______') !== $filename // Filename should not start or end with dot or space. || trim($filename, '. ') !== $filename - // Filename should not contain .php in it. - || static::contains($extension, $dangerous_extensions) + // File extension should not be part of configured dangerous extensions + || in_array($extension, $dangerous_extensions) ); } @@ -859,7 +951,6 @@ abstract class Utils * Normalize path by processing relative `.` and `..` syntax and merging path * * @param string $path - * * @return string */ public static function normalizePath($path) @@ -880,13 +971,13 @@ abstract class Utils } // Strip off leading / to ensure explode is accurate - if (Utils::startsWith($path,'/')) { + if (static::startsWith($path, '/')) { $root .= '/'; $path = ltrim($path, '/'); } // If there are any relative paths (..) handle those - if (Utils::contains($path, '..')) { + if (static::contains($path, '..')) { $segments = explode('/', trim($path, '/')); $ret = []; foreach ($segments as $segment) { @@ -904,19 +995,44 @@ abstract class Utils // Stick everything back together $normalized = $root . $path; + return $normalized; } + /** + * Check whether a function exists. + * + * Disabled functions count as non-existing functions, just like in PHP 8+. + * + * @param string $function the name of the function to check + * @return bool + */ + public static function functionExists($function): bool + { + if (!function_exists($function)) { + return false; + } + + // In PHP 7 we need to also exclude disabled methods. + return !static::isFunctionDisabled($function); + } + /** * Check whether a function is disabled in the PHP settings * * @param string $function the name of the function to check - * * @return bool */ - public static function isFunctionDisabled($function) + public static function isFunctionDisabled($function): bool { - return \in_array($function, explode(',', ini_get('disable_functions')), true); + static $list; + + if (null === $list) { + $str = trim(ini_get('disable_functions') . ',' . ini_get('suhosin.executor.func.blacklist'), ','); + $list = $str ? array_flip(preg_split('/\s*,\s*/', $str)) : []; + } + + return array_key_exists($function, $list); } /** @@ -926,12 +1042,12 @@ abstract class Utils */ public static function timezones() { - $timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL); + $timezones = DateTimeZone::listIdentifiers(DateTimeZone::ALL); $offsets = []; - $testDate = new \DateTime(); + $testDate = new DateTime(); foreach ($timezones as $zone) { - $tz = new \DateTimeZone($zone); + $tz = new DateTimeZone($zone); $offsets[$zone] = $tz->getOffset($testDate); } @@ -944,7 +1060,7 @@ abstract class Utils $pretty_offset = "UTC${offset_prefix}${offset_formatted}"; - $timezone_list[$timezone] = "(${pretty_offset}) ".str_replace('_', ' ', $timezone); + $timezone_list[$timezone] = "(${pretty_offset}) " . str_replace('_', ' ', $timezone); } return $timezone_list; @@ -953,12 +1069,11 @@ abstract class Utils /** * Recursively filter an array, filtering values by processing them through the $fn function argument * - * @param array $source the Array to filter - * @param callable $fn the function to pass through each array item - * + * @param array $source the Array to filter + * @param callable $fn the function to pass through each array item * @return array */ - public static function arrayFilterRecursive(Array $source, $fn) + public static function arrayFilterRecursive(array $source, $fn) { $result = []; foreach ($source as $key => $value) { @@ -975,6 +1090,29 @@ abstract class Utils return $result; } + /** + * Flatten a multi-dimensional associative array into query params. + * + * @param array $array + * @param string $prepend + * @return array + */ + public static function arrayToQueryParams($array, $prepend = '') + { + $results = []; + foreach ($array as $key => $value) { + $name = $prepend ? $prepend . '[' . $key . ']' : $key; + + if (is_array($value)) { + $results = array_merge($results, static::arrayToQueryParams($value, $name)); + } else { + $results[$name] = $value; + } + } + + return $results; + } + /** * Flatten an array * @@ -983,7 +1121,7 @@ abstract class Utils */ public static function arrayFlatten($array) { - $flatten = array(); + $flatten = []; foreach ($array as $key => $inner) { if (is_array($inner)) { foreach ($inner as $inner_key => $value) { @@ -1000,8 +1138,8 @@ abstract class Utils /** * Flatten a multi-dimensional associative array into dot notation * - * @param array $array - * @param string $prepend + * @param array $array + * @param string $prepend * @return array */ public static function arrayFlattenDotNotation($array, $prepend = '') @@ -1009,9 +1147,9 @@ abstract class Utils $results = array(); foreach ($array as $key => $value) { if (is_array($value)) { - $results = array_merge($results, static::arrayFlattenDotNotation($value, $prepend.$key.'.')); + $results = array_merge($results, static::arrayFlattenDotNotation($value, $prepend . $key . '.')); } else { - $results[$prepend.$key] = $value; + $results[$prepend . $key] = $value; } } @@ -1019,7 +1157,17 @@ abstract class Utils } /** - * Opposite of flatten, convert flat dot notation array to multi dimensional array + * Opposite of flatten, convert flat dot notation array to multi dimensional array. + * + * If any of the parent has a scalar value, all children get ignored: + * + * admin.pages=true + * admin.pages.read=true + * + * becomes + * + * admin: + * pages: true * * @param array $array * @param string $separator @@ -1030,14 +1178,26 @@ abstract class Utils $newArray = []; foreach ($array as $key => $value) { $dots = explode($separator, $key); - if (\count($dots) > 1) { + if (count($dots) > 1) { $last = &$newArray[$dots[0]]; foreach ($dots as $k => $dot) { if ($k === 0) { continue; } + + // Cannot use a scalar value as an array + if (null !== $last && !is_array($last)) { + continue 2; + } + $last = &$last[$dot]; } + + // Cannot use a scalar value as an array + if (null !== $last && !is_array($last)) { + continue; + } + $last = $value; } else { $newArray[$key] = $value; @@ -1063,7 +1223,6 @@ abstract class Utils if (count($parts) > 0 && in_array($parts[0], $languages_enabled)) { return $parts[0]; } - return false; } @@ -1072,7 +1231,7 @@ abstract class Utils * * @param string $date a String expressed in the system.pages.dateformat.default format, with fallback to a * strtotime argument - * @param string $format a date format to use if possible + * @param string|null $format a date format to use if possible * @return int the timestamp */ public static function date2timestamp($date, $format = null) @@ -1082,9 +1241,9 @@ abstract class Utils // try to use DateTime and default format if ($dateformat) { - $datetime = \DateTime::createFromFormat($dateformat, $date); + $datetime = DateTime::createFromFormat($dateformat, $date); } else { - $datetime = new \DateTime($date); + $datetime = new DateTime($date); } // fallback to strtotime() if DateTime approach failed @@ -1111,25 +1270,34 @@ abstract class Utils } /** - * Checks if a value is positive + * Checks if a value is positive (true) * * @param string $value - * - * @return boolean + * @return bool */ public static function isPositive($value) { return in_array($value, [true, 1, '1', 'yes', 'on', 'true'], true); } + /** + * Checks if a value is negative (false) + * + * @param string $value + * @return bool + */ + public static function isNegative($value) + { + return in_array($value, [false, 0, '0', 'no', 'off', 'false'], true); + } + /** * Generates a nonce string to be hashed. Called by self::getNonce() * We removed the IP portion in this version because it causes too many inconsistencies * with reverse proxy setups. * * @param string $action - * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours) - * + * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours) * @return string the nonce string */ private static function generateNonceString($action, $previousTick = false) @@ -1166,9 +1334,8 @@ abstract class Utils * Creates a hashed nonce tied to the passed action. Tied to the current user and time. The nonce for a given * action is the same for 12 hours. * - * @param string $action the action the nonce is tied to (e.g. save-user-admin or move-page-homepage) - * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours) - * + * @param string $action the action the nonce is tied to (e.g. save-user-admin or move-page-homepage) + * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours) * @return string the nonce */ public static function getNonce($action, $previousTick = false) @@ -1186,9 +1353,8 @@ abstract class Utils /** * Verify the passed nonce for the give action * - * @param string|string[] $nonce the nonce to verify + * @param string|string[] $nonce the nonce to verify * @param string $action the action to verify the nonce to - * * @return boolean verified or not */ public static function verifyNonce($nonce, $action) @@ -1204,9 +1370,7 @@ abstract class Utils } //Nonce generated 12-24 hours ago - $previousTick = true; - - return $nonce === self::getNonce($action, $previousTick); + return $nonce === self::getNonce($action, true); } /** @@ -1216,18 +1380,14 @@ abstract class Utils */ public static function isAdminPlugin() { - if (isset(Grav::instance()['admin'])) { - return true; - } - - return false; + return isset(Grav::instance()['admin']); } /** * Get a portion of an array (passed by reference) with dot-notation key * * @param array $array - * @param string|int $key + * @param string|int|null $key * @param null $default * @return mixed */ @@ -1256,10 +1416,10 @@ abstract class Utils * Set portion of array (passed by reference) for a dot-notation key * and set the value * - * @param array $array - * @param string|int $key - * @param mixed $value - * @param bool $merge + * @param array $array + * @param string|int|null $key + * @param mixed $value + * @param bool $merge * * @return mixed */ @@ -1274,8 +1434,7 @@ abstract class Utils while (count($keys) > 1) { $key = array_shift($keys); - if ( ! isset($array[$key]) || ! is_array($array[$key])) - { + if (!isset($array[$key]) || !is_array($array[$key])) { $array[$key] = array(); } @@ -1308,7 +1467,8 @@ abstract class Utils * * @return bool */ - public static function isApache() { + public static function isApache() + { return isset($_SERVER['SERVER_SOFTWARE']) && strpos($_SERVER['SERVER_SOFTWARE'], 'Apache') !== false; } @@ -1321,7 +1481,7 @@ abstract class Utils */ public static function sortArrayByArray(array $array, array $orderArray) { - $ordered = array(); + $ordered = []; foreach ($orderArray as $key) { if (array_key_exists($key, $array)) { $ordered[$key] = $array[$key]; @@ -1358,58 +1518,136 @@ abstract class Utils } /** - * Get path based on a token + * Get relative page path based on a token. * * @param string $path * @param PageInterface|null $page * @return string - * @throws \RuntimeException + * @throws RuntimeException */ public static function getPagePathFromToken($path, PageInterface $page = null) { - $path_parts = pathinfo($path); - $grav = Grav::instance(); - - $basename = ''; - if (isset($path_parts['extension'])) { - $basename = '/' . $path_parts['basename']; - $path = rtrim($path_parts['dirname'], ':'); - } - - $regex = '/(@self|self@)|((?:@page|page@):(?:.*))|((?:@theme|theme@):(?:.*))/'; - preg_match($regex, $path, $matches); - - if ($matches) { - if ($matches[1]) { - if (null === $page) { - throw new \RuntimeException('Page not available for this self@ reference'); - } - } elseif ($matches[2]) { - // page@ - $parts = explode(':', $path); - $route = $parts[1]; - $page = $grav['page']->find($route); - } elseif ($matches[3]) { - // theme@ - $parts = explode(':', $path); - $route = $parts[1]; - $theme = str_replace(ROOT_DIR, '', $grav['locator']->findResource("theme://")); - - return $theme . $route . $basename; - } - } else { - return $path . $basename; - } - - if (!$page) { - throw new \RuntimeException('Page route not found: ' . $path); - } - - $path = str_replace($matches[0], rtrim($page->relativePagePath(), '/'), $path); - - return $path . $basename; + return static::getPathFromToken($path, $page); } + /** + * Get relative path based on a token. + * + * Path supports following syntaxes: + * + * 'self@', 'self@/path' + * 'page@:/route', 'page@:/route/filename.ext' + * 'theme@:', 'theme@:/path' + * + * @param string $path + * @param FlexObjectInterface|PageInterface|null $object + * @return string + * @throws RuntimeException + */ + public static function getPathFromToken($path, $object = null) + { + $matches = static::resolveTokenPath($path); + if (null === $matches) { + return $path; + } + + $grav = Grav::instance(); + + switch ($matches[0]) { + case 'self': + if (null === $object) { + throw new RuntimeException(sprintf('Page not available for self@ reference: %s', $path)); + } + + if ($matches[2] === '') { + if ($object->exists()) { + $route = '/' . $matches[1]; + + if ($object instanceof PageInterface) { + return trim($object->relativePagePath() . $route, '/'); + } + + $folder = $object->getMediaFolder(); + if ($folder) { + return trim($folder . $route, '/'); + } + } else { + return ''; + } + } + + break; + case 'page': + if ($matches[1] === '') { + $route = '/' . $matches[2]; + + // Exclude filename from the page lookup. + if (pathinfo($route, PATHINFO_EXTENSION)) { + $basename = '/' . basename($route); + $route = \dirname($route); + } else { + $basename = ''; + } + + $key = trim($route === '/' ? $grav['config']->get('system.home.alias') : $route, '/'); + if ($object instanceof PageObject) { + $object = $object->getFlexDirectory()->getObject($key); + } elseif (static::isAdminPlugin()) { + /** @var Flex|null $flex */ + $flex = $grav['flex'] ?? null; + $object = $flex ? $flex->getObject($key, 'pages') : null; + } else { + /** @var Pages $pages */ + $pages = $grav['pages']; + $object = $pages->find($route); + } + + if ($object instanceof PageInterface) { + return trim($object->relativePagePath() . $basename, '/'); + } + } + + break; + case 'theme': + if ($matches[1] === '') { + $route = '/' . $matches[2]; + $theme = $grav['locator']->findResource('theme://', false); + if (false !== $theme) { + return trim($theme . $route, '/'); + } + } + + break; + } + + throw new RuntimeException(sprintf('Token path not found: %s', $path)); + } + + /** + * Returns [token, route, path] from '@token/route:/path'. Route and path are optional. If pattern does not match, return null. + * + * @param string $path + * @return string[]|null + */ + private static function resolveTokenPath(string $path): ?array + { + if (strpos($path, '@') !== false) { + $regex = '/^(@\w+|\w+@|@\w+@)([^:]*)(.*)$/u'; + if (preg_match($regex, $path, $matches)) { + return [ + trim($matches[1], '@'), + trim($matches[2], '/'), + trim($matches[3], ':/') + ]; + } + } + + return null; + } + + /** + * @return int + */ public static function getUploadLimit() { static $max_size = -1; @@ -1437,7 +1675,6 @@ abstract class Utils * @param int $bytes The filesize in Bytes. * @param string $to The unit type to convert to. Accepts K, M, or G for Kilobytes, Megabytes, or Gigabytes, respectively. * @param int $decimal_places The number of decimal places to return. - * * @return int Returns only the number of units, not the type letter. Returns 0 if the $to unit type is out of scope. * */ @@ -1476,19 +1713,19 @@ abstract class Utils /** * Parse a readable file size and return a value in bytes * - * @param string|int $size + * @param string|int|float $size * @return int */ public static function parseSize($size) { $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); - $size = preg_replace('/[^0-9\.]/', '', $size); + $size = (float)preg_replace('/[^0-9\.]/', '', $size); if ($unit) { - $size = $size * pow(1024, stripos('bkmgtpezy', $unit[0])); + $size *= 1024 ** stripos('bkmgtpezy', $unit[0]); } - return (int) abs(round($size)); + return (int)abs(round($size)); } /** @@ -1496,7 +1733,7 @@ abstract class Utils * * @param string $url * @return array - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public static function multibyteParseUrl($url) { @@ -1510,11 +1747,11 @@ abstract class Utils $parts = parse_url($enc_url); - if($parts === false) { - throw new \InvalidArgumentException('Malformed URL: ' . $url); + if ($parts === false) { + throw new InvalidArgumentException('Malformed URL: ' . $url); } - foreach($parts as $name => $value) { + foreach ($parts as $name => $value) { $parts[$name] = urldecode($value); } @@ -1525,16 +1762,15 @@ abstract class Utils * Process a string as markdown * * @param string $string - * * @param bool $block Block or Line processing * @param null $page * @return string - * @throws \Exception + * @throws Exception */ public static function processMarkdown($string, $block = true, $page = null) { $grav = Grav::instance(); - $page = $page ?? $grav['page'] ?? null; + $page = $page ?? $grav['page'] ?? null; $defaults = [ 'markdown' => $grav['config']->get('system.pages.markdown', []), 'images' => $grav['config']->get('system.images', []) @@ -1564,7 +1800,6 @@ abstract class Utils * * @param string $ip * @param int $prefix - * * @return string */ public static function getSubnet($ip, $prefix = 64) @@ -1574,21 +1809,28 @@ abstract class Utils } // Packed representation of IP - $ip = inet_pton($ip); + $ip = (string)inet_pton($ip); // Maximum netmask length = same as packed address - $len = 8*strlen($ip); - if ($prefix > $len) $prefix = $len; - - $mask = str_repeat('f', $prefix>>2); - - switch($prefix & 3) - { - case 3: $mask .= 'e'; break; - case 2: $mask .= 'c'; break; - case 1: $mask .= '8'; break; + $len = 8 * strlen($ip); + if ($prefix > $len) { + $prefix = $len; } - $mask = str_pad($mask, $len>>2, '0'); + + $mask = str_repeat('f', $prefix >> 2); + + switch ($prefix & 3) { + case 3: + $mask .= 'e'; + break; + case 2: + $mask .= 'c'; + break; + case 1: + $mask .= '8'; + break; + } + $mask = str_pad($mask, $len >> 2, '0'); // Packed representation of netmask $mask = pack('H*', $mask); @@ -1602,11 +1844,14 @@ abstract class Utils * Wrapper to ensure html, htm in the front of the supported page types * * @param array|null $defaults - * @return array|mixed + * @return array */ public static function getSupportPageTypes(array $defaults = null) { $types = Grav::instance()['config']->get('system.pages.types', $defaults); + if (!is_array($types)) { + return []; + } // remove html/htm $types = static::arrayRemoveValue($types, ['html', 'htm']); @@ -1616,4 +1861,244 @@ abstract class Utils return $types; } + + /** + * @param string $name + * @return bool + */ + public static function isDangerousFunction(string $name): bool + { + static $commandExecutionFunctions = [ + 'exec', + 'passthru', + 'system', + 'shell_exec', + 'popen', + 'proc_open', + 'pcntl_exec', + ]; + + static $codeExecutionFunctions = [ + 'assert', + 'preg_replace', + 'create_function', + 'include', + 'include_once', + 'require', + 'require_once' + ]; + + static $callbackFunctions = [ + 'ob_start' => 0, + 'array_diff_uassoc' => -1, + 'array_diff_ukey' => -1, + 'array_filter' => 1, + 'array_intersect_uassoc' => -1, + 'array_intersect_ukey' => -1, + 'array_map' => 0, + 'array_reduce' => 1, + 'array_udiff_assoc' => -1, + 'array_udiff_uassoc' => [-1, -2], + 'array_udiff' => -1, + 'array_uintersect_assoc' => -1, + 'array_uintersect_uassoc' => [-1, -2], + 'array_uintersect' => -1, + 'array_walk_recursive' => 1, + 'array_walk' => 1, + 'assert_options' => 1, + 'uasort' => 1, + 'uksort' => 1, + 'usort' => 1, + 'preg_replace_callback' => 1, + 'spl_autoload_register' => 0, + 'iterator_apply' => 1, + 'call_user_func' => 0, + 'call_user_func_array' => 0, + 'register_shutdown_function' => 0, + 'register_tick_function' => 0, + 'set_error_handler' => 0, + 'set_exception_handler' => 0, + 'session_set_save_handler' => [0, 1, 2, 3, 4, 5], + 'sqlite_create_aggregate' => [2, 3], + 'sqlite_create_function' => 2, + ]; + + static $informationDiscosureFunctions = [ + 'phpinfo', + 'posix_mkfifo', + 'posix_getlogin', + 'posix_ttyname', + 'getenv', + 'get_current_user', + 'proc_get_status', + 'get_cfg_var', + 'disk_free_space', + 'disk_total_space', + 'diskfreespace', + 'getcwd', + 'getlastmo', + 'getmygid', + 'getmyinode', + 'getmypid', + 'getmyuid' + ]; + + static $otherFunctions = [ + 'extract', + 'parse_str', + 'putenv', + 'ini_set', + 'mail', + 'header', + 'proc_nice', + 'proc_terminate', + 'proc_close', + 'pfsockopen', + 'fsockopen', + 'apache_child_terminate', + 'posix_kill', + 'posix_mkfifo', + 'posix_setpgid', + 'posix_setsid', + 'posix_setuid', + ]; + + if (in_array($name, $commandExecutionFunctions)) { + return true; + } + + if (in_array($name, $codeExecutionFunctions)) { + return true; + } + + if (isset($callbackFunctions[$name])) { + return true; + } + + if (in_array($name, $informationDiscosureFunctions)) { + return true; + } + + if (in_array($name, $otherFunctions)) { + return true; + } + + return static::isFilesystemFunction($name); + } + + /** + * @param string $name + * @return bool + */ + public static function isFilesystemFunction(string $name): bool + { + static $fileWriteFunctions = [ + 'fopen', + 'tmpfile', + 'bzopen', + 'gzopen', + // write to filesystem (partially in combination with reading) + 'chgrp', + 'chmod', + 'chown', + 'copy', + 'file_put_contents', + 'lchgrp', + 'lchown', + 'link', + 'mkdir', + 'move_uploaded_file', + 'rename', + 'rmdir', + 'symlink', + 'tempnam', + 'touch', + 'unlink', + 'imagepng', + 'imagewbmp', + 'image2wbmp', + 'imagejpeg', + 'imagexbm', + 'imagegif', + 'imagegd', + 'imagegd2', + 'iptcembed', + 'ftp_get', + 'ftp_nb_get', + ]; + + static $fileContentFunctions = [ + 'file_get_contents', + 'file', + 'filegroup', + 'fileinode', + 'fileowner', + 'fileperms', + 'glob', + 'is_executable', + 'is_uploaded_file', + 'parse_ini_file', + 'readfile', + 'readlink', + 'realpath', + 'gzfile', + 'readgzfile', + 'stat', + 'imagecreatefromgif', + 'imagecreatefromjpeg', + 'imagecreatefrompng', + 'imagecreatefromwbmp', + 'imagecreatefromxbm', + 'imagecreatefromxpm', + 'ftp_put', + 'ftp_nb_put', + 'hash_update_file', + 'highlight_file', + 'show_source', + 'php_strip_whitespace', + ]; + + static $filesystemFunctions = [ + // read from filesystem + 'file_exists', + 'fileatime', + 'filectime', + 'filemtime', + 'filesize', + 'filetype', + 'is_dir', + 'is_file', + 'is_link', + 'is_readable', + 'is_writable', + 'is_writeable', + 'linkinfo', + 'lstat', + //'pathinfo', + 'getimagesize', + 'exif_read_data', + 'read_exif_data', + 'exif_thumbnail', + 'exif_imagetype', + 'hash_file', + 'hash_hmac_file', + 'md5_file', + 'sha1_file', + 'get_meta_tags', + ]; + + if (in_array($name, $fileWriteFunctions)) { + return true; + } + + if (in_array($name, $fileContentFunctions)) { + return true; + } + + if (in_array($name, $filesystemFunctions)) { + return true; + } + + return false; + } } diff --git a/system/src/Grav/Common/Yaml.php b/system/src/Grav/Common/Yaml.php index ad6d81b..5adbd0c 100644 --- a/system/src/Grav/Common/Yaml.php +++ b/system/src/Grav/Common/Yaml.php @@ -3,7 +3,7 @@ /** * @package Grav\Common * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,11 +11,19 @@ namespace Grav\Common; use Grav\Framework\File\Formatter\YamlFormatter; +/** + * Class Yaml + * @package Grav\Common + */ abstract class Yaml { /** @var YamlFormatter */ private static $yaml; + /** + * @param string $data + * @return array + */ public static function parse($data) { if (null === static::$yaml) { @@ -25,6 +33,12 @@ abstract class Yaml return static::$yaml->decode($data); } + /** + * @param array $data + * @param int|null $inline + * @param int|null $indent + * @return string + */ public static function dump($data, $inline = null, $indent = null) { if (null === static::$yaml) { @@ -34,6 +48,9 @@ abstract class Yaml return static::$yaml->encode($data, $inline, $indent); } + /** + * @return void + */ private static function init() { $config = [ diff --git a/system/src/Grav/Console/Application/Application.php b/system/src/Grav/Console/Application/Application.php new file mode 100644 index 0000000..21cea87 --- /dev/null +++ b/system/src/Grav/Console/Application/Application.php @@ -0,0 +1,106 @@ +environment = $input->getOption('env'); + $this->language = $input->getOption('lang') ?? $this->language; + $this->init(); + + return parent::getCommandName($input); + } + + /** + * @return void + */ + protected function init(): void + { + if ($this->initialized) { + return; + } + + $this->initialized = true; + + $grav = Grav::instance(); + $grav->setup($this->environment); + } + + /** + * Add global a --env option. + * + * @return InputDefinition + */ + protected function getDefaultInputDefinition(): InputDefinition + { + $inputDefinition = parent::getDefaultInputDefinition(); + $inputDefinition->addOption( + new InputOption( + 'env', + null, + InputOption::VALUE_OPTIONAL, + 'Use environment configuration (defaults to localhost)' + ) + ); + $inputDefinition->addOption( + new InputOption( + 'lang', + null, + InputOption::VALUE_OPTIONAL, + 'Language to be used (defaults to en)' + ) + ); + + return $inputDefinition; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return void + */ + protected function configureIO(InputInterface $input, OutputInterface $output) + { + $formatter = $output->getFormatter(); + $formatter->setStyle('normal', new OutputFormatterStyle('white')); + $formatter->setStyle('yellow', new OutputFormatterStyle('yellow', null, ['bold'])); + $formatter->setStyle('red', new OutputFormatterStyle('red', null, ['bold'])); + $formatter->setStyle('cyan', new OutputFormatterStyle('cyan', null, ['bold'])); + $formatter->setStyle('green', new OutputFormatterStyle('green', null, ['bold'])); + $formatter->setStyle('magenta', new OutputFormatterStyle('magenta', null, ['bold'])); + $formatter->setStyle('white', new OutputFormatterStyle('white', null, ['bold'])); + + parent::configureIO($input, $output); + } +} diff --git a/system/src/Grav/Console/Application/CommandLoader/PluginCommandLoader.php b/system/src/Grav/Console/Application/CommandLoader/PluginCommandLoader.php new file mode 100644 index 0000000..9b7b568 --- /dev/null +++ b/system/src/Grav/Console/Application/CommandLoader/PluginCommandLoader.php @@ -0,0 +1,95 @@ +commands = []; + + try { + $path = "plugins://{$name}/cli"; + $pattern = '([A-Z]\w+Command\.php)'; + + $commands = is_dir($path) ? Folder::all($path, ['compare' => 'Filename', 'pattern' => '/' . $pattern . '$/usm', 'levels' => 1]) : []; + } catch (RuntimeException $e) { + throw new RuntimeException("Failed to load console commands for plugin {$name}"); + } + + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + foreach ($commands as $command_path) { + $full_path = $locator->findResource("plugins://{$name}/cli/{$command_path}"); + require_once $full_path; + + $command_class = 'Grav\Plugin\Console\\' . preg_replace('/.php$/', '', $command_path); + if (class_exists($command_class)) { + $command = new $command_class(); + if ($command instanceof Command) { + $this->commands[$command->getName()] = $command; + } + } + } + } + + /** + * @param string $name + * @return Command + */ + public function get($name): Command + { + $command = $this->commands[$name] ?? null; + if (null === $command) { + throw new CommandNotFoundException(sprintf('The command "%s" does not exist.', $name)); + } + + return $command; + } + + /** + * @param string $name + * @return bool + */ + public function has($name): bool + { + return isset($this->commands[$name]); + } + + /** + * @return string[] + */ + public function getNames(): array + { + return array_keys($this->commands); + } +} diff --git a/system/src/Grav/Console/Application/GpmApplication.php b/system/src/Grav/Console/Application/GpmApplication.php new file mode 100644 index 0000000..df383f2 --- /dev/null +++ b/system/src/Grav/Console/Application/GpmApplication.php @@ -0,0 +1,42 @@ +addCommands([ + new IndexCommand(), + new VersionCommand(), + new InfoCommand(), + new InstallCommand(), + new UninstallCommand(), + new UpdateCommand(), + new SelfupgradeCommand(), + new DirectInstallCommand(), + ]); + } +} diff --git a/system/src/Grav/Console/Application/GravApplication.php b/system/src/Grav/Console/Application/GravApplication.php new file mode 100644 index 0000000..739ca39 --- /dev/null +++ b/system/src/Grav/Console/Application/GravApplication.php @@ -0,0 +1,52 @@ +addCommands([ + new InstallCommand(), + new ComposerCommand(), + new SandboxCommand(), + new CleanCommand(), + new ClearCacheCommand(), + new BackupCommand(), + new NewProjectCommand(), + new SchedulerCommand(), + new SecurityCommand(), + new LogViewerCommand(), + new YamlLinterCommand(), + new ServerCommand(), + new PageSystemValidatorCommand(), + ]); + } +} diff --git a/system/src/Grav/Console/Application/PluginApplication.php b/system/src/Grav/Console/Application/PluginApplication.php new file mode 100644 index 0000000..75e9eb7 --- /dev/null +++ b/system/src/Grav/Console/Application/PluginApplication.php @@ -0,0 +1,116 @@ +addCommands([ + new PluginListCommand(), + ]); + } + + /** + * @param string $pluginName + * @return void + */ + public function setPluginName(string $pluginName): void + { + $this->pluginName = $pluginName; + } + + /** + * @return string + */ + public function getPluginName(): string + { + return $this->pluginName; + } + + /** + * @param InputInterface|null $input + * @param OutputInterface|null $output + * @return int + * @throws Throwable + */ + public function run(InputInterface $input = null, OutputInterface $output = null): int + { + if (null === $input) { + $argv = $_SERVER['argv'] ?? []; + + $bin = array_shift($argv); + $this->pluginName = array_shift($argv); + $argv = array_merge([$bin], $argv); + + $input = new ArgvInput($argv); + } + + return parent::run($input, $output); + } + + /** + * @return void + */ + protected function init(): void + { + if ($this->initialized) { + return; + } + + parent::init(); + + if (null === $this->pluginName) { + $this->setDefaultCommand('plugins:list'); + + return; + } + + $grav = Grav::instance(); + $grav->initializeCli(); + + /** @var Plugins $plugins */ + $plugins = $grav['plugins']; + + $plugin = $this->pluginName ? $plugins::get($this->pluginName) : null; + if (null === $plugin) { + throw new NamespaceNotFoundException("Plugin \"{$this->pluginName}\" is not installed."); + } + if (!$plugin->enabled) { + throw new NamespaceNotFoundException("Plugin \"{$this->pluginName}\" is not enabled."); + } + + $this->setCommandLoader(new PluginCommandLoader($this->pluginName)); + } +} diff --git a/system/src/Grav/Console/Cli/BackupCommand.php b/system/src/Grav/Console/Cli/BackupCommand.php index 54a26cf..a8a025d 100644 --- a/system/src/Grav/Console/Cli/BackupCommand.php +++ b/system/src/Grav/Console/Cli/BackupCommand.php @@ -3,7 +3,7 @@ /** * @package Grav\Console\Cli * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,21 +11,28 @@ namespace Grav\Console\Cli; use Grav\Common\Backup\Backups; use Grav\Common\Grav; -use Grav\Console\ConsoleCommand; +use Grav\Console\GravCommand; use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Question\ChoiceQuestion; -use Symfony\Component\Console\Style\SymfonyStyle; +use ZipArchive; +use function count; -class BackupCommand extends ConsoleCommand +/** + * Class BackupCommand + * @package Grav\Console\Cli + */ +class BackupCommand extends GravCommand { /** @var string $source */ protected $source; - /** @var ProgressBar $progress */ protected $progress; - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('backup') @@ -33,7 +40,6 @@ class BackupCommand extends ConsoleCommand 'id', InputArgument::OPTIONAL, 'The ID of the backup profile to perform without prompting' - ) ->setDescription('Creates a backup of the Grav instance') ->setHelp('The backup creates a zipped backup.'); @@ -41,39 +47,50 @@ class BackupCommand extends ConsoleCommand $this->source = getcwd(); } - protected function serve() + /** + * @return int + */ + protected function serve(): int { - $this->progress = new ProgressBar($this->output); - $this->progress->setFormat('Archiving %current% files [%bar%] %percent:3s%% %elapsed:6s% %message%'); - $this->progress->setBarWidth(100); + $this->initializeGrav(); - Grav::instance()['config']->init(); + $input = $this->getInput(); + $io = $this->getIO(); - $io = new SymfonyStyle($this->input, $this->output); $io->title('Grav Backup'); + if (!class_exists(ZipArchive::class)) { + $io->error('php-zip extension needs to be enabled!'); + return 1; + } + + ProgressBar::setFormatDefinition('zip', 'Archiving %current% files [%bar%] %percent:3s%% %elapsed:6s% %message%'); + + $this->progress = new ProgressBar($this->output, 100); + $this->progress->setFormat('zip'); + + /** @var Backups $backups */ $backups = Grav::instance()['backups']; - $backups_list = $backups->getBackupProfiles(); + $backups_list = $backups::getBackupProfiles(); $backups_names = $backups->getBackupNames(); $id = null; - $inline_id = $this->input->getArgument('id'); + $inline_id = $input->getArgument('id'); if (null !== $inline_id && is_numeric($inline_id)) { $id = $inline_id; } if (null === $id) { - if (\count($backups_list) > 1) { - $helper = $this->getHelper('question'); + if (count($backups_list) > 1) { $question = new ChoiceQuestion( 'Choose a backup?', $backups_names, 0 ); $question->setErrorMessage('Option %s is invalid.'); - $backup_name = $helper->ask($this->input, $this->output, $question); + $backup_name = $io->askQuestion($question); $id = array_search($backup_name, $backups_names, true); $io->newLine(); @@ -83,16 +100,19 @@ class BackupCommand extends ConsoleCommand } } - $backup = $backups->backup($id, [$this, 'outputProgress']); + $backup = $backups::backup($id, function($args) { $this->outputProgress($args); }); $io->newline(2); $io->success('Backup Successfully Created: ' . $backup); + + return 0; } /** * @param array $args + * @return void */ - public function outputProgress($args) + public function outputProgress(array $args): void { switch ($args['type']) { case 'count': @@ -115,6 +135,4 @@ class BackupCommand extends ConsoleCommand break; } } - } - diff --git a/system/src/Grav/Console/Cli/CleanCommand.php b/system/src/Grav/Console/Cli/CleanCommand.php index a905973..b0c9499 100644 --- a/system/src/Grav/Console/Cli/CleanCommand.php +++ b/system/src/Grav/Console/Cli/CleanCommand.php @@ -3,7 +3,7 @@ /** * @package Grav\Console\Cli * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,14 +14,18 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Formatter\OutputFormatterStyle; +use Symfony\Component\Console\Style\SymfonyStyle; +/** + * Class CleanCommand + * @package Grav\Console\Cli + */ class CleanCommand extends Command { - /* @var InputInterface $output */ + /** @var InputInterface */ protected $input; - - /* @var OutputInterface $output */ - protected $output; + /** @var SymfonyStyle */ + protected $io; /** @var array */ protected $paths_to_remove = [ @@ -60,6 +64,11 @@ class CleanCommand extends Command 'user/plugins/email/vendor/swiftmailer/swiftmailer/doc', 'user/themes/antimatter/.sass-cache', 'vendor/antoligy/dom-string-iterators/composer.json', + 'vendor/composer/ca-bundle/composer.json', + 'vendor/composer/ca-bundle/phpstan.neon.dist', + 'vendor/composer/semver/CHANGELOG.md', + 'vendor/composer/semver/composer.json', + 'vendor/composer/semver/phpstan.neon.dist', 'vendor/doctrine/cache/.travis.yml', 'vendor/doctrine/cache/build.properties', 'vendor/doctrine/cache/build.xml', @@ -69,20 +78,35 @@ class CleanCommand extends Command 'vendor/doctrine/cache/.gitignore', 'vendor/doctrine/cache/.git', 'vendor/doctrine/cache/tests', + 'vendor/doctrine/cache/UPGRADE.md', + 'vendor/doctrine/collections/docs', + 'vendor/doctrine/collections/.doctrine-project.json', + 'vendor/doctrine/collections/CONTRIBUTING.md', + 'vendor/doctrine/collections/psalm.xml.dist', 'vendor/doctrine/collections/composer.json', 'vendor/doctrine/collections/phpunit.xml.dist', 'vendor/doctrine/collections/tests', 'vendor/donatj/phpuseragentparser/.git', + 'vendor/donatj/phpuseragentparser/.github', 'vendor/donatj/phpuseragentparser/.gitignore', 'vendor/donatj/phpuseragentparser/.editorconfig', 'vendor/donatj/phpuseragentparser/.travis.yml', 'vendor/donatj/phpuseragentparser/composer.json', 'vendor/donatj/phpuseragentparser/phpunit.xml.dist', - 'vendor/donatj/phpuseragentparser/Tests', + 'vendor/donatj/phpuseragentparser/tests', 'vendor/donatj/phpuseragentparser/Tools', + 'vendor/donatj/phpuseragentparser/CONTRIBUTING.md', + 'vendor/donatj/phpuseragentparser/Makefile', + 'vendor/donatj/phpuseragentparser/.mddoc.xml', 'vendor/dragonmantank/cron-expression/.editorconfig', 'vendor/dragonmantank/cron-expression/composer.json', 'vendor/dragonmantank/cron-expression/tests', + 'vendor/dragonmantank/cron-expression/CHANGELOG.md', + 'vendor/enshrined/svg-sanitize/tests', + 'vendor/enshrined/svg-sanitize/.gitignore', + 'vendor/enshrined/svg-sanitize/.travis.yml', + 'vendor/enshrined/svg-sanitize/composer.json', + 'vendor/enshrined/svg-sanitize/phpunit.xml', 'vendor/erusev/parsedown/composer.json', 'vendor/erusev/parsedown/phpunit.xml.dist', 'vendor/erusev/parsedown/.travis.yml', @@ -98,10 +122,12 @@ class CleanCommand extends Command 'vendor/filp/whoops/examples', 'vendor/filp/whoops/tests', 'vendor/filp/whoops/.git', + 'vendor/filp/whoops/.github', 'vendor/filp/whoops/.gitignore', 'vendor/filp/whoops/.scrutinizer.yml', 'vendor/filp/whoops/.travis.yml', 'vendor/filp/whoops/phpunit.xml.dist', + 'vendor/filp/whoops/CHANGELOG.md', 'vendor/gregwar/image/Gregwar/Image/composer.json', 'vendor/gregwar/image/Gregwar/Image/phpunit.xml', 'vendor/gregwar/image/Gregwar/Image/phpunit.xml.dist', @@ -124,20 +150,23 @@ class CleanCommand extends Command 'vendor/gregwar/cache/Gregwar/Cache/tests', 'vendor/guzzlehttp/psr7/composer.json', 'vendor/guzzlehttp/psr7/.editorconfig', - 'vendor/kodus/psr7-server/composer.json', - 'vendor/ircmaxell/password-compat/composer.json', - 'vendor/ircmaxell/password-compat/phpunit.xml.dist', - 'vendor/ircmaxell/password-compat/version-test.php', - 'vendor/ircmaxell/password-compat/.travis.yml', - 'vendor/ircmaxell/password-compat/test', + 'vendor/guzzlehttp/psr7/CHANGELOG.md', + 'vendor/itsgoingd/clockwork/.gitattributes', + 'vendor/itsgoingd/clockwork/CHANGELOG.md', + 'vendor/itsgoingd/clockwork/composer.json', 'vendor/league/climate/composer.json', + 'vendor/league/climate/CHANGELOG.md', + 'vendor/league/climate/CONTRIBUTING.md', + 'vendor/league/climate/Dockerfile', 'vendor/league/climate/CODE_OF_CONDUCT.md', + 'vendor/matthiasmullie/minify/.github', 'vendor/matthiasmullie/minify/bin', 'vendor/matthiasmullie/minify/composer.json', 'vendor/matthiasmullie/minify/docker-compose.yml', 'vendor/matthiasmullie/minify/Dockerfile', 'vendor/matthiasmullie/minify/CONTRIBUTING.md', 'vendor/matthiasmullie/path-converter/composer.json', + 'vendor/maximebf/debugbar/.github', 'vendor/maximebf/debugbar/bower.json', 'vendor/maximebf/debugbar/composer.json', 'vendor/maximebf/debugbar/.bowerrc', @@ -154,13 +183,21 @@ class CleanCommand extends Command 'vendor/miljar/php-exif/phpunit.xml.dist', 'vendor/miljar/php-exif/Resources', 'vendor/miljar/php-exif/tests', + 'vendor/miljar/php-exif/CHANGELOG.rst', 'vendor/monolog/monolog/composer.json', 'vendor/monolog/monolog/doc', 'vendor/monolog/monolog/phpunit.xml.dist', 'vendor/monolog/monolog/.php_cs', 'vendor/monolog/monolog/tests', + 'vendor/monolog/monolog/CHANGELOG.md', + 'vendor/monolog/monolog/phpstan.neon.dist', 'vendor/nyholm/psr7/composer.json', 'vendor/nyholm/psr7/phpstan.neon.dist', + 'vendor/nyholm/psr7/CHANGELOG.md', + 'vendor/nyholm/psr7/psalm.xml', + 'vendor/nyholm/psr7-server/.github', + 'vendor/nyholm/psr7-server/composer.json', + 'vendor/nyholm/psr7-server/CHANGELOG.md', 'vendor/phive/twig-extensions-deferred/.gitignore', 'vendor/phive/twig-extensions-deferred/.travis.yml', 'vendor/phive/twig-extensions-deferred/composer.json', @@ -168,18 +205,24 @@ class CleanCommand extends Command 'vendor/phive/twig-extensions-deferred/tests', 'vendor/php-http/message-factory/composer.json', 'vendor/php-http/message-factory/puli.json', + 'vendor/php-http/message-factory/CHANGELOG.md', 'vendor/pimple/pimple/.gitignore', 'vendor/pimple/pimple/.travis.yml', 'vendor/pimple/pimple/composer.json', 'vendor/pimple/pimple/ext', 'vendor/pimple/pimple/phpunit.xml.dist', 'vendor/pimple/pimple/src/Pimple/Tests', + 'vendor/pimple/pimple/.php_cs.dist', + 'vendor/pimple/pimple/CHANGELOG', + 'vendor/psr/cache/CHANGELOG.md', + 'vendor/psr/cache/composer.json', 'vendor/psr/container/composer.json', 'vendor/psr/container/.gitignore', 'vendor/psr/http-factory/.gitignore', 'vendor/psr/http-factory/.pullapprove.yml', 'vendor/psr/http-factory/composer.json', 'vendor/psr/http-message/composer.json', + 'vendor/psr/http-message/CHANGELOG.md', 'vendor/psr/http-server-handler/composer.json', 'vendor/psr/http-server-middleware/composer.json', 'vendor/psr/simple-cache/.editorconfig', @@ -197,25 +240,47 @@ class CleanCommand extends Command 'vendor/rockettheme/toolbox/.travis.yml', 'vendor/rockettheme/toolbox/composer.json', 'vendor/rockettheme/toolbox/phpunit.xml', + 'vendor/rockettheme/toolbox/CHANGELOG.md', + 'vendor/rockettheme/toolbox/Blueprints/tests', + 'vendor/rockettheme/toolbox/ResourceLocator/tests', + 'vendor/rockettheme/toolbox/Session/tests', + 'vendor/rockettheme/toolbox/tests', 'vendor/seld/cli-prompt/composer.json', 'vendor/seld/cli-prompt/.gitignore', + 'vendor/seld/cli-prompt/.github', + 'vendor/seld/cli-prompt/phpstan.neon.dist', 'vendor/symfony/console/composer.json', 'vendor/symfony/console/phpunit.xml.dist', 'vendor/symfony/console/.gitignore', 'vendor/symfony/console/.git', 'vendor/symfony/console/Tester', 'vendor/symfony/console/Tests', - 'vendor/symfony/debug/.gitignore', - 'vendor/symfony/debug/.git', - 'vendor/symfony/debug/phpunit.xml.dist', - 'vendor/symfony/debug/composer.json', - 'vendor/symfony/debug/Tests', - 'vendor/symfony/debug/Resources', + 'vendor/symfony/console/CHANGELOG.md', + 'vendor/symfony/contracts/Cache/.gitignore', + 'vendor/symfony/contracts/Cache/composer.json', + 'vendor/symfony/contracts/EventDispatcher/.gitignore', + 'vendor/symfony/contracts/EventDispatcher/composer.json', + 'vendor/symfony/contracts/HttpClient/.gitignore', + 'vendor/symfony/contracts/HttpClient/composer.json', + 'vendor/symfony/contracts/HttpClient/Test', + 'vendor/symfony/contracts/Service/.gitignore', + 'vendor/symfony/contracts/Service/composer.json', + 'vendor/symfony/contracts/Service/Test', + 'vendor/symfony/contracts/Tests', + 'vendor/symfony/contracts/Translation/.gitignore', + 'vendor/symfony/contracts/Translation/composer.json', + 'vendor/symfony/contracts/Translation/Test', + 'vendor/symfony/contracts/.gitignore', + 'vendor/symfony/contracts/composer.json', + 'vendor/symfony/contracts/phpunit.xml.dist', 'vendor/symfony/event-dispatcher/.git', 'vendor/symfony/event-dispatcher/.gitignore', 'vendor/symfony/event-dispatcher/composer.json', 'vendor/symfony/event-dispatcher/phpunit.xml.dist', 'vendor/symfony/event-dispatcher/Tests', + 'vendor/symfony/event-dispatcher/CHANGELOG.md', + 'vendor/symfony/http-client/CHANGELOG.md', + 'vendor/symfony/http-client/composer.json', 'vendor/symfony/polyfill-ctype/composer.json', 'vendor/symfony/polyfill-iconv/.git', 'vendor/symfony/polyfill-iconv/.gitignore', @@ -229,38 +294,49 @@ class CleanCommand extends Command 'vendor/symfony/process/composer.json', 'vendor/symfony/process/phpunit.xml.dist', 'vendor/symfony/process/Tests', + 'vendor/symfony/process/CHANGELOG.md', 'vendor/symfony/var-dumper/.git', 'vendor/symfony/var-dumper/.gitignore', 'vendor/symfony/var-dumper/composer.json', 'vendor/symfony/var-dumper/phpunit.xml.dist', 'vendor/symfony/var-dumper/Test', 'vendor/symfony/var-dumper/Tests', + 'vendor/symfony/var-dumper/CHANGELOG.md', 'vendor/symfony/yaml/composer.json', 'vendor/symfony/yaml/phpunit.xml.dist', 'vendor/symfony/yaml/.gitignore', 'vendor/symfony/yaml/.git', 'vendor/symfony/yaml/Tests', + 'vendor/symfony/yaml/CHANGELOG.md', 'vendor/twig/twig/.editorconfig', 'vendor/twig/twig/.php_cs.dist', 'vendor/twig/twig/.travis.yml', 'vendor/twig/twig/.gitignore', 'vendor/twig/twig/.git', + 'vendor/twig/twig/.github', 'vendor/twig/twig/composer.json', 'vendor/twig/twig/phpunit.xml.dist', 'vendor/twig/twig/doc', 'vendor/twig/twig/ext', 'vendor/twig/twig/test', + 'vendor/twig/twig/.gitattributes', + 'vendor/twig/twig/CHANGELOG', + 'vendor/twig/twig/drupal_test.sh', 'vendor/willdurand/negotiation/.gitignore', 'vendor/willdurand/negotiation/.travis.yml', 'vendor/willdurand/negotiation/appveyor.yml', 'vendor/willdurand/negotiation/composer.json', 'vendor/willdurand/negotiation/phpunit.xml.dist', 'vendor/willdurand/negotiation/tests', + 'vendor/willdurand/negotiation/CONTRIBUTING.md', 'user/config/security.yaml', 'cache/compiled/', ]; - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('clean') @@ -271,33 +347,46 @@ class CleanCommand extends Command /** * @param InputInterface $input * @param OutputInterface $output + * @return int */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $this->setupConsole($input, $output); - $this->cleanPaths(); + return $this->cleanPaths() ? 0 : 1; } - private function cleanPaths() + /** + * @return bool + */ + private function cleanPaths(): bool { - $this->output->writeln(''); - $this->output->writeln('DELETING'); + $success = true; + + $this->io->writeln(''); + $this->io->writeln('DELETING'); $anything = false; foreach ($this->paths_to_remove as $path) { - $path = ROOT_DIR . $path; - if (is_dir($path) && @Folder::delete($path)) { - $anything = true; - $this->output->writeln('dir: ' . $path); - } elseif (is_file($path) && @unlink($path)) { - $anything = true; - $this->output->writeln('file: ' . $path); + $path = GRAV_ROOT . DS . $path; + try { + if (is_dir($path) && Folder::delete($path)) { + $anything = true; + $this->io->writeln('dir: ' . $path); + } elseif (is_file($path) && @unlink($path)) { + $anything = true; + $this->io->writeln('file: ' . $path); + } + } catch (\Exception $e) { + $success = false; + $this->io->error(sprintf('Failed to delete %s: %s', $path, $e->getMessage())); } } if (!$anything) { - $this->output->writeln(''); - $this->output->writeln('Nothing to clean...'); + $this->io->writeln(''); + $this->io->writeln('Nothing to clean...'); } + + return $success; } /** @@ -305,19 +394,19 @@ class CleanCommand extends Command * * @param InputInterface $input * @param OutputInterface $output + * @return void */ - public function setupConsole(InputInterface $input, OutputInterface $output) + public function setupConsole(InputInterface $input, OutputInterface $output): void { $this->input = $input; - $this->output = $output; + $this->io = new SymfonyStyle($input, $output); - $this->output->getFormatter()->setStyle('normal', new OutputFormatterStyle('white')); - $this->output->getFormatter()->setStyle('yellow', new OutputFormatterStyle('yellow', null, ['bold'])); - $this->output->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, ['bold'])); - $this->output->getFormatter()->setStyle('cyan', new OutputFormatterStyle('cyan', null, ['bold'])); - $this->output->getFormatter()->setStyle('green', new OutputFormatterStyle('green', null, ['bold'])); - $this->output->getFormatter()->setStyle('magenta', new OutputFormatterStyle('magenta', null, ['bold'])); - $this->output->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, ['bold'])); + $this->io->getFormatter()->setStyle('normal', new OutputFormatterStyle('white')); + $this->io->getFormatter()->setStyle('yellow', new OutputFormatterStyle('yellow', null, ['bold'])); + $this->io->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, ['bold'])); + $this->io->getFormatter()->setStyle('cyan', new OutputFormatterStyle('cyan', null, ['bold'])); + $this->io->getFormatter()->setStyle('green', new OutputFormatterStyle('green', null, ['bold'])); + $this->io->getFormatter()->setStyle('magenta', new OutputFormatterStyle('magenta', null, ['bold'])); + $this->io->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, ['bold'])); } - } diff --git a/system/src/Grav/Console/Cli/ClearCacheCommand.php b/system/src/Grav/Console/Cli/ClearCacheCommand.php index 8ef6f4f..daed2a5 100644 --- a/system/src/Grav/Console/Cli/ClearCacheCommand.php +++ b/system/src/Grav/Console/Cli/ClearCacheCommand.php @@ -3,19 +3,26 @@ /** * @package Grav\Console\Cli * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\Cli; use Grav\Common\Cache; -use Grav\Console\ConsoleCommand; +use Grav\Console\GravCommand; use Symfony\Component\Console\Input\InputOption; -class ClearCacheCommand extends ConsoleCommand +/** + * Class ClearCacheCommand + * @package Grav\Console\Cli + */ +class ClearCacheCommand extends GravCommand { - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('cache') @@ -32,49 +39,66 @@ class ClearCacheCommand extends ConsoleCommand ->setHelp('The cache command allows you to interact with Grav cache'); } - protected function serve() + /** + * @return int + */ + protected function serve(): int { + // Old versions of Grav called this command after grav upgrade. + // We need make this command to work with older GravCommand instance: + if (!method_exists($this, 'initializePlugins')) { + Cache::clearCache('all'); + + return 0; + } + + $this->initializePlugins(); $this->cleanPaths(); + + return 0; } /** * loops over the array of paths and deletes the files/folders + * + * @return void */ - private function cleanPaths() + private function cleanPaths(): void { - $this->output->writeln(''); + $input = $this->getInput(); + $io = $this->getIO(); + $io->newLine(); - if ($this->input->getOption('purge')) { - $this->output->writeln('Purging old cache'); - $this->output->writeln(''); + if ($input->getOption('purge')) { + $io->writeln('Purging old cache'); + $io->newLine(); $msg = Cache::purgeJob(); - $this->output->writeln($msg); + $io->writeln($msg); } else { - $this->output->writeln('Clearing cache'); - $this->output->writeln(''); + $io->writeln('Clearing cache'); + $io->newLine(); - if ($this->input->getOption('all')) { + if ($input->getOption('all')) { $remove = 'all'; - } elseif ($this->input->getOption('assets-only')) { + } elseif ($input->getOption('assets-only')) { $remove = 'assets-only'; - } elseif ($this->input->getOption('images-only')) { + } elseif ($input->getOption('images-only')) { $remove = 'images-only'; - } elseif ($this->input->getOption('cache-only')) { + } elseif ($input->getOption('cache-only')) { $remove = 'cache-only'; - } elseif ($this->input->getOption('tmp-only')) { + } elseif ($input->getOption('tmp-only')) { $remove = 'tmp-only'; - } elseif ($this->input->getOption('invalidate')) { + } elseif ($input->getOption('invalidate')) { $remove = 'invalidate'; } else { $remove = 'standard'; } foreach (Cache::clearCache($remove) as $result) { - $this->output->writeln($result); + $io->writeln($result); } } } } - diff --git a/system/src/Grav/Console/Cli/ComposerCommand.php b/system/src/Grav/Console/Cli/ComposerCommand.php index ffb4e99..5075d1d 100644 --- a/system/src/Grav/Console/Cli/ComposerCommand.php +++ b/system/src/Grav/Console/Cli/ComposerCommand.php @@ -3,21 +3,28 @@ /** * @package Grav\Console\Cli * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\Cli; -use Grav\Console\ConsoleCommand; +use Grav\Console\GravCommand; use Symfony\Component\Console\Input\InputOption; -class ComposerCommand extends ConsoleCommand +/** + * Class ComposerCommand + * @package Grav\Console\Cli + */ +class ComposerCommand extends GravCommand { - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this - ->setName("composer") + ->setName('composer') ->addOption( 'install', 'i', @@ -30,21 +37,28 @@ class ComposerCommand extends ConsoleCommand InputOption::VALUE_NONE, 'update the dependencies' ) - ->setDescription("Updates the composer vendor dependencies needed by Grav.") + ->setDescription('Updates the composer vendor dependencies needed by Grav.') ->setHelp('The composer command updates the composer vendor dependencies needed by Grav'); } - protected function serve() + /** + * @return int + */ + protected function serve(): int { - $action = $this->input->getOption('install') ? 'install' : ($this->input->getOption('update') ? 'update' : 'install'); + $input = $this->getInput(); + $io = $this->getIO(); - if ($this->input->getOption('install')) { + $action = $input->getOption('install') ? 'install' : ($input->getOption('update') ? 'update' : 'install'); + + if ($input->getOption('install')) { $action = 'install'; } // Updates composer first - $this->output->writeln("\nInstalling vendor dependencies"); - $this->output->writeln($this->composerUpdate(GRAV_ROOT, $action)); - } + $io->writeln("\nInstalling vendor dependencies"); + $io->writeln($this->composerUpdate(GRAV_ROOT, $action)); + return 0; + } } diff --git a/system/src/Grav/Console/Cli/InstallCommand.php b/system/src/Grav/Console/Cli/InstallCommand.php index 05f281e..22258be 100644 --- a/system/src/Grav/Console/Cli/InstallCommand.php +++ b/system/src/Grav/Console/Cli/InstallCommand.php @@ -3,32 +3,37 @@ /** * @package Grav\Console\Cli * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\Cli; -use Grav\Console\ConsoleCommand; +use Grav\Console\GravCommand; +use Grav\Framework\File\Formatter\JsonFormatter; +use Grav\Framework\File\JsonFile; use RocketTheme\Toolbox\File\YamlFile; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; +use function is_array; -class InstallCommand extends ConsoleCommand +/** + * Class InstallCommand + * @package Grav\Console\Cli + */ +class InstallCommand extends GravCommand { /** @var array */ protected $config; - - /** @var array */ - protected $local_config; - /** @var string */ protected $destination; - /** @var string */ protected $user_path; - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('install') @@ -38,6 +43,18 @@ class InstallCommand extends ConsoleCommand InputOption::VALUE_NONE, 'Symlink the required bits' ) + ->addOption( + 'plugin', + 'p', + InputOption::VALUE_REQUIRED, + 'Install plugin (symlink)' + ) + ->addOption( + 'theme', + 't', + InputOption::VALUE_REQUIRED, + 'Install theme (symlink)' + ) ->addArgument( 'destination', InputArgument::OPTIONAL, @@ -47,16 +64,22 @@ class InstallCommand extends ConsoleCommand ->setHelp('The install command installs the dependencies needed by Grav. Optionally can create symbolic links'); } - protected function serve() + /** + * @return int + */ + protected function serve(): int { + $input = $this->getInput(); + $io = $this->getIO(); + $dependencies_file = '.dependencies'; - $this->destination = $this->input->getArgument('destination') ?: ROOT_DIR; + $this->destination = $input->getArgument('destination') ?: GRAV_WEBROOT; // fix trailing slash $this->destination = rtrim($this->destination, DS) . DS; - $this->user_path = $this->destination . USER_PATH; + $this->user_path = $this->destination . GRAV_USER_PATH . DS; if ($local_config_file = $this->loadLocalConfig()) { - $this->output->writeln('Read local config from ' . $local_config_file . ''); + $io->writeln('Read local config from ' . $local_config_file . ''); } // Look for dependencies file in ROOT and USER dir @@ -65,112 +88,215 @@ class InstallCommand extends ConsoleCommand } elseif (file_exists($this->destination . $dependencies_file)) { $file = YamlFile::instance($this->destination . $dependencies_file); } else { - $this->output->writeln('ERROR Missing .dependencies file in user/ folder'); - if ($this->input->getArgument('destination')) { - $this->output->writeln('HINT Are you trying to install a plugin or a theme? Make sure you use bin/gpm install , not bin/grav install. This command is only used to install Grav skeletons.'); + $io->writeln('ERROR Missing .dependencies file in user/ folder'); + if ($input->getArgument('destination')) { + $io->writeln('HINT Are you trying to install a plugin or a theme? Make sure you use bin/gpm install , not bin/grav install. This command is only used to install Grav skeletons.'); } else { - $this->output->writeln('HINT Are you trying to install Grav? Grav is already installed. You need to run this command only if you download a skeleton from GitHub directly.'); + $io->writeln('HINT Are you trying to install Grav? Grav is already installed. You need to run this command only if you download a skeleton from GitHub directly.'); } - return; + return 1; } $this->config = $file->content(); $file->free(); - // If yaml config, process - if ($this->config) { - if (!$this->input->getOption('symlink')) { - // Updates composer first - $this->output->writeln("\nInstalling vendor dependencies"); - $this->output->writeln($this->composerUpdate(GRAV_ROOT, 'install')); + // If no config, fail. + if (!$this->config) { + $io->writeln('ERROR invalid YAML in ' . $dependencies_file); - $this->gitclone(); - } else { - $this->symlink(); - } - } else { - $this->output->writeln('ERROR invalid YAML in ' . $dependencies_file); + return 1; } + $plugin = $input->getOption('plugin'); + $theme = $input->getOption('theme'); + $name = $plugin ?? $theme; + $symlink = $name || $input->getOption('symlink'); + if (!$symlink) { + // Updates composer first + $io->writeln("\nInstalling vendor dependencies"); + $io->writeln($this->composerUpdate(GRAV_ROOT, 'install')); + + $error = $this->gitclone(); + } else { + $type = $name ? ($plugin ? 'plugin' : 'theme') : null; + + $error = $this->symlink($name, $type); + } + + return $error; } /** * Clones from Git + * + * @return int */ - private function gitclone() + private function gitclone(): int { - $this->output->writeln(''); - $this->output->writeln('Cloning Bits'); - $this->output->writeln('============'); - $this->output->writeln(''); + $io = $this->getIO(); + $io->newLine(); + $io->writeln('Cloning Bits'); + $io->writeln('============'); + $io->newLine(); + + $error = 0; + $this->destination = rtrim($this->destination, DS); foreach ($this->config['git'] as $repo => $data) { - $this->destination = rtrim($this->destination, DS); $path = $this->destination . DS . $data['path']; if (!file_exists($path)) { exec('cd "' . $this->destination . '" && git clone -b ' . $data['branch'] . ' --depth 1 ' . $data['url'] . ' ' . $data['path'], $output, $return); if (!$return) { - $this->output->writeln('SUCCESS cloned ' . $data['url'] . ' -> ' . $path . ''); + $io->writeln('SUCCESS cloned ' . $data['url'] . ' -> ' . $path . ''); } else { - $this->output->writeln('ERROR cloning ' . $data['url']); - + $io->writeln('ERROR cloning ' . $data['url']); + $error = 1; } - $this->output->writeln(''); + $io->newLine(); } else { - $this->output->writeln('' . $path . ' already exists, skipping...'); - $this->output->writeln(''); + $io->writeln('' . $path . ' already exists, skipping...'); + $io->newLine(); } - } + + return $error; } /** * Symlinks + * + * @param string|null $name + * @param string|null $type + * @return int */ - private function symlink() + private function symlink(string $name = null, string $type = null): int { - $this->output->writeln(''); - $this->output->writeln('Symlinking Bits'); - $this->output->writeln('==============='); - $this->output->writeln(''); + $io = $this->getIO(); + + $io->newLine(); + $io->writeln('Symlinking Bits'); + $io->writeln('==============='); + $io->newLine(); if (!$this->local_config) { - $this->output->writeln('No local configuration available, aborting...'); - $this->output->writeln(''); - return; + $io->writeln('No local configuration available, aborting...'); + $io->newLine(); + + return 1; } - exec('cd ' . $this->destination); - foreach ($this->config['links'] as $repo => $data) { - $repos = (array) $this->local_config[$data['scm'] . '_repos']; - $from = false; - $to = $this->destination . $data['path']; + $error = 0; + $this->destination = rtrim($this->destination, DS); - foreach ($repos as $repo) { - $path = $repo . $data['src']; - if (file_exists($path)) { - $from = $path; + if ($name) { + $src = "grav-{$type}-{$name}"; + $links = [ + $name => [ + 'scm' => 'github', // TODO: make configurable + 'src' => $src, + 'path' => "user/{$type}s/{$name}" + ] + ]; + } else { + $links = $this->config['links']; + } + + foreach ($links as $name => $data) { + $scm = $data['scm'] ?? null; + $src = $data['src'] ?? null; + $path = $data['path'] ?? null; + if (!isset($scm, $src, $path)) { + $io->writeln("Dependency '$name' has broken configuration, skipping..."); + $io->newLine(); + $error = 1; + + continue; + } + + $locations = (array) $this->local_config["{$scm}_repos"]; + $to = $this->destination . DS . $path; + + $from = null; + foreach ($locations as $location) { + $test = rtrim($location, '\\/') . DS . $src; + if (file_exists($test)) { + $from = $test; continue; } } - if (!$from) { - $this->output->writeln('source for ' . $data['src'] . ' does not exists, skipping...'); - $this->output->writeln(''); + if (is_link($to) && !realpath($to)) { + $io->writeln('Removed broken symlink '. $path .''); + unlink($to); + } + if (null === $from) { + $io->writeln('source for ' . $src . ' does not exists, skipping...'); + $io->newLine(); + $error = 1; + } elseif (!file_exists($to)) { + $error = $this->addSymlinks($from, $to, ['name' => $name, 'src' => $src, 'path' => $path]); + $io->newLine(); } else { - if (!file_exists($to)) { - symlink($from, $to); - $this->output->writeln('SUCCESS symlinked ' . $data['src'] . ' -> ' . $data['path'] . ''); - $this->output->writeln(''); - } else { - $this->output->writeln('destination: ' . $to . ' already exists, skipping...'); - $this->output->writeln(''); - } + $io->writeln('destination: ' . $path . ' already exists, skipping...'); + $io->newLine(); } } + + return $error; + } + + private function addSymlinks(string $from, string $to, array $options): int + { + $io = $this->getIO(); + + $hebe = $this->readHebe($from); + if (null === $hebe) { + symlink($from, $to); + + $io->writeln('SUCCESS symlinked ' . $options['src'] . ' -> ' . $options['path'] . ''); + } else { + $to = GRAV_ROOT; + $name = $options['name']; + $io->writeln("Processing {$name}"); + foreach ($hebe as $section => $symlinks) { + foreach ($symlinks as $symlink) { + $src = trim($symlink['source'], '/'); + $dst = trim($symlink['destination'], '/'); + $s = "{$from}/{$src}"; + $d = "{$to}/{$dst}"; + + if (is_link($d) && !realpath($d)) { + unlink($d); + $io->writeln(' Removed broken symlink '. $dst .''); + } + if (!file_exists($d)) { + symlink($s, $d); + $io->writeln(' symlinked ' . $src . ' -> ' . $dst . ''); + } + } + } + $io->writeln('SUCCESS'); + } + + return 0; + } + + private function readHebe(string $folder): ?array + { + $filename = "{$folder}/hebe.json"; + if (!is_file($filename)) { + return null; + } + + $formatter = new JsonFormatter(); + $file = new JsonFile($filename, $formatter); + $hebe = $file->load(); + $paths = $hebe['platforms']['grav']['nodes'] ?? null; + + return is_array($paths) ? $paths : null; } } diff --git a/system/src/Grav/Console/Cli/LogViewerCommand.php b/system/src/Grav/Console/Cli/LogViewerCommand.php index f9e93a3..d3924f8 100644 --- a/system/src/Grav/Console/Cli/LogViewerCommand.php +++ b/system/src/Grav/Console/Cli/LogViewerCommand.php @@ -3,22 +3,28 @@ /** * @package Grav\Console\Cli * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\Cli; +use DateTime; use Grav\Common\Grav; use Grav\Common\Helpers\LogViewer; -use Grav\Common\Utils; -use Grav\Console\ConsoleCommand; +use Grav\Console\GravCommand; use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Style\SymfonyStyle; -class LogViewerCommand extends ConsoleCommand +/** + * Class LogViewerCommand + * @package Grav\Console\Cli + */ +class LogViewerCommand extends GravCommand { - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('logviewer') @@ -35,19 +41,20 @@ class LogViewerCommand extends ConsoleCommand 'number of lines (default = 10)' ) ->setDescription('Display the last few entries of Grav log') - ->setHelp("Display the last few entries of Grav log"); + ->setHelp('Display the last few entries of Grav log'); } - protected function serve() + /** + * @return int + */ + protected function serve(): int { - $grav = Grav::instance(); - $grav->setup(); + $input = $this->getInput(); + $io = $this->getIO(); - $file = $this->input->getOption('file') ?? 'grav.log'; - $lines = $this->input->getOption('lines') ?? 20; - $verbose = $this->input->getOption('verbose', false); - - $io = new SymfonyStyle($this->input, $this->output); + $file = $input->getOption('file') ?? 'grav.log'; + $lines = $input->getOption('lines') ?? 20; + $verbose = $input->getOption('verbose') ?? false; $io->title('Log Viewer'); @@ -56,30 +63,34 @@ class LogViewerCommand extends ConsoleCommand $viewer = new LogViewer(); - $logfile = $grav['locator']->findResource("log://" . $file); + $grav = Grav::instance(); - if ($logfile) { - $rows = $viewer->objectTail($logfile, $lines, true); - foreach ($rows as $log) { - $date = $log['date']; - $level_color = LogViewer::levelColor($log['level']); - - if ($date instanceof \DateTime) { - $output = "{$log['date']->format('Y-m-d h:i:s')} [<{$level_color}>{$log['level']}]"; - if ($log['trace'] && $verbose) { - $output .= " {$log['message']}\n"; - foreach ((array) $log['trace'] as $index => $tracerow) { - $output .= "{$index}${tracerow}\n"; - } - } else { - $output .= " {$log['message']}"; - } - $io->writeln($output); - } - } - } else { + $logfile = $grav['locator']->findResource('log://' . $file); + if (!$logfile) { $io->error('cannot find the log file: logs/' . $file); + + return 1; } + $rows = $viewer->objectTail($logfile, $lines, true); + foreach ($rows as $log) { + $date = $log['date']; + $level_color = LogViewer::levelColor($log['level']); + + if ($date instanceof DateTime) { + $output = "{$log['date']->format('Y-m-d h:i:s')} [<{$level_color}>{$log['level']}]"; + if ($log['trace'] && $verbose) { + $output .= " {$log['message']}\n"; + foreach ((array) $log['trace'] as $index => $tracerow) { + $output .= "{$index}${tracerow}\n"; + } + } else { + $output .= " {$log['message']}"; + } + $io->writeln($output); + } + } + + return 0; } } diff --git a/system/src/Grav/Console/Cli/NewProjectCommand.php b/system/src/Grav/Console/Cli/NewProjectCommand.php index 90583f0..d67cb1c 100644 --- a/system/src/Grav/Console/Cli/NewProjectCommand.php +++ b/system/src/Grav/Console/Cli/NewProjectCommand.php @@ -3,20 +3,27 @@ /** * @package Grav\Console\Cli * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\Cli; -use Grav\Console\ConsoleCommand; +use Grav\Console\GravCommand; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; -class NewProjectCommand extends ConsoleCommand +/** + * Class NewProjectCommand + * @package Grav\Console\Cli + */ +class NewProjectCommand extends GravCommand { - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('new-project') @@ -36,8 +43,13 @@ class NewProjectCommand extends ConsoleCommand ->setHelp("The new-project command is a combination of the `setup` and `install` commands.\nCreates a new Grav instance and performs the installation of all the required dependencies."); } - protected function serve() + /** + * @return int + */ + protected function serve(): int { + $io = $this->getIO(); + $sandboxCommand = $this->getApplication()->find('sandbox'); $installCommand = $this->getApplication()->find('install'); @@ -53,7 +65,11 @@ class NewProjectCommand extends ConsoleCommand '-s' => $this->input->getOption('symlink') ]); - $sandboxCommand->run($sandboxArguments, $this->output); - $installCommand->run($installArguments, $this->output); + $error = $sandboxCommand->run($sandboxArguments, $io); + if ($error === 0) { + $error = $installCommand->run($installArguments, $io); + } + + return $error; } } diff --git a/system/src/Grav/Console/Cli/PageSystemValidatorCommand.php b/system/src/Grav/Console/Cli/PageSystemValidatorCommand.php new file mode 100644 index 0000000..4d234d7 --- /dev/null +++ b/system/src/Grav/Console/Cli/PageSystemValidatorCommand.php @@ -0,0 +1,299 @@ + [[]], + 'summary' => [[], [200], [200, true]], + 'content' => [[]], + 'getRawContent' => [[]], + 'rawMarkdown' => [[]], + 'value' => [['content'], ['route'], ['order'], ['ordering'], ['folder'], ['slug'], ['name'], /*['frontmatter'],*/ ['header.menu'], ['header.slug']], + 'title' => [[]], + 'menu' => [[]], + 'visible' => [[]], + 'published' => [[]], + 'publishDate' => [[]], + 'unpublishDate' => [[]], + 'process' => [[]], + 'slug' => [[]], + 'order' => [[]], + //'id' => [[]], + 'modified' => [[]], + 'lastModified' => [[]], + 'folder' => [[]], + 'date' => [[]], + 'dateformat' => [[]], + 'taxonomy' => [[]], + 'shouldProcess' => [['twig'], ['markdown']], + 'isPage' => [[]], + 'isDir' => [[]], + 'exists' => [[]], + + // Forms + 'forms' => [[]], + + // Routing + 'urlExtension' => [[]], + 'routable' => [[]], + 'link' => [[], [false], [true]], + 'permalink' => [[]], + 'canonical' => [[], [false], [true]], + 'url' => [[], [true], [true, true], [true, true, false], [false, false, true, false]], + 'route' => [[]], + 'rawRoute' => [[]], + 'routeAliases' => [[]], + 'routeCanonical' => [[]], + 'redirect' => [[]], + 'relativePagePath' => [[]], + 'path' => [[]], + //'folder' => [[]], + 'parent' => [[]], + 'topParent' => [[]], + 'currentPosition' => [[]], + 'active' => [[]], + 'activeChild' => [[]], + 'home' => [[]], + 'root' => [[]], + + // Translations + 'translatedLanguages' => [[], [false], [true]], + 'untranslatedLanguages' => [[], [false], [true]], + 'language' => [[]], + + // Legacy + 'raw' => [[]], + 'frontmatter' => [[]], + 'httpResponseCode' => [[]], + 'httpHeaders' => [[]], + 'blueprintName' => [[]], + 'name' => [[]], + 'childType' => [[]], + 'template' => [[]], + 'templateFormat' => [[]], + 'extension' => [[]], + 'expires' => [[]], + 'cacheControl' => [[]], + 'ssl' => [[]], + 'metadata' => [[]], + 'eTag' => [[]], + 'filePath' => [[]], + 'filePathClean' => [[]], + 'orderDir' => [[]], + 'orderBy' => [[]], + 'orderManual' => [[]], + 'maxCount' => [[]], + 'modular' => [[]], + 'modularTwig' => [[]], + //'children' => [[]], + 'isFirst' => [[]], + 'isLast' => [[]], + 'prevSibling' => [[]], + 'nextSibling' => [[]], + 'adjacentSibling' => [[]], + 'ancestor' => [[]], + //'inherited' => [[]], + //'inheritedField' => [[]], + 'find' => [['/']], + //'collection' => [[]], + //'evaluate' => [[]], + 'folderExists' => [[]], + //'getOriginal' => [[]], + //'getAction' => [[]], + ]; + + /** @var Grav */ + protected $grav; + + /** + * @return void + */ + protected function configure(): void + { + $this + ->setName('page-system-validator') + ->setDescription('Page validator can be used to compare site before/after update and when migrating to Flex Pages.') + ->addOption('record', 'r', InputOption::VALUE_NONE, 'Record results') + ->addOption('check', 'c', InputOption::VALUE_NONE, 'Compare site against previously recorded results') + ->setHelp('The page-system-validator command can be used to test the pages before and after upgrade'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $this->setLanguage('en'); + $this->initializePages(); + + $io->newLine(); + + $this->grav = $grav = Grav::instance(); + + $grav->fireEvent('onPageInitialized', new Event(['page' => $grav['page']])); + + /** @var Config $config */ + $config = $grav['config']; + + if ($input->getOption('record')) { + $io->writeln('Pages: ' . $config->get('system.pages.type', 'page')); + + $io->writeln('Record tests'); + $io->newLine(); + + $results = $this->record(); + $file = $this->getFile('pages-old'); + $file->save($results); + + $io->writeln('Recorded tests to ' . $file->filename()); + } elseif ($input->getOption('check')) { + $io->writeln('Pages: ' . $config->get('system.pages.type', 'page')); + + $io->writeln('Run tests'); + $io->newLine(); + + $new = $this->record(); + $file = $this->getFile('pages-new'); + $file->save($new); + $io->writeln('Recorded tests to ' . $file->filename()); + + $file = $this->getFile('pages-old'); + $old = $file->content(); + + $results = $this->check($old, $new); + $file = $this->getFile('diff'); + $file->save($results); + $io->writeln('Recorded results to ' . $file->filename()); + } else { + $io->writeln('page-system-validator [-r|--record] [-c|--check]'); + } + $io->newLine(); + + return 0; + } + + /** + * @return array + */ + private function record(): array + { + $io = $this->getIO(); + + /** @var Pages $pages */ + $pages = $this->grav['pages']; + $all = $pages->all(); + + $results = []; + $results[''] = $this->recordRow($pages->root()); + foreach ($all as $path => $page) { + if (null === $page) { + $io->writeln('Error on page ' . $path . ''); + continue; + } + + $results[$page->rawRoute()] = $this->recordRow($page); + } + + return json_decode(json_encode($results), true); + } + + /** + * @param PageInterface $page + * @return array + */ + private function recordRow(PageInterface $page): array + { + $results = []; + + foreach ($this->tests as $method => $params) { + $params = $params ?: [[]]; + foreach ($params as $p) { + $result = $page->$method(...$p); + if (in_array($method, ['summary', 'content', 'getRawContent'], true)) { + $result = preg_replace('/name="(form-nonce|__unique_form_id__)" value="[^"]+"/', + 'name="\\1" value="DYNAMIC"', $result); + $result = preg_replace('`src=("|\'|")/images/./././././[^"]+\\1`', + 'src="\\1images/GENERATED\\1', $result); + $result = preg_replace('/\?\d{10}/', '?1234567890', $result); + } elseif ($method === 'httpHeaders' && isset($result['Expires'])) { + $result['Expires'] = 'Thu, 19 Sep 2019 13:10:24 GMT (REPLACED AS DYNAMIC)'; + } elseif ($result instanceof PageInterface) { + $result = $result->rawRoute(); + } elseif (is_object($result)) { + $result = json_decode(json_encode($result), true); + } + + $ps = []; + foreach ($p as $val) { + $ps[] = (string)var_export($val, true); + } + $pstr = implode(', ', $ps); + $call = "->{$method}({$pstr})"; + $results[$call] = $result; + } + } + + return $results; + } + + /** + * @param array $old + * @param array $new + * @return array + */ + private function check(array $old, array $new): array + { + $errors = []; + foreach ($old as $path => $page) { + if (!isset($new[$path])) { + $errors[$path] = 'PAGE REMOVED'; + continue; + } + foreach ($page as $method => $test) { + if (($new[$path][$method] ?? null) !== $test) { + $errors[$path][$method] = ['old' => $test, 'new' => $new[$path][$method]]; + } + } + } + + return $errors; + } + + /** + * @param string $name + * @return CompiledYamlFile + */ + private function getFile(string $name): CompiledYamlFile + { + return CompiledYamlFile::instance('cache://tests/' . $name . '.yaml'); + } +} diff --git a/system/src/Grav/Console/Cli/SandboxCommand.php b/system/src/Grav/Console/Cli/SandboxCommand.php index f961412..d865b4a 100644 --- a/system/src/Grav/Console/Cli/SandboxCommand.php +++ b/system/src/Grav/Console/Cli/SandboxCommand.php @@ -3,18 +3,24 @@ /** * @package Grav\Console\Cli * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\Cli; -use Grav\Console\ConsoleCommand; use Grav\Common\Filesystem\Folder; +use Grav\Console\GravCommand; +use RuntimeException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; +use function count; -class SandboxCommand extends ConsoleCommand +/** + * Class SandboxCommand + * @package Grav\Console\Cli + */ +class SandboxCommand extends GravCommand { /** @var array */ protected $directories = [ @@ -57,12 +63,14 @@ class SandboxCommand extends ConsoleCommand ]; /** @var string */ - protected $default_file = "---\ntitle: HomePage\n---\n# HomePage\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque porttitor eu felis sed ornare. Sed a mauris venenatis, pulvinar velit vel, dictum enim. Phasellus ac rutrum velit. Nunc lorem purus, hendrerit sit amet augue aliquet, iaculis ultricies nisl. Suspendisse tincidunt euismod risus, quis feugiat arcu tincidunt eget. Nulla eros mi, commodo vel ipsum vel, aliquet congue odio. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Pellentesque velit orci, laoreet at adipiscing eu, interdum quis nibh. Nunc a accumsan purus."; - protected $source; + /** @var string */ protected $destination; - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('sandbox') @@ -79,39 +87,62 @@ class SandboxCommand extends ConsoleCommand 'Symlink the base grav system' ) ->setHelp("The sandbox command help create a development environment that can optionally use symbolic links to link the core of grav to the git cloned repository.\nGood for development, playing around or starting fresh"); - $this->source = getcwd(); + + $source = getcwd(); + if ($source === false) { + throw new RuntimeException('Internal Error'); + } + $this->source = $source; } - protected function serve() + /** + * @return int + */ + protected function serve(): int { - $this->destination = $this->input->getArgument('destination'); + $input = $this->getInput(); - // Symlink the Core Stuff - if ($this->input->getOption('symlink')) { - // Create Some core stuff if it doesn't exist - $this->createDirectories(); + $this->destination = $input->getArgument('destination'); - // Loop through the symlink mappings and create the symlinks - $this->symlink(); - - // Copy the Core STuff - } else { - // Create Some core stuff if it doesn't exist - $this->createDirectories(); - - // Loop through the symlink mappings and copy what otherwise would be symlinks - $this->copy(); + // Create Some core stuff if it doesn't exist + $error = $this->createDirectories(); + if ($error) { + return $error; } - $this->pages(); - $this->initFiles(); - $this->perms(); + // Copy files or create symlinks + $error = $input->getOption('symlink') ? $this->symlink() : $this->copy(); + if ($error) { + return $error; + } + + $error = $this->pages(); + if ($error) { + return $error; + } + + $error = $this->initFiles(); + if ($error) { + return $error; + } + + $error = $this->perms(); + if ($error) { + return $error; + } + + return 0; } - private function createDirectories() + /** + * @return int + */ + private function createDirectories(): int { - $this->output->writeln(''); - $this->output->writeln('Creating Directories'); + $io = $this->getIO(); + + $io->newLine(); + $io->writeln('Creating Directories'); $dirs_created = false; if (!file_exists($this->destination)) { @@ -121,20 +152,27 @@ class SandboxCommand extends ConsoleCommand foreach ($this->directories as $dir) { if (!file_exists($this->destination . $dir)) { $dirs_created = true; - $this->output->writeln(' ' . $dir . ''); + $io->writeln(' ' . $dir . ''); Folder::create($this->destination . $dir); } } if (!$dirs_created) { - $this->output->writeln(' Directories already exist'); + $io->writeln(' Directories already exist'); } + + return 0; } - private function copy() + /** + * @return int + */ + private function copy(): int { - $this->output->writeln(''); - $this->output->writeln('Copying Files'); + $io = $this->getIO(); + + $io->newLine(); + $io->writeln('Copying Files'); foreach ($this->mappings as $source => $target) { @@ -145,15 +183,22 @@ class SandboxCommand extends ConsoleCommand $from = $this->source . $source; $to = $this->destination . $target; - $this->output->writeln(' ' . $source . ' -> ' . $to); + $io->writeln(' ' . $source . ' -> ' . $to); @Folder::rcopy($from, $to); } + + return 0; } - private function symlink() + /** + * @return int + */ + private function symlink(): int { - $this->output->writeln(''); - $this->output->writeln('Resetting Symbolic Links'); + $io = $this->getIO(); + + $io->newLine(); + $io->writeln('Resetting Symbolic Links'); foreach ($this->mappings as $source => $target) { @@ -164,7 +209,7 @@ class SandboxCommand extends ConsoleCommand $from = $this->source . $source; $to = $this->destination . $target; - $this->output->writeln(' ' . $source . ' -> ' . $to); + $io->writeln(' ' . $source . ' -> ' . $to); if (is_dir($to)) { @Folder::delete($to); @@ -173,14 +218,45 @@ class SandboxCommand extends ConsoleCommand } symlink($from, $to); } + + return 0; } - private function initFiles() + /** + * @return int + */ + private function pages(): int { - $this->check(); + $io = $this->getIO(); - $this->output->writeln(''); - $this->output->writeln('File Initializing'); + $io->newLine(); + $io->writeln('Pages Initializing'); + + // get pages files and initialize if no pages exist + $pages_dir = $this->destination . '/user/pages'; + $pages_files = array_diff(scandir($pages_dir), ['..', '.']); + + if (count($pages_files) === 0) { + $destination = $this->source . '/user/pages'; + Folder::rcopy($destination, $pages_dir); + $io->writeln(' ' . $destination . ' -> Created'); + } + + return 0; + } + + /** + * @return int + */ + private function initFiles(): int + { + if (!$this->check()) { + return 1; + } + + $io = $this->getIO(); + $io->newLine(); + $io->writeln('File Initializing'); $files_init = false; // Copy files if they do not exist @@ -195,36 +271,25 @@ class SandboxCommand extends ConsoleCommand if (!file_exists($to)) { $files_init = true; copy($from, $to); - $this->output->writeln(' ' . $target . ' -> Created'); + $io->writeln(' ' . $target . ' -> Created'); } } if (!$files_init) { - $this->output->writeln(' Files already exist'); + $io->writeln(' Files already exist'); } + + return 0; } - private function pages() + /** + * @return int + */ + private function perms(): int { - $this->output->writeln(''); - $this->output->writeln('Pages Initializing'); - - // get pages files and initialize if no pages exist - $pages_dir = $this->destination . '/user/pages'; - $pages_files = array_diff(scandir($pages_dir), ['..', '.']); - - if (\count($pages_files) === 0) { - $destination = $this->source . '/user/pages'; - Folder::rcopy($destination, $pages_dir); - $this->output->writeln(' ' . $destination . ' -> Created'); - - } - } - - private function perms() - { - $this->output->writeln(''); - $this->output->writeln('Permissions Initializing'); + $io = $this->getIO(); + $io->newLine(); + $io->writeln('Permissions Initializing'); $dir_perms = 0755; @@ -232,39 +297,46 @@ class SandboxCommand extends ConsoleCommand foreach ($binaries as $bin) { chmod($bin, $dir_perms); - $this->output->writeln(' bin/' . basename($bin) . ' permissions reset to ' . decoct($dir_perms)); + $io->writeln(' bin/' . basename($bin) . ' permissions reset to ' . decoct($dir_perms)); } - $this->output->writeln(""); + $io->newLine(); + + return 0; } - private function check() + /** + * @return bool + */ + private function check(): bool { $success = true; + $io = $this->getIO(); if (!file_exists($this->destination)) { - $this->output->writeln(' file: $this->destination does not exist!'); + $io->writeln(' file: ' . $this->destination . ' does not exist!'); $success = false; } foreach ($this->directories as $dir) { if (!file_exists($this->destination . $dir)) { - $this->output->writeln(' directory: ' . $dir . ' does not exist!'); + $io->writeln(' directory: ' . $dir . ' does not exist!'); $success = false; } } foreach ($this->mappings as $target => $link) { if (!file_exists($this->destination . $target)) { - $this->output->writeln(' mappings: ' . $target . ' does not exist!'); + $io->writeln(' mappings: ' . $target . ' does not exist!'); $success = false; } } if (!$success) { - $this->output->writeln(''); - $this->output->writeln('install should be run with --symlink|--s to symlink first'); - exit; + $io->newLine(); + $io->writeln('install should be run with --symlink|--s to symlink first'); } + + return $success; } } diff --git a/system/src/Grav/Console/Cli/SchedulerCommand.php b/system/src/Grav/Console/Cli/SchedulerCommand.php index eaf45cd..c5385aa 100644 --- a/system/src/Grav/Console/Cli/SchedulerCommand.php +++ b/system/src/Grav/Console/Cli/SchedulerCommand.php @@ -3,7 +3,7 @@ /** * @package Grav\Console\Cli * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,15 +13,22 @@ use Cron\CronExpression; use Grav\Common\Grav; use Grav\Common\Utils; use Grav\Common\Scheduler\Scheduler; -use Grav\Console\ConsoleCommand; +use Grav\Console\GravCommand; use RocketTheme\Toolbox\Event\Event; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Style\SymfonyStyle; +use function is_null; -class SchedulerCommand extends ConsoleCommand +/** + * Class SchedulerCommand + * @package Grav\Console\Cli + */ +class SchedulerCommand extends GravCommand { - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('scheduler') @@ -43,24 +50,28 @@ class SchedulerCommand extends ConsoleCommand InputOption::VALUE_NONE, 'Show Job Details' ) + ->addOption( + 'run', + 'r', + InputOption::VALUE_OPTIONAL, + 'Force run all jobs or a specific job if you specify a specific Job ID', + false + ) ->setDescription('Run the Grav Scheduler. Best when integrated with system cron') ->setHelp("Running without any options will force the Scheduler to run through it's jobs and process them"); } - protected function serve() + /** + * @return int + */ + protected function serve(): int { -// error_reporting(1); + $this->initializePlugins(); + $grav = Grav::instance(); - $grav->setup(); - - $grav['uri']->init(); - $grav['config']->init(); - $grav['plugins']->init(); - $grav['themes']->init(); $grav['backups']->init(); - - // Initialize Plugins - $grav->fireEvent('onPluginsInitialized'); + $this->initializePages(); + $this->initializeThemes(); /** @var Scheduler $scheduler */ $scheduler = $grav['scheduler']; @@ -68,16 +79,20 @@ class SchedulerCommand extends ConsoleCommand $this->setHelp('foo'); - $io = new SymfonyStyle($this->input, $this->output); + $input = $this->getInput(); + $io = $this->getIO(); + $error = 0; - if ($this->input->getOption('jobs')) { + $run = $input->getOption('run'); + + if ($input->getOption('jobs')) { // Show jobs list $jobs = $scheduler->getAllJobs(); - $job_states = $scheduler->getJobStates()->content(); + $job_states = (array)$scheduler->getJobStates()->content(); $rows = []; - $table = new Table($this->output); + $table = new Table($io); $table->setStyle('box'); $headers = ['Job ID', 'Command', 'Run At', 'Status', 'Last Run', 'State']; @@ -111,13 +126,13 @@ class SchedulerCommand extends ConsoleCommand $io->newLine(); $io->note('For error details run "bin/grav scheduler -d"'); $io->newLine(); - } elseif ($this->input->getOption('details')) { + } elseif ($input->getOption('details')) { $jobs = $scheduler->getAllJobs(); - $job_states = $scheduler->getJobStates()->content(); + $job_states = (array)$scheduler->getJobStates()->content(); $io->title('Job Details'); - $table = new Table($this->output); + $table = new Table($io); $table->setStyle('box'); $table->setHeaders(['Job ID', 'Last Run', 'Next Run', 'Errors']); $rows = []; @@ -149,31 +164,62 @@ class SchedulerCommand extends ConsoleCommand $table->setRows($rows); $table->render(); + } elseif ($run !== false && $run !== null) { + $io->title('Force Run Job: ' . $run); - } elseif ($this->input->getOption('install')) { + $job = $scheduler->getJob($run); + + if ($job) { + $job->inForeground()->run(); + + if ($job->isSuccessful()) { + $io->success('Job ran successfully...'); + } else { + $error = 1; + $io->error('Job failed to run successfully...'); + } + + $output = $job->getOutput(); + + if ($output) { + $io->write($output); + } + } else { + $error = 1; + $io->error('Could not find a job with id: ' . $run); + } + } elseif ($input->getOption('install')) { $io->title('Install Scheduler'); + $verb = 'install'; + if ($scheduler->isCrontabSetup()) { - $io->success('All Ready! You have already set up Grav\'s Scheduler in your crontab'); + $io->success('All Ready! You have already set up Grav\'s Scheduler in your crontab. You can validate this by running "crontab -l" to list your current crontab entries.'); + $verb = 'reinstall'; } else { - $io->error('You still need to set up Grav\'s Scheduler in your crontab'); + $user = $scheduler->whoami(); + $error = 1; + $io->error('Can\'t find a crontab for ' . $user . '. You need to set up Grav\'s Scheduler in your crontab'); } if (!Utils::isWindows()) { - $io->note('To install, run the following command from your terminal:'); + $io->note("To $verb, run the following command from your terminal:"); $io->newLine(); $io->text(trim($scheduler->getCronCommand())); } else { - $io->note('To install, create a scheduled task in Windows.'); + $io->note("To $verb, create a scheduled task in Windows."); $io->text('Learn more at https://learn.getgrav.org/advanced/scheduler'); } } else { // Run scheduler - $scheduler->run(); + $force = $run === null; + $scheduler->run(null, $force); - if ($this->input->getOption('verbose')) { + if ($input->getOption('verbose')) { $io->title('Running Scheduled Jobs'); $io->text($scheduler->getVerboseOutput()); } } + + return $error; } } diff --git a/system/src/Grav/Console/Cli/SecurityCommand.php b/system/src/Grav/Console/Cli/SecurityCommand.php index 7f73086..7f728b6 100644 --- a/system/src/Grav/Console/Cli/SecurityCommand.php +++ b/system/src/Grav/Console/Cli/SecurityCommand.php @@ -3,7 +3,7 @@ /** * @package Grav\Console\Cli * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,79 +11,77 @@ namespace Grav\Console\Cli; use Grav\Common\Grav; use Grav\Common\Security; -use Grav\Console\ConsoleCommand; +use Grav\Console\GravCommand; use Symfony\Component\Console\Helper\ProgressBar; -use Symfony\Component\Console\Style\SymfonyStyle; +use function count; -class SecurityCommand extends ConsoleCommand +/** + * Class SecurityCommand + * @package Grav\Console\Cli + */ +class SecurityCommand extends GravCommand { /** @var ProgressBar $progress */ protected $progress; - protected $source; - - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('security') ->setDescription('Capable of running various Security checks') ->setHelp('The security runs various security checks on your Grav site'); - - $this->source = getcwd(); } - protected function serve() + /** + * @return int + */ + protected function serve(): int { + $this->initializePages(); + + $io = $this->getIO(); + /** @var Grav $grav */ $grav = Grav::instance(); - $grav->setup(); - - $grav['uri']->init(); - $grav['config']->init(); - $grav['debugger']->enabled(false); - $grav['plugins']->init(); - $grav['themes']->init(); - - $grav['twig']->init(); - $grav['pages']->init(); - - $this->progress = new ProgressBar($this->output, \count($grav['pages']->routes()) - 1); + $this->progress = new ProgressBar($this->output, count($grav['pages']->routes()) - 1); $this->progress->setFormat('Scanning %current% pages [%bar%] %percent:3s%% %elapsed:6s%'); $this->progress->setBarWidth(100); - $io = new SymfonyStyle($this->input, $this->output); $io->title('Grav Security Check'); + $io->newline(2); $output = Security::detectXssFromPages($grav['pages'], false, [$this, 'outputProgress']); - $io->newline(2); - + $error = 0; if (!empty($output)) { - $counter = 1; foreach ($output as $route => $results) { - - $results_parts = array_map(function($value, $key) { + $results_parts = array_map(static function ($value, $key) { return $key.': \''.$value . '\''; }, array_values($results), array_keys($results)); $io->writeln($counter++ .' - ' . $route . '' . implode(', ', $results_parts) . ''); } - $io->error('Security Scan complete: ' . \count($output) . ' potential XSS issues found...'); - + $error = 1; + $io->error('Security Scan complete: ' . count($output) . ' potential XSS issues found...'); } else { $io->success('Security Scan complete: No issues found...'); } $io->newline(1); + return $error; } /** * @param array $args + * @return void */ - public function outputProgress($args) + public function outputProgress(array $args): void { switch ($args['type']) { case 'count': @@ -101,6 +99,4 @@ class SecurityCommand extends ConsoleCommand break; } } - } - diff --git a/system/src/Grav/Console/Cli/ServerCommand.php b/system/src/Grav/Console/Cli/ServerCommand.php new file mode 100644 index 0000000..77bce8b --- /dev/null +++ b/system/src/Grav/Console/Cli/ServerCommand.php @@ -0,0 +1,154 @@ +setName('server') + ->addOption('port', 'p', InputOption::VALUE_OPTIONAL, 'Preferred HTTP port rather than auto-find (default is 8000-9000') + ->addOption('symfony', null, InputOption::VALUE_NONE, 'Force using Symfony server') + ->addOption('php', null, InputOption::VALUE_NONE, 'Force using built-in PHP server') + ->setDescription("Runs built-in web-server, Symfony first, then tries PHP's") + ->setHelp("Runs built-in web-server, Symfony first, then tries PHP's"); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $io->title('Grav Web Server'); + + // Ensure CLI colors are on + ini_set('cli_server.color', 'on'); + + // Options + $force_symfony = $input->getOption('symfony'); + $force_php = $input->getOption('php'); + + // Find PHP + $executableFinder = new PhpExecutableFinder(); + $php = $executableFinder->find(false); + + $this->ip = '127.0.0.1'; + $this->port = (int)($input->getOption('port') ?? 8000); + + // Get an open port + while (!$this->portAvailable($this->ip, $this->port)) { + $this->port++; + } + + // Setup the commands + $symfony_cmd = ['symfony', 'server:start', '--ansi', '--port=' . $this->port]; + $php_cmd = [$php, '-S', $this->ip.':'.$this->port, 'system/router.php']; + + $commands = [ + self::SYMFONY_SERVER => $symfony_cmd, + self::PHP_SERVER => $php_cmd + ]; + + if ($force_symfony) { + unset($commands[self::PHP_SERVER]); + } elseif ($force_php) { + unset($commands[self::SYMFONY_SERVER]); + } + + $error = 0; + foreach ($commands as $name => $command) { + $process = $this->runProcess($name, $command); + if (!$process) { + $io->note('Starting ' . $name . '...'); + } + + // Should only get here if there's an error running + if (!$process->isRunning() && (($name === self::SYMFONY_SERVER && $force_symfony) || ($name === self::PHP_SERVER))) { + $error = 1; + $io->error('Could not start ' . $name); + } + } + + return $error; + } + + /** + * @param string $name + * @param array $cmd + * @return Process + */ + protected function runProcess(string $name, array $cmd): Process + { + $io = $this->getIO(); + + $process = new Process($cmd); + $process->setTimeout(0); + $process->start(); + + if ($name === self::SYMFONY_SERVER && Utils::contains($process->getErrorOutput(), 'symfony: not found')) { + $io->error('The symfony binary could not be found, please install the CLI tools: https://symfony.com/download'); + $io->warning('Falling back to PHP web server...'); + } + + if ($name === self::PHP_SERVER) { + $io->success('Built-in PHP web server listening on http://' . $this->ip . ':' . $this->port . ' (PHP v' . PHP_VERSION . ')'); + } + + $process->wait(function ($type, $buffer) { + $this->getIO()->write($buffer); + }); + + return $process; + } + + /** + * Simple function test the port + * + * @param string $ip + * @param int $port + * @return bool + */ + protected function portAvailable(string $ip, int $port): bool + { + $fp = @fsockopen($ip, $port, $errno, $errstr, 0.1); + if (!$fp) { + return true; + } + + fclose($fp); + + return false; + } +} diff --git a/system/src/Grav/Console/Cli/YamlLinterCommand.php b/system/src/Grav/Console/Cli/YamlLinterCommand.php index 894f1c0..a9628cd 100644 --- a/system/src/Grav/Console/Cli/YamlLinterCommand.php +++ b/system/src/Grav/Console/Cli/YamlLinterCommand.php @@ -3,80 +3,122 @@ /** * @package Grav\Console\Cli * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\Cli; -use Grav\Common\Grav; -use Grav\Common\Helpers\LogViewer; use Grav\Common\Helpers\YamlLinter; -use Grav\Common\Utils; -use Grav\Console\ConsoleCommand; +use Grav\Console\GravCommand; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Style\SymfonyStyle; -class YamlLinterCommand extends ConsoleCommand +/** + * Class YamlLinterCommand + * @package Grav\Console\Cli + */ +class YamlLinterCommand extends GravCommand { - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('yamllinter') ->addOption( - 'env', - 'e', + 'all', + 'a', + InputOption::VALUE_NONE, + 'Go through the whole Grav installation' + ) + ->addOption( + 'folder', + 'f', InputOption::VALUE_OPTIONAL, - 'The environment to trigger a specific configuration. For example: localhost, mysite.dev, www.mysite.com' + 'Go through specific folder' ) ->setDescription('Checks various files for YAML errors') - ->setHelp("Checks various files for YAML errors"); + ->setHelp('Checks various files for YAML errors'); } - protected function serve() + /** + * @return int + */ + protected function serve(): int { - $grav = Grav::instance(); - $grav->setup(); - - $io = new SymfonyStyle($this->input, $this->output); + $input = $this->getInput(); + $io = $this->getIO(); $io->title('Yaml Linter'); - $io->section('User Configuration'); - $errors = YamlLinter::lintConfig(); + $error = 0; + if ($input->getOption('all')) { + $io->section('All'); + $errors = YamlLinter::lint(''); - if (empty($errors)) { - $io->success('No YAML Linting issues with configuration'); + if (empty($errors)) { + $io->success('No YAML Linting issues found'); + } else { + $error = 1; + $this->displayErrors($errors, $io); + } + } elseif ($folder = $input->getOption('folder')) { + $io->section($folder); + $errors = YamlLinter::lint($folder); + + if (empty($errors)) { + $io->success('No YAML Linting issues found'); + } else { + $error = 1; + $this->displayErrors($errors, $io); + } } else { - $this->displayErrors($errors, $io); - } - - - $io->section('Pages Frontmatter'); - $errors = YamlLinter::lintPages(); - - if (empty($errors)) { - $io->success('No YAML Linting issues with pages'); - } else { - $this->displayErrors($errors, $io); - } - - $io->section('Page Blueprints'); - $errors = YamlLinter::lintBlueprints(); - - if (empty($errors)) { - $io->success('No YAML Linting issues with blueprints'); - } else { - $this->displayErrors($errors, $io); + $io->section('User Configuration'); + $errors = YamlLinter::lintConfig(); + + if (empty($errors)) { + $io->success('No YAML Linting issues with configuration'); + } else { + $error = 1; + $this->displayErrors($errors, $io); + } + + $io->section('Pages Frontmatter'); + $errors = YamlLinter::lintPages(); + + if (empty($errors)) { + $io->success('No YAML Linting issues with pages'); + } else { + $error = 1; + $this->displayErrors($errors, $io); + } + + $io->section('Page Blueprints'); + $errors = YamlLinter::lintBlueprints(); + + if (empty($errors)) { + $io->success('No YAML Linting issues with blueprints'); + } else { + $error = 1; + $this->displayErrors($errors, $io); + } } + return $error; } - protected function displayErrors($errors, $io) + /** + * @param array $errors + * @param SymfonyStyle $io + * @return void + */ + protected function displayErrors(array $errors, SymfonyStyle $io): void { - $io->error("YAML Linting issues found..."); + $io->error('YAML Linting issues found...'); foreach ($errors as $path => $error) { - $io->writeln("$path - $error"); + $io->writeln("{$path} - {$error}"); } } } diff --git a/system/src/Grav/Console/ConsoleCommand.php b/system/src/Grav/Console/ConsoleCommand.php index c0fd9ad..044f1b0 100644 --- a/system/src/Grav/Console/ConsoleCommand.php +++ b/system/src/Grav/Console/ConsoleCommand.php @@ -3,184 +3,44 @@ /** * @package Grav\Console * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console; -use Grav\Common\Grav; -use Grav\Common\Language\Language; -use Grav\Common\Page\Page; -use Grav\Common\Processors\InitializeProcessor; -use RocketTheme\Toolbox\Event\Event; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +/** + * Class ConsoleCommand + * @package Grav\Console + */ class ConsoleCommand extends Command { use ConsoleTrait; - /** @var bool */ - private $plugins_initialized = false; - /** @var bool */ - private $themes_initialized = false; - /** @var bool */ - private $pages_initialized = false; - /** * @param InputInterface $input * @param OutputInterface $output - * - * @return int|null|void + * @return int */ protected function execute(InputInterface $input, OutputInterface $output) { $this->setupConsole($input, $output); - $this->serve(); + + return $this->serve(); } /** * Override with your implementation. + * + * @return int */ protected function serve() { + // Return error. + return 1; } - - /** - * Initialize Grav. - * - * - Load configuration - * - Disable debugger - * - Set timezone, locale - * - Load plugins - * - Set Users type to be used in the site - * - * Safe to be called multiple times. - * - * @return $this - */ - final protected function initializeGrav() - { - InitializeProcessor::initializeCli(Grav::instance()); - - return $this; - } - - /** - * Set language to be used in CLI. - * - * @param string|null $code - */ - final protected function setLanguage(string $code = null) - { - $this->initializeGrav(); - - $grav = Grav::instance(); - /** @var Language $language */ - $language = $grav['language']; - if ($language->enabled()) { - if ($code && $language->validate($code)) { - $language->setActive($code); - } else { - $language->setActive($language->getDefault()); - } - } - } - - /** - * Properly initialize plugins. - * - * - call $this->initializeGrav() - * - call onPluginsInitialized event - * - * Safe to be called multiple times. - * - * @return $this - */ - final protected function initializePlugins() - { - if (!$this->plugins_initialized) { - $this->plugins_initialized = true; - - $this->initializeGrav(); - - // Initialize plugins. - $grav = Grav::instance(); - $grav->fireEvent('onPluginsInitialized'); - } - - return $this; - } - - /** - * Properly initialize themes. - * - * - call $this->initializePlugins() - * - initialize theme (call onThemeInitialized event) - * - * Safe to be called multiple times. - * - * @return $this - */ - final protected function initializeThemes() - { - if (!$this->themes_initialized) { - $this->themes_initialized = true; - - $this->initializePlugins(); - - // Initialize themes. - $grav = Grav::instance(); - $grav['themes']->init(); - } - - return $this; - } - - /** - * Properly initialize pages. - * - * - call $this->initializeThemes() - * - initialize assets (call onAssetsInitialized event) - * - initialize twig (calls the twig events) - * - initialize pages (calls onPagesInitialized event) - * - * Safe to be called multiple times. - * - * @return $this - */ - final protected function initializePages() - { - if (!$this->pages_initialized) { - $this->pages_initialized = true; - - $this->initializeThemes(); - - $grav = Grav::instance(); - - // Initialize assets. - $grav['assets']->init(); - $grav->fireEvent('onAssetsInitialized'); - - // Initialize twig. - $grav['twig']->init(); - - // Initialize pages. - $pages = $grav['pages']; - $pages->init(); - $grav->fireEvent('onPagesInitialized', new Event(['pages' => $pages])); - } - - return $this; - } - - protected function displayGPMRelease() - { - $this->output->writeln(''); - $this->output->writeln('GPM Releases Configuration: ' . ucfirst(Grav::instance()['config']->get('system.gpm.releases')) . ''); - $this->output->writeln(''); - } - } diff --git a/system/src/Grav/Console/ConsoleTrait.php b/system/src/Grav/Console/ConsoleTrait.php index 1901095..feb10d3 100644 --- a/system/src/Grav/Console/ConsoleTrait.php +++ b/system/src/Grav/Console/ConsoleTrait.php @@ -3,91 +3,287 @@ /** * @package Grav\Console * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console; +use Exception; use Grav\Common\Cache; use Grav\Common\Grav; use Grav\Common\Composer; -use Grav\Common\GravTrait; +use Grav\Common\Language\Language; +use Grav\Common\Processors\InitializeProcessor; use Grav\Console\Cli\ClearCacheCommand; +use RocketTheme\Toolbox\Event\Event; use RocketTheme\Toolbox\File\YamlFile; -use Symfony\Component\Console\Formatter\OutputFormatterStyle; +use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +/** + * Trait ConsoleTrait + * @package Grav\Console + */ trait ConsoleTrait { + /** @var string */ protected $argv; - - /* @var InputInterface $output */ + /** @var InputInterface */ protected $input; - - /* @var OutputInterface $output */ + /** @var SymfonyStyle */ protected $output; - /** @var array */ protected $local_config; + /** @var bool */ + private $plugins_initialized = false; + /** @var bool */ + private $themes_initialized = false; + /** @var bool */ + private $pages_initialized = false; + /** * Set colors style definition for the formatter. * * @param InputInterface $input * @param OutputInterface $output + * @return void */ public function setupConsole(InputInterface $input, OutputInterface $output) { - // Initialize cache with CLI compatibility - Grav::instance()['config']->set('system.cache.cli_compatibility', true); - Grav::instance()['cache']; - $this->argv = $_SERVER['argv'][0]; - $this->input = $input; - $this->output = $output; + $this->input = $input; + $this->output = new SymfonyStyle($input, $output); - $this->output->getFormatter()->setStyle('normal', new OutputFormatterStyle('white')); - $this->output->getFormatter()->setStyle('yellow', new OutputFormatterStyle('yellow', null, array('bold'))); - $this->output->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, array('bold'))); - $this->output->getFormatter()->setStyle('cyan', new OutputFormatterStyle('cyan', null, array('bold'))); - $this->output->getFormatter()->setStyle('green', new OutputFormatterStyle('green', null, array('bold'))); - $this->output->getFormatter()->setStyle('magenta', new OutputFormatterStyle('magenta', null, array('bold'))); - $this->output->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, array('bold'))); + $this->setupGrav(); + } + + public function getInput(): InputInterface + { + return $this->input; + } + + /** + * @return SymfonyStyle + */ + public function getIO(): SymfonyStyle + { + return $this->output; + } + + /** + * Adds an option. + * + * @param string $name The option name + * @param string|array|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts + * @param int|null $mode The option mode: One of the InputOption::VALUE_* constants + * @param string $description A description text + * @param string|string[]|int|bool|null $default The default value (must be null for InputOption::VALUE_NONE) + * @return $this + * @throws InvalidArgumentException If option mode is invalid or incompatible + */ + public function addOption($name, $shortcut = null, $mode = null, $description = '', $default = null) + { + if ($name !== 'env' && $name !== 'lang') { + parent::addOption($name, $shortcut, $mode, $description, $default); + } + + return $this; + } + + /** + * @return void + */ + final protected function setupGrav(): void + { + try { + $language = $this->input->getOption('lang'); + if ($language) { + // Set used language. + $this->setLanguage($language); + } + } catch (InvalidArgumentException $e) {} + + // Initialize cache with CLI compatibility + $grav = Grav::instance(); + $grav['config']->set('system.cache.cli_compatibility', true); + } + + /** + * Initialize Grav. + * + * - Load configuration + * - Initialize logger + * - Disable debugger + * - Set timezone, locale + * - Load plugins (call PluginsLoadedEvent) + * - Set Pages and Users type to be used in the site + * + * Safe to be called multiple times. + * + * @return $this + */ + final protected function initializeGrav() + { + InitializeProcessor::initializeCli(Grav::instance()); + + return $this; + } + + /** + * Set language to be used in CLI. + * + * @param string|null $code + * @return $this + */ + final protected function setLanguage(string $code = null) + { + $this->initializeGrav(); + + $grav = Grav::instance(); + /** @var Language $language */ + $language = $grav['language']; + if ($language->enabled()) { + if ($code && $language->validate($code)) { + $language->setActive($code); + } else { + $language->setActive($language->getDefault()); + } + } + + return $this; + } + + /** + * Properly initialize plugins. + * + * - call $this->initializeGrav() + * - call onPluginsInitialized event + * + * Safe to be called multiple times. + * + * @return $this + */ + final protected function initializePlugins() + { + if (!$this->plugins_initialized) { + $this->plugins_initialized = true; + + $this->initializeGrav(); + + // Initialize plugins. + $grav = Grav::instance(); + $grav['plugins']->init(); + $grav->fireEvent('onPluginsInitialized'); + } + + return $this; + } + + /** + * Properly initialize themes. + * + * - call $this->initializePlugins() + * - initialize theme (call onThemeInitialized event) + * + * Safe to be called multiple times. + * + * @return $this + */ + final protected function initializeThemes() + { + if (!$this->themes_initialized) { + $this->themes_initialized = true; + + $this->initializePlugins(); + + // Initialize themes. + $grav = Grav::instance(); + $grav['themes']->init(); + } + + return $this; + } + + /** + * Properly initialize pages. + * + * - call $this->initializeThemes() + * - initialize assets (call onAssetsInitialized event) + * - initialize twig (calls the twig events) + * - initialize pages (calls onPagesInitialized event) + * + * Safe to be called multiple times. + * + * @return $this + */ + final protected function initializePages() + { + if (!$this->pages_initialized) { + $this->pages_initialized = true; + + $this->initializeThemes(); + + $grav = Grav::instance(); + + // Initialize assets. + $grav['assets']->init(); + $grav->fireEvent('onAssetsInitialized'); + + // Initialize twig. + $grav['twig']->init(); + + // Initialize pages. + $pages = $grav['pages']; + $pages->init(); + $grav->fireEvent('onPagesInitialized', new Event(['pages' => $pages])); + } + + return $this; } /** * @param string $path + * @return void */ public function isGravInstance($path) { + $io = $this->getIO(); + if (!file_exists($path)) { - $this->output->writeln(''); - $this->output->writeln("ERROR: Destination doesn't exist:"); - $this->output->writeln(" $path"); - $this->output->writeln(''); + $io->writeln(''); + $io->writeln("ERROR: Destination doesn't exist:"); + $io->writeln(" $path"); + $io->writeln(''); exit; } if (!is_dir($path)) { - $this->output->writeln(''); - $this->output->writeln("ERROR: Destination chosen to install is not a directory:"); - $this->output->writeln(" $path"); - $this->output->writeln(''); + $io->writeln(''); + $io->writeln("ERROR: Destination chosen to install is not a directory:"); + $io->writeln(" $path"); + $io->writeln(''); exit; } if (!file_exists($path . DS . 'index.php') || !file_exists($path . DS . '.dependencies') || !file_exists($path . DS . 'system' . DS . 'config' . DS . 'system.yaml')) { - $this->output->writeln(''); - $this->output->writeln('ERROR: Destination chosen to install does not appear to be a Grav instance:'); - $this->output->writeln(" $path"); - $this->output->writeln(''); + $io->writeln(''); + $io->writeln('ERROR: Destination chosen to install does not appear to be a Grav instance:'); + $io->writeln(" $path"); + $io->writeln(''); exit; } } + /** + * @param string $path + * @param string $action + * @return string|false + */ public function composerUpdate($path, $action = 'install') { $composer = Composer::getComposerExecutor(); @@ -97,9 +293,8 @@ trait ConsoleTrait /** * @param array $all - * * @return int - * @throws \Exception + * @throws Exception */ public function clearCache($all = []) { @@ -112,6 +307,9 @@ trait ConsoleTrait return $command->run($input, $this->output); } + /** + * @return void + */ public function invalidateCache() { Cache::invalidateCache(); @@ -120,7 +318,7 @@ trait ConsoleTrait /** * Load the local config file * - * @return mixed string the local config file name. false if local config does not exist + * @return string|false The local config file name. false if local config does not exist */ public function loadLocalConfig() { @@ -131,6 +329,7 @@ trait ConsoleTrait $file = YamlFile::instance($local_config_file); $this->local_config = $file->content(); $file->free(); + return $local_config_file; } diff --git a/system/src/Grav/Console/Gpm/DirectInstallCommand.php b/system/src/Grav/Console/Gpm/DirectInstallCommand.php index bd27651..e5c662b 100644 --- a/system/src/Grav/Console/Gpm/DirectInstallCommand.php +++ b/system/src/Grav/Console/Gpm/DirectInstallCommand.php @@ -3,32 +3,43 @@ /** * @package Grav\Console\Gpm * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\Gpm; +use Exception; use Grav\Common\Cache; use Grav\Common\Grav; use Grav\Common\Filesystem\Folder; use Grav\Common\GPM\GPM; use Grav\Common\GPM\Installer; use Grav\Common\GPM\Response; -use Grav\Console\ConsoleCommand; +use Grav\Console\GpmCommand; +use RuntimeException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; +use ZipArchive; +use function is_array; +use function is_callable; -class DirectInstallCommand extends ConsoleCommand +/** + * Class DirectInstallCommand + * @package Grav\Console\Gpm + */ +class DirectInstallCommand extends GpmCommand { /** @var string */ protected $all_yes; - /** @var string */ protected $destination; - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('direct-install') @@ -56,96 +67,108 @@ class DirectInstallCommand extends ConsoleCommand } /** - * @return bool + * @return int */ - protected function serve() + protected function serve(): int { - // Making sure the destination is usable - $this->destination = realpath($this->input->getOption('destination')); + $input = $this->getInput(); + $io = $this->getIO(); - if ( - !Installer::isGravInstance($this->destination) || - !Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK]) - ) { - $this->output->writeln('ERROR: ' . Installer::lastErrorMsg()); - exit; + if (!class_exists(ZipArchive::class)) { + $io->title('Direct Install'); + $io->error('php-zip extension needs to be enabled!'); + + return 1; } + // Making sure the destination is usable + $this->destination = realpath($input->getOption('destination')); - $this->all_yes = $this->input->getOption('all-yes'); + if (!Installer::isGravInstance($this->destination) || + !Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK]) + ) { + $io->writeln('ERROR: ' . Installer::lastErrorMsg()); - $package_file = $this->input->getArgument('package-file'); + return 1; + } + + $this->all_yes = $input->getOption('all-yes'); + + $package_file = $input->getArgument('package-file'); - $helper = $this->getHelper('question'); $question = new ConfirmationQuestion("Are you sure you want to direct-install {$package_file} [y|N] ", false); - $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question); + $answer = $this->all_yes ? true : $io->askQuestion($question); if (!$answer) { - $this->output->writeln('exiting...'); - $this->output->writeln(''); - exit; + $io->writeln('exiting...'); + $io->newLine(); + + return 1; } $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); - $tmp_zip = $tmp_dir . '/Grav-' . uniqid(); - - $this->output->writeln(''); - $this->output->writeln("Preparing to install {$package_file}"); + $tmp_zip = $tmp_dir . uniqid('/Grav-', false); + $io->newLine(); + $io->writeln("Preparing to install {$package_file}"); + $zip = null; if (Response::isRemote($package_file)) { - $this->output->write(' |- Downloading package... 0%'); + $io->write(' |- Downloading package... 0%'); try { $zip = GPM::downloadPackage($package_file, $tmp_zip); - } catch (\RuntimeException $e) { - $this->output->writeln(''); - $this->output->writeln(" `- ERROR: {$e->getMessage()}"); - $this->output->writeln(''); - exit; + } catch (RuntimeException $e) { + $io->newLine(); + $io->writeln(" `- ERROR: {$e->getMessage()}"); + $io->newLine(); + + return 1; } if ($zip) { - $this->output->write("\x0D"); - $this->output->write(' |- Downloading package... 100%'); - $this->output->writeln(''); + $io->write("\x0D"); + $io->write(' |- Downloading package... 100%'); + $io->newLine(); } - } else { - $this->output->write(' |- Copying package... 0%'); + } elseif (is_file($package_file)) { + $io->write(' |- Copying package... 0%'); $zip = GPM::copyPackage($package_file, $tmp_zip); if ($zip) { - $this->output->write("\x0D"); - $this->output->write(' |- Copying package... 100%'); - $this->output->writeln(''); + $io->write("\x0D"); + $io->write(' |- Copying package... 100%'); + $io->newLine(); } } - if (file_exists($zip)) { - $tmp_source = $tmp_dir . '/Grav-' . uniqid(); + if ($zip && file_exists($zip)) { + $tmp_source = $tmp_dir . uniqid('/Grav-', false); - $this->output->write(' |- Extracting package... '); + $io->write(' |- Extracting package... '); $extracted = Installer::unZip($zip, $tmp_source); if (!$extracted) { - $this->output->write("\x0D"); - $this->output->writeln(' |- Extracting package... failed'); + $io->write("\x0D"); + $io->writeln(' |- Extracting package... failed'); Folder::delete($tmp_source); Folder::delete($tmp_zip); - exit; + + return 1; } - $this->output->write("\x0D"); - $this->output->writeln(' |- Extracting package... ok'); + $io->write("\x0D"); + $io->writeln(' |- Extracting package... ok'); $type = GPM::getPackageType($extracted); if (!$type) { - $this->output->writeln(" '- ERROR: Not a valid Grav package"); - $this->output->writeln(''); + $io->writeln(" '- ERROR: Not a valid Grav package"); + $io->newLine(); Folder::delete($tmp_source); Folder::delete($tmp_zip); - exit; + + return 1; } $blueprint = GPM::getBlueprints($extracted); @@ -153,84 +176,86 @@ class DirectInstallCommand extends ConsoleCommand if (isset($blueprint['dependencies'])) { $dependencies = []; foreach ($blueprint['dependencies'] as $dependency) { - if (is_array($dependency)){ - if (isset($dependency['name'])) { - $dependencies[] = $dependency['name']; - } - if (isset($dependency['github'])) { - $dependencies[] = $dependency['github']; - } + if (is_array($dependency)) { + if (isset($dependency['name'])) { + $dependencies[] = $dependency['name']; + } + if (isset($dependency['github'])) { + $dependencies[] = $dependency['github']; + } } else { $dependencies[] = $dependency; } } - $this->output->writeln(' |- Dependencies found... [' . implode(',', $dependencies) . ']'); + $io->writeln(' |- Dependencies found... [' . implode(',', $dependencies) . ']'); $question = new ConfirmationQuestion(" | '- Dependencies will not be satisfied. Continue ? [y|N] ", false); - $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question); + $answer = $this->all_yes ? true : $io->askQuestion($question); if (!$answer) { - $this->output->writeln('exiting...'); - $this->output->writeln(''); + $io->writeln('exiting...'); + $io->newLine(); Folder::delete($tmp_source); Folder::delete($tmp_zip); - exit; + + return 1; } } } if ($type === 'grav') { - - $this->output->write(' |- Checking destination... '); + $io->write(' |- Checking destination... '); Installer::isValidDestination(GRAV_ROOT . '/system'); if (Installer::IS_LINK === Installer::lastErrorCode()) { - $this->output->write("\x0D"); - $this->output->writeln(' |- Checking destination... symbolic link'); - $this->output->writeln(" '- ERROR: symlinks found... " . GRAV_ROOT . ''); - $this->output->writeln(''); + $io->write("\x0D"); + $io->writeln(' |- Checking destination... symbolic link'); + $io->writeln(" '- ERROR: symlinks found... " . GRAV_ROOT . ''); + $io->newLine(); Folder::delete($tmp_source); Folder::delete($tmp_zip); - exit; + + return 1; } - $this->output->write("\x0D"); - $this->output->writeln(' |- Checking destination... ok'); + $io->write("\x0D"); + $io->writeln(' |- Checking destination... ok'); - $this->output->write(' |- Installing package... '); + $io->write(' |- Installing package... '); - static::upgradeGrav($zip, $extracted); + $this->upgradeGrav($zip, $extracted); } else { $name = GPM::getPackageName($extracted); if (!$name) { - $this->output->writeln('ERROR: Name could not be determined. Please specify with --name|-n'); - $this->output->writeln(''); + $io->writeln('ERROR: Name could not be determined. Please specify with --name|-n'); + $io->newLine(); Folder::delete($tmp_source); Folder::delete($tmp_zip); - exit; + + return 1; } $install_path = GPM::getInstallPath($type, $name); $is_update = file_exists($install_path); - $this->output->write(' |- Checking destination... '); + $io->write(' |- Checking destination... '); Installer::isValidDestination(GRAV_ROOT . DS . $install_path); - if (Installer::lastErrorCode() == Installer::IS_LINK) { - $this->output->write("\x0D"); - $this->output->writeln(' |- Checking destination... symbolic link'); - $this->output->writeln(" '- ERROR: symlink found... " . GRAV_ROOT . DS . $install_path . ''); - $this->output->writeln(''); + if (Installer::lastErrorCode() === Installer::IS_LINK) { + $io->write("\x0D"); + $io->writeln(' |- Checking destination... symbolic link'); + $io->writeln(" '- ERROR: symlink found... " . GRAV_ROOT . DS . $install_path . ''); + $io->newLine(); Folder::delete($tmp_source); Folder::delete($tmp_zip); - exit; + return 1; } - $this->output->write("\x0D"); - $this->output->writeln(' |- Checking destination... ok'); + $io->write("\x0D"); + $io->writeln(' |- Checking destination... ok'); - $this->output->write(' |- Installing package... '); + $io->write(' |- Installing package... '); Installer::install( $zip, @@ -242,46 +267,42 @@ class DirectInstallCommand extends ConsoleCommand ], $extracted ); + + // clear cache after successful upgrade + $this->clearCache(); } Folder::delete($tmp_source); - $this->output->write("\x0D"); + $io->write("\x0D"); - if(Installer::lastErrorCode()) { - $this->output->writeln(" '- " . Installer::lastErrorMsg() . ''); - $this->output->writeln(''); + if (Installer::lastErrorCode()) { + $io->writeln(" '- " . Installer::lastErrorMsg() . ''); + $io->newLine(); } else { - $this->output->writeln(' |- Installing package... ok'); - $this->output->writeln(" '- Success! "); - $this->output->writeln(''); + $io->writeln(' |- Installing package... ok'); + $io->writeln(" '- Success! "); + $io->newLine(); } - } else { - $this->output->writeln(" '- ERROR: ZIP package could not be found"); + $io->writeln(" '- ERROR: ZIP package could not be found"); + Folder::delete($tmp_zip); + + return 1; } Folder::delete($tmp_zip); - // clear cache after successful upgrade - $this->clearCache(); - - return true; + return 0; } - private function upgradeGrav($zip, $folder, $keepFolder = false) + /** + * @param string $zip + * @param string $folder + * @return void + */ + private function upgradeGrav(string $zip, string $folder): void { - static $ignores = [ - 'backup', - 'cache', - 'images', - 'logs', - 'tmp', - 'user', - '.htaccess', - 'robots.txt' - ]; - if (!is_dir($folder)) { Installer::setError('Invalid source folder'); } @@ -292,17 +313,9 @@ class DirectInstallCommand extends ConsoleCommand if ((file_exists($script) && $install = include $script) && is_callable($install)) { $install($zip); } else { - Installer::install( - $zip, - GRAV_ROOT, - ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $ignores], - $folder, - $keepFolder - ); - - Cache::clearCache(); + throw new RuntimeException('Uploaded archive file is not a valid Grav update package'); } - } catch (\Exception $e) { + } catch (Exception $e) { Installer::setError($e->getMessage()); } } diff --git a/system/src/Grav/Console/Gpm/IndexCommand.php b/system/src/Grav/Console/Gpm/IndexCommand.php index 06d662b..64d3597 100644 --- a/system/src/Grav/Console/Gpm/IndexCommand.php +++ b/system/src/Grav/Console/Gpm/IndexCommand.php @@ -3,35 +3,41 @@ /** * @package Grav\Console\Gpm * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\Gpm; +use Grav\Common\GPM\Remote\AbstractPackageCollection; use Grav\Common\GPM\Remote\Package; use Grav\Common\GPM\GPM; use Grav\Common\GPM\Remote\Packages; +use Grav\Common\GPM\Remote\Plugins; +use Grav\Common\GPM\Remote\Themes; use Grav\Common\Utils; -use Grav\Console\ConsoleCommand; -use League\CLImate\CLImate; +use Grav\Console\GpmCommand; +use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputOption; +use function count; -class IndexCommand extends ConsoleCommand +/** + * Class IndexCommand + * @package Grav\Console\Gpm + */ +class IndexCommand extends GpmCommand { - /** @var array */ + /** @var Packages */ protected $data; - /** @var GPM */ protected $gpm; - /** @var array */ protected $options; - /** @var array */ - protected $sortKeys = ['name', 'slug', 'author', 'date']; - - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('index') @@ -74,9 +80,9 @@ class IndexCommand extends ConsoleCommand ->addOption( 'sort', 's', - InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - 'Allows to sort (ASC) the results based on one or multiple keys. SORT can be either "name", "slug", "author", "date"', - ['date'] + InputOption::VALUE_REQUIRED, + 'Allows to sort (ASC) the results. SORT can be either "name", "slug", "author", "date"', + 'date' ) ->addOption( 'desc', @@ -84,74 +90,94 @@ class IndexCommand extends ConsoleCommand InputOption::VALUE_NONE, 'Reverses the order of the output.' ) + ->addOption( + 'enabled', + 'e', + InputOption::VALUE_NONE, + 'Filters the results to only enabled Themes and Plugins.' + ) + ->addOption( + 'disabled', + 'd', + InputOption::VALUE_NONE, + 'Filters the results to only disabled Themes and Plugins.' + ) ->setDescription('Lists the plugins and themes available for installation') ->setHelp('The index command lists the plugins and themes available for installation') ; } - protected function serve() + /** + * @return int + */ + protected function serve(): int { - $this->options = $this->input->getOptions(); + $input = $this->getInput(); + $this->options = $input->getOptions(); $this->gpm = new GPM($this->options['force']); $this->displayGPMRelease(); $this->data = $this->gpm->getRepository(); $data = $this->filter($this->data); - $climate = new CLImate; - $climate->extend('Grav\Console\TerminalObjects\Table'); + $io = $this->getIO(); - if (!$data) { - $this->output->writeln('No data was found in the GPM repository stored locally.'); - $this->output->writeln('Please try clearing cache and running the bin/gpm index -f command again'); - $this->output->writeln('If this doesn\'t work try tweaking your GPM system settings.'); - $this->output->writeln(''); - $this->output->writeln('For more help go to:'); - $this->output->writeln(' -> https://learn.getgrav.org/troubleshooting/common-problems#cannot-connect-to-the-gpm'); + if (count($data) === 0) { + $io->writeln('No data was found in the GPM repository stored locally.'); + $io->writeln('Please try clearing cache and running the bin/gpm index -f command again'); + $io->writeln('If this doesn\'t work try tweaking your GPM system settings.'); + $io->newLine(); + $io->writeln('For more help go to:'); + $io->writeln(' -> https://learn.getgrav.org/troubleshooting/common-problems#cannot-connect-to-the-gpm'); - die; + return 1; } foreach ($data as $type => $packages) { - $this->output->writeln('' . strtoupper($type) . ' [ ' . \count($packages) . ' ]'); + $io->writeln('' . strtoupper($type) . ' [ ' . count($packages) . ' ]'); + $packages = $this->sort($packages); if (!empty($packages)) { + $io->section('Packages table'); + $table = new Table($io); + $table->setHeaders(['Count', 'Name', 'Slug', 'Version', 'Installed', 'Enabled']); - $table = []; - $index = 0; - + $index = 0; foreach ($packages as $slug => $package) { $row = [ 'Count' => $index++ + 1, 'Name' => '' . Utils::truncate($package->name, 20, false, ' ', '...') . ' ', 'Slug' => $slug, 'Version'=> $this->version($package), - 'Installed' => $this->installed($package) + 'Installed' => $this->installed($package), + 'Enabled' => $this->enabled($package), ]; - $table[] = $row; + + $table->addRow($row); } - $climate->table($table); + $table->render(); } - $this->output->writeln(''); + $io->newLine(); } - $this->output->writeln('You can either get more informations about a package by typing:'); - $this->output->writeln(" {$this->argv} info "); - $this->output->writeln(''); - $this->output->writeln('Or you can install a package by typing:'); - $this->output->writeln(" {$this->argv} install "); - $this->output->writeln(''); + $io->writeln('You can either get more informations about a package by typing:'); + $io->writeln(" {$this->argv} info "); + $io->newLine(); + $io->writeln('Or you can install a package by typing:'); + $io->writeln(" {$this->argv} install "); + $io->newLine(); + + return 0; } /** * @param Package $package - * * @return string */ - private function version($package) + private function version(Package $package): string { $list = $this->gpm->{'getUpdatable' . ucfirst($package->package_type)}(); $package = $list[$package->slug] ?? $package; @@ -165,19 +191,14 @@ class IndexCommand extends ConsoleCommand return "v{$version}"; } - if ($updatable) { - return "v{$package->version} -> v{$package->available}"; - } - - return ''; + return "v{$package->version} -> v{$package->available}"; } /** * @param Package $package - * * @return string */ - private function installed($package) + private function installed(Package $package): string { $package = $list[$package->slug] ?? $package; $type = ucfirst(preg_replace('/s$/', '', $package->package_type)); @@ -188,11 +209,35 @@ class IndexCommand extends ConsoleCommand } /** - * @param array $data - * - * @return mixed + * @param Package $package + * @return string */ - public function filter($data) + private function enabled(Package $package): string + { + $package = $list[$package->slug] ?? $package; + $type = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $method = 'is' . $type . 'Installed'; + $installed = $this->gpm->{$method}($package->slug); + + $result = ''; + if ($installed) { + $method = 'is' . $type . 'Enabled'; + $enabled = $this->gpm->{$method}($package->slug); + if ($enabled === true) { + $result = 'enabled'; + } elseif ($enabled === false) { + $result = 'disabled'; + } + } + + return $result; + } + + /** + * @param Packages $data + * @return Packages + */ + public function filter(Packages $data): Packages { // filtering and sorting if ($this->options['plugins-only']) { @@ -203,13 +248,15 @@ class IndexCommand extends ConsoleCommand } $filter = [ + $this->options['desc'], + $this->options['disabled'], + $this->options['enabled'], $this->options['filter'], $this->options['installed-only'], $this->options['updates-only'], - $this->options['desc'] ]; - if (\count(array_filter($filter))) { + if (count(array_filter($filter))) { foreach ($data as $type => $packages) { foreach ($packages as $slug => $package) { $filter = true; @@ -220,7 +267,7 @@ class IndexCommand extends ConsoleCommand } // Filtering updatables only - if ($filter && $this->options['installed-only']) { + if ($filter && ($this->options['installed-only'] || $this->options['enabled'] || $this->options['disabled'])) { $method = ucfirst(preg_replace('/s$/', '', $package->package_type)); $function = 'is' . $method . 'Installed'; $filter = $this->gpm->{$function}($package->slug); @@ -233,6 +280,29 @@ class IndexCommand extends ConsoleCommand $filter = $this->gpm->{$function}($package->slug); } + // Filtering enabled only + if ($filter && $this->options['enabled']) { + $method = ucfirst(preg_replace('/s$/', '', $package->package_type)); + + // Check if packaged is enabled. + $function = 'is' . $method . 'Enabled'; + $filter = $this->gpm->{$function}($package->slug); + } + + // Filtering disabled only + if ($filter && $this->options['disabled']) { + $method = ucfirst(preg_replace('/s$/', '', $package->package_type)); + + // Check if package is disabled. + $function = 'is' . $method . 'Enabled'; + $enabled_filter = $this->gpm->{$function}($package->slug); + + // Apply filtering results. + if (!( $enabled_filter === false)) { + $filter = false; + } + } + if (!$filter) { unset($data[$type][$slug]); } @@ -244,22 +314,24 @@ class IndexCommand extends ConsoleCommand } /** - * @param Packages $packages + * @param AbstractPackageCollection|Plugins|Themes $packages + * @return array */ - public function sort($packages) + public function sort(AbstractPackageCollection $packages): array { - foreach ($this->options['sort'] as $key) { - $packages = $packages->sort(function ($a, $b) use ($key) { + $key = $this->options['sort']; + + // Sorting only works once. + return $packages->sort( + function ($a, $b) use ($key) { switch ($key) { case 'author': return strcmp($a->{$key}['name'], $b->{$key}['name']); - break; default: return strcmp($a->$key, $b->$key); } - }, $this->options['desc'] ? true : false); - } - - return $packages; + }, + $this->options['desc'] ? true : false + ); } } diff --git a/system/src/Grav/Console/Gpm/InfoCommand.php b/system/src/Grav/Console/Gpm/InfoCommand.php index 2fe1a2e..5dddd32 100644 --- a/system/src/Grav/Console/Gpm/InfoCommand.php +++ b/system/src/Grav/Console/Gpm/InfoCommand.php @@ -3,33 +3,36 @@ /** * @package Grav\Console\Gpm * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\Gpm; use Grav\Common\GPM\GPM; -use Grav\Console\ConsoleCommand; +use Grav\Console\GpmCommand; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; +use function strlen; -class InfoCommand extends ConsoleCommand +/** + * Class InfoCommand + * @package Grav\Console\Gpm + */ +class InfoCommand extends GpmCommand { /** @var array */ protected $data; - /** @var GPM */ protected $gpm; - /** @var string */ protected $all_yes; /** - * + * @return void */ - protected function configure() + protected function configure(): void { $this ->setName('info') @@ -51,45 +54,51 @@ class InfoCommand extends ConsoleCommand 'The package of which more informations are desired. Use the "index" command for a list of packages' ) ->setDescription('Shows more informations about a package') - ->setHelp('The info shows more informations about a package'); + ->setHelp('The info shows more information about a package'); } /** - * @return int|null|void + * @return int */ - protected function serve() + protected function serve(): int { - $this->gpm = new GPM($this->input->getOption('force')); + $input = $this->getInput(); + $io = $this->getIO(); - $this->all_yes = $this->input->getOption('all-yes'); + $this->gpm = new GPM($input->getOption('force')); + + $this->all_yes = $input->getOption('all-yes'); $this->displayGPMRelease(); - $foundPackage = $this->gpm->findPackage($this->input->getArgument('package')); + $foundPackage = $this->gpm->findPackage($input->getArgument('package')); if (!$foundPackage) { - $this->output->writeln("The package '{$this->input->getArgument('package')}' was not found in the Grav repository."); - $this->output->writeln(''); - $this->output->writeln('You can list all the available packages by typing:'); - $this->output->writeln(" {$this->argv} index"); - $this->output->writeln(''); - exit; + $io->writeln("The package '{$input->getArgument('package')}' was not found in the Grav repository."); + $io->newLine(); + $io->writeln('You can list all the available packages by typing:'); + $io->writeln(" {$this->argv} index"); + $io->newLine(); + + return 1; } - $this->output->writeln("Found package '{$this->input->getArgument('package')}' under the '" . ucfirst($foundPackage->package_type) . "' section"); - $this->output->writeln(''); - $this->output->writeln("{$foundPackage->name} [{$foundPackage->slug}]"); - $this->output->writeln(str_repeat('-', \strlen($foundPackage->name) + \strlen($foundPackage->slug) + 3)); - $this->output->writeln('' . strip_tags($foundPackage->description_plain) . ''); - $this->output->writeln(''); + $io->writeln("Found package '{$input->getArgument('package')}' under the '" . ucfirst($foundPackage->package_type) . "' section"); + $io->newLine(); + $io->writeln("{$foundPackage->name} [{$foundPackage->slug}]"); + $io->writeln(str_repeat('-', strlen($foundPackage->name) + strlen($foundPackage->slug) + 3)); + $io->writeln('' . strip_tags($foundPackage->description_plain) . ''); + $io->newLine(); $packageURL = ''; if (isset($foundPackage->author['url'])) { $packageURL = '<' . $foundPackage->author['url'] . '>'; } - $this->output->writeln('' . str_pad('Author', - 12) . ': ' . $foundPackage->author['name'] . ' <' . $foundPackage->author['email'] . '> ' . $packageURL); + $io->writeln('' . str_pad( + 'Author', + 12 + ) . ': ' . $foundPackage->author['name'] . ' <' . $foundPackage->author['email'] . '> ' . $packageURL); foreach ([ 'version', @@ -118,7 +127,7 @@ class InfoCommand extends ConsoleCommand } $name = str_pad($name, 12); - $this->output->writeln("{$name}: {$data}"); + $io->writeln("{$name}: {$data}"); } } @@ -129,52 +138,54 @@ class InfoCommand extends ConsoleCommand // display current version if installed and different if ($installed && $updatable) { $local = $this->gpm->{'getInstalled'. $type}($foundPackage->slug); - $this->output->writeln(''); - $this->output->writeln("Currently installed version: {$local->version}"); - $this->output->writeln(''); + $io->newLine(); + $io->writeln("Currently installed version: {$local->version}"); + $io->newLine(); } // display changelog information - $questionHelper = $this->getHelper('question'); - $question = new ConfirmationQuestion('Would you like to read the changelog? [y|N] ', - false); - $answer = $this->all_yes ? true : $questionHelper->ask($this->input, $this->output, $question); + $question = new ConfirmationQuestion( + 'Would you like to read the changelog? [y|N] ', + false + ); + $answer = $this->all_yes ? true : $io->askQuestion($question); if ($answer) { $changelog = $foundPackage->changelog; - $this->output->writeln(''); + $io->newLine(); foreach ($changelog as $version => $log) { $title = $version . ' [' . $log['date'] . ']'; - $content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', function ($match) { + $content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', static function ($match) { return "\n" . ucfirst($match[1]) . ':'; }, $log['content']); - $this->output->writeln("{$title}"); - $this->output->writeln(str_repeat('-', \strlen($title))); - $this->output->writeln($content); - $this->output->writeln(''); + $io->writeln("{$title}"); + $io->writeln(str_repeat('-', strlen($title))); + $io->writeln($content); + $io->newLine(); $question = new ConfirmationQuestion('Press [ENTER] to continue or [q] to quit ', true); - $answer = $this->all_yes ? false : $questionHelper->ask($this->input, $this->output, $question); + $answer = $this->all_yes ? false : $io->askQuestion($question); if (!$answer) { break; } - $this->output->writeln(''); + $io->newLine(); } } - $this->output->writeln(''); + $io->newLine(); if ($installed && $updatable) { - $this->output->writeln('You can update this package by typing:'); - $this->output->writeln(" {$this->argv} update {$foundPackage->slug}"); + $io->writeln('You can update this package by typing:'); + $io->writeln(" {$this->argv} update {$foundPackage->slug}"); } else { - $this->output->writeln("You can install this package by typing:"); - $this->output->writeln(" {$this->argv} install {$foundPackage->slug}"); + $io->writeln('You can install this package by typing:'); + $io->writeln(" {$this->argv} install {$foundPackage->slug}"); } - $this->output->writeln(''); + $io->newLine(); + return 0; } } diff --git a/system/src/Grav/Console/Gpm/InstallCommand.php b/system/src/Grav/Console/Gpm/InstallCommand.php index 8d544d4..92c8e07 100644 --- a/system/src/Grav/Console/Gpm/InstallCommand.php +++ b/system/src/Grav/Console/Gpm/InstallCommand.php @@ -3,12 +3,13 @@ /** * @package Grav\Console\Gpm * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\Gpm; +use Exception; use Grav\Common\Filesystem\Folder; use Grav\Common\GPM\GPM; use Grav\Common\GPM\Installer; @@ -17,43 +18,44 @@ use Grav\Common\GPM\Response; use Grav\Common\GPM\Remote\Package; use Grav\Common\Grav; use Grav\Common\Utils; -use Grav\Console\ConsoleCommand; +use Grav\Console\GpmCommand; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; +use ZipArchive; +use function array_key_exists; +use function count; +use function define; -\define('GIT_REGEX', '/http[s]?:\/\/(?:.*@)?(github|bitbucket)(?:.org|.com)\/.*\/(.*)/'); +define('GIT_REGEX', '/http[s]?:\/\/(?:.*@)?(github|bitbucket)(?:.org|.com)\/.*\/(.*)/'); -class InstallCommand extends ConsoleCommand +/** + * Class InstallCommand + * @package Grav\Console\Gpm + */ +class InstallCommand extends GpmCommand { /** @var array */ protected $data; - /** @var GPM */ protected $gpm; - /** @var string */ protected $destination; - /** @var string */ protected $file; - /** @var string */ protected $tmp; - - /** @var array */ - protected $local_config; - /** @var bool */ protected $use_symlinks; - /** @var array */ protected $demo_processing = []; - /** @var string */ protected $all_yes; - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('install') @@ -90,47 +92,60 @@ class InstallCommand extends ConsoleCommand * * @param GPM $gpm */ - public function setGpm(GPM $gpm) + public function setGpm(GPM $gpm): void { $this->gpm = $gpm; } /** - * @return bool + * @return int */ - protected function serve() + protected function serve(): int { - $this->gpm = new GPM($this->input->getOption('force')); + $input = $this->getInput(); + $io = $this->getIO(); - $this->all_yes = $this->input->getOption('all-yes'); + if (!class_exists(ZipArchive::class)) { + $io->title('GPM Install'); + $io->error('php-zip extension needs to be enabled!'); + + return 1; + } + + $this->gpm = new GPM($input->getOption('force')); + + $this->all_yes = $input->getOption('all-yes'); $this->displayGPMRelease(); - $this->destination = realpath($this->input->getOption('destination')); + $this->destination = realpath($input->getOption('destination')); - $packages = array_map('strtolower', $this->input->getArgument('package')); + $packages = array_map('strtolower', $input->getArgument('package')); $this->data = $this->gpm->findPackages($packages); $this->loadLocalConfig(); - if ( - !Installer::isGravInstance($this->destination) || + if (!Installer::isGravInstance($this->destination) || !Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK]) ) { - $this->output->writeln('ERROR: ' . Installer::lastErrorMsg()); - exit; + $io->writeln('ERROR: ' . Installer::lastErrorMsg()); + + return 1; } - $this->output->writeln(''); + $io->newLine(); if (!$this->data['total']) { - $this->output->writeln('Nothing to install.'); - $this->output->writeln(''); - exit; + $io->writeln('Nothing to install.'); + $io->newLine(); + + return 0; } - if (\count($this->data['not_found'])) { - $this->output->writeln('These packages were not found on Grav: ' . implode(', ', - array_keys($this->data['not_found'])) . ''); + if (count($this->data['not_found'])) { + $io->writeln('These packages were not found on Grav: ' . implode( + ', ', + array_keys($this->data['not_found']) + ) . ''); } unset($this->data['not_found'], $this->data['total']); @@ -138,39 +153,40 @@ class InstallCommand extends ConsoleCommand if (null !== $this->local_config) { // Symlinks available, ask if Grav should use them $this->use_symlinks = false; - $helper = $this->getHelper('question'); $question = new ConfirmationQuestion('Should Grav use the symlinks if available? [y|N] ', false); - $answer = $this->all_yes ? false : $helper->ask($this->input, $this->output, $question); + $answer = $this->all_yes ? false : $io->askQuestion($question); if ($answer) { $this->use_symlinks = true; } } - $this->output->writeln(''); + $io->newLine(); try { $dependencies = $this->gpm->getDependencies($packages); - } catch (\Exception $e) { + } catch (Exception $e) { //Error out if there are incompatible packages requirements and tell which ones, and what to do //Error out if there is any error in parsing the dependencies and their versions, and tell which one is broken - $this->output->writeln("{$e->getMessage()}"); - return false; + $io->writeln("{$e->getMessage()}"); + + return 1; } if ($dependencies) { try { $this->installDependencies($dependencies, 'install', 'The following dependencies need to be installed...'); - $this->installDependencies($dependencies, 'update', 'The following dependencies need to be updated...'); - $this->installDependencies($dependencies, 'ignore', "The following dependencies can be updated as there is a newer version, but it's not mandatory...", false); - } catch (\Exception $e) { - $this->output->writeln('Installation aborted'); - return false; + $this->installDependencies($dependencies, 'update', 'The following dependencies need to be updated...'); + $this->installDependencies($dependencies, 'ignore', "The following dependencies can be updated as there is a newer version, but it's not mandatory...", false); + } catch (Exception $e) { + $io->writeln('Installation aborted'); + + return 1; } - $this->output->writeln('Dependencies are OK'); - $this->output->writeln(''); + $io->writeln('Dependencies are OK'); + $io->newLine(); } @@ -178,36 +194,35 @@ class InstallCommand extends ConsoleCommand foreach ($this->data as $data) { foreach ($data as $package_name => $package) { if (array_key_exists($package_name, $dependencies)) { - $this->output->writeln("Package {$package_name} already installed as dependency"); + $io->writeln("Package {$package_name} already installed as dependency"); } else { $is_valid_destination = Installer::isValidDestination($this->destination . DS . $package->install_path); if ($is_valid_destination || Installer::lastErrorCode() == Installer::NOT_FOUND) { $this->processPackage($package, false); } else { if (Installer::lastErrorCode() == Installer::EXISTS) { - try { $this->askConfirmationIfMajorVersionUpdated($package); $this->gpm->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package->slug, $package->available, array_keys($data)); - } catch (\Exception $e) { - $this->output->writeln("{$e->getMessage()}"); - return false; + } catch (Exception $e) { + $io->writeln("{$e->getMessage()}"); + + return 1; } - $helper = $this->getHelper('question'); $question = new ConfirmationQuestion("The package {$package_name} is already installed, overwrite? [y|N] ", false); - $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question); + $answer = $this->all_yes ? true : $io->askQuestion($question); if ($answer) { $is_update = true; $this->processPackage($package, $is_update); } else { - $this->output->writeln("Package {$package_name} not overwritten"); + $io->writeln("Package {$package_name} not overwritten"); } } else { if (Installer::lastErrorCode() == Installer::IS_LINK) { - $this->output->writeln("Cannot overwrite existing symlink for {$package_name}"); - $this->output->writeln(''); + $io->writeln("Cannot overwrite existing symlink for {$package_name}"); + $io->newLine(); } } } @@ -215,7 +230,7 @@ class InstallCommand extends ConsoleCommand } } - if (\count($this->demo_processing) > 0) { + if (count($this->demo_processing) > 0) { foreach ($this->demo_processing as $package) { $this->installDemoContent($package); } @@ -224,17 +239,18 @@ class InstallCommand extends ConsoleCommand // clear cache after successful upgrade $this->clearCache(); - return true; + return 0; } /** * If the package is updated from an older major release, show warning and ask confirmation * * @param Package $package + * @return void */ - public function askConfirmationIfMajorVersionUpdated($package) + public function askConfirmationIfMajorVersionUpdated(Package $package): void { - $helper = $this->getHelper('question'); + $io = $this->getIO(); $package_name = $package->name; $new_version = $package->available ?: $this->gpm->getLatestVersionOfPackage($package->slug); $old_version = $package->version; @@ -243,14 +259,14 @@ class InstallCommand extends ConsoleCommand if ($major_version_changed) { if ($this->all_yes) { - $this->output->writeln("The package {$package_name} will be updated to a new major version {$new_version}, from {$old_version}"); + $io->writeln("The package {$package_name} will be updated to a new major version {$new_version}, from {$old_version}"); return; } $question = new ConfirmationQuestion("The package {$package_name} will be updated to a new major version {$new_version}, from {$old_version}. Be sure to read what changed with the new major release. Continue? [y|N] ", false); - if (!$helper->ask($this->input, $this->output, $question)) { - $this->output->writeln("Package {$package_name} not updated"); + if (!$io->askQuestion($question)) { + $io->writeln("Package {$package_name} not updated"); exit; } } @@ -265,22 +281,23 @@ class InstallCommand extends ConsoleCommand * @param string $type The type of dependency to show: install, update, ignore * @param string $message A message to be shown prior to listing the dependencies * @param bool $required A flag that determines if the installation is required or optional - * - * @throws \Exception + * @return void + * @throws Exception */ - public function installDependencies($dependencies, $type, $message, $required = true) + public function installDependencies(array $dependencies, string $type, string $message, bool $required = true): void { - $packages = array_filter($dependencies, function ($action) use ($type) { return $action === $type; }); - if (\count($packages) > 0) { - $this->output->writeln($message); + $io = $this->getIO(); + $packages = array_filter($dependencies, static function ($action) use ($type) { + return $action === $type; + }); + if (count($packages) > 0) { + $io->writeln($message); foreach ($packages as $dependencyName => $dependencyVersion) { - $this->output->writeln(" |- Package {$dependencyName}"); + $io->writeln(" |- Package {$dependencyName}"); } - $this->output->writeln(''); - - $helper = $this->getHelper('question'); + $io->newLine(); if ($type === 'install') { $questionAction = 'Install'; @@ -288,44 +305,45 @@ class InstallCommand extends ConsoleCommand $questionAction = 'Update'; } - if (\count($packages) === 1) { + if (count($packages) === 1) { $questionArticle = 'this'; } else { $questionArticle = 'these'; } - if (\count($packages) === 1) { + if (count($packages) === 1) { $questionNoun = 'package'; } else { $questionNoun = 'packages'; } $question = new ConfirmationQuestion("${questionAction} {$questionArticle} {$questionNoun}? [Y|n] ", true); - $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question); + $answer = $this->all_yes ? true : $io->askQuestion($question); if ($answer) { foreach ($packages as $dependencyName => $dependencyVersion) { $package = $this->gpm->findPackage($dependencyName); $this->processPackage($package, $type === 'update'); } - $this->output->writeln(''); - } else { - if ($required) { - throw new \Exception(); - } + $io->newLine(); + } elseif ($required) { + throw new Exception(); } } } /** - * @param Package $package + * @param Package|null $package * @param bool $is_update True if the package is an update + * @return void */ - private function processPackage($package, $is_update = false) + private function processPackage(?Package $package, bool $is_update = false): void { + $io = $this->getIO(); + if (!$package) { - $this->output->writeln('Package not found on the GPM!'); - $this->output->writeln(''); + $io->writeln('Package not found on the GPM!'); + $io->newLine(); return; } @@ -345,8 +363,9 @@ class InstallCommand extends ConsoleCommand * Add package to the queue to process the demo content, if demo content exists * * @param Package $package + * @return void */ - private function processDemo($package) + private function processDemo(Package $package): void { $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo'; if (file_exists($demo_dir)) { @@ -358,9 +377,11 @@ class InstallCommand extends ConsoleCommand * Prompt to install the demo content of a package * * @param Package $package + * @return void */ - private function installDemoContent($package) + private function installDemoContent(Package $package): void { + $io = $this->getIO(); $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo'; if (file_exists($demo_dir)) { @@ -368,15 +389,15 @@ class InstallCommand extends ConsoleCommand $pages_dir = $dest_dir . DS . 'pages'; // Demo content exists, prompt to install it. - $this->output->writeln("Attention: {$package->name} contains demo content"); - $helper = $this->getHelper('question'); + $io->writeln("Attention: {$package->name} contains demo content"); + $question = new ConfirmationQuestion('Do you wish to install this demo content? [y|N] ', false); - $answer = $helper->ask($this->input, $this->output, $question); + $answer = $io->askQuestion($question); if (!$answer) { - $this->output->writeln(" '- Skipped! "); - $this->output->writeln(''); + $io->writeln(" '- Skipped! "); + $io->newLine(); return; } @@ -385,11 +406,11 @@ class InstallCommand extends ConsoleCommand if (file_exists($demo_dir . DS . 'pages')) { $pages_backup = 'pages.' . date('m-d-Y-H-i-s'); $question = new ConfirmationQuestion('This will backup your current `user/pages` folder to `user/' . $pages_backup . '`, continue? [y|N]', false); - $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question); + $answer = $this->all_yes ? true : $io->askQuestion($question); if (!$answer) { - $this->output->writeln(" '- Skipped! "); - $this->output->writeln(''); + $io->writeln(" '- Skipped! "); + $io->newLine(); return; } @@ -397,27 +418,26 @@ class InstallCommand extends ConsoleCommand // backup current pages folder if (file_exists($dest_dir)) { if (rename($pages_dir, $dest_dir . DS . $pages_backup)) { - $this->output->writeln(' |- Backing up pages... ok'); + $io->writeln(' |- Backing up pages... ok'); } else { - $this->output->writeln(' |- Backing up pages... failed'); + $io->writeln(' |- Backing up pages... failed'); } } } // Confirmation received, copy over the data - $this->output->writeln(' |- Installing demo content... ok '); + $io->writeln(' |- Installing demo content... ok '); Folder::rcopy($demo_dir, $dest_dir); - $this->output->writeln(" '- Success! "); - $this->output->writeln(''); + $io->writeln(" '- Success! "); + $io->newLine(); } } /** * @param Package $package - * - * @return array|bool + * @return array|false */ - private function getGitRegexMatches($package) + private function getGitRegexMatches(Package $package) { if (isset($package->repository)) { $repository = $package->repository; @@ -432,10 +452,9 @@ class InstallCommand extends ConsoleCommand /** * @param Package $package - * - * @return bool|string + * @return string|false */ - private function getSymlinkSource($package) + private function getSymlinkSource(Package $package) { $matches = $this->getGitRegexMatches($package); @@ -445,7 +464,7 @@ class InstallCommand extends ConsoleCommand } else { $repo_dir = $matches[2]; } - + $paths = (array) $paths; foreach ($paths as $repo) { $path = rtrim($repo, '/') . '/' . $repo_dir; @@ -453,94 +472,94 @@ class InstallCommand extends ConsoleCommand return $path; } } - } return false; } /** - * @param Package $package + * @param Package $package + * @return void */ - private function processSymlink($package) + private function processSymlink(Package $package): void { + $io = $this->getIO(); exec('cd ' . $this->destination); $to = $this->destination . DS . $package->install_path; $from = $this->getSymlinkSource($package); - $this->output->writeln("Preparing to Symlink {$package->name}"); - $this->output->write(' |- Checking source... '); + $io->writeln("Preparing to Symlink {$package->name}"); + $io->write(' |- Checking source... '); if (file_exists($from)) { - $this->output->writeln('ok'); + $io->writeln('ok'); - $this->output->write(' |- Checking destination... '); + $io->write(' |- Checking destination... '); $checks = $this->checkDestination($package); if (!$checks) { - $this->output->writeln(" '- Installation failed or aborted."); - $this->output->writeln(''); + $io->writeln(" '- Installation failed or aborted."); + $io->newLine(); + } elseif (file_exists($to)) { + $io->writeln(" '- Symlink cannot overwrite an existing package, please remove first"); + $io->newLine(); } else { - if (file_exists($to)) { - $this->output->writeln(" '- Symlink cannot overwrite an existing package, please remove first"); - $this->output->writeln(''); - } else { - symlink($from, $to); + symlink($from, $to); - // extra white spaces to clear out the buffer properly - $this->output->writeln(' |- Symlinking package... ok '); - $this->output->writeln(" '- Success! "); - $this->output->writeln(''); - } + // extra white spaces to clear out the buffer properly + $io->writeln(' |- Symlinking package... ok '); + $io->writeln(" '- Success! "); + $io->newLine(); } return; } - $this->output->writeln('not found!'); - $this->output->writeln(" '- Installation failed or aborted."); + $io->writeln('not found!'); + $io->writeln(" '- Installation failed or aborted."); } /** - * @param Package $package - * @param bool $is_update - * + * @param Package $package + * @param bool $is_update * @return bool */ - private function processGpm($package, $is_update = false) + private function processGpm(Package $package, bool $is_update = false) { - $version = isset($package->available) ? $package->available : $package->version; + $io = $this->getIO(); + + $version = $package->available ?? $package->version; $license = Licenses::get($package->slug); - $this->output->writeln("Preparing to install {$package->name} [v{$version}]"); + $io->writeln("Preparing to install {$package->name} [v{$version}]"); - $this->output->write(' |- Downloading package... 0%'); + $io->write(' |- Downloading package... 0%'); $this->file = $this->downloadPackage($package, $license); if (!$this->file) { - $this->output->writeln(" '- Installation failed or aborted."); - $this->output->writeln(''); + $io->writeln(" '- Installation failed or aborted."); + $io->newLine(); return false; } - $this->output->write(' |- Checking destination... '); + $io->write(' |- Checking destination... '); $checks = $this->checkDestination($package); if (!$checks) { - $this->output->writeln(" '- Installation failed or aborted."); - $this->output->writeln(''); + $io->writeln(" '- Installation failed or aborted."); + $io->newLine(); } else { - $this->output->write(' |- Installing package... '); + $io->write(' |- Installing package... '); $installation = $this->installPackage($package, $is_update); if (!$installation) { - $this->output->writeln(" '- Installation failed or aborted."); - $this->output->writeln(''); + $io->writeln(" '- Installation failed or aborted."); + $io->newLine(); } else { - $this->output->writeln(" '- Success! "); - $this->output->writeln(''); + $io->writeln(" '- Success! "); + $io->newLine(); return true; } @@ -551,13 +570,13 @@ class InstallCommand extends ConsoleCommand /** * @param Package $package - * - * @param string $license - * - * @return string + * @param string|null $license + * @return string|null */ - private function downloadPackage($package, $license = null) + private function downloadPackage(Package $package, string $license = null) { + $io = $this->getIO(); + $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); $this->tmp = $tmp_dir . '/Grav-' . uniqid(); $filename = $package->slug . basename($package->zipball_url); @@ -565,12 +584,13 @@ class InstallCommand extends ConsoleCommand $query = ''; if (!empty($package->premium)) { - $query = \json_encode(array_merge( + $query = json_encode(array_merge( $package->premium, [ 'slug' => $package->slug, 'filename' => $package->premium['filename'], - 'license_key' => $license + 'license_key' => $license, + 'sid' => md5(GRAV_ROOT) ] )); @@ -579,21 +599,27 @@ class InstallCommand extends ConsoleCommand try { $output = Response::get($package->zipball_url . $query, [], [$this, 'progress']); - } catch (\Exception $e) { - $error = str_replace("\n", "\n | '- ", $e->getMessage()); - $this->output->write("\x0D"); - // extra white spaces to clear out the buffer properly - $this->output->writeln(' |- Downloading package... error '); - $this->output->writeln(" | '- " . $error); + } catch (Exception $e) { + if (!empty($package->premium) && $e->getCode() === 401) { + $message = 'Unauthorized Premium License Key'; + } else { + $message = $e->getMessage(); + } - return false; + $error = str_replace("\n", "\n | '- ", $message); + $io->write("\x0D"); + // extra white spaces to clear out the buffer properly + $io->writeln(' |- Downloading package... error '); + $io->writeln(" | '- " . $error); + + return null; } Folder::create($this->tmp); - $this->output->write("\x0D"); - $this->output->write(' |- Downloading package... 100%'); - $this->output->writeln(''); + $io->write("\x0D"); + $io->write(' |- Downloading package... 100%'); + $io->newLine(); file_put_contents($this->tmp . DS . $filename, $output); @@ -601,32 +627,33 @@ class InstallCommand extends ConsoleCommand } /** - * @param Package $package - * + * @param Package $package * @return bool */ - private function checkDestination($package) + private function checkDestination(Package $package): bool { - $question_helper = $this->getHelper('question'); + $io = $this->getIO(); Installer::isValidDestination($this->destination . DS . $package->install_path); - if (Installer::lastErrorCode() == Installer::IS_LINK) { - $this->output->write("\x0D"); - $this->output->writeln(' |- Checking destination... symbolic link'); + if (Installer::lastErrorCode() === Installer::IS_LINK) { + $io->write("\x0D"); + $io->writeln(' |- Checking destination... symbolic link'); if ($this->all_yes) { - $this->output->writeln(" | '- Skipped automatically."); + $io->writeln(" | '- Skipped automatically."); return false; } - $question = new ConfirmationQuestion(" | '- Destination has been detected as symlink, delete symbolic link first? [y|N] ", - false); - $answer = $question_helper->ask($this->input, $this->output, $question); + $question = new ConfirmationQuestion( + " | '- Destination has been detected as symlink, delete symbolic link first? [y|N] ", + false + ); + $answer = $io->askQuestion($question); if (!$answer) { - $this->output->writeln(" | '- You decided to not delete the symlink automatically."); + $io->writeln(" | '- You decided to not delete the symlink automatically."); return false; } @@ -634,8 +661,8 @@ class InstallCommand extends ConsoleCommand unlink($this->destination . DS . $package->install_path); } - $this->output->write("\x0D"); - $this->output->writeln(' |- Checking destination... ok'); + $io->write("\x0D"); + $io->writeln(' |- Checking destination... ok'); return true; } @@ -644,12 +671,13 @@ class InstallCommand extends ConsoleCommand * Install a package * * @param Package $package - * @param bool $is_update True if it's an update. False if it's an install - * + * @param bool $is_update True if it's an update. False if it's an install * @return bool */ - private function installPackage($package, $is_update = false) + private function installPackage(Package $package, bool $is_update = false): bool { + $io = $this->getIO(); + $type = $package->package_type; Installer::install($this->file, $this->destination, ['install_path' => $package->install_path, 'theme' => $type === 'themes', 'is_update' => $is_update]); @@ -657,35 +685,42 @@ class InstallCommand extends ConsoleCommand Folder::delete($this->tmp); if ($error_code) { - $this->output->write("\x0D"); + $io->write("\x0D"); // extra white spaces to clear out the buffer properly - $this->output->writeln(' |- Installing package... error '); - $this->output->writeln(" | '- " . Installer::lastErrorMsg()); + $io->writeln(' |- Installing package... error '); + $io->writeln(" | '- " . Installer::lastErrorMsg()); return false; } $message = Installer::getMessage(); if ($message) { - $this->output->write("\x0D"); + $io->write("\x0D"); // extra white spaces to clear out the buffer properly - $this->output->writeln(" |- {$message}"); + $io->writeln(" |- {$message}"); } - $this->output->write("\x0D"); + $io->write("\x0D"); // extra white spaces to clear out the buffer properly - $this->output->writeln(' |- Installing package... ok '); + $io->writeln(' |- Installing package... ok '); return true; } /** * @param array $progress + * @return void */ - public function progress($progress) + public function progress(array $progress): void { - $this->output->write("\x0D"); - $this->output->write(' |- Downloading package... ' . str_pad($progress['percent'], 5, ' ', - STR_PAD_LEFT) . '%'); + $io = $this->getIO(); + + $io->write("\x0D"); + $io->write(' |- Downloading package... ' . str_pad( + $progress['percent'], + 5, + ' ', + STR_PAD_LEFT + ) . '%'); } } diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index 62c6120..b840016 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -3,50 +3,56 @@ /** * @package Grav\Console\Gpm * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\Gpm; -use Grav\Common\Cache; +use Exception; use Grav\Common\Filesystem\Folder; use Grav\Common\GPM\Installer; -use Grav\Common\GPM\Remote\Package; use Grav\Common\GPM\Response; use Grav\Common\GPM\Upgrader; use Grav\Common\Grav; -use Grav\Console\ConsoleCommand; +use Grav\Console\GpmCommand; +use Grav\Installer\Install; +use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; +use ZipArchive; +use function is_callable; +use function strlen; -class SelfupgradeCommand extends ConsoleCommand +/** + * Class SelfupgradeCommand + * @package Grav\Console\Gpm + */ +class SelfupgradeCommand extends GpmCommand { /** @var array */ protected $data; - - protected $extensions; - - protected $updatable; - /** @var string */ protected $file; - /** @var array */ protected $types = ['plugins', 'themes']; - - /** @var string */ + /** @var string|null */ private $tmp; - + /** @var Upgrader */ private $upgrader; /** @var string */ protected $all_yes; - + /** @var string */ protected $overwrite; + /** @var int */ + protected $timeout; - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('self-upgrade') @@ -69,15 +75,36 @@ class SelfupgradeCommand extends ConsoleCommand InputOption::VALUE_NONE, 'Option to overwrite packages if they already exist' ) + ->addOption( + 'timeout', + 't', + InputOption::VALUE_OPTIONAL, + 'Option to set the timeout in seconds when downloading the update (0 for no timeout)', + 30 + ) ->setDescription('Detects and performs an update of Grav itself when available') ->setHelp('The update command updates Grav itself when a new version is available'); } - protected function serve() + /** + * @return int + */ + protected function serve(): int { - $this->upgrader = new Upgrader($this->input->getOption('force')); - $this->all_yes = $this->input->getOption('all-yes'); - $this->overwrite = $this->input->getOption('overwrite'); + $input = $this->getInput(); + $io = $this->getIO(); + + if (!class_exists(ZipArchive::class)) { + $io->title('GPM Self Upgrade'); + $io->error('php-zip extension needs to be enabled!'); + + return 1; + } + + $this->upgrader = new Upgrader($input->getOption('force')); + $this->all_yes = $input->getOption('all-yes'); + $this->overwrite = $input->getOption('overwrite'); + $this->timeout = (int) $input->getOption('timeout'); $this->displayGPMRelease(); @@ -88,110 +115,148 @@ class SelfupgradeCommand extends ConsoleCommand $release = strftime('%c', strtotime($this->upgrader->getReleaseDate())); if (!$this->upgrader->meetsRequirements()) { - $this->output->writeln('ATTENTION:'); - $this->output->writeln(' Grav has increased the minimum PHP requirement.'); - $this->output->writeln(' You are currently running PHP ' . phpversion() . ', but PHP ' . $this->upgrader->minPHPVersion() . ' is required.'); - $this->output->writeln(' Additional information: http://getgrav.org/blog/changing-php-requirements'); - $this->output->writeln(''); - $this->output->writeln('Selfupgrade aborted.'); - $this->output->writeln(''); - exit; + $io->writeln('ATTENTION:'); + $io->writeln(' Grav has increased the minimum PHP requirement.'); + $io->writeln(' You are currently running PHP ' . phpversion() . ', but PHP ' . $this->upgrader->minPHPVersion() . ' is required.'); + $io->writeln(' Additional information: http://getgrav.org/blog/changing-php-requirements'); + $io->newLine(); + $io->writeln('Selfupgrade aborted.'); + $io->newLine(); + + return 1; } if (!$this->overwrite && !$this->upgrader->isUpgradable()) { - $this->output->writeln("You are already running the latest version of Grav (v{$local}) released on {$release}"); - exit; + $io->writeln("You are already running the latest version of Grav v{$local}"); + $io->writeln("which was released on {$release}"); + + $config = Grav::instance()['config']; + $schema = $config->get('versions.core.grav.schema'); + if ($schema !== GRAV_SCHEMA && version_compare($schema, GRAV_SCHEMA, '<')) { + $io->newLine(); + $io->writeln('However post-install scripts have not been run.'); + if (!$this->all_yes) { + $question = new ConfirmationQuestion( + 'Would you like to run the scripts? [Y|n] ', + true + ); + $answer = $io->askQuestion($question); + } else { + $answer = true; + } + + if ($answer) { + // Finalize installation. + Install::instance()->finalize(); + + $io->write(' |- Running post-install scripts... '); + $io->writeln(" '- Success! "); + $io->newLine(); + } + } + + return 0; } Installer::isValidDestination(GRAV_ROOT . '/system'); if (Installer::IS_LINK === Installer::lastErrorCode()) { - $this->output->writeln('ATTENTION: Grav is symlinked, cannot upgrade, aborting...'); - $this->output->writeln(''); - $this->output->writeln("You are currently running a symbolically linked Grav v{$local}. Latest available is v{$remote}."); - exit; + $io->writeln('ATTENTION: Grav is symlinked, cannot upgrade, aborting...'); + $io->newLine(); + $io->writeln("You are currently running a symbolically linked Grav v{$local}. Latest available is v{$remote}."); + + return 1; } // not used but preloaded just in case! new ArrayInput([]); - $questionHelper = $this->getHelper('question'); - - - $this->output->writeln("Grav v{$remote} is now available [release date: {$release}]."); - $this->output->writeln('You are currently using v' . GRAV_VERSION . '.'); + $io->writeln("Grav v{$remote} is now available [release date: {$release}]."); + $io->writeln('You are currently using v' . GRAV_VERSION . '.'); if (!$this->all_yes) { - $question = new ConfirmationQuestion('Would you like to read the changelog before proceeding? [y|N] ', - false); - $answer = $questionHelper->ask($this->input, $this->output, $question); + $question = new ConfirmationQuestion( + 'Would you like to read the changelog before proceeding? [y|N] ', + false + ); + $answer = $io->askQuestion($question); if ($answer) { $changelog = $this->upgrader->getChangelog(GRAV_VERSION); - $this->output->writeln(''); + $io->newLine(); foreach ($changelog as $version => $log) { $title = $version . ' [' . $log['date'] . ']'; - $content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', function ($match) { + $content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', static function ($match) { return "\n" . ucfirst($match[1]) . ':'; }, $log['content']); - $this->output->writeln($title); - $this->output->writeln(str_repeat('-', \strlen($title))); - $this->output->writeln($content); - $this->output->writeln(''); + $io->writeln($title); + $io->writeln(str_repeat('-', strlen($title))); + $io->writeln($content); + $io->newLine(); } $question = new ConfirmationQuestion('Press [ENTER] to continue.', true); - $questionHelper->ask($this->input, $this->output, $question); + $io->askQuestion($question); } $question = new ConfirmationQuestion('Would you like to upgrade now? [y|N] ', false); - $answer = $questionHelper->ask($this->input, $this->output, $question); + $answer = $io->askQuestion($question); if (!$answer) { - $this->output->writeln('Aborting...'); + $io->writeln('Aborting...'); - exit; + return 1; } } - $this->output->writeln(''); - $this->output->writeln("Preparing to upgrade to v{$remote}.."); + $io->newLine(); + $io->writeln("Preparing to upgrade to v{$remote}.."); - $this->output->write(" |- Downloading upgrade [{$this->formatBytes($update['size'])}]... 0%"); + $io->write(" |- Downloading upgrade [{$this->formatBytes($update['size'])}]... 0%"); $this->file = $this->download($update); - $this->output->write(' |- Installing upgrade... '); + $io->write(' |- Installing upgrade... '); $installation = $this->upgrade(); + $error = 0; if (!$installation) { - $this->output->writeln(" '- Installation failed or aborted."); - $this->output->writeln(''); + $io->writeln(" '- Installation failed or aborted."); + $io->newLine(); + $error = 1; } else { - $this->output->writeln(" '- Success! "); - $this->output->writeln(''); + $io->writeln(" '- Success! "); + $io->newLine(); } - // clear cache after successful upgrade - $this->clearCache('all'); + if ($this->tmp && is_dir($this->tmp)) { + Folder::delete($this->tmp); + } + + return $error; } /** * @param array $package - * * @return string */ - private function download($package) + private function download(array $package): string { + $io = $this->getIO(); + $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); - $this->tmp = $tmp_dir . '/Grav-' . uniqid('', false); - $output = Response::get($package['download'], [], [$this, 'progress']); + $this->tmp = $tmp_dir . '/grav-update-' . uniqid('', false); + $options = [ + 'timeout' => $this->timeout, + ]; + + $output = Response::get($package['download'], $options, [$this, 'progress']); Folder::create($this->tmp); - $this->output->write("\x0D"); - $this->output->write(" |- Downloading upgrade [{$this->formatBytes($package['size'])}]... 100%"); - $this->output->writeln(''); + $io->write("\x0D"); + $io->write(" |- Downloading upgrade [{$this->formatBytes($package['size'])}]... 100%"); + $io->newLine(); file_put_contents($this->tmp . DS . $package['name'], $output); @@ -201,55 +266,52 @@ class SelfupgradeCommand extends ConsoleCommand /** * @return bool */ - private function upgrade() + private function upgrade(): bool { - if ($this->file) { - $folder = Installer::unZip($this->file, $this->tmp . '/zip'); - } else { - $folder = false; - } + $io = $this->getIO(); - static::upgradeGrav($this->file, $folder); + $this->upgradeGrav($this->file); $errorCode = Installer::lastErrorCode(); - - if ($this->tmp) { - Folder::delete($this->tmp); - } - - if ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR)) { - $this->output->write("\x0D"); + if ($errorCode) { + $io->write("\x0D"); // extra white spaces to clear out the buffer properly - $this->output->writeln(' |- Installing upgrade... error '); - $this->output->writeln(" | '- " . Installer::lastErrorMsg()); + $io->writeln(' |- Installing upgrade... error '); + $io->writeln(" | '- " . Installer::lastErrorMsg()); return false; } - $this->output->write("\x0D"); + $io->write("\x0D"); // extra white spaces to clear out the buffer properly - $this->output->writeln(' |- Installing upgrade... ok '); + $io->writeln(' |- Installing upgrade... ok '); return true; } /** * @param array $progress + * @return void */ - public function progress($progress) + public function progress(array $progress): void { - $this->output->write("\x0D"); - $this->output->write(" |- Downloading upgrade [{$this->formatBytes($progress['filesize']) }]... " . str_pad($progress['percent'], - 5, ' ', STR_PAD_LEFT) . '%'); + $io = $this->getIO(); + + $io->write("\x0D"); + $io->write(" |- Downloading upgrade [{$this->formatBytes($progress['filesize']) }]... " . str_pad( + $progress['percent'], + 5, + ' ', + STR_PAD_LEFT + ) . '%'); } /** * @param int|float $size * @param int $precision - * * @return string */ - public function formatBytes($size, $precision = 2) + public function formatBytes($size, int $precision = 2): string { $base = log($size) / log(1024); $suffixes = array('', 'k', 'M', 'G', 'T'); @@ -257,40 +319,25 @@ class SelfupgradeCommand extends ConsoleCommand return round(1024 ** ($base - floor($base)), $precision) . $suffixes[(int)floor($base)]; } - private function upgradeGrav($zip, $folder, $keepFolder = false) + /** + * @param string $zip + * @return void + */ + private function upgradeGrav(string $zip): void { - static $ignores = [ - 'backup', - 'cache', - 'images', - 'logs', - 'tmp', - 'user', - '.htaccess', - 'robots.txt' - ]; - - if (!is_dir($folder)) { - Installer::setError('Invalid source folder'); - } - try { + $folder = Installer::unZip($zip, $this->tmp . '/zip'); + if ($folder === false) { + throw new RuntimeException(Installer::lastErrorMsg()); + } + $script = $folder . '/system/install.php'; - /** Install $installer */ if ((file_exists($script) && $install = include $script) && is_callable($install)) { $install($zip); } else { - Installer::install( - $zip, - GRAV_ROOT, - ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $ignores], - $folder, - $keepFolder - ); - - Cache::clearCache(); + throw new RuntimeException('Uploaded archive file is not a valid Grav update package'); } - } catch (\Exception $e) { + } catch (Exception $e) { Installer::setError($e->getMessage()); } } diff --git a/system/src/Grav/Console/Gpm/UninstallCommand.php b/system/src/Grav/Console/Gpm/UninstallCommand.php index 99fc81a..cb14c65 100644 --- a/system/src/Grav/Console/Gpm/UninstallCommand.php +++ b/system/src/Grav/Console/Gpm/UninstallCommand.php @@ -3,7 +3,7 @@ /** * @package Grav\Console\Gpm * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,37 +11,43 @@ namespace Grav\Console\Gpm; use Grav\Common\GPM\GPM; use Grav\Common\GPM\Installer; -use Grav\Common\GPM\Remote\Package; +use Grav\Common\GPM\Local; +use Grav\Common\GPM\Remote; use Grav\Common\Grav; -use Grav\Console\ConsoleCommand; +use Grav\Console\GpmCommand; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; +use Throwable; +use function count; +use function in_array; +use function is_array; -class UninstallCommand extends ConsoleCommand +/** + * Class UninstallCommand + * @package Grav\Console\Gpm + */ +class UninstallCommand extends GpmCommand { /** @var array */ protected $data; - /** @var GPM */ protected $gpm; - /** @var string */ protected $destination; - /** @var string */ protected $file; - /** @var string */ protected $tmp; - /** @var array */ protected $dependencies = []; - /** @var string */ protected $all_yes; - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('uninstall') @@ -60,13 +66,19 @@ class UninstallCommand extends ConsoleCommand ->setHelp('The uninstall command allows to uninstall plugins and themes'); } - protected function serve() + /** + * @return int + */ + protected function serve(): int { + $input = $this->getInput(); + $io = $this->getIO(); + $this->gpm = new GPM(); - $this->all_yes = $this->input->getOption('all-yes'); + $this->all_yes = $input->getOption('all-yes'); - $packages = array_map('strtolower', $this->input->getArgument('package')); + $packages = array_map('strtolower', $input->getArgument('package')); $this->data = ['total' => 0, 'not_found' => []]; $total = 0; @@ -82,55 +94,70 @@ class UninstallCommand extends ConsoleCommand } $this->data['total'] = $total; - $this->output->writeln(''); + $io->newLine(); if (!$this->data['total']) { - $this->output->writeln('Nothing to uninstall.'); - $this->output->writeln(''); - exit; + $io->writeln('Nothing to uninstall.'); + $io->newLine(); + + return 0; } if (count($this->data['not_found'])) { - $this->output->writeln('These packages were not found installed: ' . implode(', ', - $this->data['not_found']) . ''); + $io->writeln('These packages were not found installed: ' . implode( + ', ', + $this->data['not_found'] + ) . ''); } unset($this->data['not_found'], $this->data['total']); - foreach ($this->data as $slug => $package) { - $this->output->writeln("Preparing to uninstall {$package->name} [v{$package->version}]"); + // Plugins need to be initialized in order to make clearcache to work. + try { + $this->initializePlugins(); + } catch (Throwable $e) { + $io->writeln("Some plugins failed to initialize: {$e->getMessage()}"); + } - $this->output->write(' |- Checking destination... '); + $error = 0; + foreach ($this->data as $slug => $package) { + $io->writeln("Preparing to uninstall {$package->name} [v{$package->version}]"); + + $io->write(' |- Checking destination... '); $checks = $this->checkDestination($slug, $package); if (!$checks) { - $this->output->writeln(" '- Installation failed or aborted."); - $this->output->writeln(''); + $io->writeln(" '- Installation failed or aborted."); + $io->newLine(); + $error = 1; } else { $uninstall = $this->uninstallPackage($slug, $package); if (!$uninstall) { - $this->output->writeln(" '- Uninstallation failed or aborted."); + $io->writeln(" '- Uninstallation failed or aborted."); + $error = 1; } else { - $this->output->writeln(" '- Success! "); + $io->writeln(" '- Success! "); } } - } // clear cache after successful upgrade $this->clearCache(); - } + return $error; + } /** * @param string $slug - * @param Package $package - * + * @param Local\Package|Remote\Package $package + * @param bool $is_dependency * @return bool */ - private function uninstallPackage($slug, $package, $is_dependency = false) + private function uninstallPackage($slug, $package, $is_dependency = false): bool { + $io = $this->getIO(); + if (!$slug) { return false; } @@ -138,44 +165,37 @@ class UninstallCommand extends ConsoleCommand //check if there are packages that have this as a dependency. Abort and show list $dependent_packages = $this->gpm->getPackagesThatDependOnPackage($slug); if (count($dependent_packages) > ($is_dependency ? 1 : 0)) { - $this->output->writeln(''); - $this->output->writeln(''); - $this->output->writeln('Uninstallation failed.'); - $this->output->writeln(''); - if (\count($dependent_packages) > ($is_dependency ? 2 : 1)) { - $this->output->writeln('The installed packages ' . implode(', ', $dependent_packages) . ' depends on this package. Please remove those first.'); + $io->newLine(2); + $io->writeln('Uninstallation failed.'); + $io->newLine(); + if (count($dependent_packages) > ($is_dependency ? 2 : 1)) { + $io->writeln('The installed packages ' . implode(', ', $dependent_packages) . ' depends on this package. Please remove those first.'); } else { - $this->output->writeln('The installed package ' . implode(', ', $dependent_packages) . ' depends on this package. Please remove it first.'); + $io->writeln('The installed package ' . implode(', ', $dependent_packages) . ' depends on this package. Please remove it first.'); } - $this->output->writeln(''); + $io->newLine(); return false; } if (isset($package->dependencies)) { - $dependencies = $package->dependencies; if ($is_dependency) { foreach ($dependencies as $key => $dependency) { - if (\in_array($dependency['name'], $this->dependencies, true)) { + if (in_array($dependency['name'], $this->dependencies, true)) { unset($dependencies[$key]); } } - } else { - if (\count($dependencies) > 0) { - $this->output->writeln(' `- Dependencies found...'); - $this->output->writeln(''); - } + } elseif (count($dependencies) > 0) { + $io->writeln(' `- Dependencies found...'); + $io->newLine(); } - $questionHelper = $this->getHelper('question'); - foreach ($dependencies as $dependency) { - $this->dependencies[] = $dependency['name']; - if (\is_array($dependency)) { + if (is_array($dependency)) { $dependency = $dependency['name']; } if ($dependency === 'grav' || $dependency === 'php') { @@ -187,27 +207,25 @@ class UninstallCommand extends ConsoleCommand $dependency_exists = $this->packageExists($dependency, $dependencyPackage); if ($dependency_exists == Installer::EXISTS) { - $this->output->writeln("A dependency on {$dependencyPackage->name} [v{$dependencyPackage->version}] was found"); + $io->writeln("A dependency on {$dependencyPackage->name} [v{$dependencyPackage->version}] was found"); $question = new ConfirmationQuestion(" |- Uninstall {$dependencyPackage->name}? [y|N] ", false); - $answer = $this->all_yes ? true : $questionHelper->ask($this->input, $this->output, $question); + $answer = $this->all_yes ? true : $io->askQuestion($question); if ($answer) { $uninstall = $this->uninstallPackage($dependency, $dependencyPackage, true); if (!$uninstall) { - $this->output->writeln(" '- Uninstallation failed or aborted."); + $io->writeln(" '- Uninstallation failed or aborted."); } else { - $this->output->writeln(" '- Success! "); - + $io->writeln(" '- Success! "); } - $this->output->writeln(''); + $io->newLine(); } else { - $this->output->writeln(" '- You decided not to uninstall {$dependencyPackage->name}."); - $this->output->writeln(''); + $io->writeln(" '- You decided not to uninstall {$dependencyPackage->name}."); + $io->newLine(); } } - } } @@ -218,63 +236,61 @@ class UninstallCommand extends ConsoleCommand $errorCode = Installer::lastErrorCode(); if ($errorCode && $errorCode !== Installer::IS_LINK && $errorCode !== Installer::EXISTS) { - $this->output->writeln(" |- Uninstalling {$package->name} package... error "); - $this->output->writeln(" | '- " . Installer::lastErrorMsg() . ''); + $io->writeln(" |- Uninstalling {$package->name} package... error "); + $io->writeln(" | '- " . Installer::lastErrorMsg() . ''); return false; } $message = Installer::getMessage(); if ($message) { - $this->output->writeln(" |- {$message}"); + $io->writeln(" |- {$message}"); } if (!$is_dependency && $this->dependencies) { - $this->output->writeln("Finishing up uninstalling {$package->name}"); + $io->writeln("Finishing up uninstalling {$package->name}"); } - $this->output->writeln(" |- Uninstalling {$package->name} package... ok "); - - + $io->writeln(" |- Uninstalling {$package->name} package... ok "); return true; } /** * @param string $slug - * @param Package $package - * + * @param Local\Package|Remote\Package $package * @return bool */ - - private function checkDestination($slug, $package) + private function checkDestination(string $slug, $package): bool { - $questionHelper = $this->getHelper('question'); + $io = $this->getIO(); $exists = $this->packageExists($slug, $package); - if ($exists == Installer::IS_LINK) { - $this->output->write("\x0D"); - $this->output->writeln(' |- Checking destination... symbolic link'); + if ($exists === Installer::IS_LINK) { + $io->write("\x0D"); + $io->writeln(' |- Checking destination... symbolic link'); if ($this->all_yes) { - $this->output->writeln(" | '- Skipped automatically."); + $io->writeln(" | '- Skipped automatically."); return false; } - $question = new ConfirmationQuestion(" | '- Destination has been detected as symlink, delete symbolic link first? [y|N] ", - false); - $answer = $this->all_yes ? true : $questionHelper->ask($this->input, $this->output, $question); + $question = new ConfirmationQuestion( + " | '- Destination has been detected as symlink, delete symbolic link first? [y|N] ", + false + ); + $answer = $io->askQuestion($question); if (!$answer) { - $this->output->writeln(" | '- You decided not to delete the symlink automatically."); + $io->writeln(" | '- You decided not to delete the symlink automatically."); return false; } } - $this->output->write("\x0D"); - $this->output->writeln(' |- Checking destination... ok'); + $io->write("\x0D"); + $io->writeln(' |- Checking destination... ok'); return true; } @@ -283,13 +299,14 @@ class UninstallCommand extends ConsoleCommand * Check if package exists * * @param string $slug - * @param Package $package + * @param Local\Package|Remote\Package $package * @return int */ - private function packageExists($slug, $package) + private function packageExists(string $slug, $package): int { $path = Grav::instance()['locator']->findResource($package->package_type . '://' . $slug); Installer::isValidDestination($path); + return Installer::lastErrorCode(); } } diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php index d8ea735..300fe61 100644 --- a/system/src/Grav/Console/Gpm/UpdateCommand.php +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -3,7 +3,7 @@ /** * @package Grav\Console\Gpm * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,43 +11,43 @@ namespace Grav\Console\Gpm; use Grav\Common\GPM\GPM; use Grav\Common\GPM\Installer; -use Grav\Console\ConsoleCommand; use Grav\Common\GPM\Upgrader; +use Grav\Console\GpmCommand; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; +use ZipArchive; +use function array_key_exists; +use function count; -class UpdateCommand extends ConsoleCommand +/** + * Class UpdateCommand + * @package Grav\Console\Gpm + */ +class UpdateCommand extends GpmCommand { /** @var array */ protected $data; - - protected $extensions; - - protected $updatable; - /** @var string */ protected $destination; - /** @var string */ protected $file; - /** @var array */ protected $types = ['plugins', 'themes']; - /** @var GPM */ protected $gpm; - /** @var string */ protected $all_yes; - + /** @var string */ protected $overwrite; - /** @var Upgrader */ protected $upgrader; - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('update') @@ -97,42 +97,55 @@ class UpdateCommand extends ConsoleCommand ->setHelp('The update command updates plugins and themes when a new version is available'); } - protected function serve() + /** + * @return int + */ + protected function serve(): int { - $this->upgrader = new Upgrader($this->input->getOption('force')); + $input = $this->getInput(); + $io = $this->getIO(); + + if (!class_exists(ZipArchive::class)) { + $io->title('GPM Update'); + $io->error('php-zip extension needs to be enabled!'); + + return 1; + } + + $this->upgrader = new Upgrader($input->getOption('force')); $local = $this->upgrader->getLocalVersion(); $remote = $this->upgrader->getRemoteVersion(); if ($local !== $remote) { - $this->output->writeln('WARNING: A new version of Grav is available. You should update Grav before updating plugins and themes. If you continue without updating Grav, some plugins or themes may stop working.'); - $this->output->writeln(''); - $questionHelper = $this->getHelper('question'); + $io->writeln('WARNING: A new version of Grav is available. You should update Grav before updating plugins and themes. If you continue without updating Grav, some plugins or themes may stop working.'); + $io->newLine(); $question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true); - $answer = $questionHelper->ask($this->input, $this->output, $question); + $answer = $io->askQuestion($question); if (!$answer) { - $this->output->writeln('Update aborted. Exiting...'); - exit; + $io->writeln('Update aborted. Exiting...'); + + return 1; } } - $this->gpm = new GPM($this->input->getOption('force')); + $this->gpm = new GPM($input->getOption('force')); - $this->all_yes = $this->input->getOption('all-yes'); - $this->overwrite = $this->input->getOption('overwrite'); + $this->all_yes = $input->getOption('all-yes'); + $this->overwrite = $input->getOption('overwrite'); $this->displayGPMRelease(); - $this->destination = realpath($this->input->getOption('destination')); + $this->destination = realpath($input->getOption('destination')); if (!Installer::isGravInstance($this->destination)) { - $this->output->writeln('ERROR: ' . Installer::lastErrorMsg()); + $io->writeln('ERROR: ' . Installer::lastErrorMsg()); exit; } - if ($this->input->getOption('plugins') === false && $this->input->getOption('themes') === false) { + if ($input->getOption('plugins') === false && $input->getOption('themes') === false) { $list_type = ['plugins' => true, 'themes' => true]; } else { - $list_type['plugins'] = $this->input->getOption('plugins'); - $list_type['themes'] = $this->input->getOption('themes'); + $list_type['plugins'] = $input->getOption('plugins'); + $list_type['themes'] = $input->getOption('themes'); } if ($this->overwrite) { @@ -143,18 +156,19 @@ class UpdateCommand extends ConsoleCommand $description = ' need updating'; } - $only_packages = array_map('strtolower', $this->input->getArgument('package')); + $only_packages = array_map('strtolower', $input->getArgument('package')); if (!$this->overwrite && !$this->data['total']) { - $this->output->writeln('Nothing to update.'); - exit; + $io->writeln('Nothing to update.'); + + return 0; } - $this->output->write("Found {$this->gpm->countInstalled()} packages installed of which {$this->data['total']}{$description}"); + $io->write("Found {$this->gpm->countInstalled()} packages installed of which {$this->data['total']}{$description}"); $limit_to = $this->userInputPackages($only_packages); - $this->output->writeln(''); + $io->newLine(); unset($this->data['total'], $limit_to['total']); @@ -162,10 +176,10 @@ class UpdateCommand extends ConsoleCommand // updates review $slugs = []; - $index = 0; + $index = 1; foreach ($this->data as $packages) { foreach ($packages as $slug => $package) { - if (!array_key_exists($slug, $limit_to) && \count($only_packages)) { + if (!array_key_exists($slug, $limit_to) && count($only_packages)) { continue; } @@ -173,9 +187,9 @@ class UpdateCommand extends ConsoleCommand $package->available = $package->version; } - $this->output->writeln( - // index - str_pad($index++ + 1, 2, '0', STR_PAD_LEFT) . '. ' . + $io->writeln( + // index + str_pad((string)$index++, 2, '0', STR_PAD_LEFT) . '. ' . // name '' . str_pad($package->name, 15) . ' ' . // version @@ -187,14 +201,14 @@ class UpdateCommand extends ConsoleCommand if (!$this->all_yes) { // prompt to continue - $this->output->writeln(''); - $questionHelper = $this->getHelper('question'); + $io->newLine(); $question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true); - $answer = $questionHelper->ask($this->input, $this->output, $question); + $answer = $io->askQuestion($question); if (!$answer) { - $this->output->writeln('Update aborted. Exiting...'); - exit; + $io->writeln('Update aborted. Exiting...'); + + return 1; } } @@ -204,30 +218,34 @@ class UpdateCommand extends ConsoleCommand $args = new ArrayInput([ 'command' => 'install', 'package' => $slugs, - '-f' => $this->input->getOption('force'), + '-f' => $input->getOption('force'), '-d' => $this->destination, '-y' => true ]); - $command_exec = $install_command->run($args, $this->output); + $command_exec = $install_command->run($args, $io); if ($command_exec != 0) { - $this->output->writeln('Error: An error occurred while trying to install the packages'); - exit; + $io->writeln('Error: An error occurred while trying to install the packages'); + + return 1; } + + return 0; } /** * @param array $only_packages - * * @return array */ - private function userInputPackages($only_packages) + private function userInputPackages(array $only_packages): array { + $io = $this->getIO(); + $found = ['total' => 0]; $ignore = []; - if (!\count($only_packages)) { - $this->output->writeln(''); + if (!count($only_packages)) { + $io->newLine(); } else { foreach ($only_packages as $only_package) { $find = $this->gpm->findPackage($only_package); @@ -247,19 +265,22 @@ class UpdateCommand extends ConsoleCommand $list = array_keys($list); if ($found['total'] !== $this->data['total']) { - $this->output->write(", only {$found['total']} will be updated"); + $io->write(", only {$found['total']} will be updated"); } - $this->output->writeln(''); - $this->output->writeln('Limiting updates for only ' . implode(', ', - $list) . ''); + $io->newLine(); + $io->writeln('Limiting updates for only ' . implode( + ', ', + $list + ) . ''); } - if (\count($ignore)) { - $this->output->writeln(''); - $this->output->writeln('Packages not found or not requiring updates: ' . implode(', ', - $ignore) . ''); - + if (count($ignore)) { + $io->newLine(); + $io->writeln('Packages not found or not requiring updates: ' . implode( + ', ', + $ignore + ) . ''); } } diff --git a/system/src/Grav/Console/Gpm/VersionCommand.php b/system/src/Grav/Console/Gpm/VersionCommand.php index becc327..124aad3 100644 --- a/system/src/Grav/Console/Gpm/VersionCommand.php +++ b/system/src/Grav/Console/Gpm/VersionCommand.php @@ -3,7 +3,7 @@ /** * @package Grav\Console\Gpm * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,17 +11,26 @@ namespace Grav\Console\Gpm; use Grav\Common\GPM\GPM; use Grav\Common\GPM\Upgrader; -use Grav\Console\ConsoleCommand; +use Grav\Common\Grav; +use Grav\Console\GpmCommand; use RocketTheme\Toolbox\File\YamlFile; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; +use function count; -class VersionCommand extends ConsoleCommand +/** + * Class VersionCommand + * @package Grav\Console\Gpm + */ +class VersionCommand extends GpmCommand { /** @var GPM */ protected $gpm; - protected function configure() + /** + * @return void + */ + protected function configure(): void { $this ->setName('version') @@ -41,12 +50,15 @@ class VersionCommand extends ConsoleCommand } /** - * @return int|null|void + * @return int */ - protected function serve() + protected function serve(): int { - $this->gpm = new GPM($this->input->getOption('force')); - $packages = $this->input->getArgument('package'); + $input = $this->getInput(); + $io = $this->getIO(); + + $this->gpm = new GPM($input->getOption('force')); + $packages = $input->getArgument('package'); $installed = false; @@ -68,10 +80,9 @@ class VersionCommand extends ConsoleCommand if ($upgrader->isUpgradable()) { $updatable = " [upgradable: v{$upgrader->getRemoteVersion()}]"; } - } else { // get currently installed version - $locator = \Grav\Common\Grav::instance()['locator']; + $locator = Grav::instance()['locator']; $blueprints_path = $locator->findResource('plugins://' . $package . DS . 'blueprints.yaml'); if (!file_exists($blueprints_path)) { // theme? $blueprints_path = $locator->findResource('themes://' . $package . DS . 'blueprints.yaml'); @@ -103,10 +114,12 @@ class VersionCommand extends ConsoleCommand $updatable = $updatable ?: ''; if ($installed || $package === 'grav') { - $this->output->writeln("You are running {$name} v{$version}{$updatable}"); + $io->writeln("You are running {$name} v{$version}{$updatable}"); } else { - $this->output->writeln("Package {$package} not found"); + $io->writeln("Package {$package} not found"); } } + + return 0; } } diff --git a/system/src/Grav/Console/GpmCommand.php b/system/src/Grav/Console/GpmCommand.php new file mode 100644 index 0000000..816e872 --- /dev/null +++ b/system/src/Grav/Console/GpmCommand.php @@ -0,0 +1,68 @@ +setupConsole($input, $output); + + $grav = Grav::instance(); + $grav['config']->init(); + $grav['uri']->init(); + // @phpstan-ignore-next-line + $grav['accounts']; + + return $this->serve(); + } + + /** + * Override with your implementation. + * + * @return int + */ + protected function serve() + { + // Return error. + return 1; + } + + /** + * @return void + */ + protected function displayGPMRelease() + { + /** @var Config $config */ + $config = Grav::instance()['config']; + + $io = $this->getIO(); + $io->newLine(); + $io->writeln('GPM Releases Configuration: ' . ucfirst($config->get('system.gpm.releases')) . ''); + $io->newLine(); + } +} diff --git a/system/src/Grav/Console/GravCommand.php b/system/src/Grav/Console/GravCommand.php new file mode 100644 index 0000000..0249f14 --- /dev/null +++ b/system/src/Grav/Console/GravCommand.php @@ -0,0 +1,52 @@ +setupConsole($input, $output); + + // Old versions of Grav called this command after grav upgrade. + // We need make this command to work with older ConsoleTrait: + if (method_exists($this, 'initializeGrav')) { + $this->initializeGrav(); + } + + return $this->serve(); + } + + /** + * Override with your implementation. + * + * @return int + */ + protected function serve() + { + // Return error. + return 1; + } +} diff --git a/system/src/Grav/Console/Plugin/PluginListCommand.php b/system/src/Grav/Console/Plugin/PluginListCommand.php new file mode 100644 index 0000000..81041c0 --- /dev/null +++ b/system/src/Grav/Console/Plugin/PluginListCommand.php @@ -0,0 +1,69 @@ +setHidden(true); + } + + /** + * @return int + */ + protected function serve(): int + { + $bin = $this->argv; + $pattern = '([A-Z]\w+Command\.php)'; + + $io = $this->getIO(); + $io->newLine(); + $io->writeln('Usage:'); + $io->writeln(" {$bin} [slug] [command] [arguments]"); + $io->newLine(); + $io->writeln('Example:'); + $io->writeln(" {$bin} error log -l 1 --trace"); + $io->newLine(); + $io->writeln('Plugins with CLI available:'); + + $plugins = Plugins::all(); + $index = 0; + foreach ($plugins as $name => $plugin) { + if (!$plugin->enabled) { + continue; + } + + $list = Folder::all("plugins://{$name}", ['compare' => 'Pathname', 'pattern' => '/\/cli\/' . $pattern . '$/usm', 'levels' => 1]); + if (!$list) { + continue; + } + + $index++; + $num = str_pad((string)$index, 2, '0', STR_PAD_LEFT); + $io->writeln(' ' . $num . '. ' . str_pad($name, 15) . " {$bin} {$name} list"); + } + + return 0; + } +} diff --git a/system/src/Grav/Console/TerminalObjects/Table.php b/system/src/Grav/Console/TerminalObjects/Table.php index 4f9df63..a71d7c0 100644 --- a/system/src/Grav/Console/TerminalObjects/Table.php +++ b/system/src/Grav/Console/TerminalObjects/Table.php @@ -3,14 +3,22 @@ /** * @package Grav\Console\TerminalObjects * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Console\TerminalObjects; +/** + * Class Table + * @package Grav\Console\TerminalObjects + * @deprecated 1.7 Use Symfony Console Table + */ class Table extends \League\CLImate\TerminalObject\Basic\Table { + /** + * @return array + */ public function result() { $this->column_widths = $this->getColumnWidths(); diff --git a/system/src/Grav/Events/FlexRegisterEvent.php b/system/src/Grav/Events/FlexRegisterEvent.php new file mode 100644 index 0000000..13aebf3 --- /dev/null +++ b/system/src/Grav/Events/FlexRegisterEvent.php @@ -0,0 +1,45 @@ +flex = $flex; + } + + /** + * @return array + */ + public function __debugInfo(): array + { + return (array)$this; + } +} diff --git a/system/src/Grav/Events/PermissionsRegisterEvent.php b/system/src/Grav/Events/PermissionsRegisterEvent.php new file mode 100644 index 0000000..5e63c85 --- /dev/null +++ b/system/src/Grav/Events/PermissionsRegisterEvent.php @@ -0,0 +1,45 @@ +permissions = $permissions; + } + + /** + * @return array + */ + public function __debugInfo(): array + { + return (array)$this; + } +} diff --git a/system/src/Grav/Events/PluginsLoadedEvent.php b/system/src/Grav/Events/PluginsLoadedEvent.php new file mode 100644 index 0000000..9dfd18b --- /dev/null +++ b/system/src/Grav/Events/PluginsLoadedEvent.php @@ -0,0 +1,53 @@ +grav = $grav; + $this->plugins = $plugins; + } + + /** + * @return array + */ + public function __debugInfo(): array + { + return [ + 'plugins' => $this->plugins + ]; + } +} diff --git a/system/src/Grav/Events/SessionStartEvent.php b/system/src/Grav/Events/SessionStartEvent.php new file mode 100644 index 0000000..e724a09 --- /dev/null +++ b/system/src/Grav/Events/SessionStartEvent.php @@ -0,0 +1,36 @@ +start() right after successful session_start() call. + * + * @property SessionInterface $session Session instance. + */ +class SessionStartEvent extends Event +{ + /** @var SessionInterface */ + public $session; + + public function __construct(SessionInterface $session) + { + $this->session = $session; + } + + public function __debugInfo(): array + { + return (array)$this; + } +} diff --git a/system/src/Grav/Framework/Acl/Access.php b/system/src/Grav/Framework/Acl/Access.php new file mode 100644 index 0000000..ccc22cf --- /dev/null +++ b/system/src/Grav/Framework/Acl/Access.php @@ -0,0 +1,236 @@ +name = $name; + $this->rules = $rules ?? []; + $this->ops = ['+' => true, '-' => false]; + if (is_string($acl)) { + $this->acl = $this->resolvePermissions($acl); + } elseif (is_array($acl)) { + $this->acl = $this->normalizeAcl($acl); + } + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param Access $parent + * @param string|null $name + * @return void + */ + public function inherit(Access $parent, string $name = null) + { + // Remove cached null actions from acl. + $acl = $this->getAllActions(); + // Get only inherited actions. + $inherited = array_diff_key($parent->getAllActions(), $acl); + + $this->inherited += $parent->inherited + array_fill_keys(array_keys($inherited), $name ?? $parent->getName()); + $acl = array_replace($acl, $inherited); + if (null === $acl) { + throw new RuntimeException('Internal error'); + } + + $this->acl = $acl; + } + + /** + * Checks user authorization to the action. + * + * @param string $action + * @param string|null $scope + * @return bool|null + */ + public function authorize(string $action, string $scope = null): ?bool + { + if (null !== $scope) { + $action = $scope !== 'test' ? "{$scope}.{$action}" : $action; + } + + return $this->get($action); + } + + /** + * @return array + */ + public function toArray(): array + { + return Utils::arrayUnflattenDotNotation($this->acl); + } + + /** + * @return array + */ + public function getAllActions(): array + { + return array_filter($this->acl, static function($val) { return $val !== null; }); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * @param string $action + * @return bool|null + */ + public function get(string $action) + { + // Get access value. + if (isset($this->acl[$action])) { + return $this->acl[$action]; + } + + // If no value is defined, check the parent access (all true|false). + $pos = strrpos($action, '.'); + $value = $pos ? $this->get(substr($action, 0, $pos)) : null; + + // Cache result for faster lookup. + $this->acl[$action] = $value; + + return $value; + } + + /** + * @param string $action + * @return bool + */ + public function isInherited(string $action): bool + { + return isset($this->inherited[$action]); + } + + /** + * @param string $action + * @return string|null + */ + public function getInherited(string $action): ?string + { + return $this->inherited[$action] ?? null; + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->acl); + } + + /** + * @return int + */ + public function count(): int + { + return count($this->acl); + } + + /** + * @param array $acl + * @return array + */ + protected function normalizeAcl(array $acl): array + { + if (empty($acl)) { + return []; + } + + // Normalize access control list. + $list = []; + foreach (Utils::arrayFlattenDotNotation($acl) as $key => $value) { + if (is_bool($value)) { + $list[$key] = $value; + } elseif ($value === 0 || $value === 1) { + $list[$key] = (bool)$value; + } elseif($value === null) { + continue; + } elseif ($this->rules && is_string($value)) { + $list[$key] = $this->resolvePermissions($value); + } elseif (Utils::isPositive($value)) { + $list[$key] = true; + } elseif (Utils::isNegative($value)) { + $list[$key] = false; + } + } + + return $list; + } + + /** + * @param string $access + * @return array + */ + protected function resolvePermissions(string $access): array + { + $len = strlen($access); + $op = true; + $list = []; + for($count = 0; $count < $len; $count++) { + $letter = $access[$count]; + if (isset($this->rules[$letter])) { + $list[$this->rules[$letter]] = $op; + $op = true; + } elseif (isset($this->ops[$letter])) { + $op = $this->ops[$letter]; + } + } + + return $list; + } +} diff --git a/system/src/Grav/Framework/Acl/Action.php b/system/src/Grav/Framework/Acl/Action.php new file mode 100644 index 0000000..1f883a0 --- /dev/null +++ b/system/src/Grav/Framework/Acl/Action.php @@ -0,0 +1,203 @@ +name = $name; + $this->type = $action['type'] ?? 'action'; + $this->visible = (bool)($action['visible'] ?? true); + $this->label = $label; + unset($action['type'], $action['label']); + $this->params = $action; + + // Include compact rules. + if (isset($action['letters'])) { + foreach ($action['letters'] as $letter => $data) { + $data['letter'] = $letter; + $childName = $this->name . '.' . $data['action']; + unset($data['action']); + $child = new Action($childName, $data); + $this->addChild($child); + } + } + } + + /** + * @return array + */ + public function getParams(): array + { + return $this->params; + } + + /** + * @param string $name + * @return mixed|null + */ + public function getParam(string $name) + { + return $this->params[$name] ?? null; + } + + /** + * @return Action|null + */ + public function getParent(): ?Action + { + return $this->parent; + } + + /** + * @param Action|null $parent + * @return void + */ + public function setParent(?Action $parent): void + { + $this->parent = $parent; + } + + /** + * @return string + */ + public function getScope(): string + { + $pos = strpos($this->name, '.'); + if ($pos) { + return substr($this->name, 0, $pos); + } + + return $this->name; + } + + /** + * @return int + */ + public function getLevels(): int + { + return substr_count($this->name, '.'); + } + + /** + * @return bool + */ + public function hasChildren(): bool + { + return !empty($this->children); + } + + /** + * @return Action[] + */ + public function getChildren(): array + { + return $this->children; + } + + /** + * @param string $name + * @return Action|null + */ + public function getChild(string $name): ?Action + { + return $this->children[$name] ?? null; + } + + /** + * @param Action $child + * @return void + */ + public function addChild(Action $child): void + { + if (strpos($child->name, "{$this->name}.") !== 0) { + throw new RuntimeException('Bad child'); + } + + $child->setParent($this); + $name = substr($child->name, strlen($this->name) + 1); + + $this->children[$name] = $child; + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->children); + } + + /** + * @return int + */ + public function count(): int + { + return count($this->children); + } + + /** + * @return array + */ + public function __debugInfo() + { + return [ + 'name' => $this->name, + 'type' => $this->type, + 'label' => $this->label, + 'params' => $this->params, + 'actions' => $this->children + ]; + } +} diff --git a/system/src/Grav/Framework/Acl/Permissions.php b/system/src/Grav/Framework/Acl/Permissions.php new file mode 100644 index 0000000..0375716 --- /dev/null +++ b/system/src/Grav/Framework/Acl/Permissions.php @@ -0,0 +1,244 @@ +actions); + $recursive = new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::SELF_FIRST); + + return iterator_to_array($recursive); + } + + /** + * @param string $name + * @return bool + */ + public function hasAction(string $name): bool + { + return isset($this->instances[$name]); + } + + /** + * @param string $name + * @return Action|null + */ + public function getAction(string $name): ?Action + { + return $this->instances[$name] ?? null; + } + + /** + * @param Action $action + * @return void + */ + public function addAction(Action $action): void + { + $name = $action->name; + $parent = $this->getParent($name); + if ($parent) { + $parent->addChild($action); + } else { + $this->actions[$name] = $action; + } + + $this->instances[$name] = $action; + + // If Action has children, add those, too. + foreach ($action->getChildren() as $child) { + $this->instances[$child->name] = $child; + } + } + + /** + * @return array + */ + public function getActions(): array + { + return $this->actions; + } + + /** + * @param Action[] $actions + * @return void + */ + public function addActions(array $actions): void + { + foreach ($actions as $action) { + $this->addAction($action); + } + } + + /** + * @param string $name + * @return bool + */ + public function hasType(string $name): bool + { + return isset($this->types[$name]); + } + + /** + * @param string $name + * @return Action|null + */ + public function getType(string $name): ?Action + { + return $this->types[$name] ?? null; + } + + /** + * @param string $name + * @param array $type + * @return void + */ + public function addType(string $name, array $type): void + { + $this->types[$name] = $type; + } + + /** + * @return array + */ + public function getTypes(): array + { + return $this->types; + } + + /** + * @param array $types + * @return void + */ + public function addTypes(array $types): void + { + $types = array_replace($this->types, $types); + if (null === $types) { + throw new RuntimeException('Internal error'); + } + + $this->types = $types; + } + + /** + * @param array|null $access + * @return Access + */ + public function getAccess(array $access = null): Access + { + return new Access($access ?? []); + } + + /** + * @param int|string $offset + * @return bool + */ + public function offsetExists($offset): bool + { + return isset($this->nested[$offset]); + } + + /** + * @param int|string $offset + * @return Action|null + */ + public function offsetGet($offset): ?Action + { + return $this->nested[$offset] ?? null; + } + + /** + * @param int|string $offset + * @param mixed $value + * @return void + */ + public function offsetSet($offset, $value): void + { + throw new RuntimeException(__METHOD__ . '(): Not Supported'); + } + + /** + * @param int|string $offset + * @return void + */ + public function offsetUnset($offset): void + { + throw new RuntimeException(__METHOD__ . '(): Not Supported'); + } + + /** + * @return int + */ + public function count(): int + { + return count($this->actions); + } + + /** + * @return ArrayIterator|Traversable + */ + public function getIterator() + { + return new ArrayIterator($this->actions); + } + + /** + * @return array + */ + public function __debugInfo() + { + return [ + 'actions' => $this->actions + ]; + } + + /** + * @param string $name + * @return Action|null + */ + protected function getParent(string $name): ?Action + { + if ($pos = strrpos($name, '.')) { + $parentName = substr($name, 0, $pos); + + $parent = $this->getAction($parentName); + if (!$parent) { + $parent = new Action($parentName); + $this->addAction($parent); + } + + return $parent; + } + + return null; + } +} diff --git a/system/src/Grav/Framework/Acl/PermissionsReader.php b/system/src/Grav/Framework/Acl/PermissionsReader.php new file mode 100644 index 0000000..2c38afb --- /dev/null +++ b/system/src/Grav/Framework/Acl/PermissionsReader.php @@ -0,0 +1,187 @@ +content(); + $actions = $content['actions'] ?? []; + $types = $content['types'] ?? []; + + return static::fromArray($actions, $types); + } + + /** + * @param array $actions + * @param array $types + * @return Action[] + */ + public static function fromArray(array $actions, array $types): array + { + static::initTypes($types); + + $list = []; + foreach (static::read($actions) as $type => $data) { + $list[$type] = new Action($type, $data); + } + + return $list; + } + + /** + * @param array $actions + * @param string $prefix + * @return array + */ + public static function read(array $actions, string $prefix = ''): array + { + $list = []; + foreach ($actions as $name => $action) { + $prefixNname = $prefix . $name; + $list[$prefixNname] = null; + + // Support nested sets of actions. + if (isset($action['actions']) && is_array($action['actions'])) { + $list += static::read($action['actions'], "{$prefixNname}."); + } + + unset($action['actions']); + + // Add defaults if they exist. + $action = static::addDefaults($action); + + // Build flat list of actions. + $list[$prefixNname] = $action; + } + + return $list; + } + + /** + * @param array $types + * @return void + */ + protected static function initTypes(array $types) + { + static::$types = []; + + $dependencies = []; + foreach ($types as $type => $defaults) { + $current = array_fill_keys((array)($defaults['use'] ?? null), null); + $defType = $defaults['type'] ?? $type; + if ($type !== $defType) { + $current[$defaults['type']] = null; + } + + $dependencies[$type] = (object)$current; + } + + // Build dependency tree. + foreach ($dependencies as $type => $dep) { + foreach (get_object_vars($dep) as $k => &$val) { + if (null === $val) { + $val = $dependencies[$k] ?? new stdClass(); + } + } + unset($val); + } + + $encoded = json_encode($dependencies); + if ($encoded === false) { + throw new RuntimeException('json_encode(): failed to encode dependencies'); + } + $dependencies = json_decode($encoded, true); + + foreach (static::getDependencies($dependencies) as $type) { + $defaults = $types[$type] ?? null; + if ($defaults) { + static::$types[$type] = static::addDefaults($defaults); + } + } + } + + /** + * @param array $dependencies + * @return array + */ + protected static function getDependencies(array $dependencies): array + { + $list = []; + foreach ($dependencies as $name => $deps) { + $current = $deps ? static::getDependencies($deps) : []; + $current[] = $name; + + $list[] = $current; + } + + return array_unique(array_merge(...$list)); + } + + /** + * @param array $action + * @return array + */ + protected static function addDefaults(array $action): array + { + $scopes = []; + + // Add used properties. + $use = (array)($action['use'] ?? null); + foreach ($use as $type) { + if (isset(static::$types[$type])) { + $used = static::$types[$type]; + unset($used['type']); + $scopes[] = $used; + } + } + unset($action['use']); + + // Add type defaults. + $type = $action['type'] ?? 'default'; + $defaults = static::$types[$type] ?? null; + if (is_array($defaults)) { + $scopes[] = $defaults; + } + + if ($scopes) { + $scopes[] = $action; + + $action = array_replace_recursive(...$scopes); + if (null === $action) { + throw new RuntimeException('Internal error'); + } + + $newType = $defaults['type'] ?? null; + if ($newType && $newType !== $type) { + $action['type'] = $newType; + } + } + + return $action; + } +} diff --git a/system/src/Grav/Framework/Acl/RecursiveActionIterator.php b/system/src/Grav/Framework/Acl/RecursiveActionIterator.php new file mode 100644 index 0000000..ac219da --- /dev/null +++ b/system/src/Grav/Framework/Acl/RecursiveActionIterator.php @@ -0,0 +1,60 @@ +current(); + + return $current->name; + } + + /** + * @see \RecursiveIterator::hasChildren() + * @return bool + */ + public function hasChildren(): bool + { + /** @var Action $current */ + $current = $this->current(); + + return $current->hasChildren(); + } + + /** + * @see \RecursiveIterator::getChildren() + * @return RecursiveActionIterator + */ + public function getChildren(): self + { + /** @var Action $current */ + $current = $this->current(); + + return new static($current->getChildren()); + } +} diff --git a/system/src/Grav/Framework/Cache/AbstractCache.php b/system/src/Grav/Framework/Cache/AbstractCache.php index 127db45..bfb5125 100644 --- a/system/src/Grav/Framework/Cache/AbstractCache.php +++ b/system/src/Grav/Framework/Cache/AbstractCache.php @@ -3,12 +3,13 @@ /** * @package Grav\Framework\Cache * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Cache; +use DateInterval; use Psr\SimpleCache\InvalidArgumentException; /** @@ -21,7 +22,7 @@ abstract class AbstractCache implements CacheInterface /** * @param string $namespace - * @param null|int|\DateInterval $defaultLifetime + * @param null|int|DateInterval $defaultLifetime * @throws InvalidArgumentException */ public function __construct($namespace = '', $defaultLifetime = null) diff --git a/system/src/Grav/Framework/Cache/Adapter/ChainCache.php b/system/src/Grav/Framework/Cache/Adapter/ChainCache.php index e1eea7b..76f5185 100644 --- a/system/src/Grav/Framework/Cache/Adapter/ChainCache.php +++ b/system/src/Grav/Framework/Cache/Adapter/ChainCache.php @@ -3,15 +3,18 @@ /** * @package Grav\Framework\Cache * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Cache\Adapter; +use DateInterval; use Grav\Framework\Cache\AbstractCache; use Grav\Framework\Cache\CacheInterface; use Grav\Framework\Cache\Exception\InvalidArgumentException; +use function count; +use function get_class; /** * Cache class for PSR-16 compatible "Simple Cache" implementation using chained cache adapters. @@ -20,20 +23,16 @@ use Grav\Framework\Cache\Exception\InvalidArgumentException; */ class ChainCache extends AbstractCache { - /** - * @var CacheInterface[] - */ + /** @var CacheInterface[] */ protected $caches; - /** - * @var int - */ + /** @var int */ protected $count; /** * Chain Cache constructor. * @param array $caches - * @param null|int|\DateInterval $defaultLifetime + * @param null|int|DateInterval $defaultLifetime * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException */ public function __construct(array $caches, $defaultLifetime = null) @@ -49,7 +48,7 @@ class ChainCache extends AbstractCache throw new InvalidArgumentException( sprintf( "The class '%s' does not implement the '%s' interface", - \get_class($cache), + get_class($cache), CacheInterface::class ) ); @@ -57,7 +56,7 @@ class ChainCache extends AbstractCache } $this->caches = array_values($caches); - $this->count = \count($caches); + $this->count = count($caches); } /** @@ -121,6 +120,7 @@ class ChainCache extends AbstractCache while ($i--) { $success = $this->caches[$i]->doClear() && $success; } + return $success; } @@ -130,6 +130,10 @@ class ChainCache extends AbstractCache public function doGetMultiple($keys, $miss) { $list = []; + /** + * @var int $i + * @var CacheInterface $cache + */ foreach ($this->caches as $i => $cache) { $list[$i] = $cache->doGetMultiple($keys, $miss); @@ -140,8 +144,12 @@ class ChainCache extends AbstractCache } } - $values = []; // Update all the previous caches with missing values. + $values = []; + /** + * @var int $i + * @var CacheInterface $items + */ foreach (array_reverse($list) as $i => $items) { $values += $items; if ($i && $values) { diff --git a/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php b/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php index 45c9d07..29e9e3b 100644 --- a/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php +++ b/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php @@ -3,12 +3,13 @@ /** * @package Grav\Framework\Cache * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Cache\Adapter; +use DateInterval; use Doctrine\Common\Cache\CacheProvider; use Grav\Framework\Cache\AbstractCache; use Grav\Framework\Cache\Exception\InvalidArgumentException; @@ -19,9 +20,7 @@ use Grav\Framework\Cache\Exception\InvalidArgumentException; */ class DoctrineCache extends AbstractCache { - /** - * @var CacheProvider - */ + /** @var CacheProvider */ protected $driver; /** @@ -29,7 +28,7 @@ class DoctrineCache extends AbstractCache * * @param CacheProvider $doctrineCache * @param string $namespace - * @param null|int|\DateInterval $defaultLifetime + * @param null|int|DateInterval $defaultLifetime * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException */ public function __construct(CacheProvider $doctrineCache, $namespace = '', $defaultLifetime = null) @@ -99,7 +98,6 @@ class DoctrineCache extends AbstractCache /** * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException */ public function doDeleteMultiple($keys) { diff --git a/system/src/Grav/Framework/Cache/Adapter/FileCache.php b/system/src/Grav/Framework/Cache/Adapter/FileCache.php index a5a60fa..1995e15 100644 --- a/system/src/Grav/Framework/Cache/Adapter/FileCache.php +++ b/system/src/Grav/Framework/Cache/Adapter/FileCache.php @@ -3,15 +3,21 @@ /** * @package Grav\Framework\Cache * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Cache\Adapter; +use ErrorException; +use FilesystemIterator; use Grav\Framework\Cache\AbstractCache; use Grav\Framework\Cache\Exception\CacheException; use Grav\Framework\Cache\Exception\InvalidArgumentException; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use RuntimeException; +use function strlen; /** * Cache class for PSR-16 compatible "Simple Cache" implementation using file backend. @@ -22,11 +28,17 @@ use Grav\Framework\Cache\Exception\InvalidArgumentException; */ class FileCache extends AbstractCache { + /** @var string */ private $directory; + /** @var string|null */ private $tmp; /** - * @inheritdoc + * FileCache constructor. + * @param string $namespace + * @param int|null $defaultLifetime + * @param string|null $folder + * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException */ public function __construct($namespace = '', $defaultLifetime = null, $folder = null) { @@ -51,12 +63,12 @@ class FileCache extends AbstractCache fclose($h); @unlink($file); } else { - $i = rawurldecode(rtrim(fgets($h))); - $value = stream_get_contents($h); + $i = rawurldecode(rtrim((string)fgets($h))); + $value = stream_get_contents($h) ?: ''; fclose($h); if ($i === $key) { - return unserialize($value); + return unserialize($value, ['allowed_classes' => true]); } } @@ -65,7 +77,7 @@ class FileCache extends AbstractCache /** * @inheritdoc - * @throws \Psr\SimpleCache\CacheException|InvalidArgumentException + * @throws CacheException */ public function doSet($key, $value, $ttl) { @@ -100,7 +112,7 @@ class FileCache extends AbstractCache public function doClear() { $result = true; - $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS)); + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->directory, FilesystemIterator::SKIP_DOTS)); foreach ($iterator as $file) { $result = ($file->isDir() || @unlink($file) || !file_exists($file)) && $result; @@ -139,7 +151,8 @@ class FileCache extends AbstractCache /** * @param string $namespace * @param string $directory - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + * @return void + * @throws InvalidArgumentException */ protected function initFileCache($namespace, $directory) { @@ -195,7 +208,8 @@ class FileCache extends AbstractCache /** * @param string $dir - * @throws \RuntimeException + * @return void + * @throws RuntimeException */ private function mkdir($dir) { @@ -210,20 +224,28 @@ class FileCache extends AbstractCache // Take yet another look, make sure that the folder doesn't exist. clearstatcache(true, $dir); if (!@is_dir($dir)) { - throw new \RuntimeException(sprintf('Unable to create directory: %s', $dir)); + throw new RuntimeException(sprintf('Unable to create directory: %s', $dir)); } } } /** + * @param int $type + * @param string $message + * @param string $file + * @param int $line + * @return bool * @internal - * @throws \ErrorException + * @throws ErrorException */ public static function throwError($type, $message, $file, $line) { - throw new \ErrorException($message, 0, $type, $file, $line); + throw new ErrorException($message, 0, $type, $file, $line); } + /** + * @return void + */ public function __destruct() { if ($this->tmp !== null && file_exists($this->tmp)) { diff --git a/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php b/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php index 2d0fe40..c043bf9 100644 --- a/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php +++ b/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php @@ -3,13 +3,14 @@ /** * @package Grav\Framework\Cache * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Cache\Adapter; use Grav\Framework\Cache\AbstractCache; +use function array_key_exists; /** * Cache class for PSR-16 compatible "Simple Cache" implementation using in memory backend. @@ -20,11 +21,14 @@ use Grav\Framework\Cache\AbstractCache; */ class MemoryCache extends AbstractCache { - /** - * @var array - */ + /** @var array */ protected $cache = []; + /** + * @param string $key + * @param mixed $miss + * @return mixed + */ public function doGet($key, $miss) { if (!array_key_exists($key, $this->cache)) { @@ -34,6 +38,12 @@ class MemoryCache extends AbstractCache return $this->cache[$key]; } + /** + * @param string $key + * @param mixed $value + * @param int $ttl + * @return bool + */ public function doSet($key, $value, $ttl) { $this->cache[$key] = $value; @@ -41,6 +51,10 @@ class MemoryCache extends AbstractCache return true; } + /** + * @param string $key + * @return bool + */ public function doDelete($key) { unset($this->cache[$key]); @@ -48,6 +62,9 @@ class MemoryCache extends AbstractCache return true; } + /** + * @return bool + */ public function doClear() { $this->cache = []; @@ -55,6 +72,10 @@ class MemoryCache extends AbstractCache return true; } + /** + * @param string $key + * @return bool + */ public function doHas($key) { return array_key_exists($key, $this->cache); diff --git a/system/src/Grav/Framework/Cache/Adapter/SessionCache.php b/system/src/Grav/Framework/Cache/Adapter/SessionCache.php index 62c68a0..e189e3e 100644 --- a/system/src/Grav/Framework/Cache/Adapter/SessionCache.php +++ b/system/src/Grav/Framework/Cache/Adapter/SessionCache.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Cache * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -21,6 +21,11 @@ class SessionCache extends AbstractCache public const VALUE = 0; public const LIFETIME = 1; + /** + * @param string $key + * @param mixed $miss + * @return mixed + */ public function doGet($key, $miss) { $stored = $this->doGetStored($key); @@ -28,12 +33,17 @@ class SessionCache extends AbstractCache return $stored ? $stored[self::VALUE] : $miss; } + /** + * @param string $key + * @param mixed $value + * @param int $ttl + * @return bool + */ public function doSet($key, $value, $ttl) { $stored = [self::VALUE => $value]; if (null !== $ttl) { $stored[self::LIFETIME] = time() + $ttl; - } $_SESSION[$this->getNamespace()][$key] = $stored; @@ -41,6 +51,10 @@ class SessionCache extends AbstractCache return true; } + /** + * @param string $key + * @return bool + */ public function doDelete($key) { unset($_SESSION[$this->getNamespace()][$key]); @@ -48,6 +62,9 @@ class SessionCache extends AbstractCache return true; } + /** + * @return bool + */ public function doClear() { unset($_SESSION[$this->getNamespace()]); @@ -55,16 +72,27 @@ class SessionCache extends AbstractCache return true; } + /** + * @param string $key + * @return bool + */ public function doHas($key) { return $this->doGetStored($key) !== null; } + /** + * @return string + */ public function getNamespace() { return 'cache-' . parent::getNamespace(); } + /** + * @param string $key + * @return mixed|null + */ protected function doGetStored($key) { $stored = $_SESSION[$this->getNamespace()][$key] ?? null; diff --git a/system/src/Grav/Framework/Cache/CacheInterface.php b/system/src/Grav/Framework/Cache/CacheInterface.php index 5aefe35..efd9d31 100644 --- a/system/src/Grav/Framework/Cache/CacheInterface.php +++ b/system/src/Grav/Framework/Cache/CacheInterface.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Cache * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -17,12 +17,55 @@ use Psr\SimpleCache\CacheInterface as SimpleCacheInterface; */ interface CacheInterface extends SimpleCacheInterface { + /** + * @param string $key + * @param mixed $miss + * @return mixed + */ public function doGet($key, $miss); + + /** + * @param string $key + * @param mixed $value + * @param int|null $ttl + * @return mixed + */ public function doSet($key, $value, $ttl); + + /** + * @param string $key + * @return mixed + */ public function doDelete($key); + + /** + * @return bool + */ public function doClear(); + + /** + * @param string[] $keys + * @param mixed $miss + * @return mixed + */ public function doGetMultiple($keys, $miss); + + /** + * @param array $values + * @param int|null $ttl + * @return mixed + */ public function doSetMultiple($values, $ttl); + + /** + * @param string[] $keys + * @return mixed + */ public function doDeleteMultiple($keys); + + /** + * @param string $key + * @return mixed + */ public function doHas($key); } diff --git a/system/src/Grav/Framework/Cache/CacheTrait.php b/system/src/Grav/Framework/Cache/CacheTrait.php index 748744a..287f924 100644 --- a/system/src/Grav/Framework/Cache/CacheTrait.php +++ b/system/src/Grav/Framework/Cache/CacheTrait.php @@ -3,13 +3,25 @@ /** * @package Grav\Framework\Cache * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Cache; +use DateInterval; +use DateTime; use Grav\Framework\Cache\Exception\InvalidArgumentException; +use stdClass; +use Traversable; +use function array_key_exists; +use function get_class; +use function gettype; +use function is_array; +use function is_int; +use function is_object; +use function is_string; +use function strlen; /** * Cache trait for PSR-16 compatible "Simple Cache" implementation @@ -19,13 +31,10 @@ trait CacheTrait { /** @var string */ private $namespace = ''; - /** @var int|null */ private $defaultLifetime = null; - - /** @var \stdClass */ + /** @var stdClass */ private $miss; - /** @var bool */ private $validation = true; @@ -33,18 +42,20 @@ trait CacheTrait * Always call from constructor. * * @param string $namespace - * @param null|int|\DateInterval $defaultLifetime - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + * @param null|int|DateInterval $defaultLifetime + * @return void + * @throws InvalidArgumentException */ protected function init($namespace = '', $defaultLifetime = null) { $this->namespace = (string) $namespace; $this->defaultLifetime = $this->convertTtl($defaultLifetime); - $this->miss = new \stdClass; + $this->miss = new stdClass; } /** * @param bool $validation + * @return void */ public function setValidation($validation) { @@ -68,8 +79,10 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + * @param string $key + * @param mixed|null $default + * @return mixed|null + * @throws InvalidArgumentException */ public function get($key, $default = null) { @@ -81,8 +94,11 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + * @param string $key + * @param mixed $value + * @param null|int|DateInterval $ttl + * @return bool + * @throws InvalidArgumentException */ public function set($key, $value, $ttl = null) { @@ -95,8 +111,9 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + * @param string $key + * @return bool + * @throws InvalidArgumentException */ public function delete($key) { @@ -106,7 +123,7 @@ trait CacheTrait } /** - * @inheritdoc + * @return bool */ public function clear() { @@ -114,18 +131,21 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + * @param iterable $keys + * @param mixed|null $default + * @return iterable + * @throws InvalidArgumentException */ public function getMultiple($keys, $default = null) { - if ($keys instanceof \Traversable) { + if ($keys instanceof Traversable) { $keys = iterator_to_array($keys, false); - } elseif (!\is_array($keys)) { + } elseif (!is_array($keys)) { + $isObject = is_object($keys); throw new InvalidArgumentException( sprintf( 'Cache keys must be array or Traversable, "%s" given', - \is_object($keys) ? \get_class($keys) : \gettype($keys) + $isObject ? get_class($keys) : gettype($keys) ) ); } @@ -137,6 +157,9 @@ trait CacheTrait $this->validateKeys($keys); $keys = array_unique($keys); $keys = array_combine($keys, $keys); + if (empty($keys)) { + return []; + } $list = $this->doGetMultiple($keys, $this->miss); @@ -154,18 +177,21 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + * @param iterable $values + * @param null|int|DateInterval $ttl + * @return bool + * @throws InvalidArgumentException */ public function setMultiple($values, $ttl = null) { - if ($values instanceof \Traversable) { + if ($values instanceof Traversable) { $values = iterator_to_array($values, true); } elseif (!is_array($values)) { + $isObject = is_object($values); throw new InvalidArgumentException( sprintf( 'Cache values must be array or Traversable, "%s" given', - \is_object($values) ? \get_class($values) : \gettype($values) + $isObject ? get_class($values) : gettype($values) ) ); } @@ -185,18 +211,20 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + * @param iterable $keys + * @return bool + * @throws InvalidArgumentException */ public function deleteMultiple($keys) { - if ($keys instanceof \Traversable) { + if ($keys instanceof Traversable) { $keys = iterator_to_array($keys, false); } elseif (!is_array($keys)) { + $isObject = is_object($keys); throw new InvalidArgumentException( sprintf( 'Cache keys must be array or Traversable, "%s" given', - \is_object($keys) ? \get_class($keys) : \gettype($keys) + $isObject ? get_class($keys) : gettype($keys) ) ); } @@ -211,8 +239,9 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + * @param string $key + * @return bool + * @throws InvalidArgumentException */ public function has($key) { @@ -221,11 +250,6 @@ trait CacheTrait return $this->doHas($key); } - abstract public function doGet($key, $miss); - abstract public function doSet($key, $value, $ttl); - abstract public function doDelete($key); - abstract public function doClear(); - /** * @param array $keys * @param mixed $miss @@ -247,7 +271,7 @@ trait CacheTrait /** * @param array $values - * @param int $ttl + * @param int|null $ttl * @return bool */ public function doSetMultiple($values, $ttl) @@ -276,28 +300,27 @@ trait CacheTrait return $success; } - abstract public function doHas($key); - /** * @param string|mixed $key - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + * @return void + * @throws InvalidArgumentException */ protected function validateKey($key) { - if (!\is_string($key)) { + if (!is_string($key)) { throw new InvalidArgumentException( sprintf( 'Cache key must be string, "%s" given', - \is_object($key) ? \get_class($key) : \gettype($key) + is_object($key) ? get_class($key) : gettype($key) ) ); } if (!isset($key[0])) { throw new InvalidArgumentException('Cache key length must be greater than zero'); } - if (\strlen($key) > 64) { + if (strlen($key) > 64) { throw new InvalidArgumentException( - sprintf('Cache key length must be less than 65 characters, key had %s characters', \strlen($key)) + sprintf('Cache key length must be less than 65 characters, key had %d characters', strlen($key)) ); } if (strpbrk($key, '{}()/\@:') !== false) { @@ -309,7 +332,8 @@ trait CacheTrait /** * @param array $keys - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + * @return void + * @throws InvalidArgumentException */ protected function validateKeys($keys) { @@ -323,9 +347,9 @@ trait CacheTrait } /** - * @param null|int|\DateInterval|mixed $ttl + * @param null|int|DateInterval $ttl * @return int|null - * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException + * @throws InvalidArgumentException */ protected function convertTtl($ttl) { @@ -333,18 +357,19 @@ trait CacheTrait return $this->getDefaultLifetime(); } - if (\is_int($ttl)) { + if (is_int($ttl)) { return $ttl; } - if ($ttl instanceof \DateInterval) { - $ttl = (int) \DateTime::createFromFormat('U', '0')->add($ttl)->format('U'); + if ($ttl instanceof DateInterval) { + $date = DateTime::createFromFormat('U', '0'); + $ttl = $date ? (int)$date->add($ttl)->format('U') : 0; } throw new InvalidArgumentException( sprintf( 'Expiration date must be an integer, a DateInterval or null, "%s" given', - \is_object($ttl) ? \get_class($ttl) : \gettype($ttl) + is_object($ttl) ? get_class($ttl) : gettype($ttl) ) ); } diff --git a/system/src/Grav/Framework/Cache/Exception/CacheException.php b/system/src/Grav/Framework/Cache/Exception/CacheException.php index 55da2f5..1db3256 100644 --- a/system/src/Grav/Framework/Cache/Exception/CacheException.php +++ b/system/src/Grav/Framework/Cache/Exception/CacheException.php @@ -3,18 +3,19 @@ /** * @package Grav\Framework\Cache * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Cache\Exception; +use Exception; use Psr\SimpleCache\CacheException as SimpleCacheException; /** * CacheException class for PSR-16 compatible "Simple Cache" implementation. * @package Grav\Framework\Cache\Exception */ -class CacheException extends \Exception implements SimpleCacheException +class CacheException extends Exception implements SimpleCacheException { } diff --git a/system/src/Grav/Framework/Cache/Exception/InvalidArgumentException.php b/system/src/Grav/Framework/Cache/Exception/InvalidArgumentException.php index 594878d..08a3435 100644 --- a/system/src/Grav/Framework/Cache/Exception/InvalidArgumentException.php +++ b/system/src/Grav/Framework/Cache/Exception/InvalidArgumentException.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Cache * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/Collection/AbstractFileCollection.php b/system/src/Grav/Framework/Collection/AbstractFileCollection.php index f6f2996..50e9051 100644 --- a/system/src/Grav/Framework/Collection/AbstractFileCollection.php +++ b/system/src/Grav/Framework/Collection/AbstractFileCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Collection * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,45 +11,37 @@ namespace Grav\Framework\Collection; use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Expr\ClosureExpressionVisitor; +use FilesystemIterator; use Grav\Common\Grav; +use RecursiveDirectoryIterator; use RocketTheme\Toolbox\ResourceLocator\RecursiveUniformResourceIterator; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use SeekableIterator; +use function array_slice; /** * Collection of objects stored into a filesystem. * * @package Grav\Framework\Collection + * @template TKey + * @template T + * @extends AbstractLazyCollection + * @mplements FileCollectionInterface */ class AbstractFileCollection extends AbstractLazyCollection implements FileCollectionInterface { - /** - * @var string - */ + /** @var string */ protected $path; - - /** - * @var \RecursiveDirectoryIterator|RecursiveUniformResourceIterator - */ + /** @var RecursiveDirectoryIterator|RecursiveUniformResourceIterator */ protected $iterator; - - /** - * @var callable - */ + /** @var callable */ protected $createObjectFunction; - - /** - * @var callable|null - */ + /** @var callable|null */ protected $filterFunction; - - /** - * @var int - */ + /** @var int */ protected $flags; - - /** - * @var int - */ + /** @var int */ protected $nestingLimit; /** @@ -94,9 +86,16 @@ class AbstractFileCollection extends AbstractLazyCollection implements FileColle if ($orderings = $criteria->getOrderings()) { $next = null; + /** + * @var string $field + * @var string $ordering + */ foreach (array_reverse($orderings) as $field => $ordering) { $next = ClosureExpressionVisitor::sortByField($field, $ordering === Criteria::DESC ? -1 : 1, $next); } + if (null === $next) { + throw new RuntimeException('Criteria is missing orderings'); + } uasort($filtered, $next); } else { @@ -107,23 +106,26 @@ class AbstractFileCollection extends AbstractLazyCollection implements FileColle $length = $criteria->getMaxResults(); if ($offset || $length) { - $filtered = \array_slice($filtered, (int)$offset, $length); + $filtered = array_slice($filtered, (int)$offset, $length); } return new ArrayCollection($filtered); } + /** + * @return void + */ protected function setIterator() { - $iteratorFlags = \RecursiveDirectoryIterator::SKIP_DOTS + \FilesystemIterator::UNIX_PATHS - + \FilesystemIterator::CURRENT_AS_SELF + \FilesystemIterator::FOLLOW_SYMLINKS; + $iteratorFlags = RecursiveDirectoryIterator::SKIP_DOTS + FilesystemIterator::UNIX_PATHS + + FilesystemIterator::CURRENT_AS_SELF + FilesystemIterator::FOLLOW_SYMLINKS; if (strpos($this->path, '://')) { /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; $this->iterator = $locator->getRecursiveIterator($this->path, $iteratorFlags); } else { - $this->iterator = new \RecursiveDirectoryIterator($this->path, $iteratorFlags); + $this->iterator = new RecursiveDirectoryIterator($this->path, $iteratorFlags); } } @@ -156,14 +158,19 @@ class AbstractFileCollection extends AbstractLazyCollection implements FileColle $this->collection = new ArrayCollection($filtered); } - protected function doInitializeByIterator(\SeekableIterator $iterator, $nestingLimit) + /** + * @param SeekableIterator $iterator + * @param int $nestingLimit + * @return array + */ + protected function doInitializeByIterator(SeekableIterator $iterator, $nestingLimit) { $children = []; $objects = []; $filter = $this->filterFunction; $objectFunction = $this->createObjectFunction; - /** @var \RecursiveDirectoryIterator $file */ + /** @var RecursiveDirectoryIterator $file */ foreach ($iterator as $file) { // Skip files if they shouldn't be included. if (!($this->flags & static::INCLUDE_FILES) && $file->isFile()) { @@ -197,7 +204,8 @@ class AbstractFileCollection extends AbstractLazyCollection implements FileColle } /** - * @param \RecursiveDirectoryIterator[] $children + * @param array $children + * @param int $nestingLimit * @return array */ protected function doInitializeChildren(array $children, $nestingLimit) @@ -212,7 +220,7 @@ class AbstractFileCollection extends AbstractLazyCollection implements FileColle } /** - * @param \RecursiveDirectoryIterator $file + * @param RecursiveDirectoryIterator $file * @return object */ protected function createObject($file) diff --git a/system/src/Grav/Framework/Collection/AbstractIndexCollection.php b/system/src/Grav/Framework/Collection/AbstractIndexCollection.php index 0e4fae2..190f422 100644 --- a/system/src/Grav/Framework/Collection/AbstractIndexCollection.php +++ b/system/src/Grav/Framework/Collection/AbstractIndexCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Collection * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,19 +11,34 @@ namespace Grav\Framework\Collection; use ArrayIterator; use Closure; +use Grav\Framework\Compat\Serializable; +use Grav\Framework\Flex\Interfaces\FlexObjectInterface; +use InvalidArgumentException; +use function array_key_exists; +use function array_slice; +use function count; /** * Abstract Index Collection. + * @template TKey + * @template T + * @implements CollectionInterface */ abstract class AbstractIndexCollection implements CollectionInterface { - /** @var array */ + use Serializable; + + /** + * @var array + * @phpstan-var array + */ private $entries; /** * Initializes a new IndexCollection. * * @param array $entries + * @phpstan-param array $entries */ public function __construct(array $entries = []) { @@ -44,7 +59,7 @@ abstract class AbstractIndexCollection implements CollectionInterface public function first() { $value = reset($this->entries); - $key = key($this->entries); + $key = (string)key($this->entries); return $this->loadElement($key, $value); } @@ -55,7 +70,7 @@ abstract class AbstractIndexCollection implements CollectionInterface public function last() { $value = end($this->entries); - $key = key($this->entries); + $key = (string)key($this->entries); return $this->loadElement($key, $value); } @@ -65,7 +80,10 @@ abstract class AbstractIndexCollection implements CollectionInterface */ public function key() { - return key($this->entries); + /** @phpstan-var TKey $key */ + $key = (string)key($this->entries); + + return $key; } /** @@ -74,7 +92,7 @@ abstract class AbstractIndexCollection implements CollectionInterface public function next() { $value = next($this->entries); - $key = key($this->entries); + $key = (string)key($this->entries); return $this->loadElement($key, $value); } @@ -85,7 +103,7 @@ abstract class AbstractIndexCollection implements CollectionInterface public function current() { $value = current($this->entries); - $key = key($this->entries); + $key = (string)key($this->entries); return $this->loadElement($key, $value); } @@ -102,7 +120,7 @@ abstract class AbstractIndexCollection implements CollectionInterface $value = $this->entries[$key]; unset($this->entries[$key]); - return $this->loadElement($key, $value); + return $this->loadElement((string)$key, $value); } /** @@ -110,7 +128,7 @@ abstract class AbstractIndexCollection implements CollectionInterface */ public function removeElement($element) { - $key = $this->isAllowedElement($element) ? $element->getKey() : null; + $key = $this->isAllowedElement($element) ? $this->getCurrentKey($element) : null; if (!$key || !isset($this->entries[$key])) { return false; @@ -178,7 +196,7 @@ abstract class AbstractIndexCollection implements CollectionInterface */ public function contains($element) { - $key = $this->isAllowedElement($element) ? $element->getKey() : null; + $key = $this->isAllowedElement($element) ? $this->getCurrentKey($element) : null; return $key && isset($this->entries[$key]); } @@ -196,9 +214,9 @@ abstract class AbstractIndexCollection implements CollectionInterface */ public function indexOf($element) { - $key = $this->isAllowedElement($element) ? $element->getKey() : null; + $key = $this->isAllowedElement($element) ? $this->getCurrentKey($element) : null; - return $key && isset($this->entries[$key]) ? $key : null; + return $key && isset($this->entries[$key]) ? $key : false; } /** @@ -210,7 +228,7 @@ abstract class AbstractIndexCollection implements CollectionInterface return null; } - return $this->loadElement($key, $this->entries[$key]); + return $this->loadElement((string)$key, $this->entries[$key]); } /** @@ -234,7 +252,7 @@ abstract class AbstractIndexCollection implements CollectionInterface */ public function count() { - return \count($this->entries); + return count($this->entries); } /** @@ -243,11 +261,7 @@ abstract class AbstractIndexCollection implements CollectionInterface public function set($key, $value) { if (!$this->isAllowedElement($value)) { - throw new \InvalidArgumentException('Invalid argument $value'); - } - - if ($key !== $value->getKey()) { - $value->setKey($key); + throw new InvalidArgumentException('Invalid argument $value'); } $this->entries[$key] = $this->getElementMeta($value); @@ -259,10 +273,10 @@ abstract class AbstractIndexCollection implements CollectionInterface public function add($element) { if (!$this->isAllowedElement($element)) { - throw new \InvalidArgumentException('Invalid argument $element'); + throw new InvalidArgumentException('Invalid argument $element'); } - $this->entries[$element->getKey()] = $this->getElementMeta($element); + $this->entries[$this->getCurrentKey($element)] = $this->getElementMeta($element); return true; } @@ -340,7 +354,7 @@ abstract class AbstractIndexCollection implements CollectionInterface */ public function slice($offset, $length = null) { - return $this->loadElements(\array_slice($this->entries, $offset, $length, true)); + return $this->loadElements(array_slice($this->entries, $offset, $length, true)); } /** @@ -350,7 +364,7 @@ abstract class AbstractIndexCollection implements CollectionInterface */ public function limit($start, $limit = null) { - return $this->createFrom(\array_slice($this->entries, $start, $limit, true)); + return $this->createFrom(array_slice($this->entries, $start, $limit, true)); } /** @@ -373,7 +387,7 @@ abstract class AbstractIndexCollection implements CollectionInterface $keys = $this->getKeys(); shuffle($keys); - return $this->createFrom(array_replace(array_flip($keys), $this->entries)); + return $this->createFrom(array_replace(array_flip($keys), $this->entries) ?? []); } /** @@ -419,20 +433,21 @@ abstract class AbstractIndexCollection implements CollectionInterface } /** - * @return string + * @return array */ - public function serialize() + public function __serialize(): array { - return serialize(['entries' => $this->entries]); + return [ + 'entries' => $this->entries + ]; } /** - * @param string $serialized + * @param array $data + * @return void */ - public function unserialize($serialized) + public function __unserialize(array $data): void { - $data = unserialize($serialized, ['allowed_classes' => false]); - $this->entries = $data['entries']; } @@ -453,7 +468,6 @@ abstract class AbstractIndexCollection implements CollectionInterface * instance should be created when constructor semantics have changed. * * @param array $entries Elements. - * * @return static */ protected function createFrom(array $entries) @@ -464,19 +478,32 @@ abstract class AbstractIndexCollection implements CollectionInterface /** * @return array */ - protected function getEntries() : array + protected function getEntries(): array { return $this->entries; } /** * @param array $entries + * @return void + * @phpstan-param array $entries */ - protected function setEntries(array $entries) : void + protected function setEntries(array $entries): void { $this->entries = $entries; } + /** + * @param FlexObjectInterface $element + * @return string + * @phpstan-param T $element + * @phpstan-return TKey + */ + protected function getCurrentKey($element) + { + return $element->getKey(); + } + /** * @param string $key * @param mixed $value @@ -487,20 +514,21 @@ abstract class AbstractIndexCollection implements CollectionInterface /** * @param array|null $entries * @return array + * @phpstan-return array */ - abstract protected function loadElements(array $entries = null) : array; + abstract protected function loadElements(array $entries = null): array; /** * @param array|null $entries * @return CollectionInterface */ - abstract protected function loadCollection(array $entries = null) : CollectionInterface; + abstract protected function loadCollection(array $entries = null): CollectionInterface; /** * @param mixed $value * @return bool */ - abstract protected function isAllowedElement($value) : bool; + abstract protected function isAllowedElement($value): bool; /** * @param mixed $element diff --git a/system/src/Grav/Framework/Collection/AbstractLazyCollection.php b/system/src/Grav/Framework/Collection/AbstractLazyCollection.php index 0653caa..af7ffe1 100644 --- a/system/src/Grav/Framework/Collection/AbstractLazyCollection.php +++ b/system/src/Grav/Framework/Collection/AbstractLazyCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Collection * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,14 +15,14 @@ use Doctrine\Common\Collections\AbstractLazyCollection as BaseAbstractLazyCollec * General JSON serializable collection. * * @package Grav\Framework\Collection + * @template TKey + * @template T + * @extends BaseAbstractLazyCollection + * @implements CollectionInterface */ abstract class AbstractLazyCollection extends BaseAbstractLazyCollection implements CollectionInterface { - /** - * The backed collection to use - * - * @var ArrayCollection - */ + /** @var ArrayCollection The backed collection to use */ protected $collection; /** @@ -31,6 +31,7 @@ abstract class AbstractLazyCollection extends BaseAbstractLazyCollection impleme public function reverse() { $this->initialize(); + return $this->collection->reverse(); } @@ -40,6 +41,7 @@ abstract class AbstractLazyCollection extends BaseAbstractLazyCollection impleme public function shuffle() { $this->initialize(); + return $this->collection->shuffle(); } @@ -49,6 +51,7 @@ abstract class AbstractLazyCollection extends BaseAbstractLazyCollection impleme public function chunk($size) { $this->initialize(); + return $this->collection->chunk($size); } @@ -58,6 +61,7 @@ abstract class AbstractLazyCollection extends BaseAbstractLazyCollection impleme public function select(array $keys) { $this->initialize(); + return $this->collection->select($keys); } @@ -67,15 +71,17 @@ abstract class AbstractLazyCollection extends BaseAbstractLazyCollection impleme public function unselect(array $keys) { $this->initialize(); + return $this->collection->unselect($keys); } /** - * {@inheritDoc} + * @return array */ public function jsonSerialize() { $this->initialize(); + return $this->collection->jsonSerialize(); } } diff --git a/system/src/Grav/Framework/Collection/ArrayCollection.php b/system/src/Grav/Framework/Collection/ArrayCollection.php index f79ab96..474a3fb 100644 --- a/system/src/Grav/Framework/Collection/ArrayCollection.php +++ b/system/src/Grav/Framework/Collection/ArrayCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Collection * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,6 +15,10 @@ use Doctrine\Common\Collections\ArrayCollection as BaseArrayCollection; * General JSON serializable collection. * * @package Grav\Framework\Collection + * @template TKey + * @template T + * @extends BaseArrayCollection + * @implements CollectionInterface */ class ArrayCollection extends BaseArrayCollection implements CollectionInterface { @@ -22,6 +26,7 @@ class ArrayCollection extends BaseArrayCollection implements CollectionInterface * Reverse the order of the items. * * @return static + * @phpstan-return static */ public function reverse() { @@ -32,13 +37,14 @@ class ArrayCollection extends BaseArrayCollection implements CollectionInterface * Shuffle items. * * @return static + * @phpstan-return static */ public function shuffle() { $keys = $this->getKeys(); shuffle($keys); - return $this->createFrom(array_replace(array_flip($keys), $this->toArray())); + return $this->createFrom(array_replace(array_flip($keys), $this->toArray()) ?? []); } /** @@ -57,8 +63,10 @@ class ArrayCollection extends BaseArrayCollection implements CollectionInterface * * Collection is returned in the order of $keys given to the function. * - * @param array $keys + * @param array $keys * @return static + * @phpstan-param array $keys + * @phpstan-return static */ public function select(array $keys) { @@ -75,8 +83,9 @@ class ArrayCollection extends BaseArrayCollection implements CollectionInterface /** * Un-select items from collection. * - * @param array $keys + * @param array $keys * @return static + * @phpstan-return static */ public function unselect(array $keys) { diff --git a/system/src/Grav/Framework/Collection/CollectionInterface.php b/system/src/Grav/Framework/Collection/CollectionInterface.php index 9239d4a..e024366 100644 --- a/system/src/Grav/Framework/Collection/CollectionInterface.php +++ b/system/src/Grav/Framework/Collection/CollectionInterface.php @@ -3,32 +3,38 @@ /** * @package Grav\Framework\Collection * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Collection; use Doctrine\Common\Collections\Collection; +use JsonSerializable; /** * Collection Interface. * * @package Grav\Framework\Collection + * @template TKey + * @template T + * @extends Collection */ -interface CollectionInterface extends Collection, \JsonSerializable +interface CollectionInterface extends Collection, JsonSerializable { /** * Reverse the order of the items. * - * @return static + * @return CollectionInterface + * @phpstan-return CollectionInterface */ public function reverse(); /** * Shuffle items. * - * @return static + * @return CollectionInterface + * @phpstan-return CollectionInterface */ public function shuffle(); @@ -45,16 +51,18 @@ interface CollectionInterface extends Collection, \JsonSerializable * * Collection is returned in the order of $keys given to the function. * - * @param array $keys - * @return static + * @param array $keys + * @return CollectionInterface + * @phpstan-return CollectionInterface */ public function select(array $keys); /** * Un-select items from collection. * - * @param array $keys - * @return static + * @param array $keys + * @return CollectionInterface + * @phpstan-return CollectionInterface */ public function unselect(array $keys); } diff --git a/system/src/Grav/Framework/Collection/FileCollection.php b/system/src/Grav/Framework/Collection/FileCollection.php index c8e5116..5dd8d55 100644 --- a/system/src/Grav/Framework/Collection/FileCollection.php +++ b/system/src/Grav/Framework/Collection/FileCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Collection * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,6 +13,9 @@ namespace Grav\Framework\Collection; * Collection of objects stored into a filesystem. * * @package Grav\Framework\Collection + * @template TKey + * @template T + * @extends AbstractFileCollection */ class FileCollection extends AbstractFileCollection { diff --git a/system/src/Grav/Framework/Collection/FileCollectionInterface.php b/system/src/Grav/Framework/Collection/FileCollectionInterface.php index fec4129..ce6e18f 100644 --- a/system/src/Grav/Framework/Collection/FileCollectionInterface.php +++ b/system/src/Grav/Framework/Collection/FileCollectionInterface.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Collection * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,6 +15,10 @@ use Doctrine\Common\Collections\Selectable; * Collection of objects stored into a filesystem. * * @package Grav\Framework\Collection + * @template TKey + * @template T + * @extends CollectionInterface + * @extends Selectable */ interface FileCollectionInterface extends CollectionInterface, Selectable { diff --git a/system/src/Grav/Framework/Compat/Serializable.php b/system/src/Grav/Framework/Compat/Serializable.php new file mode 100644 index 0000000..3c9bf6a --- /dev/null +++ b/system/src/Grav/Framework/Compat/Serializable.php @@ -0,0 +1,47 @@ +__serialize()); + } + + /** + * @param string $serialized + * @return void + */ + final public function unserialize($serialized): void + { + $this->__unserialize(unserialize($serialized, ['allowed_classes' => $this->getUnserializeAllowedClasses()])); + } + + /** + * @return array|bool + */ + protected function getUnserializeAllowedClasses() + { + return false; + } +} diff --git a/system/src/Grav/Framework/ContentBlock/ContentBlock.php b/system/src/Grav/Framework/ContentBlock/ContentBlock.php index ab63bb1..efcd5f3 100644 --- a/system/src/Grav/Framework/ContentBlock/ContentBlock.php +++ b/system/src/Grav/Framework/ContentBlock/ContentBlock.php @@ -3,12 +3,18 @@ /** * @package Grav\Framework\ContentBlock * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\ContentBlock; +use Exception; +use Grav\Framework\Compat\Serializable; +use InvalidArgumentException; +use RuntimeException; +use function get_class; + /** * Class to create nested blocks of content. * @@ -23,16 +29,25 @@ namespace Grav\Framework\ContentBlock; */ class ContentBlock implements ContentBlockInterface { + use Serializable; + + /** @var int */ protected $version = 1; + /** @var string */ protected $id; + /** @var string */ protected $tokenTemplate = '@@BLOCK-%s@@'; + /** @var string */ protected $content = ''; + /** @var array */ protected $blocks = []; + /** @var string */ protected $checksum; + /** @var bool */ protected $cached = true; /** - * @param string $id + * @param string|null $id * @return static */ public static function create($id = null) @@ -43,7 +58,7 @@ class ContentBlock implements ContentBlockInterface /** * @param array $serialized * @return ContentBlockInterface - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public static function fromArray(array $serialized) { @@ -52,14 +67,14 @@ class ContentBlock implements ContentBlockInterface $id = $serialized['id'] ?? null; if (!$type || !$id || !is_a($type, ContentBlockInterface::class, true)) { - throw new \InvalidArgumentException('Bad data'); + throw new InvalidArgumentException('Bad data'); } /** @var ContentBlockInterface $instance */ $instance = new $type($id); $instance->build($serialized); - } catch (\Exception $e) { - throw new \InvalidArgumentException(sprintf('Cannot unserialize Block: %s', $e->getMessage()), $e->getCode(), $e); + } catch (Exception $e) { + throw new InvalidArgumentException(sprintf('Cannot unserialize Block: %s', $e->getMessage()), $e->getCode(), $e); } return $instance; @@ -68,7 +83,7 @@ class ContentBlock implements ContentBlockInterface /** * Block constructor. * - * @param string $id + * @param string|null $id */ public function __construct($id = null) { @@ -97,16 +112,13 @@ class ContentBlock implements ContentBlockInterface public function toArray() { $blocks = []; - /** - * @var string $id - * @var ContentBlockInterface $block - */ + /** @var ContentBlockInterface $block */ foreach ($this->blocks as $block) { $blocks[$block->getId()] = $block->toArray(); } $array = [ - '_type' => \get_class($this), + '_type' => get_class($this), '_version' => $this->version, 'id' => $this->id, 'cached' => $this->cached @@ -153,14 +165,15 @@ class ContentBlock implements ContentBlockInterface { try { return $this->toString(); - } catch (\Exception $e) { + } catch (Exception $e) { return sprintf('Error while rendering block: %s', $e->getMessage()); } } /** * @param array $serialized - * @throws \RuntimeException + * @return void + * @throws RuntimeException */ public function build(array $serialized) { @@ -250,20 +263,20 @@ class ContentBlock implements ContentBlockInterface } /** - * @return string + * @return array */ - public function serialize() + final public function __serialize(): array { - return serialize($this->toArray()); + return $this->toArray(); } /** - * @param string $serialized + * @param array $data + * @return void */ - public function unserialize($serialized) + final public function __unserialize(array $data): void { - $array = unserialize($serialized); - $this->build($array); + $this->build($data); } /** @@ -276,13 +289,14 @@ class ContentBlock implements ContentBlockInterface /** * @param array $serialized - * @throws \RuntimeException + * @return void + * @throws RuntimeException */ protected function checkVersion(array $serialized) { $version = isset($serialized['_version']) ? (int) $serialized['_version'] : 1; if ($version !== $this->version) { - throw new \RuntimeException(sprintf('Unsupported version %s', $version)); + throw new RuntimeException(sprintf('Unsupported version %s', $version)); } } } diff --git a/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php b/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php index 330901e..fb445a3 100644 --- a/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php +++ b/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php @@ -3,20 +3,22 @@ /** * @package Grav\Framework\ContentBlock * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\ContentBlock; +use Serializable; + /** * ContentBlock Interface * @package Grav\Framework\ContentBlock */ -interface ContentBlockInterface extends \Serializable +interface ContentBlockInterface extends Serializable { /** - * @param string $id + * @param string|null $id * @return static */ public static function create($id = null); @@ -28,7 +30,7 @@ interface ContentBlockInterface extends \Serializable public static function fromArray(array $serialized); /** - * @param string $id + * @param string|null $id */ public function __construct($id = null); @@ -59,6 +61,7 @@ interface ContentBlockInterface extends \Serializable /** * @param array $serialized + * @return void */ public function build(array $serialized); diff --git a/system/src/Grav/Framework/ContentBlock/HtmlBlock.php b/system/src/Grav/Framework/ContentBlock/HtmlBlock.php index 8cefd4f..5c5eb43 100644 --- a/system/src/Grav/Framework/ContentBlock/HtmlBlock.php +++ b/system/src/Grav/Framework/ContentBlock/HtmlBlock.php @@ -3,12 +3,16 @@ /** * @package Grav\Framework\ContentBlock * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\ContentBlock; +use RuntimeException; +use function is_array; +use function is_string; + /** * HtmlBlock * @@ -16,10 +20,15 @@ namespace Grav\Framework\ContentBlock; */ class HtmlBlock extends ContentBlock implements HtmlBlockInterface { + /** @var int */ protected $version = 1; + /** @var array */ protected $frameworks = []; + /** @var array */ protected $styles = []; + /** @var array */ protected $scripts = []; + /** @var array */ protected $html = []; /** @@ -74,7 +83,7 @@ class HtmlBlock extends ContentBlock implements HtmlBlockInterface } /** - * @return array[] + * @return array */ public function toArray() { @@ -98,7 +107,8 @@ class HtmlBlock extends ContentBlock implements HtmlBlockInterface /** * @param array $serialized - * @throws \RuntimeException + * @return void + * @throws RuntimeException */ public function build(array $serialized) { @@ -132,7 +142,7 @@ class HtmlBlock extends ContentBlock implements HtmlBlockInterface */ public function addStyle($element, $priority = 0, $location = 'head') { - if (!\is_array($element)) { + if (!is_array($element)) { $element = ['href' => (string) $element]; } if (empty($element['href'])) { @@ -176,7 +186,7 @@ class HtmlBlock extends ContentBlock implements HtmlBlockInterface */ public function addInlineStyle($element, $priority = 0, $location = 'head') { - if (!\is_array($element)) { + if (!is_array($element)) { $element = ['content' => (string) $element]; } if (empty($element['content'])) { @@ -207,7 +217,7 @@ class HtmlBlock extends ContentBlock implements HtmlBlockInterface */ public function addScript($element, $priority = 0, $location = 'head') { - if (!\is_array($element)) { + if (!is_array($element)) { $element = ['src' => (string) $element]; } if (empty($element['src'])) { @@ -219,8 +229,8 @@ class HtmlBlock extends ContentBlock implements HtmlBlockInterface $src = $element['src']; $type = !empty($element['type']) ? (string) $element['type'] : 'text/javascript'; - $defer = isset($element['defer']) ? true : false; - $async = isset($element['async']) ? true : false; + $defer = isset($element['defer']); + $async = isset($element['async']); $handle = !empty($element['handle']) ? (string) $element['handle'] : ''; $this->scripts[$location][md5($src) . sha1($src)] = [ @@ -244,7 +254,7 @@ class HtmlBlock extends ContentBlock implements HtmlBlockInterface */ public function addInlineScript($element, $priority = 0, $location = 'head') { - if (!\is_array($element)) { + if (!is_array($element)) { $element = ['content' => (string) $element]; } if (empty($element['content'])) { @@ -275,7 +285,7 @@ class HtmlBlock extends ContentBlock implements HtmlBlockInterface */ public function addHtml($html, $priority = 0, $location = 'bottom') { - if (empty($html) || !\is_string($html)) { + if (empty($html) || !is_string($html)) { return false; } if (!isset($this->html[$location])) { @@ -357,6 +367,7 @@ class HtmlBlock extends ContentBlock implements HtmlBlockInterface /** * @param array $items + * @return void */ protected function sortAssetsInLocation(array &$items) { @@ -368,15 +379,15 @@ class HtmlBlock extends ContentBlock implements HtmlBlockInterface uasort( $items, - function ($a, $b) { - return ($a[':priority'] === $b[':priority']) - ? $a[':order'] - $b[':order'] : $a[':priority'] - $b[':priority']; + static function ($a, $b) { + return $a[':priority'] <=> $b[':priority'] ?: $a[':order'] <=> $b[':order']; } ); } /** * @param array $array + * @return void */ protected function sortAssets(array &$array) { diff --git a/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php b/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php index ee486d2..616e4a2 100644 --- a/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php +++ b/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\ContentBlock * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php b/system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php new file mode 100644 index 0000000..62ed3e1 --- /dev/null +++ b/system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php @@ -0,0 +1,297 @@ + 599) { + $code = 500; + } + $headers = $headers ?? []; + + return new Response($code, $headers, $content); + } + + /** + * @param array $content + * @param int|null $code + * @param array|null $headers + * @return Response + */ + protected function createJsonResponse(array $content, int $code = null, array $headers = null): ResponseInterface + { + $code = $code ?? $content['code'] ?? 200; + if (null === $code || $code < 100 || $code > 599) { + $code = 200; + } + $headers = ($headers ?? []) + [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-store, max-age=0' + ]; + + return new Response($code, $headers, json_encode($content)); + } + + /** + * @param string $filename + * @param string|resource|StreamInterface $resource + * @param array|null $headers + * @param array|null $options + * @return ResponseInterface + */ + protected function createDownloadResponse(string $filename, $resource, array $headers = null, array $options = null): ResponseInterface + { + // Required for IE, otherwise Content-Disposition may be ignored + if (ini_get('zlib.output_compression')) { + @ini_set('zlib.output_compression', 'Off'); + } + + $headers = $headers ?? []; + $options = $options ?? ['force_download' => true]; + + $file_parts = pathinfo($filename); + + if (!isset($headers['Content-Type'])) { + $mimetype = Utils::getMimeByExtension($file_parts['extension']); + + $headers['Content-Type'] = $mimetype; + } + + // TODO: add multipart download support. + //$headers['Accept-Ranges'] = 'bytes'; + + if (!empty($options['force_download'])) { + $headers['Content-Disposition'] = 'attachment; filename="' . $file_parts['basename'] . '"'; + } + + if (!isset($headers['Content-Length'])) { + $realpath = realpath($filename); + if ($realpath) { + $headers['Content-Length'] = filesize($realpath); + } + } + + $headers += [ + 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', + 'Last-Modified' => gmdate('D, d M Y H:i:s') . ' GMT', + 'Cache-Control' => 'no-store, no-cache, must-revalidate', + 'Pragma' => 'no-cache' + ]; + + return new Response(200, $headers, $resource); + } + + /** + * @param string $url + * @param int|null $code + * @return Response + */ + protected function createRedirectResponse(string $url, int $code = null): ResponseInterface + { + if (null === $code || $code < 301 || $code > 307) { + $code = (int)$this->getConfig()->get('system.pages.redirect_default_code', 302); + } + + $accept = $this->getAccept(['application/json', 'text/html']); + + if ($accept === 'application/json') { + return $this->createJsonResponse(['code' => $code, 'status' => 'redirect', 'redirect' => $url]); + } + + return new Response($code, ['Location' => $url]); + } + + /** + * @param Throwable $e + * @return ResponseInterface + */ + protected function createErrorResponse(Throwable $e): ResponseInterface + { + $response = $this->getErrorJson($e); + $message = $response['message']; + $code = $response['code']; + $reason = $e instanceof RequestException ? $e->getHttpReason() : null; + $accept = $this->getAccept(['application/json', 'text/html']); + + $request = $this->getRequest(); + $context = $request->getAttributes(); + + /** @var Route $route */ + $route = $context['route'] ?? null; + + $ext = $route ? $route->getExtension() : null; + if ($ext !== 'json' && $accept === 'text/html') { + $method = $request->getMethod(); + + // On POST etc, redirect back to the previous page. + if ($method !== 'GET' && $method !== 'HEAD') { + $this->setMessage($message, 'error'); + $referer = $request->getHeaderLine('Referer'); + + return $this->createRedirectResponse($referer, 303); + } + + // TODO: improve error page + return $this->createHtmlResponse($response['message'], $code); + } + + return new Response($code, ['Content-Type' => 'application/json'], json_encode($response), '1.1', $reason); + } + + /** + * @param Throwable $e + * @return ResponseInterface + */ + protected function createJsonErrorResponse(Throwable $e): ResponseInterface + { + $response = $this->getErrorJson($e); + $reason = $e instanceof RequestException ? $e->getHttpReason() : null; + + return new Response($response['code'], ['Content-Type' => 'application/json'], json_encode($response), '1.1', $reason); + } + + /** + * @param Throwable $e + * @return array + */ + protected function getErrorJson(Throwable $e): array + { + $code = $this->getErrorCode($e instanceof RequestException ? $e->getHttpCode() : $e->getCode()); + $message = $e->getMessage(); + $response = [ + 'code' => $code, + 'status' => 'error', + 'message' => $message, + 'error' => [ + 'code' => $code, + 'message' => $message + ] + ]; + + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + if ($debugger->enabled()) { + $response['error'] += [ + 'type' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => explode("\n", $e->getTraceAsString()) + ]; + } + + return $response; + } + + /** + * @param int $code + * @return int + */ + protected function getErrorCode(int $code): int + { + static $errorCodes = [ + 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, + 422, 423, 424, 425, 426, 428, 429, 431, 451, 500, 501, 502, 503, 504, 505, 506, 507, 508, 511 + ]; + + if (!in_array($code, $errorCodes, true)) { + $code = 500; + } + + return $code; + } + + /** + * @param array $compare + * @return mixed + */ + protected function getAccept(array $compare) + { + $accepted = []; + foreach ($this->getRequest()->getHeader('Accept') as $accept) { + foreach (explode(',', $accept) as $item) { + if (!$item) { + continue; + } + + $split = explode(';q=', $item); + $mime = array_shift($split); + $priority = array_shift($split) ?? 1.0; + + $accepted[$mime] = $priority; + } + } + + arsort($accepted); + + // TODO: add support for image/* etc + $list = array_intersect($compare, array_keys($accepted)); + if (!$list && (isset($accepted['*/*']) || isset($accepted['*']))) { + return reset($compare); + } + + return reset($list); + } + + /** + * @return ServerRequestInterface + */ + abstract protected function getRequest(): ServerRequestInterface; + + /** + * @param string $message + * @param string $type + * @return $this + */ + abstract protected function setMessage(string $message, string $type = 'info'); + + /** + * @return Config + */ + abstract protected function getConfig(): Config; +} diff --git a/system/src/Grav/Framework/DI/Container.php b/system/src/Grav/Framework/DI/Container.php index c4b3ef4..76434a3 100644 --- a/system/src/Grav/Framework/DI/Container.php +++ b/system/src/Grav/Framework/DI/Container.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\DI * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,13 +15,21 @@ use Psr\Container\ContainerInterface; class Container extends \Pimple\Container implements ContainerInterface { + /** + * @param string $id + * @return mixed + */ public function get($id) { return $this->offsetGet($id); } + /** + * @param string $id + * @return bool + */ public function has($id): bool { return $this->offsetExists($id); } -} \ No newline at end of file +} diff --git a/system/src/Grav/Framework/File/AbstractFile.php b/system/src/Grav/Framework/File/AbstractFile.php index 0411701..b82bb33 100644 --- a/system/src/Grav/Framework/File/AbstractFile.php +++ b/system/src/Grav/Framework/File/AbstractFile.php @@ -5,38 +5,40 @@ declare(strict_types=1); /** * @package Grav\Framework\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\File; +use Exception; +use Grav\Framework\Compat\Serializable; use Grav\Framework\File\Interfaces\FileInterface; use Grav\Framework\Filesystem\Filesystem; +use RuntimeException; +/** + * Class AbstractFile + * @package Grav\Framework\File + */ class AbstractFile implements FileInterface { + use Serializable; + /** @var Filesystem */ private $filesystem; - /** @var string */ private $filepath; - /** @var string|null */ private $filename; - /** @var string|null */ private $path; - /** @var string|null */ private $basename; - /** @var string|null */ private $extension; - /** @var resource|null */ private $handle; - /** @var bool */ private $locked = false; @@ -60,6 +62,9 @@ class AbstractFile implements FileInterface } } + /** + * @return void + */ public function __clone() { $this->handle = null; @@ -67,19 +72,22 @@ class AbstractFile implements FileInterface } /** - * @return string + * @return array */ - public function serialize(): string + final public function __serialize(): array { - return serialize($this->doSerialize()); + return ['filesystem_normalize' => $this->filesystem->getNormalization()] + $this->doSerialize(); } /** - * @param string $serialized + * @param array $data + * @return void */ - public function unserialize($serialized): void + final public function __unserialize(array $data): void { - $this->doUnserialize(unserialize($serialized, ['allowed_classes' => false])); + $this->filesystem = Filesystem::getInstance($data['filesystem_normalize'] ?? null); + + $this->doUnserialize($data); } /** @@ -101,7 +109,7 @@ class AbstractFile implements FileInterface $this->setPathInfo(); } - return $this->path; + return $this->path ?? ''; } /** @@ -114,7 +122,7 @@ class AbstractFile implements FileInterface $this->setPathInfo(); } - return $this->filename; + return $this->filename ?? ''; } /** @@ -127,7 +135,7 @@ class AbstractFile implements FileInterface $this->setPathInfo(); } - return $this->basename; + return $this->basename ?? ''; } /** @@ -158,7 +166,7 @@ class AbstractFile implements FileInterface */ public function getCreationTime(): int { - return is_file($this->filepath) ? filectime($this->filepath) : time(); + return is_file($this->filepath) ? (int)filectime($this->filepath) : time(); } /** @@ -167,7 +175,7 @@ class AbstractFile implements FileInterface */ public function getModificationTime(): int { - return is_file($this->filepath) ? filemtime($this->filepath) : time(); + return is_file($this->filepath) ? (int)filemtime($this->filepath) : time(); } /** @@ -178,13 +186,13 @@ class AbstractFile implements FileInterface { if (!$this->handle) { if (!$this->mkdir($this->getPath())) { - throw new \RuntimeException('Creating directory failed for ' . $this->filepath); + throw new RuntimeException('Creating directory failed for ' . $this->filepath); } - $this->handle = @fopen($this->filepath, 'cb+'); + $this->handle = @fopen($this->filepath, 'cb+') ?: null; if (!$this->handle) { $error = error_get_last(); - throw new \RuntimeException("Opening file for writing failed on error {$error['message']}"); + throw new RuntimeException("Opening file for writing failed on error {$error['message']}"); } } @@ -207,7 +215,7 @@ class AbstractFile implements FileInterface } if ($this->locked) { - flock($this->handle, LOCK_UN); + flock($this->handle, LOCK_UN | LOCK_NB); $this->locked = false; } @@ -267,7 +275,7 @@ class AbstractFile implements FileInterface $dir = $this->getPath(); if (!$this->mkdir($dir)) { - throw new \RuntimeException('Creating directory failed for ' . $filepath); + throw new RuntimeException('Creating directory failed for ' . $filepath); } try { @@ -279,21 +287,27 @@ class AbstractFile implements FileInterface $tmp = false; } } else { + // Support for symlinks. + $realpath = is_link($filepath) ? realpath($filepath) : $filepath; + if ($realpath === false) { + throw new RuntimeException('Failed to save file ' . $filepath); + } + // Create file with a temporary name and rename it to make the save action atomic. - $tmp = $this->tempname($filepath); + $tmp = $this->tempname($realpath); if (@file_put_contents($tmp, $data) === false) { $tmp = false; - } elseif (@rename($tmp, $filepath) === false) { + } elseif (@rename($tmp, $realpath) === false) { @unlink($tmp); $tmp = false; } } - } catch (\Exception $e) { + } catch (Exception $e) { $tmp = false; } if ($tmp === false) { - throw new \RuntimeException('Failed to save file ' . $filepath); + throw new RuntimeException('Failed to save file ' . $filepath); } // Touch the directory as well, thus marking it modified. @@ -327,7 +341,7 @@ class AbstractFile implements FileInterface /** * @param string $dir * @return bool - * @throws \RuntimeException + * @throws RuntimeException * @internal */ protected function mkdir(string $dir): bool @@ -362,6 +376,7 @@ class AbstractFile implements FileInterface /** * @param array $serialized + * @return void */ protected function doUnserialize(array $serialized): void { @@ -382,6 +397,7 @@ class AbstractFile implements FileInterface protected function setPathInfo(): void { + /** @var array $pathInfo */ $pathInfo = $this->filesystem->pathinfo($this->filepath); $this->filename = $pathInfo['filename'] ?? null; diff --git a/system/src/Grav/Framework/File/CsvFile.php b/system/src/Grav/Framework/File/CsvFile.php index 53af766..999f88a 100644 --- a/system/src/Grav/Framework/File/CsvFile.php +++ b/system/src/Grav/Framework/File/CsvFile.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/File/DataFile.php b/system/src/Grav/Framework/File/DataFile.php index 4914871..114146f 100644 --- a/system/src/Grav/Framework/File/DataFile.php +++ b/system/src/Grav/Framework/File/DataFile.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,7 +13,12 @@ namespace Grav\Framework\File; use Grav\Framework\File\Interfaces\FileFormatterInterface; use RuntimeException; +use function is_string; +/** + * Class DataFile + * @package Grav\Framework\File + */ class DataFile extends AbstractFile { /** @var FileFormatterInterface */ @@ -40,7 +45,11 @@ class DataFile extends AbstractFile $raw = parent::load(); try { - return $raw !== false ? $this->formatter->decode($raw) : false; + if (!is_string($raw)) { + throw new RuntimeException('Bad Data'); + } + + return $this->formatter->decode($raw); } catch (RuntimeException $e) { throw new RuntimeException(sprintf("Failed to load file '%s': %s", $this->getFilePath(), $e->getMessage()), $e->getCode(), $e); } @@ -52,7 +61,7 @@ class DataFile extends AbstractFile */ public function save($data): void { - if (\is_string($data)) { + if (is_string($data)) { // Make sure that the string is valid data. try { $this->formatter->decode($data); diff --git a/system/src/Grav/Framework/File/File.php b/system/src/Grav/Framework/File/File.php index c1a2173..1d4055a 100644 --- a/system/src/Grav/Framework/File/File.php +++ b/system/src/Grav/Framework/File/File.php @@ -5,12 +5,19 @@ declare(strict_types=1); /** * @package Grav\Framework\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\File; +use RuntimeException; +use function is_string; + +/** + * Class File + * @package Grav\Framework\File + */ class File extends AbstractFile { /** @@ -28,8 +35,8 @@ class File extends AbstractFile */ public function save($data): void { - if (!\is_string($data)) { - throw new \RuntimeException('Cannot save data, string required'); + if (!is_string($data)) { + throw new RuntimeException('Cannot save data, string required'); } parent::save($data); diff --git a/system/src/Grav/Framework/File/Formatter/AbstractFormatter.php b/system/src/Grav/Framework/File/Formatter/AbstractFormatter.php index 549ff6f..f5ff3f2 100644 --- a/system/src/Grav/Framework/File/Formatter/AbstractFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/AbstractFormatter.php @@ -5,13 +5,15 @@ declare(strict_types=1); /** * @package Grav\Framework\File\Formatter * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\File\Formatter; +use Grav\Framework\Compat\Serializable; use Grav\Framework\File\Interfaces\FileFormatterInterface; +use function is_string; /** * Abstract file formatter. @@ -20,6 +22,8 @@ use Grav\Framework\File\Interfaces\FileFormatterInterface; */ abstract class AbstractFormatter implements FileFormatterInterface { + use Serializable; + /** @var array */ private $config; @@ -35,17 +39,11 @@ abstract class AbstractFormatter implements FileFormatterInterface /** * @return string */ - public function serialize(): string + public function getMimeType(): string { - return serialize($this->doSerialize()); - } + $mime = $this->getConfig('mime'); - /** - * @param string $serialized - */ - public function unserialize($serialized): void - { - $this->doUnserialize(unserialize($serialized, ['allowed_classes' => false])); + return is_string($mime) ? $mime : 'application/octet-stream'; } /** @@ -57,7 +55,7 @@ abstract class AbstractFormatter implements FileFormatterInterface $extensions = $this->getSupportedFileExtensions(); // Call fails on bad configuration. - return reset($extensions); + return reset($extensions) ?: ''; } /** @@ -69,7 +67,7 @@ abstract class AbstractFormatter implements FileFormatterInterface $extensions = $this->getConfig('file_extension'); // Call fails on bad configuration. - return \is_string($extensions) ? [$extensions] : $extensions; + return is_string($extensions) ? [$extensions] : $extensions; } /** @@ -84,6 +82,24 @@ abstract class AbstractFormatter implements FileFormatterInterface */ abstract public function decode($data); + + /** + * @return array + */ + public function __serialize(): array + { + return ['config' => $this->config]; + } + + /** + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $this->config = $data['config']; + } + /** * Get either full configuration or a single option. * @@ -98,22 +114,4 @@ abstract class AbstractFormatter implements FileFormatterInterface return $this->config; } - - /** - * @return array - */ - protected function doSerialize(): array - { - return ['config' => $this->config]; - } - - /** - * Note: if overridden, make sure you call parent::doUnserialize() - * - * @param array $serialized - */ - protected function doUnserialize(array $serialized): void - { - $this->config = $serialized['config']; - } } diff --git a/system/src/Grav/Framework/File/Formatter/CsvFormatter.php b/system/src/Grav/Framework/File/Formatter/CsvFormatter.php index e412a73..9d0a9a8 100644 --- a/system/src/Grav/Framework/File/Formatter/CsvFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/CsvFormatter.php @@ -5,14 +5,25 @@ declare(strict_types=1); /** * @package Grav\Framework\File\Formatter * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\File\Formatter; +use Exception; use Grav\Framework\File\Interfaces\FileFormatterInterface; +use JsonSerializable; +use RuntimeException; +use stdClass; +use function is_array; +use function is_object; +use function is_scalar; +/** + * Class CsvFormatter + * @package Grav\Framework\File\Formatter + */ class CsvFormatter extends AbstractFormatter { /** @@ -23,7 +34,8 @@ class CsvFormatter extends AbstractFormatter { $config += [ 'file_extension' => ['.csv', '.tsv'], - 'delimiter' => ',' + 'delimiter' => ',', + 'mime' => 'text/x-csv' ]; parent::__construct($config); @@ -41,7 +53,9 @@ class CsvFormatter extends AbstractFormatter } /** - * {@inheritdoc} + * @param array $data + * @param string|null $delimiter + * @return string * @see FileFormatterInterface::encode() */ public function encode($data, $delimiter = null): string @@ -64,20 +78,26 @@ class CsvFormatter extends AbstractFormatter } /** - * {@inheritdoc} + * @param string $data + * @param string|null $delimiter + * @return array * @see FileFormatterInterface::decode() */ public function decode($data, $delimiter = null): array { $delimiter = $delimiter ?? $this->getDelimiter(); $lines = preg_split('/\r\n|\r|\n/', $data); - if ($lines === false) { - throw new \RuntimeException('Decoding CSV failed'); + throw new RuntimeException('Decoding CSV failed'); } // Get the field names - $header = str_getcsv(array_shift($lines), $delimiter); + $headerStr = array_shift($lines); + if (!$headerStr) { + throw new RuntimeException('CSV header missing'); + } + + $header = str_getcsv($headerStr, $delimiter); // Allow for replacing a null string with null/empty value $null_replace = $this->getConfig('null'); @@ -91,24 +111,42 @@ class CsvFormatter extends AbstractFormatter $csv_line = str_getcsv($line, $delimiter); if ($null_replace) { - array_walk($csv_line, function(&$el) use ($null_replace) { - $el = str_replace($null_replace, "\0", $el); + array_walk($csv_line, static function (&$el) use ($null_replace) { + $el = str_replace($null_replace, "\0", $el); }); } $list[] = array_combine($header, $csv_line); } } - } catch (\Exception $e) { - throw new \Exception('Badly formatted CSV line: ' . $line); + } catch (Exception $e) { + throw new RuntimeException('Badly formatted CSV line: ' . $line); } return $list; } - protected function encodeLine(array $line, $delimiter = null): string + /** + * @param array $line + * @param string $delimiter + * @return string + */ + protected function encodeLine(array $line, string $delimiter): string { foreach ($line as $key => &$value) { + // Oops, we need to convert the line to a string. + if (!is_scalar($value)) { + if (is_array($value) || $value instanceof JsonSerializable || $value instanceof stdClass) { + $value = json_encode($value); + } elseif (is_object($value)) { + if (method_exists($value, 'toJson')) { + $value = $value->toJson(); + } elseif (method_exists($value, 'toArray')) { + $value = json_encode($value->toArray()); + } + } + } + $value = $this->escape((string)$value); } unset($value); @@ -116,6 +154,10 @@ class CsvFormatter extends AbstractFormatter return implode($delimiter, $line). "\n"; } + /** + * @param string $value + * @return string + */ protected function escape(string $value) { if (preg_match('/[,"\r\n]/u', $value)) { diff --git a/system/src/Grav/Framework/File/Formatter/FormatterInterface.php b/system/src/Grav/Framework/File/Formatter/FormatterInterface.php index a8d5bc5..757e229 100644 --- a/system/src/Grav/Framework/File/Formatter/FormatterInterface.php +++ b/system/src/Grav/Framework/File/Formatter/FormatterInterface.php @@ -7,4 +7,6 @@ use Grav\Framework\File\Interfaces\FileFormatterInterface; /** * @deprecated 1.6 Use Grav\Framework\File\Interfaces\FileFormatterInterface instead */ -interface FormatterInterface extends FileFormatterInterface {}; +interface FormatterInterface extends FileFormatterInterface +{ +} diff --git a/system/src/Grav/Framework/File/Formatter/IniFormatter.php b/system/src/Grav/Framework/File/Formatter/IniFormatter.php index 8d72c52..ec16959 100644 --- a/system/src/Grav/Framework/File/Formatter/IniFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/IniFormatter.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\File\Formatter * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,6 +13,10 @@ namespace Grav\Framework\File\Formatter; use Grav\Framework\File\Interfaces\FileFormatterInterface; +/** + * Class IniFormatter + * @package Grav\Framework\File\Formatter + */ class IniFormatter extends AbstractFormatter { /** @@ -37,10 +41,10 @@ class IniFormatter extends AbstractFormatter $string = ''; foreach ($data as $key => $value) { $string .= $key . '="' . preg_replace( - ['/"/', '/\\\/', "/\t/", "/\n/", "/\r/"], - ['\"', '\\\\', '\t', '\n', '\r'], - $value - ) . "\"\n"; + ['/"/', '/\\\/', "/\t/", "/\n/", "/\r/"], + ['\"', '\\\\', '\t', '\n', '\r'], + $value + ) . "\"\n"; } return $string; diff --git a/system/src/Grav/Framework/File/Formatter/JsonFormatter.php b/system/src/Grav/Framework/File/Formatter/JsonFormatter.php index 1ae16ae..6b19690 100644 --- a/system/src/Grav/Framework/File/Formatter/JsonFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/JsonFormatter.php @@ -5,16 +5,51 @@ declare(strict_types=1); /** * @package Grav\Framework\File\Formatter * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\File\Formatter; use Grav\Framework\File\Interfaces\FileFormatterInterface; +use RuntimeException; +use function is_int; +use function is_string; +/** + * Class JsonFormatter + * @package Grav\Framework\File\Formatter + */ class JsonFormatter extends AbstractFormatter { + /** @var array */ + protected $encodeOptions = [ + 'JSON_FORCE_OBJECT' => JSON_FORCE_OBJECT, + 'JSON_HEX_QUOT' => JSON_HEX_QUOT, + 'JSON_HEX_TAG' => JSON_HEX_TAG, + 'JSON_HEX_AMP' => JSON_HEX_AMP, + 'JSON_HEX_APOS' => JSON_HEX_APOS, + 'JSON_INVALID_UTF8_IGNORE' => JSON_INVALID_UTF8_IGNORE, + 'JSON_INVALID_UTF8_SUBSTITUTE' => JSON_INVALID_UTF8_SUBSTITUTE, + 'JSON_NUMERIC_CHECK' => JSON_NUMERIC_CHECK, + 'JSON_PARTIAL_OUTPUT_ON_ERROR' => JSON_PARTIAL_OUTPUT_ON_ERROR, + 'JSON_PRESERVE_ZERO_FRACTION' => JSON_PRESERVE_ZERO_FRACTION, + 'JSON_PRETTY_PRINT' => JSON_PRETTY_PRINT, + 'JSON_UNESCAPED_LINE_TERMINATORS' => JSON_UNESCAPED_LINE_TERMINATORS, + 'JSON_UNESCAPED_SLASHES' => JSON_UNESCAPED_SLASHES, + 'JSON_UNESCAPED_UNICODE' => JSON_UNESCAPED_UNICODE, + //'JSON_THROW_ON_ERROR' => JSON_THROW_ON_ERROR // PHP 7.3 + ]; + + /** @var array */ + protected $decodeOptions = [ + 'JSON_BIGINT_AS_STRING' => JSON_BIGINT_AS_STRING, + 'JSON_INVALID_UTF8_IGNORE' => JSON_INVALID_UTF8_IGNORE, + 'JSON_INVALID_UTF8_SUBSTITUTE' => JSON_INVALID_UTF8_SUBSTITUTE, + 'JSON_OBJECT_AS_ARRAY' => JSON_OBJECT_AS_ARRAY, + //'JSON_THROW_ON_ERROR' => JSON_THROW_ON_ERROR // PHP 7.3 + ]; + public function __construct(array $config = []) { $config += [ @@ -35,7 +70,22 @@ class JsonFormatter extends AbstractFormatter */ public function getEncodeOptions(): int { - return $this->getConfig('encode_options'); + $options = $this->getConfig('encode_options'); + if (!is_int($options)) { + if (is_string($options)) { + $list = preg_split('/[\s,|]+/', $options); + $options = 0; + foreach ($list as $option) { + if (isset($this->encodeOptions[$option])) { + $options += $this->encodeOptions[$option]; + } + } + } else { + $options = 0; + } + } + + return $options; } /** @@ -45,7 +95,22 @@ class JsonFormatter extends AbstractFormatter */ public function getDecodeOptions(): int { - return $this->getConfig('decode_options'); + $options = $this->getConfig('decode_options'); + if (!is_int($options)) { + if (is_string($options)) { + $list = preg_split('/[\s,|]+/', $options); + $options = 0; + foreach ($list as $option) { + if (isset($this->decodeOptions[$option])) { + $options += $this->decodeOptions[$option]; + } + } + } else { + $options = 0; + } + } + + return $options; } /** @@ -77,10 +142,10 @@ class JsonFormatter extends AbstractFormatter $encoded = @json_encode($data, $this->getEncodeOptions()); if ($encoded === false && json_last_error() !== JSON_ERROR_NONE) { - throw new \RuntimeException('Encoding JSON failed: ' . json_last_error_msg()); + throw new RuntimeException('Encoding JSON failed: ' . json_last_error_msg()); } - return $encoded; + return $encoded ?: ''; } /** @@ -92,7 +157,7 @@ class JsonFormatter extends AbstractFormatter $decoded = @json_decode($data, $this->getDecodeAssoc(), $this->getDecodeDepth(), $this->getDecodeOptions()); if (null === $decoded && json_last_error() !== JSON_ERROR_NONE) { - throw new \RuntimeException('Decoding JSON failed: ' . json_last_error_msg()); + throw new RuntimeException('Decoding JSON failed: ' . json_last_error_msg()); } return $decoded; diff --git a/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php b/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php index a2f84e8..8a624df 100644 --- a/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\File\Formatter * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,6 +13,10 @@ namespace Grav\Framework\File\Formatter; use Grav\Framework\File\Interfaces\FileFormatterInterface; +/** + * Class MarkdownFormatter + * @package Grav\Framework\File\Formatter + */ class MarkdownFormatter extends AbstractFormatter { /** @var FileFormatterInterface */ @@ -30,7 +34,7 @@ class MarkdownFormatter extends AbstractFormatter parent::__construct($config); - $this->headerFormatter = $headerFormatter ?: new YamlFormatter($config['yaml']); + $this->headerFormatter = $headerFormatter ?? new YamlFormatter($config['yaml']); } /** @@ -93,7 +97,10 @@ class MarkdownFormatter extends AbstractFormatter $encoded .= $body; // Normalize line endings to Unix style. - $encoded = preg_replace("/(\r\n|\r)/", "\n", $encoded); + $encoded = preg_replace("/(\r\n|\r)/u", "\n", $encoded); + if (null === $encoded) { + throw new \RuntimeException('Encoding markdown failed'); + } return $encoded; } @@ -117,11 +124,14 @@ class MarkdownFormatter extends AbstractFormatter $headerRegex = "/^---\n(.+?)\n---\n{0,}(.*)$/uis"; // Normalize line endings to Unix style. - $data = preg_replace("/(\r\n|\r)/", "\n", $data); + $data = preg_replace("/(\r\n|\r)/u", "\n", $data); + if (null === $data) { + throw new \RuntimeException('Decoding markdown failed'); + } // Parse header. preg_match($headerRegex, ltrim($data), $matches); - if(empty($matches)) { + if (empty($matches)) { $content[$bodyVar] = $data; } else { // Normalize frontmatter. @@ -135,4 +145,16 @@ class MarkdownFormatter extends AbstractFormatter return $content; } + + public function __serialize(): array + { + return parent::__serialize() + ['headerFormatter' => $this->headerFormatter]; + } + + public function __unserialize(array $data): void + { + parent::__unserialize($data); + + $this->headerFormatter = $data['headerFormatter'] ?? new YamlFormatter(['inline' => 20]); + } } diff --git a/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php b/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php index 73ed812..f045d71 100644 --- a/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php @@ -5,14 +5,22 @@ declare(strict_types=1); /** * @package Grav\Framework\File\Formatter * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\File\Formatter; use Grav\Framework\File\Interfaces\FileFormatterInterface; +use RuntimeException; +use stdClass; +use function is_array; +use function is_string; +/** + * Class SerializeFormatter + * @package Grav\Framework\File\Formatter + */ class SerializeFormatter extends AbstractFormatter { /** @@ -23,7 +31,7 @@ class SerializeFormatter extends AbstractFormatter { $config += [ 'file_extension' => '.ser', - 'decode_options' => ['allowed_classes' => [\stdClass::class]] + 'decode_options' => ['allowed_classes' => [stdClass::class]] ]; parent::__construct($config); @@ -34,7 +42,7 @@ class SerializeFormatter extends AbstractFormatter * * By default only allow stdClass class. * - * @return array|bool + * @return array */ public function getOptions() { @@ -56,10 +64,11 @@ class SerializeFormatter extends AbstractFormatter */ public function decode($data) { - $decoded = @unserialize($data, $this->getOptions()); + $classes = $this->getOptions()['allowed_classes'] ?? false; + $decoded = @unserialize($data, ['allowed_classes' => $classes]); if ($decoded === false && $data !== serialize(false)) { - throw new \RuntimeException('Decoding serialized data failed'); + throw new RuntimeException('Decoding serialized data failed'); } return $this->preserveLines($decoded, ['\\n', '\\r'], ["\n", "\r"]); @@ -75,9 +84,9 @@ class SerializeFormatter extends AbstractFormatter */ protected function preserveLines($data, array $search, array $replace) { - if (\is_string($data)) { + if (is_string($data)) { $data = str_replace($search, $replace, $data); - } elseif (\is_array($data)) { + } elseif (is_array($data)) { foreach ($data as &$value) { $value = $this->preserveLines($value, $search, $replace); } @@ -86,4 +95,4 @@ class SerializeFormatter extends AbstractFormatter return $data; } -} \ No newline at end of file +} diff --git a/system/src/Grav/Framework/File/Formatter/YamlFormatter.php b/system/src/Grav/Framework/File/Formatter/YamlFormatter.php index 189626d..32d4d29 100644 --- a/system/src/Grav/Framework/File/Formatter/YamlFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/YamlFormatter.php @@ -5,20 +5,30 @@ declare(strict_types=1); /** * @package Grav\Framework\File\Formatter * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\File\Formatter; use Grav\Framework\File\Interfaces\FileFormatterInterface; +use RuntimeException; use Symfony\Component\Yaml\Exception\DumpException; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml as YamlParser; use RocketTheme\Toolbox\Compat\Yaml\Yaml as FallbackYamlParser; +use function function_exists; +/** + * Class YamlFormatter + * @package Grav\Framework\File\Formatter + */ class YamlFormatter extends AbstractFormatter { + /** + * YamlFormatter constructor. + * @param array $config + */ public function __construct(array $config = []) { $config += [ @@ -65,7 +75,10 @@ class YamlFormatter extends AbstractFormatter } /** - * {@inheritdoc} + * @param array $data + * @param int|null $inline + * @param int|null $indent + * @return string * @see FileFormatterInterface::encode() */ public function encode($data, $inline = null, $indent = null): string @@ -78,7 +91,7 @@ class YamlFormatter extends AbstractFormatter YamlParser::DUMP_EXCEPTION_ON_INVALID_TYPE ); } catch (DumpException $e) { - throw new \RuntimeException('Encoding YAML failed: ' . $e->getMessage(), 0, $e); + throw new RuntimeException('Encoding YAML failed: ' . $e->getMessage(), 0, $e); } } @@ -89,7 +102,7 @@ class YamlFormatter extends AbstractFormatter public function decode($data): array { // Try native PECL YAML PHP extension first if available. - if (\function_exists('yaml_parse') && $this->useNativeDecoder()) { + if (function_exists('yaml_parse') && $this->useNativeDecoder()) { // Safely decode YAML. $saved = @ini_get('yaml.decode_php'); @ini_set('yaml.decode_php', '0'); @@ -108,7 +121,7 @@ class YamlFormatter extends AbstractFormatter return (array) FallbackYamlParser::parse($data); } - throw new \RuntimeException('Decoding YAML failed: ' . $e->getMessage(), 0, $e); + throw new RuntimeException('Decoding YAML failed: ' . $e->getMessage(), 0, $e); } } } diff --git a/system/src/Grav/Framework/File/IniFile.php b/system/src/Grav/Framework/File/IniFile.php index cc861f9..6cda609 100644 --- a/system/src/Grav/Framework/File/IniFile.php +++ b/system/src/Grav/Framework/File/IniFile.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/File/Interfaces/FileFormatterInterface.php b/system/src/Grav/Framework/File/Interfaces/FileFormatterInterface.php index f7b36e2..49eda4f 100644 --- a/system/src/Grav/Framework/File/Interfaces/FileFormatterInterface.php +++ b/system/src/Grav/Framework/File/Interfaces/FileFormatterInterface.php @@ -5,12 +5,14 @@ declare(strict_types=1); /** * @package Grav\Framework\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\File\Interfaces; +use Serializable; + /** * Defines common interface for all file formatters. * @@ -24,8 +26,14 @@ namespace Grav\Framework\File\Interfaces; * * @since 1.6 */ -interface FileFormatterInterface extends \Serializable +interface FileFormatterInterface extends Serializable { + /** + * @return string + * @since 1.7 + */ + public function getMimeType(): string; + /** * Get default file extension from current formatter (with dot). * @@ -48,7 +56,6 @@ interface FileFormatterInterface extends \Serializable * Encode data into a string. * * @param mixed $data Data to be encoded. - * * @return string Returns encoded data as a string. * @api */ @@ -58,7 +65,6 @@ interface FileFormatterInterface extends \Serializable * Decode a string into data. * * @param string $data String to be decoded. - * * @return mixed Returns decoded data. * @api */ diff --git a/system/src/Grav/Framework/File/Interfaces/FileInterface.php b/system/src/Grav/Framework/File/Interfaces/FileInterface.php index c562710..015b1ad 100644 --- a/system/src/Grav/Framework/File/Interfaces/FileInterface.php +++ b/system/src/Grav/Framework/File/Interfaces/FileInterface.php @@ -5,12 +5,15 @@ declare(strict_types=1); /** * @package Grav\Framework\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\File\Interfaces; +use RuntimeException; +use Serializable; + /** * Defines common interface for all file readers. * @@ -24,7 +27,7 @@ namespace Grav\Framework\File\Interfaces; * * @since 1.6 */ -interface FileInterface extends \Serializable +interface FileInterface extends Serializable { /** * Get both path and filename of the file. @@ -98,7 +101,7 @@ interface FileInterface extends \Serializable * @param bool $block For non-blocking lock, set the parameter to `false`. * * @return bool Returns `true` if the file was successfully locked, `false` otherwise. - * @throws \RuntimeException + * @throws RuntimeException * @api */ public function lock(bool $block = true): bool; @@ -150,7 +153,7 @@ interface FileInterface extends \Serializable * * @param mixed $data Data to be saved. * - * @throws \RuntimeException + * @throws RuntimeException * @api */ public function save($data): void; diff --git a/system/src/Grav/Framework/File/JsonFile.php b/system/src/Grav/Framework/File/JsonFile.php index f8743ff..f3b0902 100644 --- a/system/src/Grav/Framework/File/JsonFile.php +++ b/system/src/Grav/Framework/File/JsonFile.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/File/MarkdownFile.php b/system/src/Grav/Framework/File/MarkdownFile.php index 7ee7142..4ae3aa0 100644 --- a/system/src/Grav/Framework/File/MarkdownFile.php +++ b/system/src/Grav/Framework/File/MarkdownFile.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/File/YamlFile.php b/system/src/Grav/Framework/File/YamlFile.php index 359161d..29224ce 100644 --- a/system/src/Grav/Framework/File/YamlFile.php +++ b/system/src/Grav/Framework/File/YamlFile.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\File * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/Filesystem/Filesystem.php b/system/src/Grav/Framework/Filesystem/Filesystem.php index 473a2ec..9b077d7 100644 --- a/system/src/Grav/Framework/Filesystem/Filesystem.php +++ b/system/src/Grav/Framework/Filesystem/Filesystem.php @@ -5,31 +5,38 @@ declare(strict_types=1); /** * @package Grav\Framework\Filesystem * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Filesystem; use Grav\Framework\Filesystem\Interfaces\FilesystemInterface; +use RuntimeException; +use function count; +use function dirname; +use function pathinfo; +/** + * Class Filesystem + * @package Grav\Framework\Filesystem + */ class Filesystem implements FilesystemInterface { /** @var bool|null */ private $normalize; - /** @var static */ - static protected $default; + /** @var static|null */ + protected static $default; - /** @var static */ - static protected $unsafe; + /** @var static|null */ + protected static $unsafe; - /** @var static */ - static protected $safe; + /** @var static|null */ + protected static $safe; /** * @param bool|null $normalize See $this->setNormalization() - * * @return Filesystem */ public static function getInstance(bool $normalize = null): Filesystem @@ -53,6 +60,7 @@ class Filesystem implements FilesystemInterface * Always use Filesystem::getInstance() instead. * * @param bool|null $normalize + * @internal */ protected function __construct(bool $normalize = null) { @@ -67,7 +75,6 @@ class Filesystem implements FilesystemInterface * not normalized. * * @param bool|null $normalize - * * @return Filesystem */ public function setNormalization(bool $normalize = null): self @@ -75,10 +82,18 @@ class Filesystem implements FilesystemInterface return static::getInstance($normalize); } + /** + * @return bool|null + */ + public function getNormalization(): ?bool + { + return $this->normalize; + } + /** * Force all paths to be normalized. * - * @return static + * @return self */ public function unsafe(): self { @@ -88,7 +103,7 @@ class Filesystem implements FilesystemInterface /** * Force all paths not to be normalized (speeds up the calls if given paths are known to be normalized). * - * @return static + * @return self */ public function safe(): self { @@ -129,6 +144,15 @@ class Filesystem implements FilesystemInterface return $this->toString($scheme, $path); } + /** + * {@inheritdoc} + * @see FilesystemInterface::basename() + */ + public function basename(string $path, ?string $suffix = null): string + { + return $suffix ? basename($path, $suffix) : basename($path); + } + /** * {@inheritdoc} * @see FilesystemInterface::dirname() @@ -146,11 +170,25 @@ class Filesystem implements FilesystemInterface return $this->toString($scheme, $path); } + /** + * Gets full path with trailing slash. + * + * @param string $path + * @param int $levels + * @return string + */ + public function pathname(string $path, int $levels = 1): string + { + $path = $this->dirname($path, $levels); + + return $path !== '.' ? $path . '/' : ''; + } + /** * {@inheritdoc} * @see FilesystemInterface::pathinfo() */ - public function pathinfo(string $path, int $options = null) + public function pathinfo(string $path, ?int $options = null) { [$scheme, $path] = $this->getSchemeAndHierarchy($path); @@ -165,17 +203,21 @@ class Filesystem implements FilesystemInterface * @param string|null $scheme * @param string $path * @param int $levels - * * @return array */ protected function dirnameInternal(?string $scheme, string $path, int $levels = 1): array { - $path = \dirname($path, $levels); + $path = dirname($path, $levels); if (null !== $scheme && $path === '.') { return [$scheme, '']; } + // In Windows dirname() may return backslashes, fix that. + if (DIRECTORY_SEPARATOR !== '/') { + $path = str_replace('\\', '/', $path); + } + return [$scheme, $path]; } @@ -183,18 +225,26 @@ class Filesystem implements FilesystemInterface * @param string|null $scheme * @param string $path * @param int|null $options - * - * @return array + * @return array|string */ - protected function pathinfoInternal(?string $scheme, string $path, int $options = null) + protected function pathinfoInternal(?string $scheme, string $path, ?int $options = null) { - $info = $options ? \pathinfo($path, $options) : \pathinfo($path); + if ($options) { + return pathinfo($path, $options); + } + + $info = pathinfo($path); if (null !== $scheme) { $info['scheme'] = $scheme; $dirname = isset($info['dirname']) && $info['dirname'] !== '.' ? $info['dirname'] : null; if (null !== $dirname) { + // In Windows dirname may be using backslashes, fix that. + if (DIRECTORY_SEPARATOR !== '/') { + $dirname = str_replace('\\', '/', $dirname); + } + $info['dirname'] = $scheme . '://' . $dirname; } else { $info = ['dirname' => $scheme . '://'] + $info; @@ -208,20 +258,18 @@ class Filesystem implements FilesystemInterface * Gets a 2-tuple of scheme (may be null) and hierarchical part of a filename (e.g. file:///tmp -> array(file, tmp)). * * @param string $filename - * * @return array */ protected function getSchemeAndHierarchy(string $filename): array { $components = explode('://', $filename, 2); - return 2 === \count($components) ? $components : [null, $components[0]]; + return 2 === count($components) ? $components : [null, $components[0]]; } /** * @param string|null $scheme * @param string $path - * * @return string */ protected function toString(?string $scheme, string $path): string @@ -235,9 +283,8 @@ class Filesystem implements FilesystemInterface /** * @param string $path - * * @return string - * @throws \RuntimeException + * @throws RuntimeException */ protected function normalizePathPart(string $path): string { @@ -277,14 +324,14 @@ class Filesystem implements FilesystemInterface // Resolve /../ by removing path part. if ($part === '..') { - $test = array_shift($list); + $test = array_pop($list); if ($test === null) { // Oops, user tried to access something outside of our root folder. - throw new \RuntimeException("Bad path {$path}"); + throw new RuntimeException("Bad path {$path}"); } + } else { + $list[] = $part; } - - $list[] = $part; } // Build path back together. diff --git a/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php b/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php index 7082ebf..0e280a8 100644 --- a/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php +++ b/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php @@ -5,13 +5,14 @@ declare(strict_types=1); /** * @package Grav\Framework\Filesystem * - * @copyright Copyright (C) 2015 - 2018 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Filesystem\Interfaces; use Grav\Framework\Filesystem\Filesystem; +use RuntimeException; /** * Defines several stream-save filesystem actions. @@ -28,9 +29,8 @@ interface FilesystemInterface * * @param string $path A filename or path, does not need to exist as a file. * @param int $levels The number of parent directories to go up (>= 1). - * * @return string Returns parent path. - * @throws \RuntimeException + * @throws RuntimeException * @api */ public function parent(string $path, int $levels = 1): string; @@ -39,13 +39,22 @@ interface FilesystemInterface * Normalize path by cleaning up `\`, `/./`, `//` and `/../`. * * @param string $path A filename or path, does not need to exist as a file. - * * @return string Returns normalized path. - * @throws \RuntimeException + * @throws RuntimeException * @api */ public function normalize(string $path): string; + /** + * Returns filename component of path. + * + * @param string $path A filename or path, does not need to exist as a file. + * @param string|null $suffix If the filename ends in suffix this will also be cut off. + * @return string + * @api + */ + public function basename(string $path, ?string $suffix = null): string; + /** * Stream-safe `\dirname()` replacement. * @@ -53,9 +62,8 @@ interface FilesystemInterface * * @param string $path A filename or path, does not need to exist as a file. * @param int $levels The number of parent directories to go up (>= 1). - * * @return string Returns path to the directory. - * @throws \RuntimeException + * @throws RuntimeException * @api */ public function dirname(string $path, int $levels = 1): string; @@ -66,10 +74,9 @@ interface FilesystemInterface * @see http://php.net/manual/en/function.pathinfo.php * * @param string $path A filename or path, does not need to exist as a file. - * @param int $options A PATHINFO_* constant. - * + * @param int|null $options A PATHINFO_* constant. * @return array|string * @api */ - public function pathinfo(string $path, int $options = null); + public function pathinfo(string $path, ?int $options = null); } diff --git a/system/src/Grav/Framework/Flex/Flex.php b/system/src/Grav/Framework/Flex/Flex.php index e87e903..8739282 100644 --- a/system/src/Grav/Framework/Flex/Flex.php +++ b/system/src/Grav/Framework/Flex/Flex.php @@ -5,25 +5,30 @@ declare(strict_types=1); /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Flex; +use Grav\Common\Debugger; +use Grav\Common\Grav; use Grav\Framework\Flex\Interfaces\FlexCollectionInterface; +use Grav\Framework\Flex\Interfaces\FlexInterface; use Grav\Framework\Flex\Interfaces\FlexObjectInterface; use Grav\Framework\Object\ObjectCollection; +use RuntimeException; +use function count; +use function is_array; /** * Class Flex * @package Grav\Framework\Flex */ -class Flex implements \Countable +class Flex implements FlexInterface { /** @var array */ protected $config; - /** @var FlexDirectory[] */ protected $types; @@ -38,6 +43,13 @@ class Flex implements \Countable $this->types = []; foreach ($types as $type => $blueprint) { + if (!file_exists($blueprint)) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addMessage(sprintf('Flex: blueprint for flex type %s is missing', $type), 'error'); + + continue; + } $this->addDirectoryType($type, $blueprint); } } @@ -50,7 +62,7 @@ class Flex implements \Countable */ public function addDirectoryType(string $type, string $blueprint, array $config = []) { - $config = array_merge_recursive(['enabled' => true], $this->config['object'] ?? [], $config); + $config = array_replace_recursive(['enabled' => true], $this->config ?? [], $config); $this->types[$type] = new FlexDirectory($type, $blueprint, $config); @@ -78,9 +90,9 @@ class Flex implements \Countable } /** - * @param array|string[] $types + * @param array|string[]|null $types * @param bool $keepMissing - * @return array|FlexDirectory[] + * @return array */ public function getDirectories(array $types = null, bool $keepMissing = false): array { @@ -124,13 +136,13 @@ class Flex implements \Countable * @param array $options In addition to the options in getObjects(), following options can be passed: * collection_class: Class to be used to create the collection. Defaults to ObjectCollection. * @return FlexCollectionInterface - * @throws \RuntimeException + * @throws RuntimeException */ public function getMixedCollection(array $keys, array $options = []): FlexCollectionInterface { $collectionClass = $options['collection_class'] ?? ObjectCollection::class; if (!class_exists($collectionClass)) { - throw new \RuntimeException(sprintf('Cannot create collection: Class %s does not exist', $collectionClass)); + throw new RuntimeException(sprintf('Cannot create collection: Class %s does not exist', $collectionClass)); } $objects = $this->getObjects($keys, $options); @@ -219,13 +231,13 @@ class Flex implements \Countable // Use the original key ordering. if (!$guessed) { - $list = array_replace(array_fill_keys($keys, null), $list); + $list = array_replace(array_fill_keys($keys, null), $list) ?? []; } else { // We have mixed keys, we need to map flex keys back to storage keys. $results = []; foreach ($keys as $key) { $flexKey = $guessed[$key] ?? $key; - if (\is_array($flexKey)) { + if (is_array($flexKey)) { $result = null; foreach ($flexKey as $tryKey) { if ($result = $list[$tryKey] ?? null) { @@ -262,7 +274,7 @@ class Flex implements \Countable if (null === $type && null === $keyField) { // Special handling for quick Flex key lookups. $keyField = 'storage_key'; - [$type, $key] = $this->resolveKeyAndType($key, $type); + [$key, $type] = $this->resolveKeyAndType($key, $type); } else { $type = $this->resolveType($type); } @@ -281,14 +293,19 @@ class Flex implements \Countable */ public function count(): int { - return \count($this->types); + return count($this->types); } + /** + * @param string $flexKey + * @param string|null $type + * @return array + */ protected function resolveKeyAndType(string $flexKey, string $type = null): array { $guess = false; if (strpos($flexKey, ':') !== false) { - [$type, $key] = explode(':', $flexKey, 2); + [$type, $key] = explode(':', $flexKey, 2); $type = $this->resolveType($type); } else { @@ -300,10 +317,14 @@ class Flex implements \Countable return [$key, $type, $guess]; } + /** + * @param string|null $type + * @return string + */ protected function resolveType(string $type = null): string { if (null !== $type && strpos($type, '.') !== false) { - return preg_replace('|\.obj$|', '', $type); + return preg_replace('|\.obj$|', '', $type) ?? $type; } return $type ?? ''; diff --git a/system/src/Grav/Framework/Flex/FlexCollection.php b/system/src/Grav/Framework/Flex/FlexCollection.php index 5e5431b..54e0665 100644 --- a/system/src/Grav/Framework/Flex/FlexCollection.php +++ b/system/src/Grav/Framework/Flex/FlexCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,12 +13,12 @@ use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; use Grav\Common\Debugger; use Grav\Common\Grav; +use Grav\Common\Inflector; use Grav\Common\Twig\Twig; use Grav\Common\User\Interfaces\UserInterface; +use Grav\Common\Utils; use Grav\Framework\Cache\CacheInterface; -use Grav\Framework\ContentBlock\ContentBlockInterface; use Grav\Framework\ContentBlock\HtmlBlock; -use Grav\Framework\Flex\Interfaces\FlexIndexInterface; use Grav\Framework\Flex\Interfaces\FlexObjectInterface; use Grav\Framework\Object\ObjectCollection; use Grav\Framework\Flex\Interfaces\FlexCollectionInterface; @@ -26,11 +26,20 @@ use Psr\SimpleCache\InvalidArgumentException; use RocketTheme\Toolbox\Event\Event; use Twig\Error\LoaderError; use Twig\Error\SyntaxError; +use Twig\Template; use Twig\TemplateWrapper; +use function array_filter; +use function get_class; +use function in_array; +use function is_array; +use function is_scalar; /** * Class FlexCollection * @package Grav\Framework\Flex + * @template T of FlexObjectInterface + * @extends ObjectCollection + * @implements FlexCollectionInterface */ class FlexCollection extends ObjectCollection implements FlexCollectionInterface { @@ -51,8 +60,10 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface 'getTypePrefix' => true, 'getType' => true, 'getFlexDirectory' => true, + 'hasFlexFeature' => true, + 'getFlexFeatures' => true, 'getCacheKey' => true, - 'getCacheChecksum' => true, + 'getCacheChecksum' => false, 'getTimestamp' => true, 'hasProperty' => true, 'getProperty' => true, @@ -64,6 +75,7 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface 'isAuthorized' => 'session', 'search' => true, 'sort' => true, + 'getDistinctValues' => true ]; } @@ -85,6 +97,11 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface */ public function __construct(array $entries = [], FlexDirectory $directory = null) { + // @phpstan-ignore-next-line + if (get_class($this) === __CLASS__) { + user_error('Using ' . __CLASS__ . ' directly is deprecated since Grav 1.7, use \Grav\Common\Flex\Types\Generic\GenericCollection or your own class instead', E_USER_DEPRECATED); + } + parent::__construct($entries); if ($directory) { @@ -92,6 +109,36 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface } } + /** + * {@inheritdoc} + * @see FlexCommonInterface::hasFlexFeature() + */ + public function hasFlexFeature(string $name): bool + { + return in_array($name, $this->getFlexFeatures(), true); + } + + /** + * {@inheritdoc} + * @see FlexCommonInterface::hasFlexFeature() + */ + public function getFlexFeatures(): array + { + $implements = class_implements($this); + + $list = []; + foreach ($implements as $interface) { + if ($pos = strrpos($interface, '\\')) { + $interface = substr($interface, $pos+1); + } + + $list[] = Inflector::hyphenize(str_replace('Interface', '', $interface)); + } + + return $list; + + } + /** * {@inheritdoc} * @see FlexCollectionInterface::search() @@ -102,9 +149,7 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface $matching = array_filter($matching); if ($matching) { - uksort($matching, function ($a, $b) { - return -($a <=> $b); - }); + arsort($matching, SORT_NUMERIC); } return $this->select(array_keys($matching)); @@ -175,7 +220,7 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface */ public function getCacheKey(): string { - return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1(json_encode($this->call('getKey'))); + return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1((string)json_encode($this->call('getKey'))); } /** @@ -184,7 +229,16 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface */ public function getCacheChecksum(): string { - return sha1(json_encode($this->getTimestamps())); + $list = []; + /** + * @var string $key + * @var FlexObjectInterface $object + */ + foreach ($this as $key => $object) { + $list[$key] = $object->getCacheChecksum(); + } + + return sha1((string)json_encode($list)); } /** @@ -223,6 +277,33 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface return $keys; } + /** + * Get all the values in property. + * + * Supports either single scalar values or array of scalar values. + * + * @param string $property Object property to be used to make groups. + * @param string|null $separator Separator, defaults to '.' + * @return array + */ + public function getDistinctValues(string $property, string $separator = null): array + { + $list = []; + + /** @var FlexObjectInterface $element */ + foreach ($this->getIterator() as $element) { + $value = (array)$element->getNestedProperty($property, null, $separator); + foreach ($value as $v) { + if (is_scalar($v)) { + $t = gettype($v) . (string)$v; + $list[$t] = $v; + } + } + } + + return array_values($list); + } + /** * {@inheritdoc} * @see FlexCollectionInterface::withKeyField() @@ -258,15 +339,27 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface return $this->getFlexDirectory()->getIndex($this->getKeys(), $this->getKeyField()); } + /** + * @inheritdoc} + * @see FlexCollectionInterface::getCollection() + * @return $this + */ + public function getCollection() + { + return $this; + } + /** * {@inheritdoc} * @see FlexCollectionInterface::render() */ public function render(string $layout = null, array $context = []) { - if (null === $layout) { - $layout = 'default'; + if (!$layout) { + $config = $this->getTemplateConfig(); + $layout = $config['collection']['defaults']['layout'] ?? 'default'; } + $type = $this->getFlexType(); $grav = Grav::instance(); @@ -275,20 +368,23 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface $debugger = $grav['debugger']; $debugger->startTimer('flex-collection-' . ($debugKey = uniqid($type, false)), 'Render Collection ' . $type . ' (' . $layout . ')'); - $cache = $key = null; + $key = null; foreach ($context as $value) { - if (!\is_scalar($value)) { + if (!is_scalar($value)) { $key = false; + break; } } if ($key !== false) { $key = md5($this->getCacheKey() . '.' . $layout . json_encode($context)); $cache = $this->getCache('render'); + } else { + $cache = null; } try { - $data = $cache ? $cache->get($key) : null; + $data = $cache && $key ? $cache->get($key) : null; $block = $data ? HtmlBlock::fromArray($data) : null; } catch (InvalidArgumentException $e) { @@ -305,20 +401,30 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface } if (!$block) { - $block = HtmlBlock::create($key); + $block = HtmlBlock::create($key ?: null); $block->setChecksum($checksum); - if ($key === false) { + if (!$key) { $block->disableCache(); } - $grav->fireEvent('onFlexCollectionRender', new Event([ + $event = new Event([ + 'type' => 'flex', + 'directory' => $this->getFlexDirectory(), 'collection' => $this, 'layout' => &$layout, 'context' => &$context - ])); + ]); + $this->triggerEvent('onRender', $event); $output = $this->getTemplate($layout)->render( - ['grav' => $grav, 'config' => $grav['config'], 'block' => $block, 'collection' => $this, 'layout' => $layout] + $context + [ + 'grav' => $grav, + 'config' => $grav['config'], + 'block' => $block, + 'directory' => $this->getFlexDirectory(), + 'collection' => $this, + 'layout' => $layout + ] + $context ); if ($debugger->enabled()) { @@ -328,7 +434,7 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface $block->setContent($output); try { - $cache && $block->isCached() && $cache->set($key, $block->toArray()); + $cache && $key && $block->isCached() && $cache->set($key, $block->toArray()); } catch (InvalidArgumentException $e) { $debugger->addException($e); } @@ -339,20 +445,6 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface return $block; } - /** - * @param bool $prefix - * @return string - * @deprecated 1.6 Use `->getFlexType()` instead. - */ - public function getType($prefix = false) - { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); - - $type = $prefix ? $this->getTypePrefix() : ''; - - return $type . $this->getFlexType(); - } - /** * @param FlexDirectory $type * @return $this @@ -365,9 +457,10 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface } /** + * @param string $key * @return array */ - public function getMetaData(string $key) : array + public function getMetaData(string $key): array { $object = $this->get($key); @@ -396,11 +489,12 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface * @param string|null $scope * @param UserInterface|null $user * @return static + * @phpstan-return static */ public function isAuthorized(string $action, string $scope = null, UserInterface $user = null) { $list = $this->call('isAuthorized', [$action, $scope, $user]); - $list = \array_filter($list); + $list = array_filter($list); return $this->select(array_keys($list)); } @@ -408,13 +502,15 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface /** * @param string $value * @param string $field - * @return FlexObject|null + * @return T|null */ public function find($value, $field = 'id') { - if ($value) foreach ($this as $element) { - if (mb_strtolower($element->getProperty($field)) === mb_strtolower($value)) { - return $element; + if ($value) { + foreach ($this as $element) { + if (mb_strtolower($element->getProperty($field)) === mb_strtolower($value)) { + return $element; + } } } @@ -433,12 +529,15 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface * @var array|FlexObject $object */ foreach ($this->getElements() as $key => $object) { - $elements[$key] = \is_array($object) ? $object : $object->jsonSerialize(); + $elements[$key] = is_array($object) ? $object : $object->jsonSerialize(); } return $elements; } + /** + * @return array + */ public function __debugInfo() { return [ @@ -457,8 +556,8 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface * * @param array $elements Elements. * @param string|null $keyField - * * @return static + * @phpstan-return static * @throws \InvalidArgumentException */ protected function createFrom(array $elements, $keyField = null) @@ -477,9 +576,50 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface return 'c.'; } + /** + * @return array + */ + protected function getTemplateConfig(): array + { + $config = $this->getFlexDirectory()->getConfig('site.templates', []); + $defaults = array_replace($config['defaults'] ?? [], $config['collection']['defaults'] ?? []); + $config['collection']['defaults'] = $defaults; + + return $config; + } + /** * @param string $layout - * @return TemplateWrapper + * @return array + */ + protected function getTemplatePaths(string $layout): array + { + $config = $this->getTemplateConfig(); + $type = $this->getFlexType(); + $defaults = $config['collection']['defaults'] ?? []; + + $ext = $defaults['ext'] ?? '.html.twig'; + $types = array_unique(array_merge([$type], (array)($defaults['type'] ?? null))); + $paths = $config['collection']['paths'] ?? [ + 'flex/{TYPE}/collection/{LAYOUT}{EXT}', + 'flex-objects/layouts/{TYPE}/collection/{LAYOUT}{EXT}' + ]; + $table = ['TYPE' => '%1$s', 'LAYOUT' => '%2$s', 'EXT' => '%3$s']; + + $lookups = []; + foreach ($paths as $path) { + $path = Utils::simpleTemplate($path, $table); + foreach ($types as $type) { + $lookups[] = sprintf($path, $type, $layout, $ext); + } + } + + return array_unique($lookups); + } + + /** + * @param string $layout + * @return Template|TemplateWrapper * @throws LoaderError * @throws SyntaxError */ @@ -491,18 +631,13 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface $twig = $grav['twig']; try { - return $twig->twig()->resolveTemplate( - [ - "flex-objects/layouts/{$this->getFlexType()}/collection/{$layout}.html.twig", - "flex-objects/layouts/_default/collection/{$layout}.html.twig" - ] - ); + return $twig->twig()->resolveTemplate($this->getTemplatePaths($layout)); } catch (LoaderError $e) { /** @var Debugger $debugger */ $debugger = Grav::instance()['debugger']; $debugger->addException($e); - return $twig->twig()->resolveTemplate(['flex-objects/layouts/404.html.twig']); + return $twig->twig()->resolveTemplate(['flex/404.html.twig']); } } @@ -513,13 +648,65 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface protected function getRelatedDirectory($type): ?FlexDirectory { /** @var Flex $flex */ - $flex = Grav::instance()['flex_objects']; + $flex = Grav::instance()['flex']; return $flex->getDirectory($type); } + /** + * @param string|null $keyField + * @return void + */ protected function setKeyField($keyField = null): void { $this->_keyField = $keyField ?? 'storage_key'; } + + // DEPRECATED METHODS + + /** + * @param bool $prefix + * @return string + * @deprecated 1.6 Use `->getFlexType()` instead. + */ + public function getType($prefix = false) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); + + $type = $prefix ? $this->getTypePrefix() : ''; + + return $type . $this->getFlexType(); + } + + /** + * @param string $name + * @param object|null $event + * @return $this + * @deprecated 1.7, moved to \Grav\Common\Flex\Traits\FlexObjectTrait + */ + public function triggerEvent(string $name, $event = null) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\FlexObjectTrait', E_USER_DEPRECATED); + + if (null === $event) { + $event = new Event([ + 'type' => 'flex', + 'directory' => $this->getFlexDirectory(), + 'collection' => $this + ]); + } + if (strpos($name, 'onFlexCollection') !== 0 && strpos($name, 'on') === 0) { + $name = 'onFlexCollection' . substr($name, 2); + } + + $grav = Grav::instance(); + if ($event instanceof Event) { + $grav->fireEvent($name, $event); + } else { + $grav->dispatchEvent($event); + } + + + return $this; + } } diff --git a/system/src/Grav/Framework/Flex/FlexDirectory.php b/system/src/Grav/Framework/Flex/FlexDirectory.php index c75a901..b9c9d54 100644 --- a/system/src/Grav/Framework/Flex/FlexDirectory.php +++ b/system/src/Grav/Framework/Flex/FlexDirectory.php @@ -3,37 +3,51 @@ /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Flex; +use Exception; use Grav\Common\Cache; use Grav\Common\Config\Config; use Grav\Common\Data\Blueprint; use Grav\Common\Debugger; use Grav\Common\Grav; +use Grav\Common\Page\Interfaces\PageInterface; +use Grav\Common\User\Interfaces\UserInterface; use Grav\Common\Utils; use Grav\Framework\Cache\Adapter\DoctrineCache; use Grav\Framework\Cache\Adapter\MemoryCache; use Grav\Framework\Cache\CacheInterface; use Grav\Framework\Flex\Interfaces\FlexAuthorizeInterface; use Grav\Framework\Flex\Interfaces\FlexCollectionInterface; +use Grav\Framework\Flex\Interfaces\FlexDirectoryInterface; +use Grav\Framework\Flex\Interfaces\FlexFormInterface; use Grav\Framework\Flex\Interfaces\FlexIndexInterface; use Grav\Framework\Flex\Interfaces\FlexObjectInterface; use Grav\Framework\Flex\Interfaces\FlexStorageInterface; use Grav\Framework\Flex\Storage\SimpleStorage; use Grav\Framework\Flex\Traits\FlexAuthorizeTrait; use Psr\SimpleCache\InvalidArgumentException; +use RocketTheme\Toolbox\File\YamlFile; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; use RuntimeException; +use function call_user_func_array; +use function count; +use function is_array; +use Grav\Common\Flex\Types\Generic\GenericObject; +use Grav\Common\Flex\Types\Generic\GenericCollection; +use Grav\Common\Flex\Types\Generic\GenericIndex; +use function is_callable; /** * Class FlexDirectory * @package Grav\Framework\Flex + * @template T */ -class FlexDirectory implements FlexAuthorizeInterface +class FlexDirectory implements FlexDirectoryInterface, FlexAuthorizeInterface { use FlexAuthorizeTrait; @@ -43,10 +57,8 @@ class FlexDirectory implements FlexAuthorizeInterface protected $blueprint_file; /** @var Blueprint[] */ protected $blueprints; - /** @var bool[] */ - protected $blueprints_init; - /** @var FlexIndexInterface|null */ - protected $index; + /** @var FlexIndexInterface[] */ + protected $indexes = []; /** @var FlexCollectionInterface|null */ protected $collection; /** @var bool */ @@ -57,8 +69,10 @@ class FlexDirectory implements FlexAuthorizeInterface protected $config; /** @var FlexStorageInterface */ protected $storage; - /** @var CacheInterface */ + /** @var CacheInterface[] */ protected $cache; + /** @var FlexObjectInterface[] */ + protected $objects; /** @var string */ protected $objectClassName; /** @var string */ @@ -66,6 +80,9 @@ class FlexDirectory implements FlexAuthorizeInterface /** @var string */ protected $indexClassName; + /** @var string|null */ + private $_authorize; + /** * FlexDirectory constructor. * @param string $type @@ -79,6 +96,21 @@ class FlexDirectory implements FlexAuthorizeInterface $this->blueprint_file = $blueprint_file; $this->defaults = $defaults; $this->enabled = !empty($defaults['enabled']); + $this->objects = []; + } + + /** + * @return bool + */ + public function isListed(): bool + { + $grav = Grav::instance(); + + /** @var Flex $flex */ + $flex = $grav['flex']; + $directory = $flex->getDirectory($this->type); + + return null !== $directory; } /** @@ -89,17 +121,6 @@ class FlexDirectory implements FlexAuthorizeInterface return $this->enabled; } - /** - * @return string - * @deprecated 1.6 Use ->getFlexType() method instead. - */ - public function getType(): string - { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); - - return $this->type; - } - /** * @return string */ @@ -132,32 +153,137 @@ class FlexDirectory implements FlexAuthorizeInterface public function getConfig(string $name = null, $default = null) { if (null === $this->config) { - $this->config = new Config(array_merge_recursive($this->getBlueprintInternal()->get('config', []), $this->defaults)); + $config = $this->getBlueprintInternal()->get('config', []); + $config = is_array($config) ? array_replace_recursive($config, $this->defaults, $this->getDirectoryConfig($config['admin']['views']['configure']['form'] ?? $config['admin']['configure']['form'] ?? null)) : null; + if (!is_array($config)) { + throw new RuntimeException('Bad configuration'); + } + + $this->config = new Config($config); } return null === $name ? $this->config : $this->config->get($name, $default); } /** + * @param string|null $name + * @param array $options + * @return FlexFormInterface + * @internal + */ + public function getDirectoryForm(string $name = null, array $options = []) + { + $name = $name ?: $this->getConfig('admin.views.configure.form', '') ?: $this->getConfig('admin.configure.form', ''); + + return new FlexDirectoryForm($name ?? '', $this, $options); + } + + /** + * @return Blueprint + * @internal + */ + public function getDirectoryBlueprint() + { + $name = 'configure'; + + $type = $this->getBlueprint(); + $overrides = $type->get("blueprints/{$name}"); + + $path = "blueprints://flex/shared/{$name}.yaml"; + $blueprint = new Blueprint($path); + $blueprint->load(); + if (isset($overrides['fields'])) { + $blueprint->embed('form/fields/tabs/fields', $overrides['fields']); + } + $blueprint->init(); + + return $blueprint; + } + + /** + * @param string $name + * @param array $data + * @return void + * @throws Exception + * @internal + */ + public function saveDirectoryConfig(string $name, array $data) + { + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + /** @var string $filename Filename is always string */ + $filename = $locator->findResource($this->getDirectoryConfigUri($name), true, true); + + $file = YamlFile::instance($filename); + if (!empty($data)) { + $file->save($data); + } else { + $file->delete(); + } + } + + /** + * @param string $name + * @return array + * @internal + */ + public function loadDirectoryConfig(string $name): array + { + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $filename = $locator->findResource($this->getDirectoryConfigUri($name), true); + if ($filename === false) { + return []; + } + + $file = YamlFile::instance($filename); + + return $file->content(); + } + + /** + * @param string|null $name + * @return string + */ + public function getDirectoryConfigUri(string $name = null): string + { + $name = $name ?: $this->getFlexType(); + $blueprint = $this->getBlueprint(); + + return $blueprint->get('blueprints/views/configure/file') ?? $blueprint->get('blueprints/configure/file') ?? "config://flex/{$name}.yaml"; + } + + /** + * @param string|null $name + * @return array + */ + protected function getDirectoryConfig(string $name = null): array + { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + $name = $name ?: $this->getFlexType(); + + return $config->get("flex.{$name}", []); + } + + /** + * Returns a new uninitialized instance of blueprint. + * + * Always use $object->getBlueprint() or $object->getForm()->getBlueprint() instead. + * * @param string $type * @param string $context * @return Blueprint */ public function getBlueprint(string $type = '', string $context = '') { - $blueprint = $this->getBlueprintInternal($type, $context); - - if (empty($this->blueprints_init[$type])) { - $this->blueprints_init[$type] = true; - - $blueprint->setScope('object'); - $blueprint->init(); - if (empty($blueprint->fields())) { - throw new RuntimeException(sprintf('Flex: Blueprint for %s is missing', $this->type)); - } - } - - return $blueprint; + return clone $this->getBlueprintInternal($type, $context); } /** @@ -171,7 +297,7 @@ class FlexDirectory implements FlexAuthorizeInterface $file = preg_replace('/\.yaml/', "/{$view}.yaml", $file); } - return $file; + return (string)$file; } /** @@ -182,6 +308,7 @@ class FlexDirectory implements FlexAuthorizeInterface * @param array|null $keys Array of keys. * @param string|null $keyField Field to be used as the key. * @return FlexCollectionInterface + * @phpstan-return FlexCollectionInterface */ public function getCollection(array $keys = null, string $keyField = null): FlexCollectionInterface { @@ -211,10 +338,12 @@ class FlexDirectory implements FlexAuthorizeInterface */ public function getIndex(array $keys = null, string $keyField = null): FlexIndexInterface { - $index = clone $this->loadIndex(); - $index = $index->withKeyField($keyField); + $keyField = $keyField ?? ''; + $index = $this->indexes[$keyField] ?? $this->loadIndex($keyField); + $index = clone $index; if (null !== $keys) { + /** @var FlexIndexInterface $index */ $index = $index->select($keys); } @@ -222,80 +351,24 @@ class FlexDirectory implements FlexAuthorizeInterface } /** - * Returns an object if it exists. + * Returns an object if it exists. If no arguments are passed (or both of them are null), method creates a new empty object. * * Note: It is not safe to use the object without checking if the user can access it. * - * @param string $key + * @param string|null $key * @param string|null $keyField Field to be used as the key. * @return FlexObjectInterface|null */ - public function getObject($key, string $keyField = null): ?FlexObjectInterface + public function getObject($key = null, string $keyField = null): ?FlexObjectInterface { - return $this->getIndex(null, $keyField)->get($key); - } - - /** - * @param array $data - * @param string|null $key - * @return FlexObjectInterface - */ - public function update(array $data, string $key = null): FlexObjectInterface - { - $object = null !== $key ? $this->getIndex()->get($key): null; - - $storage = $this->getStorage(); - - if (null === $object) { - $object = $this->createObject($data, $key, true); - $key = $object->getStorageKey(); - - if ($key) { - $rows = $storage->replaceRows([$key => $object->prepareStorage()]); - } else { - $rows = $storage->createRows([$object->prepareStorage()]); - } - } else { - $oldKey = $object->getStorageKey(); - $object->update($data); - $newKey = $object->getStorageKey(); - - if ($oldKey !== $newKey) { - $object->triggerEvent('move'); - $storage->renameRow($oldKey, $newKey); - // TODO: media support. - } - - $object->save(); + if (null === $key) { + return $this->createObject([], ''); } - try { - $this->clearCache(); - } catch (InvalidArgumentException $e) { - /** @var Debugger $debugger */ - $debugger = Grav::instance()['debugger']; - $debugger->addException($e); + $keyField = $keyField ?? ''; + $index = $this->indexes[$keyField] ?? $this->loadIndex($keyField); - // Caching failed, but we can ignore that for now. - } - - return $object; - } - - /** - * @param string $key - * @return FlexObjectInterface|null - */ - public function remove(string $key): ?FlexObjectInterface - { - $object = $this->getIndex()->get($key); - if (!$object) { - return null; - } - - $object->delete(); - - return $object; + return $index->get($key); } /** @@ -313,19 +386,19 @@ class FlexDirectory implements FlexAuthorizeInterface /** @var Cache $gravCache */ $gravCache = $grav['cache']; - $config = $this->getConfig('cache.' . $namespace); + $config = $this->getConfig('object.cache.' . $namespace); if (empty($config['enabled'])) { $cache = new MemoryCache('flex-objects-' . $this->getFlexType()); } else { - $timeout = $config['timeout'] ?? 60; + $lifetime = $config['lifetime'] ?? 60; $key = $gravCache->getKey(); if (Utils::isAdminPlugin()) { $key = substr($key, 0, -1); } - $cache = new DoctrineCache($gravCache->getCacheDriver(), 'flex-objects-' . $this->getFlexType() . $key, $timeout); + $cache = new DoctrineCache($gravCache->getCacheDriver(), 'flex-objects-' . $this->getFlexType() . $key, $lifetime); } - } catch (\Exception $e) { + } catch (Exception $e) { /** @var Debugger $debugger */ $debugger = Grav::instance()['debugger']; $debugger->addException($e); @@ -360,25 +433,26 @@ class FlexDirectory implements FlexAuthorizeInterface $this->getCache('object')->clear(); $this->getCache('render')->clear(); - $this->index = null; + $this->indexes = []; + $this->objects = []; return $this; } /** * @param string|null $key - * @return string + * @return string|null */ - public function getStorageFolder(string $key = null): string + public function getStorageFolder(string $key = null): ?string { return $this->getStorage()->getStoragePath($key); } /** * @param string|null $key - * @return string + * @return string|null */ - public function getMediaFolder(string $key = null): string + public function getMediaFolder(string $key = null): ?string { return $this->getStorage()->getMediaPath($key); } @@ -411,7 +485,7 @@ class FlexDirectory implements FlexAuthorizeInterface /** * @param array $entries - * @param string $keyField + * @param string|null $keyField * @return FlexCollectionInterface */ public function createCollection(array $entries, string $keyField = null): FlexCollectionInterface @@ -424,7 +498,7 @@ class FlexDirectory implements FlexAuthorizeInterface /** * @param array $entries - * @param string $keyField + * @param string|null $keyField * @return FlexIndexInterface */ public function createIndex(array $entries, string $keyField = null): FlexIndexInterface @@ -441,11 +515,10 @@ class FlexDirectory implements FlexAuthorizeInterface public function getObjectClass(): string { if (!$this->objectClassName) { - $this->objectClassName = $this->getConfig('data.object', 'Grav\\Framework\\Flex\\FlexObject'); + $this->objectClassName = $this->getConfig('data.object', GenericObject::class); } return $this->objectClassName; - } /** @@ -454,7 +527,7 @@ class FlexDirectory implements FlexAuthorizeInterface public function getCollectionClass(): string { if (!$this->collectionClassName) { - $this->collectionClassName = $this->getConfig('data.collection', 'Grav\\Framework\\Flex\\FlexCollection'); + $this->collectionClassName = $this->getConfig('data.collection', GenericCollection::class); } return $this->collectionClassName; @@ -467,7 +540,7 @@ class FlexDirectory implements FlexAuthorizeInterface public function getIndexClass(): string { if (!$this->indexClassName) { - $this->indexClassName = $this->getConfig('data.index', 'Grav\\Framework\\Flex\\FlexIndex'); + $this->indexClassName = $this->getConfig('data.index', GenericIndex::class); } return $this->indexClassName; @@ -475,7 +548,7 @@ class FlexDirectory implements FlexAuthorizeInterface /** * @param array $entries - * @param string $keyField + * @param string|null $keyField * @return FlexCollectionInterface */ public function loadCollection(array $entries, string $keyField = null): FlexCollectionInterface @@ -492,76 +565,178 @@ class FlexDirectory implements FlexAuthorizeInterface { /** @var Debugger $debugger */ $debugger = Grav::instance()['debugger']; - $debugger->startTimer('flex-objects', sprintf('Flex: Initializing %d %s', \count($entries), $this->type)); - $storage = $this->getStorage(); - $cache = $this->getCache('object'); - - // Get storage keys for the objects. $keys = []; $rows = []; + $fetch = []; + + // Build lookup arrays with storage keys for the objects. foreach ($entries as $key => $value) { - $k = $value['storage_key']; + $k = $value['storage_key'] ?? ''; + if ($k === '') { + continue; + } + $v = $this->objects[$k] ?? null; $keys[$k] = $key; - $rows[$k] = null; + $rows[$k] = $v; + if (!$v) { + $fetch[] = $k; + } } - // Fetch rows from the cache. - try { - $rows = $cache->getMultiple(array_keys($rows)); - } catch (InvalidArgumentException $e) { - $debugger->addException($e); + // Attempt to fetch missing rows from the cache. + if ($fetch) { + $rows = (array)array_replace($rows, $this->loadCachedObjects($fetch)); } // Read missing rows from the storage. $updated = []; + $storage = $this->getStorage(); $rows = $storage->readRows($rows, $updated); + // Create objects from the rows. + $isListed = $this->isListed(); + $list = []; + foreach ($rows as $storageKey => $row) { + $usedKey = $keys[$storageKey]; + + if ($row instanceof FlexObjectInterface) { + $object = $row; + } else { + if ($row === null) { + $debugger->addMessage(sprintf('Flex: Object %s was not found from %s storage', $storageKey, $this->type), 'debug'); + continue; + } + + if (isset($row['__ERROR'])) { + $message = sprintf('Flex: Object %s is broken in %s storage: %s', $storageKey, $this->type, $row['__ERROR']); + $debugger->addException(new RuntimeException($message)); + $debugger->addMessage($message, 'error'); + continue; + } + + if (!isset($row['__META'])) { + $row['__META'] = [ + 'storage_key' => $storageKey, + 'storage_timestamp' => $entries[$usedKey]['storage_timestamp'] ?? 0, + ]; + } + + $key = $row['__META']['key'] ?? $entries[$usedKey]['key'] ?? $usedKey; + $object = $this->createObject($row, $key, false); + $this->objects[$storageKey] = $object; + if ($isListed) { + // If unserialize works for the object, serialize the object to speed up the loading. + $updated[$storageKey] = $object; + } + } + + $list[$usedKey] = $object; + } + // Store updated rows to the cache. if ($updated) { + $cache = $this->getCache('object'); + if (!$cache instanceof MemoryCache) { + ///** @var Debugger $debugger */ + //$debugger = Grav::instance()['debugger']; + //$debugger->addMessage(sprintf('Flex: Caching %d %s', \count($entries), $this->type), 'debug'); + } try { - if (!$cache instanceof MemoryCache) { - $debugger->addMessage(sprintf('Flex: Caching %d %s: %s', \count($updated), $this->type, implode(', ', array_keys($updated))), 'debug'); - } $cache->setMultiple($updated); } catch (InvalidArgumentException $e) { $debugger->addException($e); - // TODO: log about the issue. } } - // Create objects from the rows. - $list = []; - foreach ($rows as $storageKey => $row) { - if ($row === null) { - $debugger->addMessage(sprintf('Flex: Object %s was not found from %s storage', $storageKey, $this->type), 'debug'); - continue; - } - - if (isset($row['__error'])) { - $message = sprintf('Flex: Object %s is broken in %s storage: %s', $storageKey, $this->type, $row['__error']); - $debugger->addException(new \RuntimeException($message)); - $debugger->addMessage($message, 'error'); - continue; - } - - $usedKey = $keys[$storageKey]; - $row += [ - 'storage_key' => $storageKey, - 'storage_timestamp' => $entries[$usedKey]['storage_timestamp'], - ]; - - $key = $entries[$usedKey]['key'] ?? $usedKey; - $object = $this->createObject($row, $key, false); - $list[$usedKey] = $object; + if ($fetch) { + $debugger->stopTimer('flex-objects'); } - $debugger->stopTimer('flex-objects'); - return $list; } + protected function loadCachedObjects(array $fetch): array + { + if (!$fetch) { + return []; + } + + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + + $cache = $this->getCache('object'); + + // Attempt to fetch missing rows from the cache. + $fetched = []; + try { + $loading = count($fetch); + + $debugger->startTimer('flex-objects', sprintf('Flex: Loading %d %s', $loading, $this->type)); + + $fetched = (array)$cache->getMultiple($fetch); + if ($fetched) { + $index = $this->loadIndex('storage_key'); + + // Make sure cached objects are up to date: compare against index checksum/timestamp. + /** + * @var string $key + * @var mixed $value + */ + foreach ($fetched as $key => $value) { + if ($value instanceof FlexObjectInterface) { + $objectMeta = $value->getMetaData(); + } else { + $objectMeta = $value['__META'] ?? []; + } + $indexMeta = $index->getMetaData($key); + + $indexChecksum = $indexMeta['checksum'] ?? $indexMeta['storage_timestamp'] ?? null; + $objectChecksum = $objectMeta['checksum'] ?? $objectMeta['storage_timestamp'] ?? null; + if ($indexChecksum !== $objectChecksum) { + unset($fetched[$key]); + } + } + } + + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + } + + return $fetched; + } + + /** + * @return void + */ + public function reloadIndex(): void + { + $this->getCache('index')->clear(); + + $this->indexes = []; + $this->objects = []; + } + + /** + * @param string $scope + * @param string $action + * @return string + */ + public function getAuthorizeRule(string $scope, string $action): string + { + if (!$this->_authorize) { + $config = $this->getConfig('admin.permissions'); + if ($config) { + $this->_authorize = array_key_first($config) . '.%2$s'; + } else { + $this->_authorize = '%1$s.flex-object.%2$s'; + } + } + + return sprintf($this->_authorize, $scope, $action); + } + /** * @param string $type_view * @param string $context @@ -579,6 +754,13 @@ class FlexDirectory implements FlexAuthorizeInterface $view = array_shift($parts) ?: ''; $blueprint = new Blueprint($this->getBlueprintFile($view)); + $blueprint->addDynamicHandler('data', function (array &$field, $property, array &$call) { + $this->dynamicDataField($field, $property, $call); + }); + $blueprint->addDynamicHandler('flex', function (array &$field, $property, array &$call) { + $this->dynamicFlexField($field, $property, $call); + }); + if ($context) { $blueprint->setContext($context); } @@ -595,6 +777,66 @@ class FlexDirectory implements FlexAuthorizeInterface return $this->blueprints[$type_view]; } + /** + * @param array $field + * @param string $property + * @param array $call + * @return void + */ + protected function dynamicDataField(array &$field, $property, array $call) + { + $params = $call['params']; + if (is_array($params)) { + $function = array_shift($params); + } else { + $function = $params; + $params = []; + } + + $object = $call['object']; + if ($function === '\Grav\Common\Page\Pages::pageTypes') { + $params = [$object instanceof PageInterface && $object->isModule() ? 'modular' : 'standard']; + } + + $data = null; + if (is_callable($function)) { + $data = call_user_func_array($function, $params); + } + + // If function returns a value, + if (null !== $data) { + if (is_array($data) && isset($field[$property]) && is_array($field[$property])) { + // Combine field and @data-field together. + $field[$property] += $data; + } else { + // Or create/replace field with @data-field. + $field[$property] = $data; + } + } + } + + /** + * @param array $field + * @param string $property + * @param array $call + * @return void + */ + protected function dynamicFlexField(array &$field, $property, array $call) + { + $params = (array)$call['params']; + $object = $call['object'] ?? null; + $method = array_shift($params); + + if ($object && method_exists($object, $method)) { + $value = $object->{$method}(...$params); + if (is_array($value) && isset($field[$property]) && is_array($field[$property])) { + $field[$property] = array_merge_recursive($field[$property], $value); + } else { + $field[$property] = $value; + } + } + } + /** * @return FlexStorageInterface */ @@ -604,7 +846,7 @@ class FlexDirectory implements FlexAuthorizeInterface $storage = $this->getConfig('data.storage'); - if (!\is_array($storage)) { + if (!is_array($storage)) { $storage = ['options' => ['folder' => $storage]]; } @@ -615,16 +857,22 @@ class FlexDirectory implements FlexAuthorizeInterface } /** + * @param string $keyField * @return FlexIndexInterface */ - protected function loadIndex(): FlexIndexInterface + protected function loadIndex(string $keyField): FlexIndexInterface { static $i = 0; - $index = $this->index; + $index = $this->indexes[$keyField] ?? null; + if (null !== $index) { + return $index; + } + $index = $this->indexes['storage_key'] ?? null; if (null === $index) { - $i++; $j = $i; + $i++; + $j = $i; /** @var Debugger $debugger */ $debugger = Grav::instance()['debugger']; $debugger->startTimer('flex-keys-' . $this->type . $j, "Flex: Loading {$this->type} index"); @@ -639,13 +887,15 @@ class FlexDirectory implements FlexAuthorizeInterface $keys = null; } - if (null === $keys) { + if (!is_array($keys)) { /** @var string|FlexIndexInterface $className */ $className = $this->getIndexClass(); $keys = $className::loadEntriesFromStorage($storage); if (!$cache instanceof MemoryCache) { - $debugger->addMessage(sprintf('Flex: Caching %s index of %d objects', $this->type, \count($keys)), - 'debug'); + $debugger->addMessage( + sprintf('Flex: Caching %s index of %d objects', $this->type, count($keys)), + 'debug' + ); } try { $cache->set('__keys', $keys); @@ -655,15 +905,137 @@ class FlexDirectory implements FlexAuthorizeInterface } } + $ordering = $this->getConfig('data.ordering', []); + // We need to do this in two steps as orderBy() calls loadIndex() again and we do not want infinite loop. - $this->index = $this->createIndex($keys); - /** @var FlexCollectionInterface $collection */ - $collection = $this->index->orderBy($this->getConfig('data.ordering', [])); - $this->index = $index = $collection->getIndex(); + $this->indexes['storage_key'] = $index = $this->createIndex($keys, 'storage_key'); + if ($ordering) { + /** @var FlexCollectionInterface $collection */ + $collection = $this->indexes['storage_key']->orderBy($ordering); + $this->indexes['storage_key'] = $index = $collection->getIndex(); + } $debugger->stopTimer('flex-keys-' . $this->type . $j); } + if ($keyField !== 'storage_key') { + $this->indexes[$keyField] = $index = $index->withKeyField($keyField ?: null); + } + return $index; } + + /** + * @param string $action + * @return string + */ + protected function getAuthorizeAction(string $action): string + { + // Handle special action save, which can mean either update or create. + if ($action === 'save') { + $action = 'create'; + } + + return $action; + } + /** + * @return UserInterface|null + */ + protected function getActiveUser(): ?UserInterface + { + /** @var UserInterface|null $user */ + $user = Grav::instance()['user'] ?? null; + + return $user; + } + + /** + * @return string + */ + protected function getAuthorizeScope(): string + { + return isset(Grav::instance()['admin']) ? 'admin' : 'site'; + } + + // DEPRECATED METHODS + + /** + * @return string + * @deprecated 1.6 Use ->getFlexType() method instead. + */ + public function getType(): string + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); + + return $this->type; + } + + /** + * @param array $data + * @param string|null $key + * @return FlexObjectInterface + * @deprecated 1.7 Use $object->update()->save() instead. + */ + public function update(array $data, string $key = null): FlexObjectInterface + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() should not be used anymore: use $object->update()->save() instead.', E_USER_DEPRECATED); + + $object = null !== $key ? $this->getIndex()->get($key): null; + + $storage = $this->getStorage(); + + if (null === $object) { + $object = $this->createObject($data, $key ?? '', true); + $key = $object->getStorageKey(); + + if ($key) { + $storage->replaceRows([$key => $object->prepareStorage()]); + } else { + $storage->createRows([$object->prepareStorage()]); + } + } else { + $oldKey = $object->getStorageKey(); + $object->update($data); + $newKey = $object->getStorageKey(); + + if ($oldKey !== $newKey) { + $object->triggerEvent('move'); + $storage->renameRow($oldKey, $newKey); + // TODO: media support. + } + + $object->save(); + } + + try { + $this->clearCache(); + } catch (InvalidArgumentException $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + // Caching failed, but we can ignore that for now. + } + + return $object; + } + + /** + * @param string $key + * @return FlexObjectInterface|null + * @deprecated 1.7 Use $object->delete() instead. + */ + public function remove(string $key): ?FlexObjectInterface + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() should not be used anymore: use $object->delete() instead.', E_USER_DEPRECATED); + + $object = $this->getIndex()->get($key); + if (!$object) { + return null; + } + + $object->delete(); + + return $object; + } } diff --git a/system/src/Grav/Framework/Flex/FlexDirectoryForm.php b/system/src/Grav/Framework/Flex/FlexDirectoryForm.php new file mode 100644 index 0000000..ba26b63 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexDirectoryForm.php @@ -0,0 +1,482 @@ +getDirectoryForm($name, $options); + } + + /** + * FlexForm constructor. + * @param string $name + * @param FlexDirectory $directory + * @param array|null $options + */ + public function __construct(string $name, FlexDirectory $directory, array $options = null) + { + $this->name = $name; + $this->setDirectory($directory); + $this->setName($directory->getFlexType(), $name); + $this->setId($this->getName()); + + $uniqueId = $options['unique_id'] ?? null; + if (!$uniqueId) { + $uniqueId = md5($directory->getFlexType() . '-directory-' . $this->name); + } + $this->setUniqueId($uniqueId); + $this->setFlashLookupFolder($directory->getDirectoryBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]'); + $this->form = $options['form'] ?? null; + + $this->initialize(); + } + + /** + * @return $this + */ + public function initialize() + { + $this->messages = []; + $this->submitted = false; + $this->data = new Data($this->directory->loadDirectoryConfig($this->name), $this->getBlueprint()); + $this->files = []; + $this->unsetFlash(); + + /** @var FlexFormFlash $flash */ + $flash = $this->getFlash(); + if ($flash->exists()) { + $data = $flash->getData(); + $includeOriginal = (bool)($this->getBlueprint()->form()['images']['original'] ?? null); + + $directory = $flash->getDirectory(); + if (null === $directory) { + throw new RuntimeException('Flash has no directory'); + } + $this->directory = $directory; + $this->data = $data ? new Data($data, $this->getBlueprint()) : null; + $this->files = $flash->getFilesByFields($includeOriginal); + } + + return $this; + } + + /** + * @param string $name + * @param mixed $default + * @param string|null $separator + * @return mixed + */ + public function get($name, $default = null, $separator = null) + { + switch (strtolower($name)) { + case 'id': + case 'uniqueid': + case 'name': + case 'noncename': + case 'nonceaction': + case 'action': + case 'data': + case 'files': + case 'errors'; + case 'fields': + case 'blueprint': + case 'page': + $method = 'get' . $name; + return $this->{$method}(); + } + + return $this->traitGet($name, $default, $separator); + } + + /** + * @param string $name + * @param mixed $value + * @param string|null $separator + * @return $this + */ + public function set($name, $value, $separator = null) + { + switch (strtolower($name)) { + case 'id': + case 'uniqueid': + $method = 'set' . $name; + return $this->{$method}(); + } + + return $this->traitSet($name, $value, $separator); + } + + /** + * @return string + */ + public function getName(): string + { + return $this->flexName; + } + + protected function setName(string $type, string $name): void + { + // Make sure that both type and name do not have dash (convert dashes to underscores). + $type = str_replace('-', '_', $type); + $name = str_replace('-', '_', $name); + $this->flexName = $name ? "flex_conf-{$type}-{$name}" : "flex_conf-{$type}"; + } + + /** + * @return Data|object + */ + public function getData() + { + if (null === $this->data) { + $this->data = new Data([], $this->getBlueprint()); + } + + return $this->data; + } + + /** + * Get a value from the form. + * + * Note: Used in form fields. + * + * @param string $name + * @return mixed + */ + public function getValue(string $name) + { + // Attempt to get value from the form data. + $value = $this->data ? $this->data[$name] : null; + + // Return the form data or fall back to the object property. + return $value ?? null; + } + + /** + * @param string $name + * @return array|mixed|null + */ + public function getDefaultValue(string $name) + { + return $this->getBlueprint()->getDefaultValue($name); + } + + /** + * @return array + */ + public function getDefaultValues(): array + { + return $this->getBlueprint()->getDefaults(); + } + /** + * @return string + */ + public function getFlexType(): string + { + return $this->directory->getFlexType(); + } + + /** + * Get form flash object. + * + * @return FormFlashInterface|FlexFormFlash + */ + public function getFlash() + { + if (null === $this->flash) { + $grav = Grav::instance(); + $config = [ + 'session_id' => $this->getSessionId(), + 'unique_id' => $this->getUniqueId(), + 'form_name' => $this->getName(), + 'folder' => $this->getFlashFolder(), + 'directory' => $this->getDirectory() + ]; + + $this->flash = new FlexFormFlash($config); + $this->flash + ->setUrl($grav['uri']->url) + ->setUser($grav['user'] ?? null); + } + + return $this->flash; + } + + /** + * @return FlexDirectory + */ + public function getDirectory(): FlexDirectory + { + return $this->directory; + } + + /** + * @return Blueprint + */ + public function getBlueprint(): Blueprint + { + if (null === $this->blueprint) { + try { + $blueprint = $this->getDirectory()->getDirectoryBlueprint(); + if ($this->form) { + // We have field overrides available. + $blueprint->extend(['form' => $this->form], true); + $blueprint->init(); + } + } catch (RuntimeException $e) { + if (!isset($this->form['fields'])) { + throw $e; + } + + // Blueprint is not defined, but we have custom form fields available. + $blueprint = new Blueprint(null, ['form' => $this->form]); + $blueprint->load(); + $blueprint->setScope('directory'); + $blueprint->init(); + } + + $this->blueprint = $blueprint; + } + + return $this->blueprint; + } + + /** + * @return Route|null + */ + public function getFileUploadAjaxRoute(): ?Route + { + return null; + } + + /** + * @param string $field + * @param string $filename + * @return Route|null + */ + public function getFileDeleteAjaxRoute($field, $filename): ?Route + { + return null; + } + + /** + * @param array $params + * @param string|null $extension + * @return string + */ + public function getMediaTaskRoute(array $params = [], string $extension = null): string + { + return ''; + } + + /** + * @param string $name + * @return mixed|null + */ + public function __get($name) + { + $method = "get{$name}"; + if (method_exists($this, $method)) { + return $this->{$method}(); + } + + $form = $this->getBlueprint()->form(); + + return $form[$name] ?? null; + } + + /** + * @param string $name + * @param mixed $value + * @return void + */ + public function __set($name, $value) + { + $method = "set{$name}"; + if (method_exists($this, $method)) { + $this->{$method}($value); + } + } + + /** + * @param string $name + * @return bool + */ + public function __isset($name) + { + $method = "get{$name}"; + if (method_exists($this, $method)) { + return true; + } + + $form = $this->getBlueprint()->form(); + + return isset($form[$name]); + } + + /** + * @param string $name + * @return void + */ + public function __unset($name) + { + } + + /** + * @return array|bool + */ + protected function getUnserializeAllowedClasses() + { + return [FlexObject::class]; + } + + /** + * Note: this method clones the object. + * + * @param FlexDirectory $directory + * @return $this + */ + protected function setDirectory(FlexDirectory $directory): self + { + $this->directory = $directory; + + return $this; + } + + /** + * @param string $layout + * @return Template|TemplateWrapper + * @throws LoaderError + * @throws SyntaxError + */ + protected function getTemplate($layout) + { + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + return $twig->twig()->resolveTemplate( + [ + "flex-objects/layouts/{$this->getFlexType()}/form/{$layout}.html.twig", + "flex-objects/layouts/_default/form/{$layout}.html.twig", + "forms/{$layout}/form.html.twig", + 'forms/default/form.html.twig' + ] + ); + } + + /** + * @param array $data + * @param array $files + * @return void + * @throws Exception + */ + protected function doSubmit(array $data, array $files) + { + $this->directory->saveDirectoryConfig($this->name, $data); + + $this->reset(); + } + + /** + * @return array + */ + protected function doSerialize(): array + { + return $this->doTraitSerialize() + [ + 'directory' => $this->directory, + ]; + } + + /** + * @param array $data + * @return void + */ + protected function doUnserialize(array $data): void + { + $this->doTraitUnserialize($data); + + $this->directory = $data['directory']; + } + + /** + * Filter validated data. + * + * @param ArrayAccess|Data|null $data + */ + protected function filterData($data = null): void + { + if ($data instanceof Data) { + $data->filter(false, true); + } + } +} diff --git a/system/src/Grav/Framework/Flex/FlexForm.php b/system/src/Grav/Framework/Flex/FlexForm.php index 6ded345..192f38f 100644 --- a/system/src/Grav/Framework/Flex/FlexForm.php +++ b/system/src/Grav/Framework/Flex/FlexForm.php @@ -3,21 +3,27 @@ /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Flex; +use ArrayAccess; +use Exception; use Grav\Common\Data\Blueprint; use Grav\Common\Data\Data; use Grav\Common\Grav; use Grav\Common\Twig\Twig; -use Grav\Common\Utils; use Grav\Framework\Flex\Interfaces\FlexFormInterface; +use Grav\Framework\Flex\Interfaces\FlexObjectFormInterface; use Grav\Framework\Flex\Interfaces\FlexObjectInterface; +use Grav\Framework\Form\Interfaces\FormFlashInterface; use Grav\Framework\Form\Traits\FormTrait; use Grav\Framework\Route\Route; +use JsonSerializable; +use RocketTheme\Toolbox\ArrayTraits\NestedArrayAccessWithGetters; +use RuntimeException; use Twig\Error\LoaderError; use Twig\Error\SyntaxError; use Twig\Template; @@ -27,48 +33,182 @@ use Twig\TemplateWrapper; * Class FlexForm * @package Grav\Framework\Flex */ -class FlexForm implements FlexFormInterface +class FlexForm implements FlexObjectFormInterface, JsonSerializable { + use NestedArrayAccessWithGetters { + NestedArrayAccessWithGetters::get as private traitGet; + NestedArrayAccessWithGetters::set as private traitSet; + } use FormTrait { FormTrait::doSerialize as doTraitSerialize; FormTrait::doUnserialize as doTraitUnserialize; } + /** @var array */ + private $items = []; + /** @var array|null */ private $form; - /** @var FlexObjectInterface */ private $object; + /** @var string */ + private $flexName; + /** @var callable|null */ + private $submitMethod; + + /** + * @param array $options Options to initialize the form instance: + * (string) name: Form name, allows you to use custom form. + * (string) unique_id: Unique id for this form instance. + * (array) form: Custom form fields. + * (FlexObjectInterface) object: Object instance. + * (string) key: Object key, used only if object instance isn't given. + * (FlexDirectory) directory: Flex Directory, mandatory if object isn't given. + * + * @return FlexFormInterface + */ + public static function instance(array $options = []) + { + if (isset($options['object'])) { + $object = $options['object']; + if (!$object instanceof FlexObjectInterface) { + throw new RuntimeException(__METHOD__ . "(): 'object' should be instance of FlexObjectInterface", 400); + } + } elseif (isset($options['directory'])) { + $directory = $options['directory']; + if (!$directory instanceof FlexDirectory) { + throw new RuntimeException(__METHOD__ . "(): 'directory' should be instance of FlexDirectory", 400); + } + $key = $options['key'] ?? ''; + $object = $directory->getObject($key) ?? $directory->createObject([], $key); + } else { + throw new RuntimeException(__METHOD__ . "(): You need to pass option 'directory' or 'object'", 400); + } + + $name = $options['name'] ?? ''; + + // There is no reason to pass object and directory. + unset($options['object'], $options['directory']); + + return $object->getForm($name, $options); + } /** * FlexForm constructor. * @param string $name * @param FlexObjectInterface $object - * @param array|null $form + * @param array|null $options */ - public function __construct(string $name, FlexObjectInterface $object, array $form = null) + public function __construct(string $name, FlexObjectInterface $object, array $options = null) { $this->name = $name; - $this->form = $form; - - $uniqueId = $object->exists() ? $object->getStorageKey() : "{$object->getFlexType()}:new"; $this->setObject($object); + $this->setName($object->getFlexType(), $name); $this->setId($this->getName()); - $this->setUniqueId(md5($uniqueId)); + + $uniqueId = $options['unique_id'] ?? null; + if (!$uniqueId) { + if ($object->exists()) { + $uniqueId = $object->getStorageKey(); + } elseif ($object->hasKey()) { + $uniqueId = "{$object->getKey()}:new"; + } else { + $uniqueId = "{$object->getFlexType()}:new"; + } + $uniqueId = md5($uniqueId); + } + $this->setUniqueId($uniqueId); + $directory = $object->getFlexDirectory(); + $this->setFlashLookupFolder($options['flash_folder'] ?? $directory->getBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]'); + $this->form = $options['form'] ?? null; + + if (!empty($options['reset'])) { + $this->getFlash()->delete(); + } + + $this->initialize(); + } + + /** + * @return $this + */ + public function initialize() + { $this->messages = []; $this->submitted = false; + $this->data = null; + $this->files = []; + $this->unsetFlash(); + /** @var FlexFormFlash $flash */ $flash = $this->getFlash(); if ($flash->exists()) { $data = $flash->getData(); - $includeOriginal = (bool)($this->getBlueprint()->form()['images']['original'] ?? null); + if (null !== $data) { + $data = new Data($data, $this->getBlueprint()); + $data->setKeepEmptyValues(true); + $data->setMissingValuesAsNull(true); + } - $this->data = $data ? new Data($data, $this->getBlueprint()) : null; + $object = $flash->getObject(); + if (null === $object) { + throw new RuntimeException('Flash has no object'); + } + + $this->object = $object; + $this->data = $data; + + $includeOriginal = (bool)($this->getBlueprint()->form()['images']['original'] ?? null); $this->files = $flash->getFilesByFields($includeOriginal); - } else { - $this->data = null; - $this->files = []; } + + return $this; + } + + /** + * @param string $name + * @param mixed $default + * @param string|null $separator + * @return mixed + */ + public function get($name, $default = null, $separator = null) + { + switch (strtolower($name)) { + case 'id': + case 'uniqueid': + case 'name': + case 'noncename': + case 'nonceaction': + case 'action': + case 'data': + case 'files': + case 'errors'; + case 'fields': + case 'blueprint': + case 'page': + $method = 'get' . $name; + return $this->{$method}(); + } + + return $this->traitGet($name, $default, $separator); + } + + /** + * @param string $name + * @param mixed $value + * @param string|null $separator + * @return FlexForm + */ + public function set($name, $value, $separator = null) + { + switch (strtolower($name)) { + case 'id': + case 'uniqueid': + $method = 'set' . $name; + return $this->{$method}(); + } + + return $this->traitSet($name, $value, $separator); } /** @@ -76,10 +216,27 @@ class FlexForm implements FlexFormInterface */ public function getName(): string { - $object = $this->getObject(); - $name = $this->name ?: 'object'; + return $this->flexName; + } - return "flex-{$object->getFlexType()}-{$name}"; + /** + * @param callable|null $submitMethod + */ + public function setSubmitMethod(?callable $submitMethod): void + { + $this->submitMethod = $submitMethod; + } + + /** + * @param string $type + * @param string $name + */ + protected function setName(string $type, string $name): void + { + // Make sure that both type and name do not have dash (convert dashes to underscores). + $type = str_replace('-', '_', $type); + $name = str_replace('-', '_', $name); + $this->flexName = $name ? "flex-{$type}-{$name}" : "flex-{$type}"; } /** @@ -107,6 +264,10 @@ class FlexForm implements FlexFormInterface return $value ?? $this->getObject()->getFormValue($name); } + /** + * @param string $name + * @return array|mixed|null + */ public function getDefaultValue(string $name) { return $this->object->getDefaultValue($name); @@ -127,6 +288,32 @@ class FlexForm implements FlexFormInterface return $this->object->getFlexType(); } + /** + * Get form flash object. + * + * @return FormFlashInterface|FlexFormFlash + */ + public function getFlash() + { + if (null === $this->flash) { + $grav = Grav::instance(); + $config = [ + 'session_id' => $this->getSessionId(), + 'unique_id' => $this->getUniqueId(), + 'form_name' => $this->getName(), + 'folder' => $this->getFlashFolder(), + 'object' => $this->getObject() + ]; + + $this->flash = new FlexFormFlash($config); + $this->flash + ->setUrl($grav['uri']->url) + ->setUser($grav['user'] ?? null); + } + + return $this->flash; + } + /** * @return FlexObjectInterface */ @@ -135,6 +322,9 @@ class FlexForm implements FlexFormInterface return $this->object; } + /** + * @return FlexObjectInterface + */ public function updateObject(): FlexObjectInterface { $data = $this->data instanceof Data ? $this->data->toArray() : []; @@ -150,13 +340,13 @@ class FlexForm implements FlexFormInterface { if (null === $this->blueprint) { try { - $blueprint = $this->getObject()->getBlueprint(Utils::isAdminPlugin() ? '' : $this->name); + $blueprint = $this->getObject()->getBlueprint($this->name); if ($this->form) { // We have field overrides available. $blueprint->extend(['form' => $this->form], true); $blueprint->init(); } - } catch (\RuntimeException $e) { + } catch (RuntimeException $e) { if (!isset($this->form['fields'])) { throw $e; } @@ -202,7 +392,12 @@ class FlexForm implements FlexFormInterface return $object->route('/edit.json/task:media.delete'); } - public function getMediaTaskRoute(array $params = [], $extension = null): string + /** + * @param array $params + * @param string|null $extension + * @return string + */ + public function getMediaTaskRoute(array $params = [], string $extension = null): string { $grav = Grav::instance(); /** @var Flex $flex */ @@ -216,17 +411,9 @@ class FlexForm implements FlexFormInterface } /** - * Implements \Serializable::unserialize(). - * - * @param string $data + * @param string $name + * @return mixed|null */ - public function unserialize($data): void - { - $data = unserialize($data, ['allowed_classes' => [FlexObject::class]]); - - $this->doUnserialize($data); - } - public function __get($name) { $method = "get{$name}"; @@ -239,6 +426,11 @@ class FlexForm implements FlexFormInterface return $form[$name] ?? null; } + /** + * @param string $name + * @param mixed $value + * @return void + */ public function __set($name, $value) { $method = "set{$name}"; @@ -247,6 +439,10 @@ class FlexForm implements FlexFormInterface } } + /** + * @param string $name + * @return bool + */ public function __isset($name) { $method = "get{$name}"; @@ -259,10 +455,22 @@ class FlexForm implements FlexFormInterface return isset($form[$name]); } + /** + * @param string $name + * @return void + */ public function __unset($name) { } + /** + * @return array|bool + */ + protected function getUnserializeAllowedClasses() + { + return [FlexObject::class]; + } + /** * Note: this method clones the object. * @@ -302,20 +510,29 @@ class FlexForm implements FlexFormInterface /** * @param array $data * @param array $files - * @throws \Exception + * @return void + * @throws Exception */ protected function doSubmit(array $data, array $files) { /** @var FlexObject $object */ $object = clone $this->getObject(); - $object->update($data, $files); - $object->save(); + $method = $this->submitMethod; + if ($method) { + $method($data, $files, $object); + } else { + $object->update($data, $files); + $object->save(); + } $this->setObject($object); $this->reset(); } + /** + * @return array + */ protected function doSerialize(): array { return $this->doTraitSerialize() + [ @@ -323,6 +540,10 @@ class FlexForm implements FlexFormInterface ]; } + /** + * @param array $data + * @return void + */ protected function doUnserialize(array $data): void { $this->doTraitUnserialize($data); @@ -330,12 +551,13 @@ class FlexForm implements FlexFormInterface $this->object = $data['object']; } - /** + /** * Filter validated data. * - * @param \ArrayAccess $data + * @param ArrayAccess|Data|null $data + * @return void */ - protected function filterData(\ArrayAccess $data): void + protected function filterData($data = null): void { if ($data instanceof Data) { $data->filter(true, true); diff --git a/system/src/Grav/Framework/Flex/FlexFormFlash.php b/system/src/Grav/Framework/Flex/FlexFormFlash.php new file mode 100644 index 0000000..feb7a9e --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexFormFlash.php @@ -0,0 +1,130 @@ +object = $object; + $this->directory = $object->getFlexDirectory(); + } + + /** + * @return FlexObjectInterface|null + */ + public function getObject(): ?FlexObjectInterface + { + return $this->object; + } + + /** + * @param FlexDirectory $directory + */ + public function setDirectory(FlexDirectory $directory): void + { + $this->directory = $directory; + } + + /** + * @return FlexDirectory|null + */ + public function getDirectory(): ?FlexDirectory + { + return $this->directory; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $serialized = parent::jsonSerialize(); + + $object = $this->getObject(); + if ($object instanceof FlexObjectInterface) { + $serialized['object'] = [ + 'type' => $object->getFlexType(), + 'key' => $object->getKey() ?: null, + 'storage_key' => $object->getStorageKey(), + 'timestamp' => $object->getTimestamp(), + 'serialized' => $object->prepareStorage() + ]; + } else { + $directory = $this->getDirectory(); + if ($directory instanceof FlexDirectory) { + $serialized['directory'] = [ + 'type' => $directory->getFlexType() + ]; + } + } + + return $serialized; + } + + /** + * @param array|null $data + * @param array $config + * @return void + */ + protected function init(?array $data, array $config): void + { + parent::init($data, $config); + + $data = $data ?? []; + /** @var FlexObjectInterface|null $object */ + $object = $config['object'] ?? null; + $create = true; + if ($object) { + $directory = $object->getFlexDirectory(); + $create = !$object->exists(); + } elseif (null === ($directory = $config['directory'] ?? null)) { + $flex = $config['flex'] ?? static::$flex; + $type = $data['object']['type'] ?? $data['directory']['type'] ?? null; + $directory = $flex && $type ? $flex->getDirectory($type) : null; + } + + if ($directory && $create && isset($data['object']['serialized'])) { + // TODO: update instead of create new. + $object = $directory->createObject($data['object']['serialized'], $data['object']['key'] ?? ''); + } + + if ($object) { + $this->setObject($object); + } elseif ($directory) { + $this->setDirectory($directory); + } + } +} diff --git a/system/src/Grav/Framework/Flex/FlexIndex.php b/system/src/Grav/Framework/Flex/FlexIndex.php index 8b96b4a..08785c2 100644 --- a/system/src/Grav/Framework/Flex/FlexIndex.php +++ b/system/src/Grav/Framework/Flex/FlexIndex.php @@ -3,15 +3,17 @@ /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Flex; +use Exception; use Grav\Common\Debugger; use Grav\Common\File\CompiledYamlFile; use Grav\Common\Grav; +use Grav\Common\Inflector; use Grav\Common\Session; use Grav\Framework\Cache\CacheInterface; use Grav\Framework\Collection\CollectionInterface; @@ -19,26 +21,39 @@ use Grav\Framework\Flex\Interfaces\FlexCollectionInterface; use Grav\Framework\Flex\Interfaces\FlexIndexInterface; use Grav\Framework\Flex\Interfaces\FlexObjectInterface; use Grav\Framework\Flex\Interfaces\FlexStorageInterface; -use Grav\Framework\Object\Interfaces\ObjectCollectionInterface; use Grav\Framework\Object\Interfaces\ObjectInterface; use Grav\Framework\Object\ObjectIndex; use Monolog\Logger; use Psr\SimpleCache\InvalidArgumentException; +use RuntimeException; +use function count; +use function get_class; +use function in_array; +/** + * Class FlexIndex + * @package Grav\Framework\Flex + * @template T of FlexObjectInterface + * @template C of FlexCollectionInterface + * @extends ObjectIndex + * @implements FlexIndexInterface + * @mixin C + */ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexIndexInterface { - /** @var FlexDirectory */ - private $_flexDirectory; + const VERSION = 1; + /** @var FlexDirectory|null */ + private $_flexDirectory; /** @var string */ private $_keyField; - /** @var array */ private $_indexKeys; /** * @param FlexDirectory $directory * @return static + * @phpstan-return static */ public static function createFromStorage(FlexDirectory $directory) { @@ -66,6 +81,23 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde return $storage->getExistingKeys(); } + /** + * You can define indexes for fast lookup. + * + * Primary key: $meta['key'] + * Secondary keys: $meta['my_field'] + * + * @param array $meta + * @param array $data + * @param FlexStorageInterface $storage + * @return void + */ + public static function updateObjectMeta(array &$meta, array $data, FlexStorageInterface $storage) + { + // For backwards compatibility, no need to call this method when you override this method. + static::updateIndexData($meta, $data); + } + /** * Initializes a new FlexIndex. * @@ -74,12 +106,46 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde */ public function __construct(array $entries = [], FlexDirectory $directory = null) { + // @phpstan-ignore-next-line + if (get_class($this) === __CLASS__) { + user_error('Using ' . __CLASS__ . ' directly is deprecated since Grav 1.7, use \Grav\Common\Flex\Types\Generic\GenericIndex or your own class instead', E_USER_DEPRECATED); + } + parent::__construct($entries); $this->_flexDirectory = $directory; $this->setKeyField(null); } + /** + * {@inheritdoc} + * @see FlexCommonInterface::hasFlexFeature() + */ + public function hasFlexFeature(string $name): bool + { + return in_array($name, $this->getFlexFeatures(), true); + } + + /** + * {@inheritdoc} + * @see FlexCommonInterface::hasFlexFeature() + */ + public function getFlexFeatures(): array + { + $implements = class_implements($this->getFlexDirectory()->getCollectionClass()); + + $list = []; + foreach ($implements as $interface) { + if ($pos = strrpos($interface, '\\')) { + $interface = substr($interface, $pos+1); + } + + $list[] = Inflector::hyphenize(str_replace('Interface', '', $interface)); + } + + return $list; + } + /** * {@inheritdoc} * @see FlexCollectionInterface::search() @@ -114,7 +180,7 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde */ public function getFlexType(): string { - return $this->_flexDirectory->getFlexType(); + return $this->getFlexDirectory()->getFlexType(); } /** @@ -123,6 +189,10 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde */ public function getFlexDirectory(): FlexDirectory { + if (null === $this->_flexDirectory) { + throw new RuntimeException('Flex Directory not defined, object is not fully defined'); + } + return $this->_flexDirectory; } @@ -152,7 +222,12 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde */ public function getCacheChecksum(): string { - return sha1($this->getCacheKey() . json_encode($this->getTimestamps())); + $list = []; + foreach ($this->getEntries() as $key => $value) { + $list[$key] = $value['checksum'] ?? $value['storage_timestamp']; + } + + return sha1((string)json_encode($list)); } /** @@ -181,7 +256,7 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde { // Get storage keys for the objects. $keys = []; - $type = $this->_flexDirectory->getFlexType() . '.obj:'; + $type = $this->getFlexDirectory()->getFlexType() . '.obj:'; foreach ($this->getEntries() as $key => $value) { $keys[$key] = $value['flex_key'] ?? $type . $value['storage_key']; @@ -201,7 +276,7 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde return $this; } - $type = $keyField === 'flex_key' ? $this->_flexDirectory->getFlexType() . '.obj:' : ''; + $type = $keyField === 'flex_key' ? $this->getFlexDirectory()->getFlexType() . '.obj:' : ''; $entries = []; foreach ($this->getEntries() as $key => $value) { if (!isset($value['key'])) { @@ -227,6 +302,15 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde return $this; } + /** + * @return FlexCollectionInterface + * @phpstan-return C + */ + public function getCollection() + { + return $this->loadCollection(); + } + /** * {@inheritdoc} * @see FlexCollectionInterface::render() @@ -236,20 +320,6 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde return $this->__call('render', [$layout, $context]); } - /** - * @param bool $prefix - * @return string - * @deprecated 1.6 Use `->getFlexType()` instead. - */ - public function getType($prefix = false) - { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); - - $type = $prefix ? $this->getTypePrefix() : ''; - - return $type . $this->getFlexType(); - } - /** * {@inheritdoc} * @see FlexIndexInterface::getFlexKeys() @@ -270,9 +340,10 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde } /** + * @param string $key * @return array */ - public function getMetaData(string $key): array + public function getMetaData($key): array { return $this->getEntries()[$key] ?? []; } @@ -280,7 +351,7 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde /** * @return string */ - public function getKeyField() : string + public function getKeyField(): string { return $this->_keyField ?? 'storage_key'; } @@ -291,12 +362,13 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde */ public function getCache(string $namespace = null) { - return $this->_flexDirectory->getCache($namespace); + return $this->getFlexDirectory()->getCache($namespace); } /** * @param array $orderings - * @return FlexIndex|FlexCollection + * @return static + * @phpstan-return static */ public function orderBy(array $orderings) { @@ -304,6 +376,13 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde return $this; } + // Handle primary key alias. + $keyField = $this->getFlexDirectory()->getStorage()->getKeyField(); + if ($keyField !== 'key' && $keyField !== 'storage_key' && isset($orderings[$keyField])) { + $orderings['key'] = $orderings[$keyField]; + unset($orderings[$keyField]); + } + // Check if ordering needs to load the objects. if (array_diff_key($orderings, $this->getIndexKeys())) { return $this->__call('orderBy', [$orderings]); @@ -328,16 +407,16 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde } // Order by current field. - if ($ordering === 'DESC') { - arsort($search, SORT_NATURAL); + if (strtoupper($ordering) === 'DESC') { + arsort($search, SORT_NATURAL | SORT_FLAG_CASE); } else { - asort($search, SORT_NATURAL); + asort($search, SORT_NATURAL | SORT_FLAG_CASE); } $previous = $search; } - return $this->createFrom(array_replace($previous, $this->getEntries())); + return $this->createFrom(array_replace($previous ?? [], $this->getEntries()) ?? []); } /** @@ -348,13 +427,18 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde return $this->__call('call', [$method, $arguments]); } + /** + * @param string $name + * @param array $arguments + * @return mixed + */ public function __call($name, $arguments) { /** @var Debugger $debugger */ $debugger = Grav::instance()['debugger']; /** @var FlexCollection $className */ - $className = $this->_flexDirectory->getCollectionClass(); + $className = $this->getFlexDirectory()->getCollectionClass(); $cachedMethods = $className::getCachedMethods(); $flexType = $this->getFlexType(); @@ -364,30 +448,32 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde if ($type === 'session') { /** @var Session $session */ $session = Grav::instance()['session']; - $cacheKey = $session->getId() . $session->user->username; + $cacheKey = $session->getId() . ($session->user->username ?? ''); } else { $cacheKey = ''; } $key = "{$flexType}.idx." . sha1($name . '.' . $cacheKey . json_encode($arguments) . $this->getCacheKey()); + $checksum = $this->getCacheChecksum(); $cache = $this->getCache('object'); try { - $result = $cache->get($key); + $cached = $cache->get($key); + $test = $cached[0] ?? null; + $result = $test === $checksum ? ($cached[1] ?? null) : null; // Make sure the keys aren't changed if the returned type is the same index type. if ($result instanceof self && $flexType === $result->getFlexType()) { $result = $result->withKeyField($this->getKeyField()); } } catch (InvalidArgumentException $e) { - /** @var Debugger $debugger */ - $debugger = Grav::instance()['debugger']; $debugger->addException($e); } if (!isset($result)) { $collection = $this->loadCollection(); $result = $collection->{$name}(...$arguments); + $debugger->addMessage("Cache miss: '{$flexType}::{$name}()'", 'debug'); try { // If flex collection is returned, convert it back to flex index. @@ -397,7 +483,7 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde $cached = $result; } - $cache->set($key, $cached); + $cache->set($key, [$checksum, $cached]); } catch (InvalidArgumentException $e) { $debugger->addException($e); @@ -408,8 +494,7 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde $collection = $this->loadCollection(); $result = $collection->{$name}(...$arguments); if (!isset($cachedMethods[$name])) { - $class = \get_class($collection); - $debugger->addMessage("Call '{$class}:{$name}()' isn't cached", 'debug'); + $debugger->addMessage("Call '{$flexType}:{$name}()' isn't cached", 'debug'); } } @@ -417,32 +502,45 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde } /** - * @return string + * @return array */ - public function serialize() + public function __serialize(): array { - return serialize(['type' => $this->getFlexType(), 'entries' => $this->getEntries()]); + return ['type' => $this->getFlexType(), 'entries' => $this->getEntries()]; } /** - * @param string $serialized + * @param array $data + * @return void */ - public function unserialize($serialized) + public function __unserialize(array $data): void { - $data = unserialize($serialized, ['allowed_classes' => false]); - - $this->_flexDirectory = Grav::instance()['flex_objects']->getDirectory($data['type']); + $this->_flexDirectory = Grav::instance()['flex']->getDirectory($data['type']); $this->setEntries($data['entries']); } + /** + * @return array + */ + public function __debugInfo() + { + return [ + 'type:private' => $this->getFlexType(), + 'key:private' => $this->getKey(), + 'entries_key:private' => $this->getKeyField(), + 'entries:private' => $this->getEntries() + ]; + } + /** * @param array $entries - * @param string $keyField + * @param string|null $keyField * @return static + * @phpstan-return static */ protected function createFrom(array $entries, string $keyField = null) { - $index = new static($entries, $this->_flexDirectory); + $index = new static($entries, $this->getFlexDirectory()); $index->setKeyField($keyField ?? $this->_keyField); return $index; @@ -450,12 +548,16 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde /** * @param string|null $keyField + * @return void */ protected function setKeyField(string $keyField = null) { $this->_keyField = $keyField ?? 'storage_key'; } + /** + * @return array + */ protected function getIndexKeys() { if (null === $this->_indexKeys) { @@ -476,6 +578,7 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde /** * @param array $indexKeys + * @return void */ protected function setIndexKeys(array $indexKeys) { @@ -506,7 +609,7 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde */ protected function loadElement($key, $value): ?ObjectInterface { - $objects = $this->_flexDirectory->loadObjects([$key => $value]); + $objects = $this->getFlexDirectory()->loadObjects([$key => $value]); return $objects ? reset($objects): null; } @@ -517,16 +620,17 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde */ protected function loadElements(array $entries = null): array { - return $this->_flexDirectory->loadObjects($entries ?? $this->getEntries()); + return $this->getFlexDirectory()->loadObjects($entries ?? $this->getEntries()); } /** * @param array|null $entries - * @return ObjectCollectionInterface + * @return CollectionInterface + * @phpstan-return C */ protected function loadCollection(array $entries = null): CollectionInterface { - return $this->_flexDirectory->loadCollection($entries ?? $this->getEntries(), $this->_keyField); + return $this->getFlexDirectory()->loadCollection($entries ?? $this->getEntries(), $this->_keyField); } /** @@ -544,17 +648,43 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde */ protected function getElementMeta($object) { - return $object->getTimestamp(); + return $object->getMetaData(); + } + + /** + * @param FlexObjectInterface $element + * @return string + */ + protected function getCurrentKey($element) + { + $keyField = $this->getKeyField(); + if ($keyField === 'storage_key') { + return $element->getStorageKey(); + } + if ($keyField === 'flex_key') { + return $element->getFlexKey(); + } + if ($keyField === 'key') { + return $element->getKey(); + } + + return $element->getKey(); } /** * @param FlexStorageInterface $storage * @param array $index Saved index * @param array $entries Updated index + * @param array $options * @return array Compiled list of entries */ - protected static function updateIndexFile(FlexStorageInterface $storage, array $index, array $entries): array + protected static function updateIndexFile(FlexStorageInterface $storage, array $index, array $entries, array $options = []): array { + $indexFile = static::getIndexFile($storage); + if (null === $indexFile) { + return $entries; + } + // Calculate removed objects. $removed = array_diff_key($index, $entries); @@ -563,7 +693,7 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde $index = array_diff_key($index, $removed); } - if ($entries) { + if ($entries && empty($options['force_update'])) { // Calculate difference between saved index and current data. foreach ($index as $key => $entry) { $storage_key = $entry['storage_key'] ?? null; @@ -583,87 +713,139 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde } // Index should be updated, lock the index file for saving. - $indexFile = static::getIndexFile($storage); $indexFile->lock(); - // Read all the data rows into an array. + // Read all the data rows into an array using chunks of 100. $keys = array_fill_keys(array_keys($entries), null); - $rows = $storage->readRows($keys); - - $keyField = $storage->getKeyField(); - - // Go through all the updated objects and refresh their index data. + $chunks = array_chunk($keys, 100, true); $updated = $added = []; - foreach ($rows as $key => $row) { - if (null !== $row) { - $entry = ['key' => $key] + $entries[$key]; - if ($keyField !== 'storage_key' && isset($row[$keyField])) { - $entry['key'] = $row[$keyField]; - } - static::updateIndexData($entry, $row); - if (isset($row['__error'])) { - $entry['__error'] = true; - static::onException(new \RuntimeException(sprintf('Object failed to load: %s (%s)', $key, $row['__error']))); - } - if (isset($index[$key])) { - // Update object in the index. - $updated[$key] = $entry; - } else { - // Add object into the index. - $added[$key] = $entry; - } + foreach ($chunks as $keys) { + $rows = $storage->readRows($keys); - // Either way, update the entry. - $index[$key] = $entry; - } elseif (isset($index[$key])) { - // Remove object from the index. - $removed[$key] = $index[$key]; - unset($index[$key]); + $keyField = $storage->getKeyField(); + + // Go through all the updated objects and refresh their index data. + foreach ($rows as $key => $row) { + if (null !== $row || !empty($options['include_missing'])) { + $entry = $entries[$key] + ['key' => $key]; + if ($keyField !== 'storage_key' && isset($row[$keyField])) { + $entry['key'] = $row[$keyField]; + } + static::updateObjectMeta($entry, $row ?? [], $storage); + if (isset($row['__ERROR'])) { + $entry['__ERROR'] = true; + static::onException(new RuntimeException(sprintf('Object failed to load: %s (%s)', $key, + $row['__ERROR']))); + } + if (isset($index[$key])) { + // Update object in the index. + $updated[$key] = $entry; + } else { + // Add object into the index. + $added[$key] = $entry; + } + + // Either way, update the entry. + $index[$key] = $entry; + } elseif (isset($index[$key])) { + // Remove object from the index. + $removed[$key] = $index[$key]; + unset($index[$key]); + } } + unset($rows); } // Sort the index before saving it. - ksort($index, SORT_NATURAL); + ksort($index, SORT_NATURAL | SORT_FLAG_CASE); static::onChanges($index, $added, $updated, $removed); - $indexFile->save(['count' => \count($index), 'index' => $index]); + $indexFile->save(['version' => static::VERSION, 'timestamp' => time(), 'count' => count($index), 'index' => $index]); $indexFile->unlock(); return $index; } + /** + * @param array $entry + * @param array $data + * @return void + * @deprecated 1.7 Use static ::updateObjectMeta() method instead. + */ protected static function updateIndexData(array &$entry, array $data) { } - protected static function loadEntriesFromIndex(FlexStorageInterface $storage) + /** + * @param FlexStorageInterface $storage + * @return array + */ + protected static function loadIndex(FlexStorageInterface $storage) { $indexFile = static::getIndexFile($storage); - $data = []; - try { - $data = (array)$indexFile->content(); - } catch (\Exception $e) { - $e = new \RuntimeException(sprintf('Index failed to load: %s', $e->getMessage()), $e->getCode(), $e); + if ($indexFile) { + $data = []; + try { + $data = (array)$indexFile->content(); + $version = $data['version'] ?? null; + if ($version !== static::VERSION) { + $data = []; + } + } catch (Exception $e) { + $e = new RuntimeException(sprintf('Index failed to load: %s', $e->getMessage()), $e->getCode(), $e); - static::onException($e); + static::onException($e); + } + + if ($data) { + return $data; + } } + return ['version' => static::VERSION, 'timestamp' => 0, 'count' => 0, 'index' => []]; + } + + /** + * @param FlexStorageInterface $storage + * @return array + */ + protected static function loadEntriesFromIndex(FlexStorageInterface $storage) + { + $data = static::loadIndex($storage); + return $data['index'] ?? []; } + /** + * @param FlexStorageInterface $storage + * @return CompiledYamlFile|null + */ protected static function getIndexFile(FlexStorageInterface $storage) { + if (!method_exists($storage, 'isIndexed') || !$storage->isIndexed()) { + return null; + } + + $path = $storage->getStoragePath(); + if (!$path) { + return null; + } + // Load saved index file. $grav = Grav::instance(); $locator = $grav['locator']; - $filename = $locator->findResource($storage->getStoragePath() . '/index.yaml', true, true); + $filename = $locator->findResource("{$path}/index.yaml", true, true); return CompiledYamlFile::instance($filename); } - protected static function onException(\Exception $e) + /** + * @param Exception $e + * @return void + */ + protected static function onException(Exception $e) { $grav = Grav::instance(); @@ -677,28 +859,43 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde $debugger->addMessage($e, 'error'); } + /** + * @param array $entries + * @param array $added + * @param array $updated + * @param array $removed + * @return void + */ protected static function onChanges(array $entries, array $added, array $updated, array $removed) { - $message = sprintf('Index updated, %d objects (%d added, %d updated, %d removed).', \count($entries), \count($added), \count($updated), \count($removed)); + $addedCount = count($added); + $updatedCount = count($updated); + $removedCount = count($removed); - $grav = Grav::instance(); + if ($addedCount + $updatedCount + $removedCount) { + $message = sprintf('Index updated, %d objects (%d added, %d updated, %d removed).', count($entries), $addedCount, $updatedCount, $removedCount); - /** @var Logger $logger */ - $logger = $grav['log']; - $logger->addDebug($message); + $grav = Grav::instance(); - /** @var Debugger $debugger */ - $debugger = $grav['debugger']; - $debugger->addMessage($message, 'debug'); + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage($message, 'debug'); + } } - public function __debugInfo() + // DEPRECATED METHODS + + /** + * @param bool $prefix + * @return string + * @deprecated 1.6 Use `->getFlexType()` instead. + */ + public function getType($prefix = false) { - return [ - 'type:private' => $this->getFlexType(), - 'key:private' => $this->getKey(), - 'entries_key:private' => $this->getKeyField(), - 'entries:private' => $this->getEntries() - ]; + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); + + $type = $prefix ? $this->getTypePrefix() : ''; + + return $type . $this->getFlexType(); } } diff --git a/system/src/Grav/Framework/Flex/FlexObject.php b/system/src/Grav/Framework/Flex/FlexObject.php index 7fbdbc0..b65e5ae 100644 --- a/system/src/Grav/Framework/Flex/FlexObject.php +++ b/system/src/Grav/Framework/Flex/FlexObject.php @@ -3,23 +3,27 @@ /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Flex; +use ArrayAccess; +use Exception; use Grav\Common\Data\Blueprint; use Grav\Common\Debugger; use Grav\Common\Grav; +use Grav\Common\Inflector; use Grav\Common\Twig\Twig; +use Grav\Common\User\Interfaces\UserInterface; use Grav\Common\Utils; use Grav\Framework\Cache\CacheInterface; use Grav\Framework\ContentBlock\HtmlBlock; use Grav\Framework\Flex\Interfaces\FlexAuthorizeInterface; -use Grav\Framework\Flex\Interfaces\FlexCollectionInterface; use Grav\Framework\Flex\Interfaces\FlexFormInterface; use Grav\Framework\Flex\Traits\FlexAuthorizeTrait; +use Grav\Framework\Flex\Traits\FlexRelatedDirectoryTrait; use Grav\Framework\Object\Access\NestedArrayAccessTrait; use Grav\Framework\Object\Access\NestedPropertyTrait; use Grav\Framework\Object\Access\OverloadedPropertyTrait; @@ -29,10 +33,17 @@ use Grav\Framework\Object\Interfaces\ObjectInterface; use Grav\Framework\Object\Property\LazyPropertyTrait; use Psr\SimpleCache\InvalidArgumentException; use RocketTheme\Toolbox\Event\Event; +use RuntimeException; use Twig\Error\LoaderError; use Twig\Error\SyntaxError; use Twig\Template; use Twig\TemplateWrapper; +use function get_class; +use function in_array; +use function is_array; +use function is_object; +use function is_scalar; +use function is_string; /** * Class FlexObject @@ -48,15 +59,22 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface use OverloadedPropertyTrait; use NestedArrayAccessTrait; use FlexAuthorizeTrait; + use FlexRelatedDirectoryTrait; /** @var FlexDirectory */ private $_flexDirectory; /** @var FlexFormInterface[] */ private $_forms = []; + /** @var Blueprint[] */ + private $_blueprint = []; /** @var array */ - private $_storage; + private $_meta; /** @var array */ protected $_changes; + /** @var string */ + protected $storage_key; + /** @var int */ + protected $storage_timestamp; /** * @return array @@ -68,8 +86,10 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface 'getType' => true, 'getFlexType' => true, 'getFlexDirectory' => true, + 'hasFlexFeature' => true, + 'getFlexFeatures' => true, 'getCacheKey' => true, - 'getCacheChecksum' => true, + 'getCacheChecksum' => false, 'getTimestamp' => true, 'value' => true, 'exists' => true, @@ -81,10 +101,17 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface ]; } + /** + * @param array $elements + * @param array $storage + * @param FlexDirectory $directory + * @param bool $validate + * @return static + */ public static function createFromStorage(array $elements, array $storage, FlexDirectory $directory, bool $validate = false) { $instance = new static($elements, $storage['key'], $directory, $validate); - $instance->setStorage($storage); + $instance->setMetaData($storage); return $instance; } @@ -95,14 +122,23 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function __construct(array $elements, $key, FlexDirectory $directory, bool $validate = false) { + if (get_class($this) === __CLASS__) { + user_error('Using ' . __CLASS__ . ' directly is deprecated since Grav 1.7, use \Grav\Common\Flex\Types\Generic\GenericObject or your own class instead', E_USER_DEPRECATED); + } + $this->_flexDirectory = $directory; + if (isset($elements['__META'])) { + $this->setMetaData($elements['__META']); + unset($elements['__META']); + } + if ($validate) { $blueprint = $this->getFlexDirectory()->getBlueprint(); - $blueprint->validate($elements); + $blueprint->validate($elements, ['xss_check' => false]); - $elements = $blueprint->filter($elements); + $elements = $blueprint->filter($elements, true, true); } $this->filterElements($elements); @@ -110,6 +146,35 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface $this->objectConstruct($elements, $key); } + /** + * {@inheritdoc} + * @see FlexCommonInterface::hasFlexFeature() + */ + public function hasFlexFeature(string $name): bool + { + return in_array($name, $this->getFlexFeatures(), true); + } + + /** + * {@inheritdoc} + * @see FlexCommonInterface::hasFlexFeature() + */ + public function getFlexFeatures(): array + { + $implements = class_implements($this); + + $list = []; + foreach ($implements as $interface) { + if ($pos = strrpos($interface, '\\')) { + $interface = substr($interface, $pos+1); + } + + $list[] = Inflector::hyphenize(str_replace('Interface', '', $interface)); + } + + return $list; + } + /** * {@inheritdoc} * @see FlexObjectInterface::getFlexType() @@ -128,13 +193,71 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface return $this->_flexDirectory; } + /** + * Refresh object from the storage. + * + * @param bool $keepMissing + * @return bool True if the object was refreshed + */ + public function refresh(bool $keepMissing = false): bool + { + $key = $this->getStorageKey(); + if ('' === $key) { + return false; + } + + $storage = $this->getFlexDirectory()->getStorage(); + $meta = $storage->getMetaData([$key])[$key] ?? null; + + $newChecksum = $meta['checksum'] ?? $meta['storage_timestamp'] ?? null; + $curChecksum = $this->_meta['checksum'] ?? $this->_meta['storage_timestamp'] ?? null; + + // Check if object is up to date with the storage. + if (null === $newChecksum || $newChecksum === $curChecksum) { + return false; + } + + // Get current elements (if requested). + $current = $keepMissing ? $this->getElements() : []; + // Get elements from the filesystem. + $elements = $storage->readRows([$key => null])[$key] ?? null; + if (null !== $elements) { + $meta = $elements['__META'] ?? $meta; + unset($elements['__META']); + $this->filterElements($elements); + $newKey = $meta['key'] ?? $this->getKey(); + if ($meta) { + $this->setMetaData($meta); + } + $this->objectConstruct($elements, $newKey); + + if ($current) { + // Inject back elements which are missing in the filesystem. + $data = $this->getBlueprint()->flattenData($current); + foreach ($data as $property => $value) { + if (strpos($property, '.') === false) { + $this->defProperty($property, $value); + } else { + $this->defNestedProperty($property, $value); + } + } + } + + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addMessage("Refreshed {$this->getFlexType()} object {$this->getKey()}", 'debug'); + } + + return true; + } + /** * {@inheritdoc} * @see FlexObjectInterface::getTimestamp() */ public function getTimestamp(): int { - return $this->_storage['storage_timestamp'] ?? 0; + return $this->_meta['storage_timestamp'] ?? 0; } /** @@ -143,7 +266,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function getCacheKey(): string { - return $this->getTypePrefix() . $this->getFlexType() . '.' . $this->getStorageKey(); + return $this->hasKey() ? $this->getTypePrefix() . $this->getFlexType() . '.' . $this->getKey() : ''; } /** @@ -152,7 +275,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function getCacheChecksum(): string { - return (string)$this->getTimestamp(); + return (string)($this->_meta['checksum'] ?? $this->getTimestamp()); } /** @@ -161,18 +284,20 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function search(string $search, $properties = null, array $options = null): float { - $options = $options ?? $this->getFlexDirectory()->getConfig('data.search.options', []); - $properties = $properties ?? $this->getFlexDirectory()->getConfig('data.search.fields', []); + $properties = (array)($properties ?? $this->getFlexDirectory()->getConfig('data.search.fields')); if (!$properties) { - foreach ($this->getFlexDirectory()->getConfig('admin.list.fields', []) as $property => $value) { + $fields = $this->getFlexDirectory()->getConfig('admin.views.list.fields') ?? $this->getFlexDirectory()->getConfig('admin.list.fields', []); + foreach ($fields as $property => $value) { if (!empty($value['link'])) { $properties[] = $property; } } } + $options = $options ?? (array)$this->getFlexDirectory()->getConfig('data.search.options'); + $weight = 0; - foreach ((array)$properties as $property) { + foreach ($properties as $property) { $weight += $this->searchNestedProperty($property, $search, $options); } @@ -185,7 +310,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function getKey() { - return $this->_key ?: $this->getFlexType() . '@@' . spl_object_hash($this); + return (string)$this->_key; } /** @@ -194,7 +319,13 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function getFlexKey(): string { - return $this->_storage['flex_key'] ?? $this->_flexDirectory->getFlexType() . '.obj:' . $this->getStorageKey(); + $key = $this->_meta['flex_key'] ?? null; + + if (!$key && $key = $this->getStorageKey()) { + $key = $this->_flexDirectory->getFlexType() . '.obj:' . $key; + } + + return (string)$key; } /** @@ -203,7 +334,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function getStorageKey(): string { - return $this->_storage['storage_key'] ?? $this->getTypePrefix() . $this->getFlexType() . '@@' . spl_object_hash($this); + return (string)($this->storage_key ?? $this->_meta['storage_key'] ?? null); } /** @@ -212,7 +343,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function getMetaData(): array { - return $this->getStorage(); + return $this->_meta ?? []; } /** @@ -249,7 +380,11 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface public function searchNestedProperty(string $property, string $search, array $options = null): float { $options = $options ?? $this->getFlexDirectory()->getConfig('data.search.options', []); - $value = $this->getNestedProperty($property); + if ($property === 'key') { + $value = $this->getKey(); + } else { + $value = $this->getNestedProperty($property); + } return $this->searchValue($property, $value, $search, $options); } @@ -263,24 +398,38 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ protected function searchValue(string $name, $value, string $search, array $options = null): float { - $search = trim($search); + $options = $options ?? []; + // Ignore empty search strings. + $search = trim($search); if ($search === '') { return 0; } - if (!\is_string($value) || $value === '') { + // Search only non-empty string values. + if (!is_string($value) || $value === '') { return 0; } + $caseSensitive = $options['case_sensitive'] ?? false; + $tested = false; - if (($tested |= !empty($options['starts_with'])) && Utils::startsWith($value, $search, $options['case_sensitive'] ?? false)) { + if (($tested |= !empty($options['same_as']))) { + if ($caseSensitive) { + if ($value === $search) { + return (float)$options['same_as']; + } + } elseif (mb_strtolower($value) === mb_strtolower($search)) { + return (float)$options['same_as']; + } + } + if (($tested |= !empty($options['starts_with'])) && Utils::startsWith($value, $search, $caseSensitive)) { return (float)$options['starts_with']; } - if (($tested |= !empty($options['ends_with'])) && Utils::endsWith($value, $search, $options['case_sensitive'] ?? false)) { + if (($tested |= !empty($options['ends_with'])) && Utils::endsWith($value, $search, $caseSensitive)) { return (float)$options['ends_with']; } - if ((!$tested || !empty($options['contains'])) && Utils::contains($value, $search, $options['case_sensitive'] ?? false)) { + if ((!$tested || !empty($options['contains'])) && Utils::contains($value, $search, $caseSensitive)) { return (float)($options['contains'] ?? 1); } @@ -305,20 +454,6 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface return 'o.'; } - /** - * @param bool $prefix - * @return string - * @deprecated 1.6 Use `->getFlexType()` instead. - */ - public function getType($prefix = false) - { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); - - $type = $prefix ? $this->getTypePrefix() : ''; - - return $type . $this->getFlexType(); - } - /** * Alias of getBlueprint() * @@ -345,7 +480,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function setStorageKey($key = null) { - $this->_storage['storage_key'] = $key; + $this->storage_key = $key ?? ''; return $this; } @@ -356,7 +491,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function setTimestamp($timestamp = null) { - $this->_storage['storage_timestamp'] = $timestamp ?? time(); + $this->storage_timestamp = $timestamp ?? time(); return $this; } @@ -367,8 +502,9 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function render(string $layout = null, array $context = []) { - if (null === $layout) { - $layout = 'default'; + if (!$layout) { + $config = $this->getTemplateConfig(); + $layout = $config['object']['defaults']['layout'] ?? 'default'; } $type = $this->getFlexType(); @@ -379,20 +515,28 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface $debugger = $grav['debugger']; $debugger->startTimer('flex-object-' . ($debugKey = uniqid($type, false)), 'Render Object ' . $type . ' (' . $layout . ')'); - $cache = $key = null; - foreach ($context as $value) { - if (!\is_scalar($value)) { - $key = false; + $key = $this->getCacheKey(); + + // Disable caching if context isn't all scalars. + if ($key) { + foreach ($context as $value) { + if (!is_scalar($value)) { + $key = ''; + break; + } } } - if ($key !== false) { - $key = md5($this->getCacheKey() . '.' . $layout . json_encode($context)); + if ($key) { + // Create a new key which includes layout and context. + $key = md5($key . '.' . $layout . json_encode($context)); $cache = $this->getCache('render'); + } else { + $cache = null; } try { - $data = $cache && $key ? $cache->get($key) : null; + $data = $cache ? $cache->get($key) : null; $block = $data ? HtmlBlock::fromArray($data) : null; } catch (InvalidArgumentException $e) { @@ -413,18 +557,28 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface if (!$block) { $block = HtmlBlock::create($key ?: null); $block->setChecksum($checksum); - if ($key === false) { + if (!$cache) { $block->disableCache(); } - $grav->fireEvent('onFlexObjectRender', new Event([ + $event = new Event([ + 'type' => 'flex', + 'directory' => $this->getFlexDirectory(), 'object' => $this, 'layout' => &$layout, 'context' => &$context - ])); + ]); + $this->triggerEvent('onRender', $event); $output = $this->getTemplate($layout)->render( - ['grav' => $grav, 'config' => $grav['config'], 'block' => $block, 'object' => $this, 'layout' => $layout] + $context + [ + 'grav' => $grav, + 'config' => $grav['config'], + 'block' => $block, + 'directory' => $this->getFlexDirectory(), + 'object' => $this, + 'layout' => $layout + ] + $context ); if ($debugger->enabled()) { @@ -435,7 +589,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface $block->setContent($output); try { - $cache && $key && $block->isCached() && $cache->set($key, $block->toArray()); + $cache && $block->isCached() && $cache->set($key, $block->toArray()); } catch (InvalidArgumentException $e) { $debugger->addException($e); } @@ -460,16 +614,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function prepareStorage(): array { - return $this->getElements(); - } - - /** - * @param string $name - * @return $this - */ - public function triggerEvent($name) - { - return $this; + return ['__META' => $this->getMetaData()] + $this->getElements(); } /** @@ -491,8 +636,8 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface $test = $blueprint->mergeData($elements, $data); // Validate and filter elements and throw an error if any issues were found. - $blueprint->validate($test + ['storage_key' => $this->getStorageKey(), 'timestamp' => $this->getTimestamp()]); - $data = $blueprint->filter($data, false, true); + $blueprint->validate($test + ['storage_key' => $this->getStorageKey(), 'timestamp' => $this->getTimestamp()], ['xss_check' => false]); + $data = $blueprint->filter($data, true, true); // Finally update the object. foreach ($blueprint->flattenData($data) as $key => $value) { @@ -525,12 +670,23 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface } if ($this->exists()) { - throw new \RuntimeException('Cannot create new object (Already exists)'); + throw new RuntimeException('Cannot create new object (Already exists)'); } return $this->save(); } + /** + * @param string|null $key + * @return FlexObject|FlexObjectInterface + */ + public function createCopy(string $key = null) + { + $this->markAsCopy(); + + return $this->create($key); + } + /** * {@inheritdoc} * @see FlexObjectInterface::save() @@ -539,36 +695,49 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface { $this->triggerEvent('onBeforeSave'); - $result = $this->getFlexDirectory()->getStorage()->replaceRows([$this->getStorageKey() => $this->prepareStorage()]); + $storage = $this->getFlexDirectory()->getStorage(); + + $storageKey = $this->getStorageKey() ?: '@@' . spl_object_hash($this); + + $result = $storage->replaceRows([$storageKey => $this->prepareStorage()]); + + if (method_exists($this, 'clearMediaCache')) { + $this->clearMediaCache(); + } $value = reset($result); - $storageKey = (string)key($result); - if ($value && $storageKey) { - $this->setStorageKey($storageKey); - if (!$this->hasKey()) { - $this->setKey($storageKey); + $meta = $value['__META'] ?? null; + if ($meta) { + /** @var FlexIndex $indexClass */ + $indexClass = $this->getFlexDirectory()->getIndexClass(); + $indexClass::updateObjectMeta($meta, $value, $storage); + $this->_meta = $meta; + } + + if ($value) { + $storageKey = $meta['storage_key'] ?? (string)key($result); + if ($storageKey !== '') { + $this->setStorageKey($storageKey); } + + $newKey = $meta['key'] ?? ($this->hasKey() ? $this->getKey() : null); + $this->setKey($newKey ?? $storageKey); } // FIXME: For some reason locator caching isn't cleared for the file, investigate! $locator = Grav::instance()['locator']; $locator->clearCache(); - // Make sure that the object exists before continuing (just in case). - if (!$this->exists()) { - throw new \RuntimeException('Saving failed: Object does not exist!'); - } - if (method_exists($this, 'saveUpdatedMedia')) { $this->saveUpdatedMedia(); } try { - $this->getFlexDirectory()->clearCache(); + $this->getFlexDirectory()->reloadIndex(); if (method_exists($this, 'clearMediaCache')) { $this->clearMediaCache(); } - } catch (\Exception $e) { + } catch (Exception $e) { /** @var Debugger $debugger */ $debugger = Grav::instance()['debugger']; $debugger->addException($e); @@ -587,16 +756,20 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function delete() { + if (!$this->exists()) { + return $this; + } + $this->triggerEvent('onBeforeDelete'); $this->getFlexDirectory()->getStorage()->deleteRows([$this->getStorageKey() => $this->prepareStorage()]); try { - $this->getFlexDirectory()->clearCache(); + $this->getFlexDirectory()->reloadIndex(); if (method_exists($this, 'clearMediaCache')) { $this->clearMediaCache(); } - } catch (\Exception $e) { + } catch (Exception $e) { /** @var Debugger $debugger */ $debugger = Grav::instance()['debugger']; $debugger->addException($e); @@ -615,17 +788,25 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ public function getBlueprint(string $name = '') { - return $this->_flexDirectory->getBlueprint($name ? '.' . $name : $name); + if (!isset($this->_blueprint[$name])) { + $blueprint = $this->doGetBlueprint($name); + $blueprint->setScope('object'); + $blueprint->setObject($this); + + $this->_blueprint[$name] = $blueprint->init(); + } + + return $this->_blueprint[$name]; } /** * {@inheritdoc} * @see FlexObjectInterface::getForm() */ - public function getForm(string $name = '', array $form = null) + public function getForm(string $name = '', array $options = null) { if (!isset($this->_forms[$name])) { - $this->_forms[$name] = $this->createFormObject($name, $form); + $this->_forms[$name] = $this->createFormObject($name, $options); } return $this->_forms[$name]; @@ -652,9 +833,9 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface while ($path) { $offset = array_shift($path); - if ((\is_array($current) || $current instanceof \ArrayAccess) && isset($current[$offset])) { + if ((is_array($current) || $current instanceof ArrayAccess) && isset($current[$offset])) { $current = $current[$offset]; - } elseif (\is_object($current) && isset($current->{$offset})) { + } elseif (is_object($current) && isset($current->{$offset})) { $current = $current->{$offset}; } else { return null; @@ -689,16 +870,11 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface } /** - * @param string $name - * @param mixed|null $default - * @param string|null $separator - * @return mixed - * - * @deprecated 1.6 Use ->getFormValue() method instead. + * @param FlexDirectory $directory */ - public function value($name, $default = null, $separator = null) + public function setFlexDirectory(FlexDirectory $directory): void { - return $this->getFormValue($name, $default, $separator); + $this->_flexDirectory = $directory; } /** @@ -711,16 +887,53 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface return $this->getFlexKey(); } + /** + * @return array + */ public function __debugInfo() { return [ 'type:private' => $this->getFlexType(), + 'storage_key:protected' => $this->getStorageKey(), + 'storage_timestamp:protected' => $this->getTimestamp(), 'key:private' => $this->getKey(), 'elements:private' => $this->getElements(), - 'storage:private' => $this->getStorage() + 'storage:private' => $this->getMetaData() ]; } + /** + * Clone object. + */ + public function __clone() + { + // Allows future compatibility as parent::__clone() works. + } + + protected function markAsCopy(): void + { + $meta = $this->getMetaData(); + $meta['copy'] = true; + $this->_meta = $meta; + } + + /** + * @param string $name + * @return Blueprint + */ + protected function doGetBlueprint(string $name = ''): Blueprint + { + return $this->_flexDirectory->getBlueprint($name ? '.' . $name : $name); + } + + /** + * @param array $meta + */ + protected function setMetaData(array $meta): void + { + $this->_meta = $meta; + } + /** * @return array */ @@ -730,14 +943,16 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface 'type' => $this->getFlexType(), 'key' => $this->getKey(), 'elements' => $this->getElements(), - 'storage' => $this->getStorage() + 'storage' => $this->getMetaData() ]; } /** * @param array $serialized + * @param FlexDirectory|null $directory + * @return void */ - protected function doUnserialize(array $serialized): void + protected function doUnserialize(array $serialized, FlexDirectory $directory = null): void { $type = $serialized['type'] ?? 'unknown'; @@ -745,103 +960,58 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface throw new \InvalidArgumentException("Cannot unserialize '{$type}': Bad data"); } - $grav = Grav::instance(); - /** @var Flex|null $flex */ - $flex = $grav['flex_objects'] ?? null; - $directory = $flex ? $flex->getDirectory($type) : null; - if (!$directory) { - throw new \InvalidArgumentException("Cannot unserialize '{$type}': Not found"); + if (null === $directory) { + $directory = $this->getFlexContainer()->getDirectory($type); + if (!$directory) { + throw new \InvalidArgumentException("Cannot unserialize Flex type '{$type}': Directory not found"); + } } + $this->setFlexDirectory($directory); - $this->setStorage($serialized['storage']); + $this->setMetaData($serialized['storage']); $this->setKey($serialized['key']); $this->setElements($serialized['elements']); } - /** - * @param FlexDirectory $directory - */ - public function setFlexDirectory(FlexDirectory $directory): void - { - $this->_flexDirectory = $directory; - } - /** - * @param array $storage - */ - protected function setStorage(array $storage) : void - { - $this->_storage = $storage; - } - /** * @return array */ - protected function getStorage() : array + protected function getTemplateConfig() { - return $this->_storage ?? []; - } + $config = $this->getFlexDirectory()->getConfig('site.templates', []); + $defaults = array_replace($config['defaults'] ?? [], $config['object']['defaults'] ?? []); + $config['object']['defaults'] = $defaults; - /** - * @param string $type - * @param string $property - * @return FlexCollectionInterface - */ - protected function getCollectionByProperty($type, $property) - { - $directory = $this->getRelatedDirectory($type); - $collection = $directory->getCollection(); - $list = $this->getNestedProperty($property) ?: []; - - /** @var FlexCollection $collection */ - $collection = $collection->filter(function ($object) use ($list) { return \in_array($object->id, $list, true); }); - - return $collection; - } - - /** - * @param string $type - * @return FlexDirectory - * @throws \RuntimeException - */ - protected function getRelatedDirectory($type): FlexDirectory - { - /** @var Flex $flex */ - $flex = Grav::instance()['flex_objects']; - $directory = $flex->getDirectory($type); - if (!$directory) { - throw new \RuntimeException(ucfirst($type). ' directory does not exist!'); - } - - return $directory; + return $config; } /** * @param string $layout - * @return Template|TemplateWrapper - * @throws LoaderError - * @throws SyntaxError + * @return array */ - protected function getTemplate($layout) + protected function getTemplatePaths(string $layout): array { - $grav = Grav::instance(); + $config = $this->getTemplateConfig(); + $type = $this->getFlexType(); + $defaults = $config['object']['defaults'] ?? []; - /** @var Twig $twig */ - $twig = $grav['twig']; + $ext = $defaults['ext'] ?? '.html.twig'; + $types = array_unique(array_merge([$type], (array)($defaults['type'] ?? null))); + $paths = $config['object']['paths'] ?? [ + 'flex/{TYPE}/object/{LAYOUT}{EXT}', + 'flex-objects/layouts/{TYPE}/object/{LAYOUT}{EXT}' + ]; + $table = ['TYPE' => '%1$s', 'LAYOUT' => '%2$s', 'EXT' => '%3$s']; - try { - return $twig->twig()->resolveTemplate( - [ - "flex-objects/layouts/{$this->getFlexType()}/object/{$layout}.html.twig", - "flex-objects/layouts/_default/object/{$layout}.html.twig" - ] - ); - } catch (LoaderError $e) { - /** @var Debugger $debugger */ - $debugger = Grav::instance()['debugger']; - $debugger->addException($e); - - return $twig->twig()->resolveTemplate(['flex-objects/layouts/404.html.twig']); + $lookups = []; + foreach ($paths as $path) { + $path = Utils::simpleTemplate($path, $table); + foreach ($types as $type) { + $lookups[] = sprintf($path, $type, $layout, $ext); + } } + + return array_unique($lookups); } /** @@ -853,25 +1023,189 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface */ protected function filterElements(array &$elements): void { - if (!empty($elements['storage_key'])) { - $this->_storage['storage_key'] = trim($elements['storage_key']); + if (isset($elements['storage_key'])) { + $elements['storage_key'] = trim($elements['storage_key']); } - if (!empty($elements['storage_timestamp'])) { - $this->_storage['storage_timestamp'] = (int)$elements['storage_timestamp']; + if (isset($elements['storage_timestamp'])) { + $elements['storage_timestamp'] = (int)$elements['storage_timestamp']; } - unset ($elements['storage_key'], $elements['storage_timestamp'], $elements['_post_entries_save']); + unset($elements['_post_entries_save']); } /** * This methods allows you to override form objects in child classes. * * @param string $name Form name - * @param array|null $form Form fields + * @param array|null $options Form optiosn * @return FlexFormInterface */ - protected function createFormObject(string $name, array $form = null) + protected function createFormObject(string $name, array $options = null) { - return new FlexForm($name, $this, $form); + return new FlexForm($name, $this, $options); + } + + /** + * @param string $action + * @return string + */ + protected function getAuthorizeAction(string $action): string + { + // Handle special action save, which can mean either update or create. + if ($action === 'save') { + $action = $this->exists() ? 'update' : 'create'; + } + + return $action; + } + + // DEPRECATED METHODS + + /** + * @param bool $prefix + * @return string + * @deprecated 1.6 Use `->getFlexType()` instead. + */ + public function getType($prefix = false) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED); + + $type = $prefix ? $this->getTypePrefix() : ''; + + return $type . $this->getFlexType(); + } + + /** + * @param string $name + * @param mixed|null $default + * @param string|null $separator + * @return mixed + * + * @deprecated 1.6 Use ->getFormValue() method instead. + */ + public function value($name, $default = null, $separator = null) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.6, use ->getFormValue() method instead', E_USER_DEPRECATED); + + return $this->getFormValue($name, $default, $separator); + } + + /** + * @param string $name + * @param object|null $event + * @return $this + * @deprecated 1.7 Moved to \Grav\Common\Flex\Traits\FlexObjectTrait + */ + public function triggerEvent(string $name, $event = null) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\FlexObjectTrait', E_USER_DEPRECATED); + + if (null === $event) { + $event = new Event([ + 'type' => 'flex', + 'directory' => $this->getFlexDirectory(), + 'object' => $this + ]); + } + if (strpos($name, 'onFlexObject') !== 0 && strpos($name, 'on') === 0) { + $name = 'onFlexObject' . substr($name, 2); + } + + $grav = Grav::instance(); + if ($event instanceof Event) { + $grav->fireEvent($name, $event); + } else { + $grav->dispatchEvent($event); + } + + return $this; + } + + /** + * @param array $storage + * @deprecated 1.7 Use `->setMetaData()` instead. + */ + protected function setStorage(array $storage): void + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->setMetaData() method instead', E_USER_DEPRECATED); + + $this->setMetaData($storage); + } + + /** + * @return array + * @deprecated 1.7 Use `->getMetaData()` instead. + */ + protected function getStorage(): array + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->getMetaData() method instead', E_USER_DEPRECATED); + + return $this->getMetaData(); + } + + /** + * @param string $layout + * @return Template|TemplateWrapper + * @throws LoaderError + * @throws SyntaxError + * @deprecated 1.7 Moved to \Grav\Common\Flex\Traits\GravTrait + */ + protected function getTemplate($layout) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\GravTrait', E_USER_DEPRECATED); + + $grav = Grav::instance(); + + /** @var Twig $twig */ + $twig = $grav['twig']; + + try { + return $twig->twig()->resolveTemplate($this->getTemplatePaths($layout)); + } catch (LoaderError $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + return $twig->twig()->resolveTemplate(['flex/404.html.twig']); + } + } + + /** + * @return Flex + * @deprecated 1.7 Moved to \Grav\Common\Flex\Traits\GravTrait + */ + protected function getFlexContainer(): Flex + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\GravTrait', E_USER_DEPRECATED); + + /** @var Flex $flex */ + $flex = Grav::instance()['flex']; + + return $flex; + } + + /** + * @return UserInterface|null + * @deprecated 1.7 Moved to \Grav\Common\Flex\Traits\GravTrait + */ + protected function getActiveUser(): ?UserInterface + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\GravTrait', E_USER_DEPRECATED); + + /** @var UserInterface|null $user */ + $user = Grav::instance()['user'] ?? null; + + return $user; + } + + /** + * @return string + * @deprecated 1.7 Moved to \Grav\Common\Flex\Traits\GravTrait + */ + protected function getAuthorizeScope(): string + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\GravTrait', E_USER_DEPRECATED); + + return isset(Grav::instance()['admin']) ? 'admin' : 'site'; } } diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php index cfcb8a9..102dbf4 100644 --- a/system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -19,14 +19,15 @@ use Grav\Common\User\Interfaces\UserInterface; interface FlexAuthorizeInterface { /** - * Check if user is authorized to perform an action for the object. + * Check if user is authorized for the action. * - * @param string $action One of: `create`, `read`, `update`, `delete`, `save`, `list` - * @param string|null $scope One of: `admin`, `site` - * @param UserInterface|null $user Optional user. Defaults to the current user. + * Note: There are two deny values: denied (false), not set (null). This allows chaining multiple rules together + * when the previous rules were not matched. * - * @return bool Returns `true` if user is authorized to perform action, `false` otherwise. - * @api + * @param string $action + * @param string|null $scope + * @param UserInterface|null $user + * @return bool|null */ - public function isAuthorized(string $action, string $scope = null, UserInterface $user = null): bool; + public function isAuthorized(string $action, string $scope = null, UserInterface $user = null): ?bool; } diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexCollectionInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexCollectionInterface.php index b6eace5..16be3d5 100644 --- a/system/src/Grav/Framework/Flex/Interfaces/FlexCollectionInterface.php +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexCollectionInterface.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,12 +15,15 @@ use Grav\Framework\Flex\Flex; use Grav\Framework\Object\Interfaces\NestedObjectInterface; use Grav\Framework\Object\Interfaces\ObjectCollectionInterface; use Grav\Framework\Flex\FlexDirectory; +use InvalidArgumentException; /** * Defines a collection of Flex Objects. * * @used-by \Grav\Framework\Flex\FlexCollection * @since 1.6 + * @template T + * @extends ObjectCollectionInterface */ interface FlexCollectionInterface extends FlexCommonInterface, ObjectCollectionInterface, NestedObjectInterface { @@ -31,8 +34,7 @@ interface FlexCollectionInterface extends FlexCommonInterface, ObjectCollectionI * * @param FlexObjectInterface[] $entries Associated array of Flex Objects to be included in the collection. * @param FlexDirectory $directory Flex Directory where all the objects belong into. - * @param string $keyField Key field used to index the collection. - * + * @param string|null $keyField Key field used to index the collection. * @return static Returns a new Flex Collection. */ public static function createFromArray(array $entries, FlexDirectory $directory, string $keyField = null); @@ -43,9 +45,8 @@ interface FlexCollectionInterface extends FlexCommonInterface, ObjectCollectionI * @used-by FlexDirectory::createCollection() Official method to create Flex Collection. * * @param FlexObjectInterface[] $entries Associated array of Flex Objects to be included in the collection. - * @param FlexDirectory $directory Flex Directory where all the objects belong into. - * - * @throws \InvalidArgumentException + * @param FlexDirectory|null $directory Flex Directory where all the objects belong into. + * @throws InvalidArgumentException */ public function __construct(array $entries = [], FlexDirectory $directory = null); @@ -55,8 +56,8 @@ interface FlexCollectionInterface extends FlexCommonInterface, ObjectCollectionI * @param string $search Search string. * @param string|string[]|null $properties Properties to search for, defaults to configured properties. * @param array|null $options Search options, defaults to configured options. - * * @return FlexCollectionInterface Returns a Flex Collection with only matching objects. + * @phpstan-return static * @api */ public function search(string $search, $properties = null, array $options = null); @@ -67,6 +68,7 @@ interface FlexCollectionInterface extends FlexCommonInterface, ObjectCollectionI * @param array $orderings Pair of [property => 'ASC'|'DESC', ...]. * * @return FlexCollectionInterface Returns a sorted version from the collection. + * @phpstan-return static */ public function sort(array $orderings); @@ -75,6 +77,7 @@ interface FlexCollectionInterface extends FlexCommonInterface, ObjectCollectionI * * @param array $filters * @return FlexCollectionInterface + * @phpstan-return static */ public function filterBy(array $filters); @@ -109,8 +112,8 @@ interface FlexCollectionInterface extends FlexCommonInterface, ObjectCollectionI * Return new collection with a different key. * * @param string|null $keyField Switch key field of the collection. - * * @return FlexCollectionInterface Returns a new Flex Collection with new key field. + * @phpstan-return static * @api */ public function withKeyField(string $keyField = null); @@ -119,6 +122,23 @@ interface FlexCollectionInterface extends FlexCommonInterface, ObjectCollectionI * Get Flex Index from the Flex Collection. * * @return FlexIndexInterface Returns a Flex Index from the current collection. + * @phpstan-return FlexIndexInterface */ public function getIndex(); + + /** + * Load all the objects into memory, + * + * @return FlexCollectionInterface + * @phpstan-return static + */ + public function getCollection(); + + /** + * Get metadata associated to the object + * + * @param string $key Key. + * @return array + */ + public function getMetaData(string $key): array; } diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php index 482aef2..ed045e2 100644 --- a/system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -38,6 +38,21 @@ interface FlexCommonInterface extends RenderInterface */ public function getFlexDirectory(): FlexDirectory; + /** + * Test whether the feature is implemented in the object / collection. + * + * @param string $name + * @return bool + */ + public function hasFlexFeature(string $name): bool; + + /** + * Get full list of features the object / collection implements. + * + * @return array + */ + public function getFlexFeatures(): array; + /** * Get last updated timestamp for the object / collection. * diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexDirectoryFormInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexDirectoryFormInterface.php new file mode 100644 index 0000000..df6f7d6 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexDirectoryFormInterface.php @@ -0,0 +1,27 @@ +getBlueprint() or $object->getForm()->getBlueprint() instead. + * + * @param string $type + * @param string $context + * @return Blueprint + */ + public function getBlueprint(string $type = '', string $context = ''); + + /** + * @param string $view + * @return string + */ + public function getBlueprintFile(string $view = ''): string; + + /** + * Get collection. In the site this will be filtered by the default filters (published etc). + * + * Use $directory->getIndex() if you want unfiltered collection. + * + * @param array|null $keys Array of keys. + * @param string|null $keyField Field to be used as the key. + * @return FlexCollectionInterface + */ + public function getCollection(array $keys = null, string $keyField = null): FlexCollectionInterface; + + /** + * Get the full collection of all stored objects. + * + * Use $directory->getCollection() if you want a filtered collection. + * + * @param array|null $keys Array of keys. + * @param string|null $keyField Field to be used as the key. + * @return FlexIndexInterface + */ + public function getIndex(array $keys = null, string $keyField = null): FlexIndexInterface; + + /** + * Returns an object if it exists. If no arguments are passed (or both of them are null), method creates a new empty object. + * + * Note: It is not safe to use the object without checking if the user can access it. + * + * @param string|null $key + * @param string|null $keyField Field to be used as the key. + * @return FlexObjectInterface|null + */ + public function getObject($key = null, string $keyField = null): ?FlexObjectInterface; + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null); + + /** + * @return $this + */ + public function clearCache(); + + /** + * @param string|null $key + * @return string|null + */ + public function getStorageFolder(string $key = null): ?string; + + /** + * @param string|null $key + * @return string|null + */ + public function getMediaFolder(string $key = null): ?string; + + /** + * @return FlexStorageInterface + */ + public function getStorage(): FlexStorageInterface; + + /** + * @param array $data + * @param string $key + * @param bool $validate + * @return FlexObjectInterface + */ + public function createObject(array $data, string $key = '', bool $validate = false): FlexObjectInterface; + + /** + * @param array $entries + * @param string|null $keyField + * @return FlexCollectionInterface + */ + public function createCollection(array $entries, string $keyField = null): FlexCollectionInterface; + + /** + * @param array $entries + * @param string|null $keyField + * @return FlexIndexInterface + */ + public function createIndex(array $entries, string $keyField = null): FlexIndexInterface; + + /** + * @return string + */ + public function getObjectClass(): string; + + /** + * @return string + */ + public function getCollectionClass(): string; + + /** + * @return string + */ + public function getIndexClass(): string; + + /** + * @param array $entries + * @param string|null $keyField + * @return FlexCollectionInterface + */ + public function loadCollection(array $entries, string $keyField = null): FlexCollectionInterface; + + /** + * @param array $entries + * @return FlexObjectInterface[] + * @internal + */ + public function loadObjects(array $entries): array; + + /** + * @return void + */ + public function reloadIndex(): void; + + /** + * @param string $scope + * @param string $action + * @return string + */ + public function getAuthorizeRule(string $scope, string $action): string; +} diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php index 480a2f2..9539a15 100644 --- a/system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,6 +11,7 @@ namespace Grav\Framework\Flex\Interfaces; use Grav\Framework\Form\Interfaces\FormInterface; use Grav\Framework\Route\Route; +use Serializable; /** * Defines Forms for Flex Objects. @@ -18,16 +19,8 @@ use Grav\Framework\Route\Route; * @used-by \Grav\Framework\Flex\FlexForm * @since 1.6 */ -interface FlexFormInterface extends \Serializable, FormInterface +interface FlexFormInterface extends Serializable, FormInterface { - /** - * Get object associated to the form. - * - * @return FlexObjectInterface Returns Flex Object associated to the form. - * @api - */ - public function getObject(); - /** * Get media task route. * @@ -47,7 +40,6 @@ interface FlexFormInterface extends \Serializable, FormInterface * * @param string $field Field where the file is associated into. * @param string $filename Filename for the file. - * * @return Route|null Returns Route object or null if file uploads are not enabled. */ public function getFileDeleteAjaxRoute($field, $filename); diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexIndexInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexIndexInterface.php index f6e8136..d751e4b 100644 --- a/system/src/Grav/Framework/Flex/Interfaces/FlexIndexInterface.php +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexIndexInterface.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -21,6 +21,8 @@ use Grav\Framework\Flex\FlexDirectory; * * @used-by \Grav\Framework\Flex\FlexIndex * @since 1.6 + * @template T + * @extends FlexCollectionInterface */ interface FlexIndexInterface extends FlexCollectionInterface { @@ -30,7 +32,6 @@ interface FlexIndexInterface extends FlexCollectionInterface * @used-by FlexDirectory::getIndex() Official method to get Index from a Flex Directory. * * @param FlexDirectory $directory Flex directory. - * * @return static Returns a new Flex Index. */ public static function createFromStorage(FlexDirectory $directory); @@ -41,7 +42,6 @@ interface FlexIndexInterface extends FlexCollectionInterface * @used-by FlexDirectory::getIndex() Official method to get Index from a Flex Directory. * * @param FlexStorageInterface $storage Flex Storage associated to the directory. - * * @return array Returns a list of existing objects [storage_key => [storage_key => xxx, storage_timestamp => 123456, ...]] */ public static function loadEntriesFromStorage(FlexStorageInterface $storage): array; @@ -50,14 +50,13 @@ interface FlexIndexInterface extends FlexCollectionInterface * Return new collection with a different key. * * @param string|null $keyField Switch key field of the collection. - * - * @return FlexIndexInterface Returns a new Flex Collection with new key field. + * @return static Returns a new Flex Collection with new key field. * @api */ public function withKeyField(string $keyField = null); /** - * @param string $indexKey + * @param string|null $indexKey * @return array */ public function getIndexMap(string $indexKey = null); diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexInterface.php new file mode 100644 index 0000000..3c5a103 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexInterface.php @@ -0,0 +1,98 @@ + + */ + public function getDirectories(array $types = null, bool $keepMissing = false): array; + + /** + * @param string $type + * @return FlexDirectory|null + */ + public function getDirectory(string $type): ?FlexDirectory; + + /** + * @param string $type + * @param array|null $keys + * @param string|null $keyField + * @return FlexCollectionInterface|null + */ + public function getCollection(string $type, array $keys = null, string $keyField = null): ?FlexCollectionInterface; + + /** + * @param array $keys + * @param array $options In addition to the options in getObjects(), following options can be passed: + * collection_class: Class to be used to create the collection. Defaults to ObjectCollection. + * @return FlexCollectionInterface + * @throws RuntimeException + */ + public function getMixedCollection(array $keys, array $options = []): FlexCollectionInterface; + + /** + * @param array $keys + * @param array $options Following optional options can be passed: + * types: List of allowed types. + * type: Allowed type if types isn't defined, otherwise acts as default_type. + * default_type: Set default type for objects given without type (only used if key_field isn't set). + * keep_missing: Set to true if you want to return missing objects as null. + * key_field: Key field which is used to match the objects. + * @return array + */ + public function getObjects(array $keys, array $options = []): array; + + /** + * @param string $key + * @param string|null $type + * @param string|null $keyField + * @return FlexObjectInterface|null + */ + public function getObject(string $key, string $type = null, string $keyField = null): ?FlexObjectInterface; + + /** + * @return int + */ + public function count(): int; +} diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexObjectFormInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexObjectFormInterface.php new file mode 100644 index 0000000..044ee61 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexObjectFormInterface.php @@ -0,0 +1,27 @@ + row, ...] pairs. */ public function createRows(array $rows): array; @@ -71,8 +75,7 @@ interface FlexStorageInterface * If you pass object or array as value, that value will be used to save I/O. * * @param array $rows Array of `[key => row, ...]` pairs. - * @param array $fetched Optional reference to store only fetched items. - * + * @param array|null $fetched Optional reference to store only fetched items. * @return array Returns rows. Note that non-existing rows will have `null` as their value. */ public function readRows(array $rows, array &$fetched = null): array; @@ -81,7 +84,6 @@ interface FlexStorageInterface * Update existing rows in the storage. * * @param array $rows Array of `[key => row, ...]` pairs. - * * @return array Returns updated rows. Note that non-existing rows will not be saved and have `null` as their value. */ public function updateRows(array $rows): array; @@ -90,7 +92,6 @@ interface FlexStorageInterface * Delete rows from the storage. * * @param array $rows Array of `[key => row, ...]` pairs. - * * @return array Returns deleted rows. Note that non-existing rows have `null` as their value. */ public function deleteRows(array $rows): array; @@ -101,7 +102,6 @@ interface FlexStorageInterface * All rows should have a specified key for replace to work properly. * * @param array $rows Array of `[key => row, ...]` pairs. - * * @return array Returns both created and updated rows. */ public function replaceRows(array $rows): array; @@ -109,7 +109,13 @@ interface FlexStorageInterface /** * @param string $src * @param string $dst - * + * @return bool + */ + public function copyRow(string $src, string $dst): bool; + + /** + * @param string $src + * @param string $dst * @return bool */ public function renameRow(string $src, string $dst): bool; @@ -118,17 +124,15 @@ interface FlexStorageInterface * Get filesystem path for the collection or object storage. * * @param string|null $key Optional storage key. - * - * @return string Path in the filesystem. Can be URI. + * @return string|null Path in the filesystem. Can be URI or null if storage is not filesystem based. */ - public function getStoragePath(string $key = null): string; + public function getStoragePath(string $key = null): ?string; /** * Get filesystem path for the collection or object media. * * @param string|null $key Optional storage key. - * - * @return string Path in the filesystem. Can be URI. + * @return string|null Path in the filesystem. Can be URI or null if media isn't supported. */ - public function getMediaPath(string $key = null): string; + public function getMediaPath(string $key = null): ?string; } diff --git a/system/src/Grav/Framework/Flex/Interfaces/FlexTranslateInterface.php b/system/src/Grav/Framework/Flex/Interfaces/FlexTranslateInterface.php new file mode 100644 index 0000000..28e4888 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexTranslateInterface.php @@ -0,0 +1,51 @@ + + */ +class FlexPageCollection extends FlexCollection +{ + /** + * @return array + */ + public static function getCachedMethods(): array + { + return [ + // Collection filtering + 'withPublished' => true, + 'withVisible' => true, + 'withRoutable' => true, + + 'isFirst' => true, + 'isLast' => true, + + // Find objects + 'prevSibling' => false, + 'nextSibling' => false, + 'adjacentSibling' => false, + 'currentPosition' => true, + + 'getNextOrder' => false, + ] + parent::getCachedMethods(); + } + + /** + * @param bool $bool + * @return static + * @phpstan-return static + */ + public function withPublished(bool $bool = true) + { + $list = array_keys(array_filter($this->call('isPublished', [$bool]))); + + return $this->select($list); + } + + /** + * @param bool $bool + * @return static + * @phpstan-return static + */ + public function withVisible(bool $bool = true) + { + $list = array_keys(array_filter($this->call('isVisible', [$bool]))); + + return $this->select($list); + } + + /** + * @param bool $bool + * @return static + * @phpstan-return static + */ + public function withRoutable(bool $bool = true) + { + $list = array_keys(array_filter($this->call('isRoutable', [$bool]))); + + return $this->select($list); + } + + /** + * Check to see if this item is the first in the collection. + * + * @param string $path + * @return bool True if item is first. + */ + public function isFirst($path): bool + { + $keys = $this->getKeys(); + $first = reset($keys); + + return $path === $first; + } + + /** + * Check to see if this item is the last in the collection. + * + * @param string $path + * @return bool True if item is last. + */ + public function isLast($path): bool + { + $keys = $this->getKeys(); + $last = end($keys); + + return $path === $last; + } + + /** + * Gets the previous sibling based on current position. + * + * @param string $path + * @return PageInterface|false The previous item. + * @phpstan-return T|false + */ + public function prevSibling($path) + { + return $this->adjacentSibling($path, -1); + } + + /** + * Gets the next sibling based on current position. + * + * @param string $path + * @return PageInterface|false The next item. + * @phpstan-return T|false + */ + public function nextSibling($path) + { + return $this->adjacentSibling($path, 1); + } + + /** + * Returns the adjacent sibling based on a direction. + * + * @param string $path + * @param int $direction either -1 or +1 + * @return PageInterface|false The sibling item. + * @phpstan-return T|false + */ + public function adjacentSibling($path, $direction = 1) + { + $keys = $this->getKeys(); + $pos = array_search($path, $keys, true); + + if ($pos !== false) { + $pos += $direction; + if (isset($keys[$pos])) { + return $this[$keys[$pos]]; + } + } + + return false; + } + + /** + * Returns the item in the current position. + * + * @param string $path the path the item + * @return int|null The index of the current page, null if not found. + */ + public function currentPosition($path): ?int + { + $pos = array_search($path, $this->getKeys(), true); + + return $pos !== false ? $pos : null; + } + + /** + * @return string + */ + public function getNextOrder() + { + $directory = $this->getFlexDirectory(); + + $collection = $directory->getIndex(); + $keys = $collection->getStorageKeys(); + + // Assign next free order. + /** @var FlexPageObject|null $last */ + $last = null; + $order = 0; + foreach ($keys as $folder => $key) { + preg_match(FlexPageIndex::ORDER_PREFIX_REGEX, $folder, $test); + $test = $test[0] ?? null; + if ($test && $test > $order) { + $order = $test; + $last = $key; + } + } + + $last = $collection[$last]; + + return sprintf('%d.', $last ? $last->value('order') + 1 : 1); + } +} diff --git a/system/src/Grav/Framework/Flex/Pages/FlexPageIndex.php b/system/src/Grav/Framework/Flex/Pages/FlexPageIndex.php new file mode 100644 index 0000000..904d1f6 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/FlexPageIndex.php @@ -0,0 +1,48 @@ + + */ +class FlexPageIndex extends FlexIndex +{ + public const ORDER_PREFIX_REGEX = '/^\d+\./u'; + + /** + * @param string $route + * @return string + * @internal + */ + public static function normalizeRoute(string $route) + { + static $case_insensitive; + + if (null === $case_insensitive) { + $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls', false); + } + + return $case_insensitive ? mb_strtolower($route) : $route; + } +} diff --git a/system/src/Grav/Framework/Flex/Pages/FlexPageObject.php b/system/src/Grav/Framework/Flex/Pages/FlexPageObject.php new file mode 100644 index 0000000..621dae0 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/FlexPageObject.php @@ -0,0 +1,495 @@ +header)) { + $this->header = clone($this->header); + } + } + + /** + * @return array + */ + public static function getCachedMethods(): array + { + return [ + // Page Content Interface + 'header' => false, + 'summary' => true, + 'content' => true, + 'value' => false, + 'media' => false, + 'title' => true, + 'menu' => true, + 'visible' => true, + 'published' => true, + 'publishDate' => true, + 'unpublishDate' => true, + 'process' => true, + 'slug' => true, + 'order' => true, + 'id' => true, + 'modified' => true, + 'lastModified' => true, + 'folder' => true, + 'date' => true, + 'dateformat' => true, + 'taxonomy' => true, + 'shouldProcess' => true, + 'isPage' => true, + 'isDir' => true, + 'folderExists' => true, + + // Page + 'isPublished' => true, + 'isOrdered' => true, + 'isVisible' => true, + 'isRoutable' => true, + 'getCreated_Timestamp' => true, + 'getPublish_Timestamp' => true, + 'getUnpublish_Timestamp' => true, + 'getUpdated_Timestamp' => true, + ] + parent::getCachedMethods(); + } + + /** + * @param bool $test + * @return bool + */ + public function isPublished(bool $test = true): bool + { + $time = time(); + $start = $this->getPublish_Timestamp(); + $stop = $this->getUnpublish_Timestamp(); + + return $this->published() && $start <= $time && (!$stop || $time <= $stop) === $test; + } + + /** + * @param bool $test + * @return bool + */ + public function isOrdered(bool $test = true): bool + { + return ($this->order() !== false) === $test; + } + + /** + * @param bool $test + * @return bool + */ + public function isVisible(bool $test = true): bool + { + return $this->visible() === $test; + } + + /** + * @param bool $test + * @return bool + */ + public function isRoutable(bool $test = true): bool + { + return $this->routable() === $test; + } + + /** + * @return int + */ + public function getCreated_Timestamp(): int + { + return $this->getFieldTimestamp('created_date') ?? 0; + } + + /** + * @return int + */ + public function getPublish_Timestamp(): int + { + return $this->getFieldTimestamp('publish_date') ?? $this->getCreated_Timestamp(); + } + + /** + * @return int|null + */ + public function getUnpublish_Timestamp(): ?int + { + return $this->getFieldTimestamp('unpublish_date'); + } + + /** + * @return int + */ + public function getUpdated_Timestamp(): int + { + return $this->getFieldTimestamp('updated_date') ?? $this->getPublish_Timestamp(); + } + + /** + * @inheritdoc + */ + public function getFormValue(string $name, $default = null, string $separator = null) + { + $test = new stdClass(); + + $value = $this->pageContentValue($name, $test); + if ($value !== $test) { + return $value; + } + + switch ($name) { + case 'name': + return $this->getProperty('template'); + case 'route': + return $this->hasKey() ? '/' . $this->getKey() : null; + case 'header.permissions.groups': + $encoded = json_encode($this->getPermissions()); + if ($encoded === false) { + throw new RuntimeException('json_encode(): failed to encode group permissions'); + } + + return json_decode($encoded, true); + } + + return parent::getFormValue($name, $default, $separator); + } + + /** + * Get master storage key. + * + * @return string + * @see FlexObjectInterface::getStorageKey() + */ + public function getMasterKey(): string + { + $key = (string)($this->storage_key ?? $this->getMetaData()['storage_key'] ?? null); + if (($pos = strpos($key, '|')) !== false) { + $key = substr($key, 0, $pos); + } + + return $key; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getCacheKey() + */ + public function getCacheKey(): string + { + return $this->hasKey() ? $this->getTypePrefix() . $this->getFlexType() . '.' . $this->getKey() . '.' . $this->getLanguage() : ''; + } + + /** + * @param string|null $key + * @return FlexObjectInterface + */ + public function createCopy(string $key = null) + { + $this->copy(); + + return parent::createCopy($key); + } + + /** + * @param array|bool $reorder + * @return FlexObject|FlexObjectInterface + */ + public function save($reorder = true) + { + return parent::save(); + } + + /** + * Gets the Page Unmodified (original) version of the page. + * + * Assumes that object has been cloned before modifying it. + * + * @return FlexPageObject|null The original version of the page. + */ + public function getOriginal() + { + return $this->_original; + } + + /** + * Store the Page Unmodified (original) version of the page. + * + * Can be called multiple times, only the first call matters. + * + * @return void + */ + public function storeOriginal(): void + { + if (null === $this->_original) { + $this->_original = clone $this; + } + } + + /** + * Get display order for the associated media. + * + * @return array + */ + public function getMediaOrder(): array + { + $order = $this->getNestedProperty('header.media_order'); + + if (is_array($order)) { + return $order; + } + + if (!$order) { + return []; + } + + return array_map('trim', explode(',', $order)); + } + + // Overrides for header properties. + + /** + * Common logic to load header properties. + * + * @param string $property + * @param mixed $var + * @param callable $filter + * @return mixed|null + */ + protected function loadHeaderProperty(string $property, $var, callable $filter) + { + // We have to use parent methods in order to avoid loops. + $value = null === $var ? parent::getProperty($property) : null; + if (null === $value) { + $value = $filter($var ?? $this->getProperty('header')->get($property)); + + parent::setProperty($property, $value); + if ($this->doHasProperty($property)) { + $value = parent::getProperty($property); + } + } + + return $value; + } + + /** + * Common logic to load header properties. + * + * @param string $property + * @param mixed $var + * @param callable $filter + * @return mixed|null + */ + protected function loadProperty(string $property, $var, callable $filter) + { + // We have to use parent methods in order to avoid loops. + $value = null === $var ? parent::getProperty($property) : null; + if (null === $value) { + $value = $filter($var); + + parent::setProperty($property, $value); + if ($this->doHasProperty($property)) { + $value = parent::getProperty($property); + } + } + + return $value; + } + + /** + * @param string $property + * @param mixed $default + * @return mixed + */ + public function getProperty($property, $default = null) + { + $method = static::$headerProperties[$property] ?? static::$calculatedProperties[$property] ?? null; + if ($method && method_exists($this, $method)) { + return $this->{$method}(); + } + + return parent::getProperty($property, $default); + } + + /** + * @param string $property + * @param mixed $value + * @return $this + */ + public function setProperty($property, $value) + { + $method = static::$headerProperties[$property] ?? static::$calculatedProperties[$property] ?? null; + if ($method && method_exists($this, $method)) { + $this->{$method}($value); + + return $this; + } + + parent::setProperty($property, $value); + + return $this; + } + + /** + * @param string $property + * @param mixed $value + * @param string|null $separator + * @return $this + */ + public function setNestedProperty($property, $value, $separator = null) + { + $separator = $separator ?: '.'; + if (strpos($property, 'header' . $separator) === 0) { + $this->getProperty('header')->set(str_replace('header' . $separator, '', $property), $value, $separator); + + return $this; + } + + parent::setNestedProperty($property, $value, $separator); + + return $this; + } + + /** + * @param string $property + * @param string|null $separator + * @return $this + */ + public function unsetNestedProperty($property, $separator = null) + { + $separator = $separator ?: '.'; + if (strpos($property, 'header' . $separator) === 0) { + $this->getProperty('header')->undef(str_replace('header' . $separator, '', $property), $separator); + + return $this; + } + + parent::unsetNestedProperty($property, $separator); + + return $this; + } + + /** + * @param array $elements + * @param bool $extended + * @return void + */ + protected function filterElements(array &$elements, bool $extended = false): void + { + // Markdown storage conversion to page structure. + if (array_key_exists('content', $elements)) { + $elements['markdown'] = $elements['content']; + unset($elements['content']); + } + + if (!$extended) { + $folder = !empty($elements['folder']) ? trim($elements['folder']) : ''; + + if ($folder) { + $order = !empty($elements['order']) ? (int)$elements['order'] : null; + // TODO: broken + $elements['storage_key'] = $order ? sprintf('%02d.%s', $order, $folder) : $folder; + } + } + + parent::filterElements($elements); + } + + /** + * @param string $field + * @return int|null + */ + protected function getFieldTimestamp(string $field): ?int + { + $date = $this->getFieldDateTime($field); + + return $date ? $date->getTimestamp() : null; + } + + /** + * @param string $field + * @return DateTime|null + */ + protected function getFieldDateTime(string $field): ?DateTime + { + try { + $value = $this->getProperty($field); + if (is_numeric($value)) { + $value = '@' . $value; + } + $date = $value ? new DateTime($value) : null; + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + $date = null; + } + + return $date; + } + + /** + * @return UserCollectionInterface|null + * @internal + */ + protected function loadAccounts() + { + return Grav::instance()['accounts'] ?? null; + } +} diff --git a/system/src/Grav/Framework/Flex/Pages/Traits/PageAuthorsTrait.php b/system/src/Grav/Framework/Flex/Pages/Traits/PageAuthorsTrait.php new file mode 100644 index 0000000..5d3e968 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/Traits/PageAuthorsTrait.php @@ -0,0 +1,249 @@ + */ + private $_authors; + /** @var array|null */ + private $_permissionsCache; + + /** + * Returns true if object has the named author. + * + * @param string $username + * @return bool + */ + public function hasAuthor(string $username): bool + { + $authors = (array)$this->getNestedProperty('header.permissions.authors'); + if (empty($authors)) { + return false; + } + + foreach ($authors as $author) { + if ($username === $author) { + return true; + } + } + + return false; + } + + /** + * Get list of all author objects. + * + * @return array + */ + public function getAuthors(): array + { + if (null === $this->_authors) { + $this->_authors = $this->loadAuthors($this->getNestedProperty('header.permissions.authors', [])); + } + + return $this->_authors; + } + + /** + * @param bool $inherit + * @return array + */ + public function getPermissions(bool $inherit = false) + { + if (null === $this->_permissionsCache) { + $permissions = []; + if ($inherit && $this->getNestedProperty('header.permissions.inherit', true)) { + $parent = $this->parent(); + if ($parent && method_exists($parent, 'getPermissions')) { + $permissions = $parent->getPermissions($inherit); + } + } + + $this->_permissionsCache = $this->loadPermissions($permissions); + } + + return $this->_permissionsCache; + } + + /** + * @param iterable $authors + * @return array + */ + protected function loadAuthors(iterable $authors): array + { + $accounts = $this->loadAccounts(); + if (null === $accounts || empty($authors)) { + return []; + } + + $list = []; + foreach ($authors as $username) { + if (!is_string($username)) { + throw new InvalidArgumentException('Iterable should return username (string).', 500); + } + $list[] = $accounts->load($username); + } + + return $list; + } + + /** + * @param string $action + * @param string|null $scope + * @param UserInterface|null $user + * @param bool $isAuthor + * @return bool|null + */ + public function isParentAuthorized(string $action, string $scope = null, UserInterface $user = null, bool $isAuthor = false): ?bool + { + $scope = $scope ?? $this->getAuthorizeScope(); + + $isMe = null === $user; + if ($isMe) { + $user = $this->getActiveUser(); + } + + if (null === $user) { + return false; + } + + return $this->isAuthorizedByGroup($user, $action, $scope, $isMe, $isAuthor); + } + + /** + * @param UserInterface $user + * @param string $action + * @param string $scope + * @param bool $isMe + * @return bool|null + */ + protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe): ?bool + { + if ($action === 'delete' && $this->root()) { + // Do not allow deleting root. + return false; + } + + $isAuthor = !$isMe || $user->authorized ? $this->hasAuthor($user->username) : false; + + return $this->isAuthorizedByGroup($user, $action, $scope, $isMe, $isAuthor) ?? parent::isAuthorizedOverride($user, $action, $scope, $isMe); + } + + /** + * Group authorization works as follows: + * + * 1. if any of the groups deny access, return false + * 2. else if any of the groups allow access, return true + * 3. else return null + * + * @param UserInterface $user + * @param string $action + * @param string $scope + * @param bool $isMe + * @param bool $isAuthor + * @return bool|null + */ + protected function isAuthorizedByGroup(UserInterface $user, string $action, string $scope, bool $isMe, bool $isAuthor): ?bool + { + $authorized = null; + + // In admin we want to check against group permissions. + $pageGroups = $this->getPermissions(); + $userGroups = (array)$user->groups; + + /** @var Access $access */ + foreach ($pageGroups as $group => $access) { + if ($group === 'defaults') { + // Special defaults permissions group does not apply to guest. + if ($isMe && !$user->authorized) { + continue; + } + } elseif ($group === 'authors') { + if (!$isAuthor) { + continue; + } + } elseif (!in_array($group, $userGroups, true)) { + continue; + } + + $auth = $access->authorize($action); + if (is_bool($auth)) { + if ($auth === false) { + return false; + } + + $authorized = true; + } + } + + if (null === $authorized && $this->getNestedProperty('header.permissions.inherit', true)) { + // Authorize against parent page. + $parent = $this->parent(); + if ($parent && method_exists($parent, 'isParentAuthorized')) { + $authorized = $parent->isParentAuthorized($action, $scope, !$isMe ? $user : null, $isAuthor); + } + } + + return $authorized; + } + + /** + * @param array $parent + * @return array + */ + protected function loadPermissions(array $parent = []): array + { + static $rules = [ + 'c' => 'create', + 'r' => 'read', + 'u' => 'update', + 'd' => 'delete', + 'p' => 'publish', + 'l' => 'list' + ]; + + $permissions = $this->getNestedProperty('header.permissions.groups'); + $name = $this->root() ? '' : '/' . $this->getKey(); + + $list = []; + if (is_array($permissions)) { + foreach ($permissions as $group => $access) { + $list[$group] = new Access($access, $rules, $name); + } + } + foreach ($parent as $group => $access) { + if (isset($list[$group])) { + $object = $list[$group]; + } else { + $object = new Access([], $rules, $name); + $list[$group] = $object; + } + + $object->inherit($access); + } + + return $list; + } +} diff --git a/system/src/Grav/Framework/Flex/Pages/Traits/PageContentTrait.php b/system/src/Grav/Framework/Flex/Pages/Traits/PageContentTrait.php new file mode 100644 index 0000000..6aff732 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/Traits/PageContentTrait.php @@ -0,0 +1,840 @@ + 'slug', + 'routes' => false, + 'title' => 'title', + 'language' => 'language', + 'template' => 'template', + 'menu' => 'menu', + 'routable' => 'routable', + 'visible' => 'visible', + 'redirect' => 'redirect', + 'external_url' => false, + 'order_dir' => 'orderDir', + 'order_by' => 'orderBy', + 'order_manual' => 'orderManual', + 'dateformat' => 'dateformat', + 'date' => 'date', + 'markdown_extra' => false, + 'taxonomy' => 'taxonomy', + 'max_count' => 'maxCount', + 'process' => 'process', + 'published' => 'published', + 'publish_date' => 'publishDate', + 'unpublish_date' => 'unpublishDate', + 'expires' => 'expires', + 'cache_control' => 'cacheControl', + 'etag' => 'eTag', + 'last_modified' => 'lastModified', + 'ssl' => 'ssl', + 'template_format' => 'templateFormat', + 'debugger' => false, + ]; + + /** @var array */ + protected static $calculatedProperties = [ + 'name' => 'name', + 'parent' => 'parent', + 'parent_key' => 'parentStorageKey', + 'folder' => 'folder', + 'order' => 'order', + 'template' => 'template', + ]; + + /** @var object */ + protected $header; + + /** @var string */ + protected $_summary; + + /** @var string */ + protected $_content; + + /** + * Method to normalize the route. + * + * @param string $route + * @return string + * @internal + */ + public static function normalizeRoute($route): string + { + $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls'); + + return $case_insensitive ? mb_strtolower($route) : $route; + } + + /** + * @inheritdoc + */ + public function header($var = null) + { + if (null !== $var) { + $this->setProperty('header', $var); + } + + return $this->getProperty('header'); + } + + /** + * @inheritdoc + */ + public function summary($size = null, $textOnly = false): string + { + return $this->processSummary($size, $textOnly); + } + + /** + * @inheritdoc + */ + public function setSummary($summary): void + { + $this->_summary = $summary; + } + + /** + * @inheritdoc + * @throws Exception + */ + public function content($var = null): string + { + if (null !== $var) { + $this->_content = $var; + } + + return $this->_content ?? $this->processContent($this->getRawContent()); + } + + /** + * @inheritdoc + */ + public function getRawContent(): string + { + return $this->_content ?? $this->getArrayProperty('markdown') ?? ''; + } + + /** + * @inheritdoc + */ + public function setRawContent($content): void + { + $this->_content = $content ?? ''; + } + + /** + * @inheritdoc + */ + public function rawMarkdown($var = null): string + { + if ($var !== null) { + $this->setProperty('markdown', $var); + } + + return $this->getProperty('markdown') ?? ''; + } + + /** + * @inheritdoc + * + * Implement by calling: + * + * $test = new \stdClass(); + * $value = $this->pageContentValue($name, $test); + * if ($value !== $test) { + * return $value; + * } + * return parent::value($name, $default); + */ + abstract public function value($name, $default = null, $separator = null); + + /** + * @inheritdoc + */ + public function media($var = null): Media + { + if ($var instanceof Media) { + $this->setProperty('media', $var); + } + + return $this->getProperty('media'); + } + + /** + * @inheritdoc + */ + public function title($var = null): string + { + return $this->loadHeaderProperty( + 'title', + $var, + function ($value) { + return trim($value ?? ($this->root() ? '' : ucfirst($this->slug()))); + } + ); + } + + /** + * @inheritdoc + */ + public function menu($var = null): string + { + return $this->loadHeaderProperty( + 'menu', + $var, + function ($value) { + return trim($value ?: $this->title()); + } + ); + } + + /** + * @inheritdoc + */ + public function visible($var = null): bool + { + $value = $this->loadHeaderProperty( + 'visible', + $var, + function ($value) { + return ($value ?? $this->order() !== false) && !$this->isModule(); + } + ); + + return $value && $this->published(); + } + + /** + * @inheritdoc + */ + public function published($var = null): bool + { + return $this->loadHeaderProperty( + 'published', + $var, + static function ($value) { + return (bool)($value ?? true); + } + ); + } + + /** + * @inheritdoc + */ + public function publishDate($var = null): ?int + { + return $this->loadHeaderProperty( + 'publish_date', + $var, + function ($value) { + return $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : null; + } + ); + } + + /** + * @inheritdoc + */ + public function unpublishDate($var = null): ?int + { + return $this->loadHeaderProperty( + 'unpublish_date', + $var, + function ($value) { + return $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : null; + } + ); + } + + /** + * @inheritdoc + */ + public function process($var = null): array + { + return $this->loadHeaderProperty( + 'process', + $var, + function ($value) { + $value = array_replace(Grav::instance()['config']->get('system.pages.process', []), is_array($value) ? $value : []) ?? []; + foreach ($value as $process => $status) { + $value[$process] = (bool)$status; + } + + return $value; + } + ); + } + + /** + * @inheritdoc + */ + public function slug($var = null) + { + return $this->loadHeaderProperty( + 'slug', + $var, + function ($value) { + if (is_string($value)) { + return $value; + } + + $folder = $this->folder(); + if (null === $folder) { + return null; + } + + $folder = preg_replace(static::PAGE_ORDER_PREFIX_REGEX, '', $folder); + if (null === $folder) { + return null; + } + + return static::normalizeRoute($folder); + } + ); + } + + /** + * @inheritdoc + */ + public function order($var = null) + { + $property = $this->loadProperty( + 'order', + $var, + function ($value) { + if (null === $value) { + $folder = $this->folder(); + if (null !== $folder) { + preg_match(static::PAGE_ORDER_REGEX, $folder, $order); + } + + $value = $order[1] ?? false; + } + + if ($value === '') { + $value = false; + } + if ($value !== false) { + $value = (int)$value; + } + + return $value; + } + ); + + return $property !== false ? sprintf('%02d.', $property) : false; + } + + /** + * @inheritdoc + */ + public function id($var = null): string + { + $property = 'id'; + $value = null === $var ? $this->getProperty($property) : null; + if (null === $value) { + $value = $this->language() . ($var ?? ($this->modified() . md5('flex-' . $this->getFlexType() . '-' . $this->getKey()))); + + $this->setProperty($property, $value); + if ($this->doHasProperty($property)) { + $value = $this->getProperty($property); + } + } + + return $value; + } + + /** + * @inheritdoc + */ + public function modified($var = null): int + { + $property = 'modified'; + $value = null === $var ? $this->getProperty($property) : null; + if (null === $value) { + $value = (int)($var ?: $this->getTimestamp()); + + $this->setProperty($property, $value); + if ($this->doHasProperty($property)) { + $value = $this->getProperty($property); + } + } + + return $value; + } + + /** + * @inheritdoc + */ + public function lastModified($var = null): bool + { + return $this->loadHeaderProperty( + 'last_modified', + $var, + static function ($value) { + return (bool)($value ?? Grav::instance()['config']->get('system.pages.last_modified')); + } + ); + } + + /** + * @inheritdoc + */ + public function date($var = null): int + { + return $this->loadHeaderProperty( + 'date', + $var, + function ($value) { + $value = $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : false; + + return $value ?: $this->modified(); + } + ); + } + + /** + * @inheritdoc + */ + public function dateformat($var = null): ?string + { + return $this->loadHeaderProperty( + 'dateformat', + $var, + static function ($value) { + return $value; + } + ); + } + + /** + * @inheritdoc + */ + public function taxonomy($var = null): array + { + return $this->loadHeaderProperty( + 'taxonomy', + $var, + static function ($value) { + if (is_array($value)) { + // make sure first level are arrays + array_walk($value, static function (&$val) { + $val = (array) $val; + }); + // make sure all values are strings + array_walk_recursive($value, static function (&$val) { + $val = (string) $val; + }); + } + + return $value ?? []; + } + ); + } + + /** + * @inheritdoc + */ + public function shouldProcess($process): bool + { + $test = $this->process(); + + return !empty($test[$process]); + } + + /** + * @inheritdoc + */ + public function isPage(): bool + { + return !in_array($this->template(), ['', 'folder'], true); + } + + /** + * @inheritdoc + */ + public function isDir(): bool + { + return !$this->isPage(); + } + + /** + * @return bool + */ + public function isModule(): bool + { + return $this->modularTwig(); + } + + /** + * @param Header|stdClass|array|null $value + * @return Header + */ + protected function offsetLoad_header($value) + { + if ($value instanceof Header) { + return $value; + } + + if (null === $value) { + $value = []; + } elseif ($value instanceof stdClass) { + $value = (array)$value; + } + + return new Header($value); + } + + /** + * @param Header|stdClass|array|null $value + * @return Header + */ + protected function offsetPrepare_header($value) + { + return $this->offsetLoad_header($value); + } + + /** + * @param Header|null $value + * @return array + */ + protected function offsetSerialize_header(?Header $value) + { + return $value ? $value->toArray() : []; + } + + /** + * @param string $name + * @param mixed|null $default + * @return mixed + */ + protected function pageContentValue($name, $default = null) + { + switch ($name) { + case 'frontmatter': + $frontmatter = $this->getArrayProperty('frontmatter'); + if ($frontmatter === null) { + $header = $this->prepareStorage()['header'] ?? null; + if ($header) { + $formatter = new YamlFormatter(); + $frontmatter = $formatter->encode($header); + } else { + $frontmatter = ''; + } + } + return $frontmatter; + case 'content': + return $this->getProperty('markdown'); + case 'order': + return (string)$this->order(); + case 'menu': + return $this->menu(); + case 'ordering': + return $this->order() !== false ? '1' : '0'; + case 'folder': + $folder = $this->folder(); + + return null !== $folder ? preg_replace(static::PAGE_ORDER_PREFIX_REGEX, '', $folder) : ''; + case 'slug': + return $this->slug(); + case 'published': + return $this->published(); + case 'visible': + return $this->visible(); + case 'media': + return $this->media()->all(); + case 'media.file': + return $this->media()->files(); + case 'media.video': + return $this->media()->videos(); + case 'media.image': + return $this->media()->images(); + case 'media.audio': + return $this->media()->audios(); + } + + return $default; + } + + /** + * @param int|null $size + * @param bool $textOnly + * @return string + */ + protected function processSummary($size = null, $textOnly = false): string + { + $config = (array)Grav::instance()['config']->get('site.summary'); + $config_page = (array)$this->getNestedProperty('header.summary'); + if ($config_page) { + $config = array_merge($config, $config_page); + } + + // Summary is not enabled, return the whole content. + if (empty($config['enabled'])) { + return $this->content(); + } + + $content = $this->_summary ?? $this->content(); + if ($textOnly) { + $content = strip_tags($content); + } + $content_size = mb_strwidth($content, 'utf-8'); + $summary_size = $this->_summary !== null ? $content_size : $this->getProperty('summary_size'); + + // Return calculated summary based on summary divider's position. + $format = $config['format'] ?? ''; + + // Return entire page content on wrong/unknown format. + if ($format !== 'long' && $format !== 'short') { + return $content; + } + + if ($format === 'short' && null !== $summary_size) { + // Slice the string on breakpoint. + if ($content_size > $summary_size) { + return mb_substr($content, 0, $summary_size); + } + + return $content; + } + + // If needed, get summary size from the config. + $size = $size ?? $config['size'] ?? null; + + // Return calculated summary based on defaults. + $size = is_numeric($size) ? (int)$size : -1; + if ($size < 0) { + $size = 300; + } + + // If the size is zero or smaller than the summary limit, return the entire page content. + if ($size === 0 || $content_size <= $size) { + return $content; + } + + // Only return string but not html, wrap whatever html tag you want when using. + if ($textOnly) { + return mb_strimwidth($content, 0, $size, '...', 'UTF-8'); + } + + $summary = Utils::truncateHTML($content, $size); + + return html_entity_decode($summary, ENT_COMPAT | ENT_HTML5, 'UTF-8'); + } + + /** + * Gets and Sets the content based on content portion of the .md file + * + * @param string $content + * @return string + * @throws Exception + */ + protected function processContent($content): string + { + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + $process_markdown = $this->shouldProcess('markdown'); + $process_twig = $this->shouldProcess('twig') || $this->isModule(); + $cache_enable = $this->getNestedProperty('header.cache_enable') ?? $config->get('system.cache.enabled', true); + + $twig_first = $this->getNestedProperty('header.twig_first') ?? $config->get('system.pages.twig_first', false); + $never_cache_twig = $this->getNestedProperty('header.never_cache_twig') ?? $config->get('system.pages.never_cache_twig', false); + + $cached = null; + if ($cache_enable) { + $cache = $this->getCache('render'); + $key = md5($this->getCacheKey() . '-content'); + $cached = $cache->get($key); + if ($cached && $cached['checksum'] === $this->getCacheChecksum()) { + $this->_content = $cached['content'] ?? ''; + $this->_content_meta = $cached['content_meta'] ?? null; + + if ($process_twig && $never_cache_twig) { + $this->_content = $this->processTwig($this->_content); + } + } else { + $cached = null; + } + } + + if (!$cached) { + $markdown_options = []; + if ($process_markdown) { + // Build markdown options. + $markdown_options = (array)$config->get('system.pages.markdown'); + $markdown_page_options = (array)$this->getNestedProperty('header.markdown'); + if ($markdown_page_options) { + $markdown_options = array_merge($markdown_options, $markdown_page_options); + } + + // pages.markdown_extra is deprecated, but still check it... + if (!isset($markdown_options['extra'])) { + $extra = $this->getNestedProperty('header.markdown_extra') ?? $config->get('system.pages.markdown_extra'); + if (null !== $extra) { + user_error('Configuration option \'system.pages.markdown_extra\' is deprecated since Grav 1.5, use \'system.pages.markdown.extra\' instead', E_USER_DEPRECATED); + + $markdown_options['extra'] = $extra; + } + } + } + $options = [ + 'markdown' => $markdown_options, + 'images' => $config->get('system.images', []) + ]; + + $this->_content = $content; + $grav->fireEvent('onPageContentRaw', new Event(['page' => $this])); + + if ($twig_first && !$never_cache_twig) { + if ($process_twig) { + $this->_content = $this->processTwig($this->_content); + } + + if ($process_markdown) { + $this->_content = $this->processMarkdown($this->_content, $options); + } + + // Content Processed but not cached yet + $grav->fireEvent('onPageContentProcessed', new Event(['page' => $this])); + } else { + if ($process_markdown) { + $options['keep_twig'] = $process_twig; + $this->_content = $this->processMarkdown($this->_content, $options); + } + + // Content Processed but not cached yet + $grav->fireEvent('onPageContentProcessed', new Event(['page' => $this])); + + if ($cache_enable && $never_cache_twig) { + $this->cachePageContent(); + } + + if ($process_twig) { + $this->_content = $this->processTwig($this->_content); + } + } + + if ($cache_enable && !$never_cache_twig) { + $this->cachePageContent(); + } + } + + // Handle summary divider + $delimiter = $config->get('site.summary.delimiter', '==='); + $divider_pos = mb_strpos($this->_content, "

    {$delimiter}

    "); + if ($divider_pos !== false) { + $this->setProperty('summary_size', $divider_pos); + $this->_content = str_replace("

    {$delimiter}

    ", '', $this->_content); + } + + // Fire event when Page::content() is called + $grav->fireEvent('onPageContent', new Event(['page' => $this])); + + return $this->_content; + } + + /** + * Process the Twig page content. + * + * @param string $content + * @return string + */ + protected function processTwig($content): string + { + /** @var Twig $twig */ + $twig = Grav::instance()['twig']; + + /** @var PageInterface $this */ + return $twig->processPage($this, $content); + } + + /** + * Process the Markdown content. + * + * Uses Parsedown or Parsedown Extra depending on configuration. + * + * @param string $content + * @param array $options + * @return string + * @throws Exception + */ + protected function processMarkdown($content, array $options = []): string + { + /** @var PageInterface $self */ + $self = $this; + + $excerpts = new Excerpts($self, $options); + + // Initialize the preferred variant of markdown parser. + if (isset($options['extra'])) { + $parsedown = new ParsedownExtra($excerpts); + } else { + $parsedown = new Parsedown($excerpts); + } + + $keepTwig = (bool)($options['keep_twig'] ?? false); + if ($keepTwig) { + $token = [ + '/' . Utils::generateRandomString(3), + Utils::generateRandomString(3) . '/' + ]; + // Base64 encode any twig. + $content = preg_replace_callback( + ['/({#.*?#})/mu', '/({{.*?}})/mu', '/({%.*?%})/mu'], + static function ($matches) use ($token) { return $token[0] . base64_encode($matches[1]) . $token[1]; }, + $content + ); + } + + $content = $parsedown->text($content); + + if ($keepTwig) { + // Base64 decode the encoded twig. + $content = preg_replace_callback( + ['`' . $token[0] . '([A-Za-z0-9+/]+={0,2})' . $token[1] . '`mu'], + static function ($matches) { return base64_decode($matches[1]); }, + $content + ); + } + + return $content; + } + + abstract protected function loadHeaderProperty(string $property, $var, callable $filter); +} diff --git a/system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php b/system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php new file mode 100644 index 0000000..aff0257 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php @@ -0,0 +1,1118 @@ +getFlexDirectory()->getStorage(); + if (method_exists($storage, 'readRaw')) { + return $storage->readRaw($this->getStorageKey()); + } + + $array = $this->prepareStorage(); + $formatter = new MarkdownFormatter(); + + return $formatter->encode($array); + } + + /** + * Gets and Sets the page frontmatter + * + * @param string|null $var + * @return string + */ + public function frontmatter($var = null): string + { + if (null !== $var) { + $formatter = new YamlFormatter(); + $this->setProperty('frontmatter', $var); + $this->setProperty('header', $formatter->decode($var)); + + return $var; + } + + $storage = $this->getFlexDirectory()->getStorage(); + if (method_exists($storage, 'readFrontmatter')) { + return $storage->readFrontmatter($this->getStorageKey()); + } + + $array = $this->prepareStorage(); + $formatter = new YamlFormatter(); + + return $formatter->encode($array['header'] ?? []); + } + + /** + * Modify a header value directly + * + * @param string $key + * @param string|array $value + * @return void + */ + public function modifyHeader($key, $value): void + { + $this->setNestedProperty("header.{$key}", $value); + } + + /** + * @return int + */ + public function httpResponseCode(): int + { + $code = (int)$this->getNestedProperty('header.http_response_code'); + + return $code ?: 200; + } + + /** + * @return array + */ + public function httpHeaders(): array + { + $headers = []; + + $format = $this->templateFormat(); + $cache_control = $this->cacheControl(); + $expires = $this->expires(); + + // Set Content-Type header. + $headers['Content-Type'] = Utils::getMimeByExtension($format, 'text/html'); + + // Calculate Expires Headers if set to > 0. + if ($expires > 0) { + $expires_date = gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT'; + if (!$cache_control) { + $headers['Cache-Control'] = 'max-age=' . $expires; + } + $headers['Expires'] = $expires_date; + } + + // Set Cache-Control header. + if ($cache_control) { + $headers['Cache-Control'] = strtolower($cache_control); + } + + // Set Last-Modified header. + if ($this->lastModified()) { + $last_modified_date = gmdate('D, d M Y H:i:s', $this->modified()) . ' GMT'; + $headers['Last-Modified'] = $last_modified_date; + } + + // Calculate ETag based on the serialized page and modified time. + if ($this->eTag()) { + $headers['ETag'] = '1'; + } + + // Set Vary: Accept-Encoding header. + $grav = Grav::instance(); + if ($grav['config']->get('system.pages.vary_accept_encoding', false)) { + $headers['Vary'] = 'Accept-Encoding'; + } + + return $headers; + } + + /** + * Get the contentMeta array and initialize content first if it's not already + * + * @return array + */ + public function contentMeta(): array + { + // Content meta is generated during the content is being rendered, so make sure we have done it. + $this->content(); + + return $this->_content_meta ?? []; + } + + /** + * Add an entry to the page's contentMeta array + * + * @param string $name + * @param string $value + * @return void + */ + public function addContentMeta($name, $value): void + { + $this->_content_meta[$name] = $value; + } + + /** + * Return the whole contentMeta array as it currently stands + * + * @param string|null $name + * @return string|array|null + */ + public function getContentMeta($name = null) + { + if ($name) { + return $this->_content_meta[$name] ?? null; + } + + return $this->_content_meta ?? []; + } + + /** + * Sets the whole content meta array in one shot + * + * @param array $content_meta + * @return array + */ + public function setContentMeta($content_meta): array + { + return $this->_content_meta = $content_meta; + } + + /** + * Fires the onPageContentProcessed event, and caches the page content using a unique ID for the page + */ + public function cachePageContent(): void + { + $value = [ + 'checksum' => $this->getCacheChecksum(), + 'content' => $this->_content, + 'content_meta' => $this->_content_meta + ]; + + $cache = $this->getCache('render'); + $key = md5($this->getCacheKey() . '-content'); + + $cache->set($key, $value); + } + + /** + * Get file object to the page. + * + * @return MarkdownFile|null + */ + public function file(): ?MarkdownFile + { + // TODO: + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Prepare move page to new location. Moves also everything that's under the current page. + * + * You need to call $this->save() in order to perform the move. + * + * @param PageInterface $parent New parent page. + * @return $this + */ + public function move(PageInterface $parent) + { + if ($this->route() === $parent->route()) { + throw new RuntimeException('Failed: Cannot set page parent to self'); + } + $rawRoute = $this->rawRoute(); + if ($rawRoute && Utils::startsWith($parent->rawRoute(), $rawRoute)) { + throw new RuntimeException('Failed: Cannot set page parent to a child of current page'); + } + + $this->storeOriginal(); + + // TODO: + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Prepare a copy from the page. Copies also everything that's under the current page. + * + * Returns a new Page object for the copy. + * You need to call $this->save() in order to perform the move. + * + * @param PageInterface|null $parent New parent page. + * @return $this + */ + public function copy(PageInterface $parent = null) + { + $this->storeOriginal(); + + $filesystem = Filesystem::getInstance(false); + + $parentStorageKey = ltrim($filesystem->dirname("/{$this->getMasterKey()}"), '/'); + + /** @var FlexPageIndex $index */ + $index = $this->getFlexDirectory()->getIndex(); + + if ($parent) { + if ($parent instanceof FlexPageObject) { + $k = $parent->getMasterKey(); + if ($k !== $parentStorageKey) { + $parentStorageKey = $k; + } + } else { + throw new RuntimeException('Cannot copy page, parent is of unknown type'); + } + } else { + $parent = $parentStorageKey + ? $this->getFlexDirectory()->getObject($parentStorageKey, 'storage_key') + : (method_exists($index, 'getRoot') ? $index->getRoot() : null); + } + + // Find non-existing key. + $parentKey = $parent ? $parent->getKey() : ''; + if ($this instanceof FlexPageObject) { + $key = trim($parentKey . '/' . $this->folder(), '/'); + } else { + $key = trim($parentKey . '/' . basename($this->getKey()), '/'); + } + + if ($index->containsKey($key)) { + $key = preg_replace('/\d+$/', '', $key); + $i = 1; + do { + $i++; + $test = "{$key}{$i}"; + } while ($index->containsKey($test)); + $key = $test; + } + $folder = basename($key); + + // Get the folder name. + $order = $this->getProperty('order'); + if ($order) { + $order++; + } + + $parts = []; + if ($parentStorageKey !== '') { + $parts[] = $parentStorageKey; + } + $parts[] = $order ? sprintf('%02d.%s', $order, $folder) : $folder; + + // Finally update the object. + $this->setKey($key); + $this->setStorageKey(implode('/', $parts)); + + $this->markAsCopy(); + + return $this; + } + + /** + * Get the blueprint name for this page. Use the blueprint form field if set + * + * @return string + */ + public function blueprintName(): string + { + $blueprint_name = filter_input(INPUT_POST, 'blueprint', FILTER_SANITIZE_STRING) ?: $this->template(); + + return $blueprint_name; + } + + /** + * Validate page header. + * + * @return void + * @throws Exception + */ + public function validate(): void + { + $blueprint = $this->getBlueprint(); + $blueprint->validate($this->toArray()); + } + + /** + * Filter page header from illegal contents. + * + * @return void + */ + public function filter(): void + { + $blueprints = $this->getBlueprint(); + $values = $blueprints->filter($this->toArray()); + if ($values && isset($values['header'])) { + $this->header($values['header']); + } + } + + /** + * Get unknown header variables. + * + * @return array + */ + public function extra(): array + { + $data = $this->prepareStorage(); + + return $this->getBlueprint()->extra((array)($data['header'] ?? []), 'header.'); + } + + /** + * Convert page to an array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'header' => (array)$this->header(), + 'content' => (string)$this->getFormValue('content') + ]; + } + + /** + * Convert page to YAML encoded string. + * + * @return string + */ + public function toYaml(): string + { + return Yaml::dump($this->toArray(), 20); + } + + /** + * Convert page to JSON encoded string. + * + * @return string + */ + public function toJson(): string + { + $json = json_encode($this->toArray()); + if (!is_string($json)) { + throw new RuntimeException('Internal error'); + } + + return $json; + } + + /** + * Gets and sets the name field. If no name field is set, it will return 'default.md'. + * + * @param string|null $var The name of this page. + * @return string The name of this page. + */ + public function name($var = null): string + { + return $this->loadProperty( + 'name', + $var, + function ($value) { + $value = $value ?? $this->getMetaData()['template'] ?? 'default'; + if (!preg_match('/\.md$/', $value)) { + $language = $this->language(); + if ($language) { + // TODO: better language support + $value .= ".{$language}"; + } + $value .= '.md'; + } + $value = preg_replace('|^modular/|', '', $value); + + $this->unsetProperty('template'); + + return $value; + } + ); + } + + /** + * Returns child page type. + * + * @return string + */ + public function childType(): string + { + return (string)$this->getNestedProperty('header.child_type'); + } + + /** + * Gets and sets the template field. This is used to find the correct Twig template file to render. + * If no field is set, it will return the name without the .md extension + * + * @param string|null $var the template name + * @return string the template name + */ + public function template($var = null): string + { + return $this->loadHeaderProperty( + 'template', + $var, + function ($value) { + return trim($value ?? (($this->isModule() ? 'modular/' : '') . str_replace($this->extension(), '', $this->name()))); + } + ); + } + + /** + * Allows a page to override the output render format, usually the extension provided in the URL. + * (e.g. `html`, `json`, `xml`, etc). + * + * @param string|null $var + * @return string + */ + public function templateFormat($var = null): string + { + return $this->loadHeaderProperty( + 'template_format', + $var, + function ($value) { + return ltrim($value ?? $this->getNestedProperty('header.append_url_extension') ?: Utils::getPageFormat(), '.'); + } + ); + } + + /** + * Gets and sets the extension field. + * + * @param string|null $var + * @return string + */ + public function extension($var = null): string + { + if (null !== $var) { + $this->setProperty('format', $var); + } + + $language = $this->language(); + if ($language) { + $language = '.' . $language; + } + $format = '.' . ($this->getProperty('format') ?? pathinfo($this->name(), PATHINFO_EXTENSION)); + + return $language . $format; + } + + /** + * Gets and sets the expires field. If not set will return the default + * + * @param int|null $var The new expires value. + * @return int The expires value + */ + public function expires($var = null): int + { + return $this->loadHeaderProperty( + 'expires', + $var, + static function ($value) { + return (int)($value ?? Grav::instance()['config']->get('system.pages.expires')); + } + ); + } + + /** + * Gets and sets the cache-control property. If not set it will return the default value (null) + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for more details on valid options + * + * @param string|null $var + * @return string|null + */ + public function cacheControl($var = null): ?string + { + return $this->loadHeaderProperty( + 'cache_control', + $var, + static function ($value) { + return ((string)($value ?? Grav::instance()['config']->get('system.pages.cache_control'))) ?: null; + } + ); + } + + /** + * @param bool|null $var + * @return bool|null + */ + public function ssl($var = null): ?bool + { + return $this->loadHeaderProperty( + 'ssl', + $var, + static function ($value) { + return $value ? (bool)$value : null; + } + ); + } + + /** + * Returns the state of the debugger override setting for this page + * + * @return bool + */ + public function debugger(): bool + { + return (bool)$this->getNestedProperty('header.debugger', true); + } + + /** + * Function to merge page metadata tags and build an array of Metadata objects + * that can then be rendered in the page. + * + * @param array|null $var an Array of metadata values to set + * @return array an Array of metadata values for the page + */ + public function metadata($var = null): array + { + if ($var !== null) { + $this->_metadata = (array)$var; + } + + // if not metadata yet, process it. + if (null === $this->_metadata) { + $this->_metadata = []; + + $config = Grav::instance()['config']; + + // Set the Generator tag + $defaultMetadata = ['generator' => 'GravCMS']; + $siteMetadata = $config->get('site.metadata', []); + $headerMetadata = $this->getNestedProperty('header.metadata', []); + + // Get initial metadata for the page + $metadata = array_merge($defaultMetadata, $siteMetadata, $headerMetadata); + + $header_tag_http_equivs = ['content-type', 'default-style', 'refresh', 'x-ua-compatible', 'content-security-policy']; + $escape = !$config->get('system.strict_mode.twig_compat', false) || $config->get('system.twig.autoescape', true); + + // Build an array of meta objects.. + foreach ($metadata as $key => $value) { + // Lowercase the key + $key = strtolower($key); + + // If this is a property type metadata: "og", "twitter", "facebook" etc + // Backward compatibility for nested arrays in metas + if (is_array($value)) { + foreach ($value as $property => $prop_value) { + $prop_key = $key . ':' . $property; + $this->_metadata[$prop_key] = [ + 'name' => $prop_key, + 'property' => $prop_key, + 'content' => $escape ? htmlspecialchars($prop_value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $prop_value + ]; + } + } elseif ($value) { + // If it this is a standard meta data type + if (in_array($key, $header_tag_http_equivs, true)) { + $this->_metadata[$key] = [ + 'http_equiv' => $key, + 'content' => $escape ? htmlspecialchars($value, ENT_COMPAT, 'UTF-8') : $value + ]; + } elseif ($key === 'charset') { + $this->_metadata[$key] = ['charset' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value]; + } else { + // if it's a social metadata with separator, render as property + $separator = strpos($key, ':'); + $hasSeparator = $separator && $separator < strlen($key) - 1; + $entry = [ + 'content' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value + ]; + + if ($hasSeparator && !Utils::startsWith($key, 'twitter')) { + $entry['property'] = $key; + } else { + $entry['name'] = $key; + } + + $this->_metadata[$key] = $entry; + } + } + } + } + + return $this->_metadata; + } + + /** + * Reset the metadata and pull from header again + */ + public function resetMetadata(): void + { + $this->_metadata = null; + } + + /** + * Gets and sets the option to show the etag header for the page. + * + * @param bool|null $var show etag header + * @return bool show etag header + */ + public function eTag($var = null): bool + { + return $this->loadHeaderProperty( + 'etag', + $var, + static function ($value) { + return (bool)($value ?? Grav::instance()['config']->get('system.pages.etag')); + } + ); + } + + /** + * Gets and sets the path to the .md file for this Page object. + * + * @param string|null $var the file path + * @return string|null the file path + */ + public function filePath($var = null): ?string + { + if (null !== $var) { + // TODO: + throw new RuntimeException(__METHOD__ . '(string): Not Implemented'); + } + + $folder = $this->getStorageFolder(); + if (!$folder) { + return null; + } + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $folder = $locator->isStream($folder) ? $locator->getResource($folder) : GRAV_ROOT . "/{$folder}"; + + return $folder . '/' . ($this->isPage() ? $this->name() : 'default.md'); + } + + /** + * Gets the relative path to the .md file + * + * @return string|null The relative file path + */ + public function filePathClean(): ?string + { + $folder = $this->getStorageFolder(); + if (!$folder) { + return null; + } + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $folder = $locator->isStream($folder) ? $locator->getResource($folder, false) : $folder; + + return $folder . '/' . ($this->isPage() ? $this->name() : 'default.md'); + } + + /** + * Gets and sets the order by which any sub-pages should be sorted. + * + * @param string|null $var the order, either "asc" or "desc" + * @return string the order, either "asc" or "desc" + */ + public function orderDir($var = null): string + { + return $this->loadHeaderProperty( + 'order_dir', + $var, + static function ($value) { + return strtolower(trim($value) ?: Grav::instance()['config']->get('system.pages.order.dir')) === 'desc' ? 'desc' : 'asc'; + } + ); + } + + /** + * Gets and sets the order by which the sub-pages should be sorted. + * + * default - is the order based on the file system, ie 01.Home before 02.Advark + * title - is the order based on the title set in the pages + * date - is the order based on the date set in the pages + * folder - is the order based on the name of the folder with any numerics omitted + * + * @param string|null $var supported options include "default", "title", "date", and "folder" + * @return string supported options include "default", "title", "date", and "folder" + */ + public function orderBy($var = null): string + { + return $this->loadHeaderProperty( + 'order_by', + $var, + static function ($value) { + return trim($value) ?: Grav::instance()['config']->get('system.pages.order.by'); + } + ); + } + + /** + * Gets the manual order set in the header. + * + * @param string|null $var supported options include "default", "title", "date", and "folder" + * @return array + */ + public function orderManual($var = null): array + { + return $this->loadHeaderProperty( + 'order_manual', + $var, + static function ($value) { + return (array)$value; + } + ); + } + + /** + * Gets and sets the maxCount field which describes how many sub-pages should be displayed if the + * sub_pages header property is set for this page object. + * + * @param int|null $var the maximum number of sub-pages + * @return int the maximum number of sub-pages + */ + public function maxCount($var = null): int + { + return $this->loadHeaderProperty( + 'max_count', + $var, + static function ($value) { + return (int)($value ?? Grav::instance()['config']->get('system.pages.list.count')); + } + ); + } + + /** + * Gets and sets the modular var that helps identify this page is a modular child + * + * @param bool|null $var true if modular_twig + * @return bool true if modular_twig + * @deprecated 1.7 Use ->isModule() or ->modularTwig() method instead. + */ + public function modular($var = null): bool + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->isModule() or ->modularTwig() method instead', E_USER_DEPRECATED); + + return $this->modularTwig($var); + } + + /** + * Gets and sets the modular_twig var that helps identify this page as a modular child page that will need + * twig processing handled differently from a regular page. + * + * @param bool|null $var true if modular_twig + * @return bool true if modular_twig + */ + public function modularTwig($var = null): bool + { + if ($var !== null) { + $this->setProperty('modular_twig', (bool)$var); + if ($var) { + $this->visible(false); + } + } + + return (bool)($this->getProperty('modular_twig') ?? strpos($this->slug(), '_') === 0); + } + + /** + * Returns children of this page. + * + * @return PageCollectionInterface|FlexIndexInterface + */ + public function children() + { + $meta = $this->getMetaData(); + $keys = array_keys($meta['children'] ?? []); + $prefix = $this->getMasterKey(); + if ($prefix) { + foreach ($keys as &$key) { + $key = $prefix . '/' . $key; + } + unset($key); + } + + return $this->getFlexDirectory()->getIndex($keys, 'storage_key'); + } + + /** + * Check to see if this item is the first in an array of sub-pages. + * + * @return bool True if item is first. + */ + public function isFirst(): bool + { + $parent = $this->parent(); + $children = $parent ? $parent->children() : null; + if ($children instanceof FlexCollectionInterface) { + $children = $children->withKeyField(); + } + + return $children instanceof PageCollectionInterface ? $children->isFirst($this->getKey()) : true; + } + + /** + * Check to see if this item is the last in an array of sub-pages. + * + * @return bool True if item is last + */ + public function isLast(): bool + { + $parent = $this->parent(); + $children = $parent ? $parent->children() : null; + if ($children instanceof FlexCollectionInterface) { + $children = $children->withKeyField(); + } + + return $children instanceof PageCollectionInterface ? $children->isLast($this->getKey()) : true; + } + + /** + * Gets the previous sibling based on current position. + * + * @return PageInterface|false the previous Page item + */ + public function prevSibling() + { + return $this->adjacentSibling(-1); + } + + /** + * Gets the next sibling based on current position. + * + * @return PageInterface|false the next Page item + */ + public function nextSibling() + { + return $this->adjacentSibling(1); + } + + /** + * Returns the adjacent sibling based on a direction. + * + * @param int $direction either -1 or +1 + * @return PageInterface|false the sibling page + */ + public function adjacentSibling($direction = 1) + { + $parent = $this->parent(); + $children = $parent ? $parent->children() : null; + if ($children instanceof FlexCollectionInterface) { + $children = $children->withKeyField(); + } + + if ($children instanceof PageCollectionInterface) { + $child = $children->adjacentSibling($this->getKey(), $direction); + if ($child instanceof PageInterface) { + return $child; + } + } + + return false; + } + + /** + * Helper method to return an ancestor page. + * + * @param string|null $lookup Name of the parent folder + * @return PageInterface|null page you were looking for if it exists + */ + public function ancestor($lookup = null) + { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->ancestor($this->getProperty('parent_route'), $lookup); + } + + /** + * Helper method to return an ancestor page to inherit from. The current + * page object is returned. + * + * @param string $field Name of the parent folder + * @return PageInterface|null + */ + public function inherited($field) + { + [$inherited, $currentParams] = $this->getInheritedParams($field); + + $this->modifyHeader($field, $currentParams); + + return $inherited; + } + + /** + * Helper method to return an ancestor field only to inherit from. The + * first occurrence of an ancestor field will be returned if at all. + * + * @param string $field Name of the parent folder + * @return array + */ + public function inheritedField($field): array + { + [, $currentParams] = $this->getInheritedParams($field); + + return $currentParams; + } + + /** + * Method that contains shared logic for inherited() and inheritedField() + * + * @param string $field Name of the parent folder + * @return array + */ + protected function getInheritedParams($field): array + { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + $inherited = $pages->inherited($this->getProperty('parent_route'), $field); + $inheritedParams = $inherited ? (array)$inherited->value('header.' . $field) : []; + $currentParams = (array)$this->getFormValue('header.' . $field); + if ($inheritedParams && is_array($inheritedParams)) { + $currentParams = array_replace_recursive($inheritedParams, $currentParams); + } + + return [$inherited, $currentParams]; + } + + /** + * Helper method to return a page. + * + * @param string $url the url of the page + * @param bool $all + * @return PageInterface|null page you were looking for if it exists + */ + public function find($url, $all = false) + { + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->find($url, $all); + } + + /** + * Get a collection of pages in the current context. + * + * @param string|array $params + * @param bool $pagination + * @return PageCollectionInterface|Collection + * @throws InvalidArgumentException + */ + public function collection($params = 'content', $pagination = true) + { + if (is_string($params)) { + // Look into a page header field. + $params = (array)$this->getFormValue('header.' . $params); + } elseif (!is_array($params)) { + throw new InvalidArgumentException('Argument should be either header variable name or array of parameters'); + } + + if (!$pagination) { + $params['pagination'] = false; + } + $context = [ + 'pagination' => $pagination, + 'self' => $this + ]; + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->getCollection($params, $context); + } + + /** + * @param string|array $value + * @param bool $only_published + * @return PageCollectionInterface|Collection + */ + public function evaluate($value, $only_published = true) + { + $params = [ + 'items' => $value, + 'published' => $only_published + ]; + $context = [ + 'event' => false, + 'pagination' => false, + 'url_taxonomy_filters' => false, + 'self' => $this + ]; + + /** @var Pages $pages */ + $pages = Grav::instance()['pages']; + + return $pages->getCollection($params, $context); + } + + /** + * Returns whether or not the current folder exists + * + * @return bool + */ + public function folderExists(): bool + { + return $this->exists() || is_dir($this->getStorageFolder() ?? ''); + } + + /** + * Gets the action. + * + * @return string|null The Action string. + */ + public function getAction(): ?string + { + $meta = $this->getMetaData(); + if (!empty($meta['copy'])) { + return 'copy'; + } + if (isset($meta['storage_key']) && $this->getStorageKey() !== $meta['storage_key']) { + return 'move'; + } + + return null; + } +} diff --git a/system/src/Grav/Framework/Flex/Pages/Traits/PageRoutableTrait.php b/system/src/Grav/Framework/Flex/Pages/Traits/PageRoutableTrait.php new file mode 100644 index 0000000..90773cd --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/Traits/PageRoutableTrait.php @@ -0,0 +1,550 @@ +loadHeaderProperty( + 'url_extension', + null, + function ($value) { + if ($this->home()) { + return ''; + } + + return $value ?? Grav::instance()['config']->get('system.pages.append_url_extension', ''); + } + ); + } + + /** + * Gets and Sets whether or not this Page is routable, ie you can reach it via a URL. + * The page must be *routable* and *published* + * + * @param bool|null $var true if the page is routable + * @return bool true if the page is routable + */ + public function routable($var = null): bool + { + $value = $this->loadHeaderProperty( + 'routable', + $var, + static function ($value) { + return $value ?? true; + } + ); + + return $value && $this->published() && !$this->isModule() && !$this->root() && $this->getLanguages(true); + } + + /** + * Gets the URL for a page - alias of url(). + * + * @param bool $include_host + * @return string the permalink + */ + public function link($include_host = false): string + { + return $this->url($include_host); + } + + /** + * Gets the URL with host information, aka Permalink. + * @return string The permalink. + */ + public function permalink(): string + { + return $this->url(true, false, true, true); + } + + /** + * Returns the canonical URL for a page + * + * @param bool $include_lang + * @return string + */ + public function canonical($include_lang = true): string + { + return $this->url(true, true, $include_lang); + } + + /** + * Gets the url for the Page. + * + * @param bool $include_host Defaults false, but true would include http://yourhost.com + * @param bool $canonical true to return the canonical URL + * @param bool $include_base + * @param bool $raw_route + * @return string The url. + */ + public function url($include_host = false, $canonical = false, $include_base = true, $raw_route = false): string + { + // Override any URL when external_url is set + $external = $this->getNestedProperty('header.external_url'); + if ($external) { + return $external; + } + + $grav = Grav::instance(); + + /** @var Pages $pages */ + $pages = $grav['pages']; + + /** @var Config $config */ + $config = $grav['config']; + + // get base route (multi-site base and language) + $route = $include_base ? $pages->baseRoute() : ''; + + // add full route if configured to do so + if (!$include_host && $config->get('system.absolute_urls', false)) { + $include_host = true; + } + + if ($canonical) { + $route .= $this->routeCanonical(); + } elseif ($raw_route) { + $route .= $this->rawRoute(); + } else { + $route .= $this->route(); + } + + /** @var Uri $uri */ + $uri = $grav['uri']; + $url = $uri->rootUrl($include_host) . '/' . trim($route, '/') . $this->urlExtension(); + + return Uri::filterPath($url); + } + + /** + * Gets the route for the page based on the route headers if available, else from + * the parents route and the current Page's slug. + * + * @param string $var Set new default route. + * @return string|null The route for the Page. + */ + public function route($var = null): ?string + { + if (null !== $var) { + // TODO: not the best approach, but works... + $this->setNestedProperty('header.routes.default', $var); + } + + // Return default route if given. + $default = $this->getNestedProperty('header.routes.default'); + if (is_string($default)) { + return $default; + } + + return $this->routeInternal(); + } + + /** + * @return string|null + */ + protected function routeInternal(): ?string + { + $route = $this->_route; + if (null !== $route) { + return $route; + } + + if ($this->root()) { + return null; + } + + // Root and orphan nodes have no route. + $parent = $this->parent(); + if (!$parent) { + return null; + } + + if ($parent->home()) { + /** @var Config $config */ + $config = Grav::instance()['config']; + $hide = (bool)$config->get('system.home.hide_in_urls', false); + $route = '/' . ($hide ? '' : $parent->slug()); + } else { + $route = $parent->route(); + } + + if ($route !== '' && $route !== '/') { + $route .= '/'; + } + + if (!$this->home()) { + $route .= $this->slug(); + } + + $this->_route = $route; + + return $route; + } + + /** + * Helper method to clear the route out so it regenerates next time you use it + */ + public function unsetRouteSlug(): void + { + // TODO: + throw new RuntimeException(__METHOD__ . '(): Not Implemented'); + } + + /** + * Gets and Sets the page raw route + * + * @param string|null $var + * @return string|null + */ + public function rawRoute($var = null): ?string + { + if (null !== $var) { + // TODO: + throw new RuntimeException(__METHOD__ . '(string): Not Implemented'); + } + + if ($this->root()) { + return null; + } + + return '/' . $this->getKey(); + } + + /** + * Gets the route aliases for the page based on page headers. + * + * @param array|null $var list of route aliases + * @return array The route aliases for the Page. + */ + public function routeAliases($var = null): array + { + if (null !== $var) { + $this->setNestedProperty('header.routes.aliases', (array)$var); + } + + $aliases = (array)$this->getNestedProperty('header.routes.aliases'); + $default = $this->getNestedProperty('header.routes.default'); + if ($default) { + $aliases[] = $default; + } + + return $aliases; + } + + /** + * Gets the canonical route for this page if its set. If provided it will use + * that value, else if it's `true` it will use the default route. + * + * @param string|null $var + * @return string|null + */ + public function routeCanonical($var = null): ?string + { + if (null !== $var) { + $this->setNestedProperty('header.routes.canonical', (array)$var); + } + + $canonical = $this->getNestedProperty('header.routes.canonical'); + + return is_string($canonical) ? $canonical : $this->route(); + } + + /** + * Gets the redirect set in the header. + * + * @param string|null $var redirect url + * @return string|null + */ + public function redirect($var = null): ?string + { + return $this->loadHeaderProperty( + 'redirect', + $var, + static function ($value) { + return trim($value) ?: null; + } + ); + } + + /** + * Returns the clean path to the page file + * + * Needed in admin for Page Media. + */ + public function relativePagePath(): ?string + { + $folder = $this->getMediaFolder(); + if (!$folder) { + return null; + } + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $path = $locator->isStream($folder) ? $locator->findResource($folder, false) : $folder; + + return is_string($path) ? $path : null; + } + + /** + * Gets and sets the path to the folder where the .md for this Page object resides. + * This is equivalent to the filePath but without the filename. + * + * @param string|null $var the path + * @return string|null the path + */ + public function path($var = null): ?string + { + if (null !== $var) { + // TODO: + throw new RuntimeException(__METHOD__ . '(string): Not Implemented'); + } + + $path = $this->_path; + if ($path) { + return $path; + } + + if ($this->root()) { + $folder = $this->getFlexDirectory()->getStorageFolder(); + } else { + $folder = $this->getStorageFolder(); + } + + if ($folder) { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $folder = $locator->isStream($folder) ? $locator->getResource($folder) : GRAV_ROOT . "/{$folder}"; + } + + return $this->_path = is_string($folder) ? $folder : null; + } + + /** + * Get/set the folder. + * + * @param string|null $var Optional path, including numeric prefix. + * @return string|null + */ + public function folder($var = null): ?string + { + return $this->loadProperty( + 'folder', + $var, + function ($value) { + if (null === $value) { + $value = $this->getMasterKey() ?: $this->getKey(); + } + + return basename($value) ?: null; + } + ); + } + + /** + * Get/set the folder. + * + * @param string|null $var Optional path, including numeric prefix. + * @return string|null + */ + public function parentStorageKey($var = null): ?string + { + return $this->loadProperty( + 'parent_key', + $var, + function ($value) { + if (null === $value) { + $filesystem = Filesystem::getInstance(false); + $value = $this->getMasterKey() ?: $this->getKey(); + $value = ltrim($filesystem->dirname("/{$value}"), '/') ?: ''; + } + + return $value; + } + ); + } + + /** + * Gets and Sets the parent object for this page + * + * @param PageInterface|null $var the parent page object + * @return PageInterface|null the parent page object if it exists. + */ + public function parent(PageInterface $var = null) + { + if (null !== $var) { + // TODO: + throw new RuntimeException(__METHOD__ . '(PageInterface): Not Implemented'); + } + + if ($this->_parentCache || $this->root()) { + return $this->_parentCache; + } + + // Use filesystem as \dirname() does not work in Windows because of '/foo' becomes '\'. + $filesystem = Filesystem::getInstance(false); + $directory = $this->getFlexDirectory(); + $parentKey = ltrim($filesystem->dirname("/{$this->getKey()}"), '/'); + if ('' !== $parentKey) { + $parent = $directory->getObject($parentKey); + $language = $this->getLanguage(); + if ($language && $parent && method_exists($parent, 'getTranslation')) { + $parent = $parent->getTranslation($language) ?? $parent; + } + + $this->_parentCache = $parent; + } else { + $index = $directory->getIndex(); + + $this->_parentCache = \is_callable([$index, 'getRoot']) ? $index->getRoot() : null; + } + + return $this->_parentCache; + } + + /** + * Gets the top parent object for this page. Can return page itself. + * + * @return PageInterface The top parent page object. + */ + public function topParent() + { + $topParent = $this; + while ($topParent) { + $parent = $topParent->parent(); + if (!$parent || !$parent->parent()) { + break; + } + $topParent = $parent; + } + + return $topParent; + } + + /** + * Returns the item in the current position. + * + * @return int|null the index of the current page. + */ + public function currentPosition(): ?int + { + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; + if ($collection instanceof PageCollectionInterface && $path = $this->path()) { + return $collection->currentPosition($path); + } + + return 1; + } + + /** + * Returns whether or not this page is the currently active page requested via the URL. + * + * @return bool True if it is active + */ + public function active(): bool + { + $grav = Grav::instance(); + $uri_path = rtrim(urldecode($grav['uri']->path()), '/') ?: '/'; + $routes = $grav['pages']->routes(); + + return isset($routes[$uri_path]) && $routes[$uri_path] === $this->path(); + } + + /** + * Returns whether or not this URI's URL contains the URL of the active page. + * Or in other words, is this page's URL in the current URL + * + * @return bool True if active child exists + */ + public function activeChild(): bool + { + $grav = Grav::instance(); + /** @var Uri $uri */ + $uri = $grav['uri']; + /** @var Pages $pages */ + $pages = $grav['pages']; + $uri_path = rtrim(urldecode($uri->path()), '/'); + $routes = $pages->routes(); + + if (isset($routes[$uri_path])) { + $page = $pages->find($uri->route()); + /** @var PageInterface|null $child_page */ + $child_page = $page ? $page->parent() : null; + while ($child_page && !$child_page->root()) { + if ($this->path() === $child_page->path()) { + return true; + } + $child_page = $child_page->parent(); + } + } + + return false; + } + + /** + * Returns whether or not this page is the currently configured home page. + * + * @return bool True if it is the homepage + */ + public function home(): bool + { + $home = Grav::instance()['config']->get('system.home.alias'); + + return '/' . $this->getKey() === $home; + } + + /** + * Returns whether or not this page is the root node of the pages tree. + * + * @param bool|null $var + * @return bool True if it is the root + */ + public function root($var = null): bool + { + if (null !== $var) { + $this->root = (bool)$var; + } + + return $this->root === true || $this->getKey() === '/'; + } +} diff --git a/system/src/Grav/Framework/Flex/Pages/Traits/PageTranslateTrait.php b/system/src/Grav/Framework/Flex/Pages/Traits/PageTranslateTrait.php new file mode 100644 index 0000000..877fec1 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/Traits/PageTranslateTrait.php @@ -0,0 +1,283 @@ +findTranslation($languageCode, $fallback); + + return null !== $code; + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return FlexObjectInterface|PageInterface|null + */ + public function getTranslation(string $languageCode = null, bool $fallback = null) + { + if ($this->root()) { + return $this; + } + + $code = $this->findTranslation($languageCode, $fallback); + if (null === $code) { + $object = null; + } elseif ('' === $code) { + $object = $this->getLanguage() ? $this->getFlexDirectory()->getObject($this->getMasterKey(), 'storage_key') : $this; + } else { + $meta = $this->getMetaData(); + $meta['template'] = $this->getLanguageTemplates()[$code] ?? $meta['template']; + $key = $this->getStorageKey() . '|' . $meta['template'] . '.' . $code; + $meta['storage_key'] = $key; + $meta['lang'] = $code; + $object = $this->getFlexDirectory()->loadObjects([$key => $meta])[$key] ?? null; + } + + return $object; + } + + /** + * @param bool $includeDefault If set to true, return separate entries for '' and 'en' (default) language. + * @return array + */ + public function getAllLanguages(bool $includeDefault = false): array + { + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + $languages = $language->getLanguages(); + if (!$languages) { + return []; + } + + $translated = $this->getLanguageTemplates(); + + if ($includeDefault) { + $languages[] = ''; + } elseif (isset($translated[''])) { + $default = $language->getDefault(); + if (is_bool($default)) { + $default = ''; + } + $translated[$default] = $translated['']; + unset($translated['']); + } + + $languages = array_fill_keys($languages, false); + $translated = array_fill_keys(array_keys($translated), true); + + return array_replace($languages, $translated); + } + + /** + * Returns all translated languages. + * + * @param bool $includeDefault If set to true, return separate entries for '' and 'en' (default) language. + * @return array + */ + public function getLanguages(bool $includeDefault = false): array + { + $languages = $this->getLanguageTemplates(); + + if (!$includeDefault && isset($languages[''])) { + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + $default = $language->getDefault(); + if (is_bool($default)) { + $default = ''; + } + $languages[$default] = $languages['']; + unset($languages['']); + } + + return array_keys($languages); + } + + /** + * @return string + */ + public function getLanguage(): string + { + return $this->language() ?? ''; + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return string|null + */ + public function findTranslation(string $languageCode = null, bool $fallback = null): ?string + { + $translated = $this->getLanguageTemplates(); + + // If there's no translations (including default), we have an empty folder. + if (!$translated) { + return ''; + } + + // FIXME: only published is not implemented... + $languages = $this->getFallbackLanguages($languageCode, $fallback); + + $language = null; + foreach ($languages as $code) { + if (isset($translated[$code])) { + $language = $code; + break; + } + } + + return $language; + } + + /** + * Return an array with the routes of other translated languages + * + * @param bool $onlyPublished only return published translations + * @return array the page translated languages + */ + public function translatedLanguages($onlyPublished = false): array + { + // FIXME: only published is not implemented... + $translated = $this->getLanguageTemplates(); + if (!$translated) { + return $translated; + } + + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + $languages = $language->getLanguages(); + $languages[] = ''; + + $translated = array_intersect_key($translated, array_flip($languages)); + $list = array_fill_keys($languages, null); + foreach ($translated as $languageCode => $languageFile) { + $path = ($languageCode ? '/' : '') . $languageCode; + $list[$languageCode] = "{$path}/{$this->getKey()}"; + } + + return array_filter($list); + } + + /** + * Return an array listing untranslated languages available + * + * @param bool $includeUnpublished also list unpublished translations + * @return array the page untranslated languages + */ + public function untranslatedLanguages($includeUnpublished = false): array + { + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + + $languages = $language->getLanguages(); + $translated = array_keys($this->translatedLanguages(!$includeUnpublished)); + + return array_values(array_diff($languages, $translated)); + } + + /** + * Get page language + * + * @param string|null $var + * @return string|null + */ + public function language($var = null): ?string + { + return $this->loadHeaderProperty( + 'lang', + $var, + function ($value) { + $value = $value ?? $this->getMetaData()['lang'] ?? ''; + + return trim($value) ?: null; + } + ); + } + + /** + * @return array + */ + protected function getLanguageTemplates(): array + { + if (null === $this->_languages) { + $template = $this->getProperty('template'); + $meta = $this->getMetaData(); + $translations = $meta['markdown'] ?? []; + $list = []; + foreach ($translations as $code => $search) { + if (isset($search[$template])) { + // Use main template if possible. + $list[$code] = $template; + } elseif (!empty($search)) { + // Fall back to first matching template. + $list[$code] = key($search); + } + } + + $this->_languages = $list; + } + + return $this->_languages; + } + + /** + * @param string|null $languageCode + * @param bool|null $fallback + * @return array + */ + protected function getFallbackLanguages(string $languageCode = null, bool $fallback = null): array + { + $fallback = $fallback ?? true; + if (!$fallback && null !== $languageCode) { + return [$languageCode]; + } + + $grav = Grav::instance(); + + /** @var Language $language */ + $language = $grav['language']; + $languageCode = $languageCode ?? ($language->getLanguage() ?: ''); + if ($languageCode === '' && $fallback) { + return $language->getFallbackLanguages(null, true); + } + + return $fallback ? $language->getFallbackLanguages($languageCode, true) : [$languageCode]; + } +} diff --git a/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php b/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php index cc460cc..1934f50 100644 --- a/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php +++ b/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -20,9 +20,9 @@ use Grav\Framework\File\Formatter\MarkdownFormatter; use Grav\Framework\File\Formatter\YamlFormatter; use Grav\Framework\File\Interfaces\FileFormatterInterface; use Grav\Framework\Flex\Interfaces\FlexStorageInterface; -use RocketTheme\Toolbox\File\File; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; use RuntimeException; +use function is_array; /** * Class AbstractFilesystemStorage @@ -34,11 +34,22 @@ abstract class AbstractFilesystemStorage implements FlexStorageInterface protected $dataFormatter; /** @var string */ protected $keyField = 'storage_key'; + /** @var int */ + protected $keyLen = 32; + /** @var bool */ + protected $caseSensitive = true; + /** + * @return bool + */ + public function isIndexed(): bool + { + return false; + } /** * {@inheritdoc} - * @see FlexStorageInterface::hasKey() + * @see FlexStorageInterface::hasKeys() */ public function hasKeys(array $keys): array { @@ -51,17 +62,66 @@ abstract class AbstractFilesystemStorage implements FlexStorageInterface } /** - * @return string + * {@inheritDoc} + * @see FlexStorageInterface::getKeyField() */ public function getKeyField(): string { return $this->keyField; } + /** + * @param array $keys + * @param bool $includeParams + * @return string + */ + public function buildStorageKey(array $keys, bool $includeParams = true): string + { + $key = $keys['key'] ?? ''; + $params = $includeParams ? $this->buildStorageKeyParams($keys) : ''; + + return $params ? "{$key}|{$params}" : $key; + } + + /** + * @param array $keys + * @return string + */ + public function buildStorageKeyParams(array $keys): string + { + return ''; + } + + /** + * @param array $row + * @return array + */ + public function extractKeysFromRow(array $row): array + { + return [ + 'key' => $this->normalizeKey($row[$this->keyField] ?? '') + ]; + } + + /** + * @param string $key + * @return array + */ + public function extractKeysFromStorageKey(string $key): array + { + return [ + 'key' => $key + ]; + } + + /** + * @param string|array $formatter + * @return void + */ protected function initDataFormatter($formatter): void { // Initialize formatter. - if (!\is_array($formatter)) { + if (!is_array($formatter)) { $formatter = ['class' => $formatter]; } $formatterClassName = $formatter['class'] ?? JsonFormatter::class; @@ -72,7 +132,7 @@ abstract class AbstractFilesystemStorage implements FlexStorageInterface /** * @param string $filename - * @return null|string + * @return string|null */ protected function detectDataFormatter(string $filename): ?string { @@ -92,12 +152,13 @@ abstract class AbstractFilesystemStorage implements FlexStorageInterface /** * @param string $filename - * @return File + * @return CompiledJsonFile|CompiledYamlFile|CompiledMarkdownFile */ protected function getFile(string $filename) { $filename = $this->resolvePath($filename); + // TODO: start using the new file classes. switch ($this->dataFormatter->getDefaultFileExtension()) { case '.json': $file = CompiledJsonFile::instance($filename); @@ -125,10 +186,10 @@ abstract class AbstractFilesystemStorage implements FlexStorageInterface $locator = Grav::instance()['locator']; if (!$locator->isStream($path)) { - return $path; + return GRAV_ROOT . "/{$path}"; } - return (string)($locator->findResource($path) ?: $locator->findResource($path, true, true)); + return $locator->getResource($path); } /** @@ -138,7 +199,20 @@ abstract class AbstractFilesystemStorage implements FlexStorageInterface */ protected function generateKey(): string { - return substr(hash('sha256', random_bytes(32)), 0, 32); + return substr(hash('sha256', random_bytes($this->keyLen)), 0, $this->keyLen); + } + + /** + * @param string $key + * @return string + */ + public function normalizeKey(string $key): string + { + if ($this->caseSensitive === true) { + return $key; + } + + return mb_strtolower($key); } /** @@ -149,6 +223,6 @@ abstract class AbstractFilesystemStorage implements FlexStorageInterface */ protected function validateKey(string $key): bool { - return (bool) preg_match('/^[^\\/\\?\\*:;{}\\\\\\n]+$/u', $key); + return $key && (bool) preg_match('/^[^\\/?*:;{}\\\\\\n]+$/u', $key); } } diff --git a/system/src/Grav/Framework/Flex/Storage/FileStorage.php b/system/src/Grav/Framework/Flex/Storage/FileStorage.php index e263e6c..eabd658 100644 --- a/system/src/Grav/Framework/Flex/Storage/FileStorage.php +++ b/system/src/Grav/Framework/Flex/Storage/FileStorage.php @@ -5,13 +5,16 @@ declare(strict_types=1); /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Flex\Storage; +use FilesystemIterator; use Grav\Framework\Flex\Interfaces\FlexStorageInterface; +use RuntimeException; +use SplFileInfo; /** * Class FileStorage @@ -25,7 +28,7 @@ class FileStorage extends FolderStorage */ public function __construct(array $options) { - $this->dataPattern = '{FOLDER}/{KEY}'; + $this->dataPattern = '{FOLDER}/{KEY}{EXT}'; if (!isset($options['formatter']) && isset($options['pattern'])) { $options['formatter'] = $this->detectDataFormatter($options['pattern']); @@ -38,9 +41,83 @@ class FileStorage extends FolderStorage * {@inheritdoc} * @see FlexStorageInterface::getMediaPath() */ - public function getMediaPath(string $key = null): string + public function getMediaPath(string $key = null): ?string { - return $key ? \dirname($this->getStoragePath($key)) . '/' . $key : $this->getStoragePath(); + $path = $this->getStoragePath(); + if (!$path) { + return null; + } + + return $key ? "{$path}/{$key}" : $path; + } + + /** + * @param string $src + * @param string $dst + * @return bool + */ + public function copyRow(string $src, string $dst): bool + { + if ($this->hasKey($dst)) { + throw new RuntimeException("Cannot copy object: key '{$dst}' is already taken"); + } + + if (!$this->hasKey($src)) { + return false; + } + + return true; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::renameRow() + */ + public function renameRow(string $src, string $dst): bool + { + if (!$this->hasKey($src)) { + return false; + } + + // Remove old file. + $path = $this->getPathFromKey($src); + $file = $this->getFile($path); + $file->delete(); + $file->free(); + unset($file); + + return true; + } + + /** + * @param string $src + * @param string $dst + * @return bool + */ + protected function copyFolder(string $src, string $dst): bool + { + // Nothing to copy. + return true; + } + + /** + * @param string $src + * @param string $dst + * @return bool + */ + protected function moveFolder(string $src, string $dst): bool + { + // Nothing to move. + return true; + } + + /** + * @param string $key + * @return bool + */ + protected function canDeleteFolder(string $key): bool + { + return false; } /** @@ -56,26 +133,26 @@ class FileStorage extends FolderStorage */ protected function buildIndex(): array { - if (!file_exists($this->getStoragePath())) { + $this->clearCache(); + + $path = $this->getStoragePath(); + if (!$path || !file_exists($path)) { return []; } - $flags = \FilesystemIterator::KEY_AS_PATHNAME | \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS; - $iterator = new \FilesystemIterator($this->getStoragePath(), $flags); + $flags = FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS; + $iterator = new FilesystemIterator($path, $flags); $list = []; - /** @var \SplFileInfo $info */ + /** @var SplFileInfo $info */ foreach ($iterator as $filename => $info) { if (!$info->isFile() || !($key = $this->getKeyFromPath($filename)) || strpos($info->getFilename(), '.') === 0) { continue; } - $list[$key] = [ - 'storage_key' => $key, - 'storage_timestamp' => $info->getMTime() - ]; + $list[$key] = $this->getObjectMeta($key); } - ksort($list, SORT_NATURAL); + ksort($list, SORT_NATURAL | SORT_FLAG_CASE); return $list; } diff --git a/system/src/Grav/Framework/Flex/Storage/FolderStorage.php b/system/src/Grav/Framework/Flex/Storage/FolderStorage.php index 76397c2..229194d 100644 --- a/system/src/Grav/Framework/Flex/Storage/FolderStorage.php +++ b/system/src/Grav/Framework/Flex/Storage/FolderStorage.php @@ -5,18 +5,30 @@ declare(strict_types=1); /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Flex\Storage; +use FilesystemIterator; use Grav\Common\Filesystem\Folder; use Grav\Common\Grav; +use Grav\Common\Utils; +use Grav\Framework\Filesystem\Filesystem; use Grav\Framework\Flex\Interfaces\FlexStorageInterface; use RocketTheme\Toolbox\File\File; use InvalidArgumentException; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use SplFileInfo; +use function array_key_exists; +use function basename; +use function count; +use function is_scalar; +use function is_string; +use function mb_strpos; +use function mb_substr; /** * Class FolderStorage @@ -24,14 +36,20 @@ use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; */ class FolderStorage extends AbstractFilesystemStorage { - /** @var string */ + /** @var string Folder where all the data is stored. */ protected $dataFolder; - /** @var string */ - protected $dataPattern = '{FOLDER}/{KEY}/item'; + /** @var string Pattern to access an object. */ + protected $dataPattern = '{FOLDER}/{KEY}/{FILE}{EXT}'; + /** @var string Filename for the object. */ + protected $dataFile; + /** @var string File extension for the object. */ + protected $dataExt; /** @var bool */ protected $prefixed; /** @var bool */ protected $indexed; + /** @var array */ + protected $meta = []; /** * {@inheritdoc} @@ -44,16 +62,37 @@ class FolderStorage extends AbstractFilesystemStorage $this->initDataFormatter($options['formatter'] ?? []); $this->initOptions($options); + } - // Make sure that the data folder exists. - $folder = $this->resolvePath($this->dataFolder); - if (!file_exists($folder)) { - try { - Folder::create($folder); - } catch (\RuntimeException $e) { - throw new \RuntimeException(sprintf('Flex: %s', $e->getMessage())); - } + /** + * @return bool + */ + public function isIndexed(): bool + { + return $this->indexed; + } + + /** + * @return void + */ + public function clearCache(): void + { + $this->meta = []; + } + + /** + * @param string[] $keys + * @param bool $reload + * @return array + */ + public function getMetaData(array $keys, bool $reload = false): array + { + $list = []; + foreach ($keys as $key) { + $list[$key] = $this->getObjectMeta((string)$key, $reload); } + + return $list; } /** @@ -71,7 +110,9 @@ class FolderStorage extends AbstractFilesystemStorage */ public function hasKey(string $key): bool { - return $key && strpos($key, '@@') === false && file_exists($this->getPathFromKey($key)); + $meta = $this->getObjectMeta($key); + + return array_key_exists('exists', $meta) ? $meta['exists'] : !empty($meta['storage_timestamp']); } /** @@ -82,11 +123,7 @@ class FolderStorage extends AbstractFilesystemStorage { $list = []; foreach ($rows as $key => $row) { - // Create new file and save it. - $key = $this->getNewKey(); - $path = $this->getPathFromKey($key); - $file = $this->getFile($path); - $list[$key] = $this->saveFile($file, $row); + $list[$key] = $this->saveRow('@@', $row); } return $list; @@ -100,16 +137,11 @@ class FolderStorage extends AbstractFilesystemStorage { $list = []; foreach ($rows as $key => $row) { - if (null === $row || (!\is_object($row) && !\is_array($row))) { + if (null === $row || is_scalar($row)) { // Only load rows which haven't been loaded before. $key = (string)$key; - if (!$this->hasKey($key)) { - $list[$key] = null; - } else { - $path = $this->getPathFromKey($key); - $file = $this->getFile($path); - $list[$key] = $this->loadFile($file); - } + $list[$key] = $this->loadRow($key); + if (null !== $fetched) { $fetched[$key] = $list[$key]; } @@ -131,13 +163,7 @@ class FolderStorage extends AbstractFilesystemStorage $list = []; foreach ($rows as $key => $row) { $key = (string)$key; - if (!$this->hasKey($key)) { - $list[$key] = null; - } else { - $path = $this->getPathFromKey($key); - $file = $this->getFile($path); - $list[$key] = $this->saveFile($file, $row); - } + $list[$key] = $this->hasKey($key) ? $this->saveRow($key, $row) : null; } return $list; @@ -150,6 +176,7 @@ class FolderStorage extends AbstractFilesystemStorage public function deleteRows(array $rows): array { $list = []; + $baseMediaPath = $this->getMediaPath(); foreach ($rows as $key => $row) { $key = (string)$key; if (!$this->hasKey($key)) { @@ -159,11 +186,17 @@ class FolderStorage extends AbstractFilesystemStorage $file = $this->getFile($path); $list[$key] = $this->deleteFile($file); - $storage = $this->getStoragePath($key); - $media = $this->getMediaPath($key); + if ($this->canDeleteFolder($key)) { + $storagePath = $this->getStoragePath($key); + $mediaPath = $this->getMediaPath($key); - $this->deleteFolder($storage, true); - $media && $this->deleteFolder($media, true); + if ($storagePath) { + $this->deleteFolder($storagePath, true); + } + if ($mediaPath && $mediaPath !== $storagePath && $mediaPath !== $baseMediaPath) { + $this->deleteFolder($mediaPath, true); + } + } } } @@ -179,44 +212,82 @@ class FolderStorage extends AbstractFilesystemStorage $list = []; foreach ($rows as $key => $row) { $key = (string)$key; - if (strpos($key, '@@')) { - $key = $this->getNewKey(); - } - $path = $this->getPathFromKey($key); - $file = $this->getFile($path); - $list[$key] = $this->saveFile($file, $row); + $list[$key] = $this->saveRow($key, $row); } return $list; } + /** + * @param string $src + * @param string $dst + * @return bool + */ + public function copyRow(string $src, string $dst): bool + { + if ($this->hasKey($dst)) { + throw new RuntimeException("Cannot copy object: key '{$dst}' is already taken"); + } + + if (!$this->hasKey($src)) { + return false; + } + + $srcPath = $this->getStoragePath($src); + $dstPath = $this->getStoragePath($dst); + if (!$srcPath || !$dstPath) { + return false; + } + + return $this->copyFolder($srcPath, $dstPath); + } + /** * {@inheritdoc} * @see FlexStorageInterface::renameRow() */ public function renameRow(string $src, string $dst): bool { - if ($this->hasKey($dst)) { - throw new \RuntimeException("Cannot rename object: key '{$dst}' is already taken"); - } - if (!$this->hasKey($src)) { return false; } - return $this->moveFolder($this->getMediaPath($src), $this->getMediaPath($dst)); + $srcPath = $this->getStoragePath($src); + $dstPath = $this->getStoragePath($dst); + if (!$srcPath || !$dstPath) { + throw new RuntimeException("Destination path '{$dst}' is empty"); + } + + if ($srcPath === $dstPath) { + return true; + } + + if ($this->hasKey($dst)) { + throw new RuntimeException("Cannot rename object '{$src}': key '{$dst}' is already taken $srcPath $dstPath"); + } + + return $this->moveFolder($srcPath, $dstPath); } /** * {@inheritdoc} * @see FlexStorageInterface::getStoragePath() */ - public function getStoragePath(string $key = null): string + public function getStoragePath(string $key = null): ?string { - if (null === $key) { + if (null === $key || $key === '') { $path = $this->dataFolder; } else { - $path = sprintf($this->dataPattern, $this->dataFolder, $key, substr($key, 0, 2)); + $parts = $this->parseKey($key, false); + $options = [ + $this->dataFolder, // {FOLDER} + $parts['key'], // {KEY} + $parts['key:2'], // {KEY:2} + '***', // {FILE} + '***' // {EXT} + ]; + + $path = rtrim(explode('***', sprintf($this->dataPattern, ...$options))[0], '/'); } return $path; @@ -226,9 +297,9 @@ class FolderStorage extends AbstractFilesystemStorage * {@inheritdoc} * @see FlexStorageInterface::getMediaPath() */ - public function getMediaPath(string $key = null): string + public function getMediaPath(string $key = null): ?string { - return null !== $key ? \dirname($this->getStoragePath($key)) : $this->getStoragePath(); + return $this->getStoragePath($key); } /** @@ -239,117 +310,34 @@ class FolderStorage extends AbstractFilesystemStorage */ public function getPathFromKey(string $key): string { - return sprintf($this->dataPattern, $this->dataFolder, $key, substr($key, 0, 2)); + $parts = $this->parseKey($key); + $options = [ + $this->dataFolder, // {FOLDER} + $parts['key'], // {KEY} + $parts['key:2'], // {KEY:2} + $parts['file'], // {FILE} + $this->dataExt // {EXT} + ]; + + return sprintf($this->dataPattern, ...$options); } /** - * @param File $file - * @return array|null - */ - protected function loadFile(File $file): ?array - { - if (!$file->exists()) { - return null; - } - - try { - $content = (array)$file->content(); - if (isset($content[0])) { - throw new \RuntimeException('Broken object file.'); - } - } catch (\RuntimeException $e) { - $content = ['__error' => $e->getMessage()]; - } - - return $content; - } - - /** - * @param File $file - * @param array $data + * @param string $key + * @param bool $variations * @return array */ - protected function saveFile(File $file, array $data): array + public function parseKey(string $key, bool $variations = true): array { - try { - $file->save($data); - - /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - if ($locator->isStream($file->filename())) { - $locator->clearCache($file->filename()); - } - } catch (\RuntimeException $e) { - throw new \RuntimeException(sprintf('Flex saveFile(%s): %s', $file->filename(), $e->getMessage())); + $keys = [ + 'key' => $key, + 'key:2' => mb_substr($key, 0, 2), + ]; + if ($variations) { + $keys['file'] = $this->dataFile; } - return $data; - } - - /** - * @param File $file - * @return array|string - */ - protected function deleteFile(File $file) - { - try { - $data = $file->content(); - $file->delete(); - - /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - if ($locator->isStream($file->filename())) { - $locator->clearCache($file->filename()); - } - } catch (\RuntimeException $e) { - throw new \RuntimeException(sprintf('Flex deleteFile(%s): %s', $file->filename(), $e->getMessage())); - } - - return $data; - } - - /** - * @param string $src - * @param string $dst - * @return bool - */ - protected function moveFolder(string $src, string $dst): bool - { - try { - Folder::move($this->resolvePath($src), $this->resolvePath($dst)); - - /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - if ($locator->isStream($src) || $locator->isStream($dst)) { - $locator->clearCache(); - } - } catch (\RuntimeException $e) { - throw new \RuntimeException(sprintf('Flex moveFolder(%s, %s): %s', $src, $dst, $e->getMessage())); - } - - return true; - } - - /** - * @param string $path - * @param bool $include_target - * @return bool - */ - protected function deleteFolder(string $path, bool $include_target = false): bool - { - try { - $success = Folder::delete($this->resolvePath($path), $include_target); - - /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - if ($locator->isStream($path)) { - $locator->clearCache(); - } - - return $success; - } catch (\RuntimeException $e) { - throw new \RuntimeException(sprintf('Flex deleteFolder(%s): %s', $path, $e->getMessage())); - } + return $keys; } /** @@ -363,6 +351,193 @@ class FolderStorage extends AbstractFilesystemStorage return basename($path); } + /** + * Prepares the row for saving and returns the storage key for the record. + * + * @param array $row + * @return void + */ + protected function prepareRow(array &$row): void + { + if (array_key_exists($this->keyField, $row)) { + $key = $row[$this->keyField]; + if ($key === $this->normalizeKey($key)) { + unset($row[$this->keyField]); + } + } + } + + /** + * @param string $key + * @return array + */ + protected function loadRow(string $key): ?array + { + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + try { + $data = (array)$file->content(); + if (isset($data[0])) { + throw new RuntimeException('Broken object file'); + } + } catch (RuntimeException $e) { + $data = ['__ERROR' => $e->getMessage()]; + } finally { + $file->free(); + unset($file); + } + + $data['__META'] = $this->getObjectMeta($key); + + return $data; + } + + /** + * @param string $key + * @param array $row + * @return array + */ + protected function saveRow(string $key, array $row): array + { + try { + if (isset($row[$this->keyField])) { + $key = $row[$this->keyField]; + } + if (strpos($key, '@@') !== false) { + $key = $this->getNewKey(); + } + + $key = $this->normalizeKey($key); + + // Check if the row already exists and if the key has been changed. + $oldKey = $row['__META']['storage_key'] ?? null; + if (is_string($oldKey) && $oldKey !== $key) { + $isCopy = $row['__META']['copy'] ?? false; + if ($isCopy) { + $this->copyRow($oldKey, $key); + } else { + $this->renameRow($oldKey, $key); + } + } + + $this->prepareRow($row); + unset($row['__META'], $row['__ERROR']); + + $path = $this->getPathFromKey($key); + $file = $this->getFile($path); + + $file->save($row); + + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex saveFile(%s): %s', $path ?? $key, $e->getMessage())); + } finally { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->clearCache(); + + if (isset($file)) { + $file->free(); + unset($file); + } + } + + $row['__META'] = $this->getObjectMeta($key, true); + + return $row; + } + + /** + * @param File $file + * @return array|string + */ + protected function deleteFile(File $file) + { + $filename = $file->filename(); + try { + $data = $file->content(); + if ($file->exists()) { + $file->delete(); + } + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex deleteFile(%s): %s', $filename, $e->getMessage())); + } finally { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->clearCache(); + + $file->free(); + } + + return $data; + } + + /** + * @param string $src + * @param string $dst + * @return bool + */ + protected function copyFolder(string $src, string $dst): bool + { + try { + Folder::copy($this->resolvePath($src), $this->resolvePath($dst)); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex copyFolder(%s, %s): %s', $src, $dst, $e->getMessage())); + } finally { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->clearCache(); + } + + return true; + } + + /** + * @param string $src + * @param string $dst + * @return bool + */ + protected function moveFolder(string $src, string $dst): bool + { + try { + Folder::move($this->resolvePath($src), $this->resolvePath($dst)); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex moveFolder(%s, %s): %s', $src, $dst, $e->getMessage())); + } finally { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->clearCache(); + } + + return true; + } + + /** + * @param string $path + * @param bool $include_target + * @return bool + */ + protected function deleteFolder(string $path, bool $include_target = false): bool + { + try { + return Folder::delete($this->resolvePath($path), $include_target); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex deleteFolder(%s): %s', $path, $e->getMessage())); + } finally { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $locator->clearCache(); + } + } + + /** + * @param string $key + * @return bool + */ + protected function canDeleteFolder(string $key): bool + { + return true; + } + /** * Returns list of all stored keys in [key => timestamp] pairs. * @@ -370,8 +545,10 @@ class FolderStorage extends AbstractFilesystemStorage */ protected function buildIndex(): array { + $this->clearCache(); + $path = $this->getStoragePath(); - if (!file_exists($path)) { + if (!$path || !file_exists($path)) { return []; } @@ -381,46 +558,76 @@ class FolderStorage extends AbstractFilesystemStorage $list = $this->buildIndexFromFilesystem($path); } - ksort($list, SORT_NATURAL); + ksort($list, SORT_NATURAL | SORT_FLAG_CASE); return $list; } + /** + * @param string $key + * @param bool $reload + * @return array + */ + protected function getObjectMeta(string $key, bool $reload = false): array + { + if (!$reload && isset($this->meta[$key])) { + return $this->meta[$key]; + } + + if ($key && strpos($key, '@@') === false) { + $filename = $this->getPathFromKey($key); + $modified = is_file($filename) ? filemtime($filename) : 0; + } else { + $modified = 0; + } + + $meta = [ + 'storage_key' => $key, + 'storage_timestamp' => $modified + ]; + + $this->meta[$key] = $meta; + + return $meta; + } + + /** + * @param string $path + * @return array + */ protected function buildIndexFromFilesystem($path) { - $flags = \FilesystemIterator::KEY_AS_PATHNAME | \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS; + $flags = FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS; - $iterator = new \FilesystemIterator($path, $flags); + $iterator = new FilesystemIterator($path, $flags); $list = []; - /** @var \SplFileInfo $info */ + /** @var SplFileInfo $info */ foreach ($iterator as $filename => $info) { - if (!$info->isDir()) { + if (!$info->isDir() || strpos($info->getFilename(), '.') === 0) { continue; } $key = $this->getKeyFromPath($filename); - $filename = $this->getPathFromKey($key); - $modified = is_file($filename) ? filemtime($filename) : null; - if (null === $modified) { - continue; + $meta = $this->getObjectMeta($key); + if ($meta['storage_timestamp']) { + $list[$key] = $meta; } - - $list[$key] = [ - 'storage_key' => $key, - 'storage_timestamp' => $modified - ]; } return $list; } + /** + * @param string $path + * @return array + */ protected function buildPrefixedIndexFromFilesystem($path) { - $flags = \FilesystemIterator::KEY_AS_PATHNAME | \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS; + $flags = FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS; - $iterator = new \FilesystemIterator($path, $flags); + $iterator = new FilesystemIterator($path, $flags); $list = []; - /** @var \SplFileInfo $info */ + /** @var SplFileInfo $info */ foreach ($iterator as $filename => $info) { if (!$info->isDir() || strpos($info->getFilename(), '.') === 0) { continue; @@ -433,7 +640,7 @@ class FolderStorage extends AbstractFilesystemStorage return []; } - return \count($list) > 1 ? array_merge(...$list) : $list[0]; + return count($list) > 1 ? array_merge(...$list) : $list[0]; } /** @@ -451,6 +658,7 @@ class FolderStorage extends AbstractFilesystemStorage /** * @param array $options + * @return void */ protected function initOptions(array $options): void { @@ -459,12 +667,38 @@ class FolderStorage extends AbstractFilesystemStorage /** @var string $pattern */ $pattern = !empty($options['pattern']) ? $options['pattern'] : $this->dataPattern; - $this->dataFolder = $options['folder']; + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + $folder = $options['folder']; + if ($locator->isStream($folder)) { + $folder = $locator->getResource($folder, false); + } + + $this->dataFolder = $folder; + $this->dataFile = $options['file'] ?? 'item'; + $this->dataExt = $extension; + if (mb_strpos($pattern, '{FILE}') === false && mb_strpos($pattern, '{EXT}') === false) { + if (isset($options['file'])) { + $pattern .= '/{FILE}{EXT}'; + } else { + $filesystem = Filesystem::getInstance(true); + $this->dataFile = basename($pattern, $extension); + $pattern = $filesystem->dirname($pattern) . '/{FILE}{EXT}'; + } + } $this->prefixed = (bool)($options['prefixed'] ?? strpos($pattern, '/{KEY:2}/')); $this->indexed = (bool)($options['indexed'] ?? false); $this->keyField = $options['key'] ?? 'storage_key'; + $this->keyLen = (int)($options['key_len'] ?? 32); + $this->caseSensitive = (bool)($options['case_sensitive'] ?? true); - $pattern = preg_replace(['/{FOLDER}/', '/{KEY}/', '/{KEY:2}/'], ['%1$s', '%2$s', '%3$s'], $pattern); - $this->dataPattern = \dirname($pattern) . '/' . basename($pattern, $extension) . $extension; + $variables = ['FOLDER' => '%1$s', 'KEY' => '%2$s', 'KEY:2' => '%3$s', 'FILE' => '%4$s', 'EXT' => '%5$s']; + $pattern = Utils::simpleTemplate($pattern, $variables); + + if (!$pattern) { + throw new RuntimeException('Bad storage folder pattern'); + } + + $this->dataPattern = $pattern; } } diff --git a/system/src/Grav/Framework/Flex/Storage/SimpleStorage.php b/system/src/Grav/Framework/Flex/Storage/SimpleStorage.php index 088b952..85c1429 100644 --- a/system/src/Grav/Framework/Flex/Storage/SimpleStorage.php +++ b/system/src/Grav/Framework/Flex/Storage/SimpleStorage.php @@ -5,14 +5,20 @@ declare(strict_types=1); /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Flex\Storage; +use Grav\Common\Data\Data; use Grav\Common\Filesystem\Folder; +use Grav\Framework\Filesystem\Filesystem; use InvalidArgumentException; +use LogicException; +use RuntimeException; +use function is_scalar; +use function is_string; /** * Class SimpleStorage @@ -24,8 +30,12 @@ class SimpleStorage extends AbstractFilesystemStorage protected $dataFolder; /** @var string */ protected $dataPattern; - /** @var array */ + /** @var string */ + protected $prefix; + /** @var array|null */ protected $data; + /** @var int */ + protected $modified = 0; /** * {@inheritdoc} @@ -40,22 +50,55 @@ class SimpleStorage extends AbstractFilesystemStorage $formatter = $options['formatter'] ?? $this->detectDataFormatter($options['folder']); $this->initDataFormatter($formatter); + $filesystem = Filesystem::getInstance(true); + $extension = $this->dataFormatter->getDefaultFileExtension(); $pattern = basename($options['folder']); $this->dataPattern = basename($pattern, $extension) . $extension; - $this->dataFolder = \dirname($options['folder']); + $this->dataFolder = $filesystem->dirname($options['folder']); + $this->keyField = $options['key'] ?? 'storage_key'; + $this->keyLen = (int)($options['key_len'] ?? 32); + $this->prefix = $options['prefix'] ?? null; // Make sure that the data folder exists. if (!file_exists($this->dataFolder)) { try { Folder::create($this->dataFolder); - } catch (\RuntimeException $e) { - throw new \RuntimeException(sprintf('Flex: %s', $e->getMessage())); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex: %s', $e->getMessage())); } } } + /** + * @return void + */ + public function clearCache(): void + { + $this->data = null; + $this->modified = 0; + } + + /** + * @param string[] $keys + * @param bool $reload + * @return array + */ + public function getMetaData(array $keys, bool $reload = false): array + { + if (null === $this->data || $reload) { + $this->buildIndex(); + } + + $list = []; + foreach ($keys as $key) { + $list[$key] = $this->getObjectMeta((string)$key); + } + + return $list; + } + /** * {@inheritdoc} * @see FlexStorageInterface::getExistingKeys() @@ -90,8 +133,7 @@ class SimpleStorage extends AbstractFilesystemStorage $list = []; foreach ($rows as $key => $row) { - $key = $this->getNewKey(); - $this->data[$key] = $list[$key] = $row; + $list[$key] = $this->saveRow('@@', $rows); } if ($list) { @@ -113,10 +155,10 @@ class SimpleStorage extends AbstractFilesystemStorage $list = []; foreach ($rows as $key => $row) { - if (null === $row || (!\is_object($row) && !\is_array($row))) { + if (null === $row || is_scalar($row)) { // Only load rows which haven't been loaded before. $key = (string)$key; - $list[$key] = $this->hasKey($key) ? $this->data[$key] : null; + $list[$key] = $this->hasKey($key) ? $this->loadRow($key) : null; if (null !== $fetched) { $fetched[$key] = $list[$key]; } @@ -139,15 +181,19 @@ class SimpleStorage extends AbstractFilesystemStorage $this->buildIndex(); } + $save = false; $list = []; foreach ($rows as $key => $row) { $key = (string)$key; if ($this->hasKey($key)) { - $this->data[$key] = $list[$key] = $row; + $list[$key] = $this->saveRow($key, $row); + $save = true; + } else { + $list[$key] = null; } } - if ($list) { + if ($save) { $this->save(); } @@ -192,10 +238,7 @@ class SimpleStorage extends AbstractFilesystemStorage $list = []; foreach ($rows as $key => $row) { - if (strpos($key, '@@')) { - $key = $this->getNewKey(); - } - $this->data[$key] = $list[$key] = $row; + $list[$key] = $this->saveRow((string)$key, $row); } if ($list) { @@ -205,6 +248,26 @@ class SimpleStorage extends AbstractFilesystemStorage return $list; } + /** + * @param string $src + * @param string $dst + * @return bool + */ + public function copyRow(string $src, string $dst): bool + { + if ($this->hasKey($dst)) { + throw new RuntimeException("Cannot copy object: key '{$dst}' is already taken"); + } + + if (!$this->hasKey($src)) { + return false; + } + + $this->data[$dst] = $this->data[$src]; + + return true; + } + /** * {@inheritdoc} * @see FlexStorageInterface::renameRow() @@ -216,7 +279,7 @@ class SimpleStorage extends AbstractFilesystemStorage } if ($this->hasKey($dst)) { - throw new \RuntimeException("Cannot rename object: key '{$dst}' is already taken"); + throw new RuntimeException("Cannot rename object: key '{$dst}' is already taken"); } if (!$this->hasKey($src)) { @@ -229,7 +292,7 @@ class SimpleStorage extends AbstractFilesystemStorage $data = array_combine($keys, $this->data); if (false === $data) { - throw new \LogicException('Bad data'); + throw new LogicException('Bad data'); } $this->data = $data; @@ -241,7 +304,7 @@ class SimpleStorage extends AbstractFilesystemStorage * {@inheritdoc} * @see FlexStorageInterface::getStoragePath() */ - public function getStoragePath(string $key = null): string + public function getStoragePath(string $key = null): ?string { return $this->dataFolder . '/' . $this->dataPattern; } @@ -250,23 +313,114 @@ class SimpleStorage extends AbstractFilesystemStorage * {@inheritdoc} * @see FlexStorageInterface::getMediaPath() */ - public function getMediaPath(string $key = null): string + public function getMediaPath(string $key = null): ?string { - return sprintf('%s/%s/%s', $this->dataFolder, basename($this->dataPattern, $this->dataFormatter->getDefaultFileExtension()), $key); + return null; } - protected function save() : void + /** + * Prepares the row for saving and returns the storage key for the record. + * + * @param array $row + */ + protected function prepareRow(array &$row): void + { + unset($row[$this->keyField]); + } + + /** + * @param string $key + * @return array + */ + protected function loadRow(string $key): ?array + { + $data = $this->data[$key] ?? []; + if ($this->keyField !== 'storage_key') { + $data[$this->keyField] = $key; + } + $data['__META'] = $this->getObjectMeta($key); + + return $data; + } + + /** + * @param string $key + * @param array $row + * @return array + */ + protected function saveRow(string $key, array $row): array + { + try { + if (isset($row[$this->keyField])) { + $key = $row[$this->keyField]; + } + if (strpos($key, '@@') !== false) { + $key = $this->getNewKey(); + } + + // Check if the row already exists and if the key has been changed. + $oldKey = $row['__META']['storage_key'] ?? null; + if (is_string($oldKey) && $oldKey !== $key) { + $isCopy = $row['__META']['copy'] ?? false; + if ($isCopy) { + $this->copyRow($oldKey, $key); + } else { + $this->renameRow($oldKey, $key); + } + } + + $this->prepareRow($row); + unset($row['__META'], $row['__ERROR']); + + $this->data[$key] = $row; + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex saveRow(%s): %s', $key, $e->getMessage())); + } + + $row['__META'] = $this->getObjectMeta($key, true); + + return $row; + } + + /** + * @param string $key + * @param bool $variations + * @return array + */ + public function parseKey(string $key, bool $variations = true): array + { + return [ + 'key' => $key, + ]; + } + + protected function save(): void { if (null === $this->data) { $this->buildIndex(); } try { - $file = $this->getFile($this->getStoragePath()); - $file->save($this->data); - $file->free(); - } catch (\RuntimeException $e) { - throw new \RuntimeException(sprintf('Flex save(): %s', $e->getMessage())); + $path = $this->getStoragePath(); + if (!$path) { + throw new RuntimeException('Storage path is not defined'); + } + $file = $this->getFile($path); + if ($this->prefix) { + $data = new Data((array)$file->content()); + $content = $data->set($this->prefix, $this->data)->toArray(); + } else { + $content = $this->data; + } + $file->save($content); + $this->modified = (int)$file->modified(); // cast false to 0 + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf('Flex save(): %s', $e->getMessage())); + } finally { + if (isset($file)) { + $file->free(); + unset($file); + } } } @@ -288,22 +442,51 @@ class SimpleStorage extends AbstractFilesystemStorage */ protected function buildIndex(): array { - $file = $this->getFile($this->getStoragePath()); - $modified = $file->modified(); + $path = $this->getStoragePath(); + if (!$path) { + $this->data = []; - $this->data = (array) $file->content(); + return []; + } + + $file = $this->getFile($path); + $this->modified = (int)$file->modified(); // cast false to 0 + + $content = (array) $file->content(); + if ($this->prefix) { + $data = new Data($content); + $content = $data->get($this->prefix); + } + + $file->free(); + unset($file); + + $this->data = $content; $list = []; foreach ($this->data as $key => $info) { - $list[$key] = [ - 'storage_key' => $key, - 'storage_timestamp' => $modified - ]; + $list[$key] = $this->getObjectMeta((string)$key); } return $list; } + /** + * @param string $key + * @param bool $reload + * @return array + */ + protected function getObjectMeta(string $key, bool $reload = false): array + { + $modified = isset($this->data[$key]) ? $this->modified : 0; + + return [ + 'storage_key' => $key, + 'key' => $key, + 'storage_timestamp' => $modified + ]; + } + /** * @return string */ diff --git a/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php b/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php index e1b63d9..8cbd1d5 100644 --- a/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php +++ b/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php @@ -5,13 +5,12 @@ declare(strict_types=1); /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Flex\Traits; -use Grav\Common\Grav; use Grav\Common\User\Interfaces\UserInterface; use Grav\Framework\Flex\FlexDirectory; use Grav\Framework\Flex\Interfaces\FlexObjectInterface; @@ -21,40 +20,107 @@ use Grav\Framework\Flex\Interfaces\FlexObjectInterface; */ trait FlexAuthorizeTrait { - private $_authorize = '%s.flex-object.%s'; - - public function isAuthorized(string $action, string $scope = null, UserInterface $user = null) : bool + /** + * Check if user is authorized for the action. + * + * Note: There are two deny values: denied (false), not set (null). This allows chaining multiple rules together + * when the previous rules were not matched. + * + * To override the default behavior, please use isAuthorizedOverride(). + * + * @param string $action + * @param string|null $scope + * @param UserInterface|null $user + * @return bool|null + * @final + */ + public function isAuthorized(string $action, string $scope = null, UserInterface $user = null): ?bool { + $action = $this->getAuthorizeAction($action); + $scope = $scope ?? $this->getAuthorizeScope(); + + $isMe = null === $user; + if ($isMe) { + $user = $this->getActiveUser(); + } + if (null === $user) { - /** @var UserInterface $user */ - $user = Grav::instance()['user'] ?? null; + return false; } - return $user && ($this->isAuthorizedAction($user, $action, $scope) || $this->isAuthorizedSuperAdmin($user)); + // Finally authorize against given action. + return $this->isAuthorizedOverride($user, $action, $scope, $isMe); } - protected function isAuthorizedSuperAdmin(UserInterface $user): bool + /** + * Please override this method + * + * @param UserInterface $user + * @param string $action + * @param string $scope + * @param bool $isMe + * @return bool|null + */ + protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe): ?bool { - return $user->authorize('admin.super'); + return $this->isAuthorizedAction($user, $action, $scope, $isMe); } - protected function isAuthorizedAction(UserInterface $user, string $action, string $scope = null) : bool + /** + * Check if user is authorized for the action. + * + * @param UserInterface $user + * @param string $action + * @param string $scope + * @param bool $isMe + * @return bool|null + */ + protected function isAuthorizedAction(UserInterface $user, string $action, string $scope, bool $isMe): ?bool { - $scope = $scope ?? isset(Grav::instance()['admin']) ? 'admin' : 'site'; - - if ($action === 'save' && $this instanceof FlexObjectInterface) { - $action = $this->exists() ? 'update' : 'create'; - } - + // Check if the action has been denied in the flex type configuration. $directory = $this instanceof FlexDirectory ? $this : $this->getFlexDirectory(); $config = $directory->getConfig(); $allowed = $config->get("{$scope}.actions.{$action}") ?? $config->get("actions.{$action}") ?? true; + if (false === $allowed) { + return false; + } - return $allowed && $user->authorize(sprintf($this->_authorize, $scope, $action)); + // TODO: Not needed anymore with flex users, remove in 2.0. + $auth = $user instanceof FlexObjectInterface ? null : $user->authorize('admin.super'); + if (true === $auth) { + return true; + } + + // Finally authorize the action. + return $user->authorize($this->getAuthorizeRule($scope, $action), !$isMe ? 'test' : null); } - protected function setAuthorizeRule(string $authorize) : void + /** + * @param UserInterface $user + * @return bool|null + * @deprecated 1.7 Not needed for Flex Users. + */ + protected function isAuthorizedSuperAdmin(UserInterface $user): ?bool { - $this->_authorize = $authorize; + // Action authorization includes super user authorization if using Flex Users. + if ($user instanceof FlexObjectInterface) { + return null; + } + + return $user->authorize('admin.super'); + } + + /** + * @param string $scope + * @param string $action + * @return string + */ + protected function getAuthorizeRule(string $scope, string $action): string + { + if ($this instanceof FlexDirectory) { + return $this->getAuthorizeRule($scope, $action); + } + + return $this->getFlexDirectory()->getAuthorizeRule($scope, $action); } } diff --git a/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php b/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php index c894634..c3b810d 100644 --- a/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php +++ b/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php @@ -5,26 +5,34 @@ namespace Grav\Framework\Flex\Traits; /** * @package Grav\Framework\Flex * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ -use Grav\Common\Cache; use Grav\Common\Config\Config; -use Grav\Common\Filesystem\Folder; use Grav\Common\Grav; use Grav\Common\Media\Interfaces\MediaCollectionInterface; +use Grav\Common\Media\Interfaces\MediaUploadInterface; use Grav\Common\Media\Traits\MediaTrait; -use Grav\Common\Page\Medium\AbstractMedia; +use Grav\Common\Page\Media; use Grav\Common\Page\Medium\Medium; use Grav\Common\Page\Medium\MediumFactory; use Grav\Common\Utils; +use Grav\Framework\Cache\CacheInterface; +use Grav\Framework\Filesystem\Filesystem; use Grav\Framework\Flex\FlexDirectory; use Grav\Framework\Form\FormFlashFile; use Psr\Http\Message\UploadedFileInterface; -use RocketTheme\Toolbox\File\YamlFile; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; use RuntimeException; +use function array_key_exists; +use function in_array; +use function is_array; +use function is_callable; +use function is_int; +use function is_object; +use function is_string; +use function strpos; /** * Implements Grav Page content and header manipulation methods. @@ -35,29 +43,23 @@ trait FlexMediaTrait MediaTrait::getMedia as protected getExistingMedia; } + /** @var array */ protected $_uploads; - public function __debugInfo() - { - return parent::__debugInfo() + [ - 'uploads:private' => $this->getUpdatedMedia() - ]; - } - /** - * @return string + * @return string|null */ public function getStorageFolder() { - return $this->exists() ? $this->getFlexDirectory()->getStorageFolder($this->getStorageKey()) : ''; + return $this->exists() ? $this->getFlexDirectory()->getStorageFolder($this->getStorageKey()) : null; } /** - * @return string + * @return string|null */ public function getMediaFolder() { - return $this->exists() ? $this->getFlexDirectory()->getMediaFolder($this->getStorageKey()) : ''; + return $this->exists() ? $this->getFlexDirectory()->getMediaFolder($this->getStorageKey()) : null; } /** @@ -65,245 +67,380 @@ trait FlexMediaTrait */ public function getMedia() { - if ($this->media === null) { - /** @var AbstractMedia $media */ + $media = $this->media; + if (null === $media) { $media = $this->getExistingMedia(); // Include uploaded media to the object media. - /** @var FormFlashFile $upload */ - foreach ($this->getUpdatedMedia() as $filename => $upload) { - // Just make sure we do not include removed or moved media. - if ($upload && $upload->getError() === \UPLOAD_ERR_OK && !$upload->isMoved()) { - $media->add($filename, MediumFactory::fromUploadedFile($upload)); - } - } - - $media->setTimestamps(); + $this->addUpdatedMedia($media); } - return $this->media; + return $media; } - public function checkUploadedMediaFile(UploadedFileInterface $uploadedFile) + /** + * @param string $field + * @return MediaCollectionInterface|null + */ + public function getMediaField(string $field): ?MediaCollectionInterface { - $grav = Grav::instance(); - $language = $grav['language']; - - switch ($uploadedFile->getError()) { - case UPLOAD_ERR_OK: - break; - case UPLOAD_ERR_NO_FILE: - if ($uploadedFile instanceof FormFlashFile) { - break; - } - throw new RuntimeException($language->translate('PLUGIN_ADMIN.NO_FILES_SENT'), 400); - case UPLOAD_ERR_INI_SIZE: - case UPLOAD_ERR_FORM_SIZE: - throw new RuntimeException($language->translate('PLUGIN_ADMIN.EXCEEDED_FILESIZE_LIMIT'), 400); - case UPLOAD_ERR_NO_TMP_DIR: - throw new RuntimeException($language->translate('PLUGIN_ADMIN.UPLOAD_ERR_NO_TMP_DIR'), 400); - default: - throw new RuntimeException($language->translate('PLUGIN_ADMIN.UNKNOWN_ERRORS'), 400); + // Field specific media. + $settings = $this->getFieldSettings($field); + if (!empty($settings['media_field'])) { + $var = 'destination'; + } elseif (!empty($settings['media_picker_field'])) { + $var = 'folder'; } - $filename = $uploadedFile->getClientFilename(); - - if (!Utils::checkFilename($filename)) { - throw new RuntimeException(sprintf($language->translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD'), $filename, 'Bad filename'), 400); - } - - $grav_limit = Utils::getUploadLimit(); - if ($grav_limit > 0 && $uploadedFile->getSize() > $grav_limit) { - throw new RuntimeException($language->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400); - } - - $this->checkMediaFilename($filename); - } - - public function checkMediaFilename(string $filename) - { - // Check the file extension. - $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); - - $grav = Grav::instance(); - - /** @var Config $config */ - $config = $grav['config']; - - // If not a supported type, return - if (!$extension || !$config->get("media.types.{$extension}")) { - $language = $grav['language']; - throw new RuntimeException($language->translate('PLUGIN_ADMIN.UNSUPPORTED_FILE_TYPE') . ': ' . $extension, 400); - } - } - - public function uploadMediaFile(UploadedFileInterface $uploadedFile, string $filename = null): void - { - $this->checkUploadedMediaFile($uploadedFile); - - if ($filename) { - $this->checkMediaFilename(basename($filename)); + if (empty($var)) { + // Not a media field. + $media = null; + } elseif ($settings['self']) { + // Uses main media. + $media = $this->getMedia(); } else { - $filename = $uploadedFile->getClientFilename(); + // Uses custom media. + $media = new Media($settings[$var]); + $this->addUpdatedMedia($media); } - $media = $this->getMedia(); - $grav = Grav::instance(); - - /** @var UniformResourceLocator $locator */ - $locator = $grav['locator']; - $path = $media->getPath(); - if (!$path) { - $language = $grav['language']; - - throw new RuntimeException($language->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE'), 400); - } - - if ($locator->isStream($path)) { - $path = $locator->findResource($path, true, true); - $locator->clearCache($path); - } - - try { - // Upload it - $filepath = sprintf('%s/%s', $path, $filename); - Folder::create(\dirname($filepath)); - if ($uploadedFile instanceof FormFlashFile) { - $metadata = $uploadedFile->getMetaData(); - if ($metadata) { - $file = YamlFile::instance($filepath . '.meta.yaml'); - $file->save(['upload' => $metadata]); - } - if ($uploadedFile->getError() === \UPLOAD_ERR_OK) { - $uploadedFile->moveTo($filepath); - } elseif (!file_exists($filepath) && $pos = strpos($filename, '/')) { - $origpath = sprintf('%s/%s', $path, substr($filename, $pos)); - if (file_exists($origpath)) { - copy($origpath, $filepath); - } - } - } else { - $uploadedFile->moveTo($filepath); - } - } catch (\Exception $e) { - $language = $grav['language']; - - throw new RuntimeException($language->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE'), 400); - } - - $this->clearMediaCache(); + return $media; } - public function deleteMediaFile(string $filename): void + /** + * @param string $field + * @return array|null + */ + public function getFieldSettings(string $field): ?array { - $grav = Grav::instance(); - $language = $grav['language']; + if ($field === '') { + return null; + } - $basename = basename($filename); - $dirname = dirname($filename); - $dirname = $dirname === '.' ? '' : '/' . $dirname; + // Load settings for the field. + $schema = $this->getBlueprint()->schema(); + $settings = $field && is_object($schema) ? (array)$schema->getProperty($field) : null; + if (!isset($settings) || !is_array($settings)) { + return null; + } - if (!Utils::checkFilename($basename)) { - throw new RuntimeException($language->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': Bad filename: ' . $filename, 400); + $type = $settings['type'] ?? ''; + + // Media field. + if (!empty($settings['media_field']) || array_key_exists('destination', $settings) || in_array($type, ['avatar', 'file', 'pagemedia'], true)) { + $settings['media_field'] = true; + $var = 'destination'; + } + + // Media picker field. + if (!empty($settings['media_picker_field']) || in_array($type, ['filepicker', 'pagemediaselect'], true)) { + $settings['media_picker_field'] = true; + $var = 'folder'; + } + + // Set media folder for media fields. + if (isset($var)) { + $folder = $settings[$var] ?? ''; + if (in_array(rtrim($folder, '/'), ['', '@self', 'self@', '@self@'], true)) { + $settings[$var] = $this->getMediaFolder(); + $settings['self'] = true; + } else { + $settings[$var] = Utils::getPathFromToken($folder, $this); + $settings['self'] = false; + } + } + + return $settings; + } + + /** + * @param string $field + * @return array + * @internal + */ + protected function getMediaFieldSettings(string $field): array + { + $settings = $this->getFieldSettings($field) ?? []; + + return $settings + ['accept' => '*', 'limit' => 1000, 'self' => true]; + } + + protected function getMediaFields(): array + { + // Load settings for the field. + $schema = $this->getBlueprint()->schema(); + + $list = []; + foreach ($schema->getState()['items'] as $field => $settings) { + if (isset($settings['type']) && (in_array($settings['type'], ['avatar', 'file', 'pagemedia']) || !empty($settings['destination']))) { + $list[] = $field; + } + } + + return $list; + } + + /** + * @param array|mixed $value + * @param array $settings + * @return array|mixed + */ + protected function parseFileProperty($value, array $settings = []) + { + if (!is_array($value)) { + return $value; } $media = $this->getMedia(); - $path = $media->getPath(); - if (!$path) { - return; - } + $originalMedia = is_callable([$this, 'getOriginalMedia']) ? $this->getOriginalMedia() : null; - /** @var UniformResourceLocator $locator */ - $locator = $grav['locator']; + $list = []; + foreach ($value as $filename => $info) { + if (!is_array($info)) { + $list[$filename] = $info; + continue; + } - $targetPath = $path . '/' . $dirname; - $targetFile = $path . '/' . $filename; - if ($locator->isStream($targetFile)) { - $targetPath = $locator->findResource($targetPath, true, true); - $targetFile = $locator->findResource($targetFile, true, true); - $locator->clearCache($targetPath); - $locator->clearCache($targetFile); - } + if (is_int($filename)) { + $filename = $info['path'] ?? $info['name']; + } - $fileParts = pathinfo($basename); + /** @var Medium|null $imageFile */ + $imageFile = $media[$filename]; - if (!file_exists($targetPath)) { - return; - } + /** @var Medium|null $originalFile */ + $originalFile = $originalMedia ? $originalMedia[$filename] : null; - if (file_exists($targetFile)) { - $result = unlink($targetFile); - if (!$result) { - throw new RuntimeException($language->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500); + $url = $imageFile ? $imageFile->url() : null; + $originalUrl = $originalFile ? $originalFile->url() : null; + $list[$filename] = [ + 'name' => $info['name'] ?? null, + 'type' => $info['type'] ?? null, + 'size' => $info['size'] ?? null, + 'path' => $filename, + 'thumb_url' => $url, + 'image_url' => $originalUrl ?? $url + ]; + if ($originalFile) { + $list[$filename]['cropData'] = (object)($originalFile->metadata()['upload']['crop'] ?? []); } } - // Remove Extra Files - foreach (scandir($targetPath, SCANDIR_SORT_NONE) as $file) { - $preg_name = preg_quote($fileParts['filename'], '`'); - $preg_ext =preg_quote($fileParts['extension'], '`'); - $preg_filename = preg_quote($basename, '`'); + return $list; + } - if (preg_match("`({$preg_name}@\d+x\.{$preg_ext}(?:\.meta\.yaml)?$|{$preg_filename}\.meta\.yaml)$`", $file)) { - $testPath = $targetPath . '/' . $file; - if ($locator->isStream($testPath)) { - $testPath = $locator->findResource($testPath, true, true); - $locator->clearCache($testPath); - } - - if (is_file($testPath)) { - $result = unlink($testPath); - if (!$result) { - throw new RuntimeException($language->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500); - } - } - } + /** + * @param UploadedFileInterface $uploadedFile + * @param string|null $filename + * @param string|null $field + * @return void + * @internal + */ + public function checkUploadedMediaFile(UploadedFileInterface $uploadedFile, string $filename = null, string $field = null) + { + $media = $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + throw new RuntimeException("Media for {$this->getFlexDirectory()->getFlexType()} doesn't support file uploads."); } + $media->checkUploadedFile($uploadedFile, $filename, $this->getMediaFieldSettings($field ?? '')); + } + + /** + * @param UploadedFileInterface $uploadedFile + * @param string|null $filename + * @param string|null $field + * @return void + * @internal + */ + public function uploadMediaFile(UploadedFileInterface $uploadedFile, string $filename = null, string $field = null): void + { + $settings = $this->getMediaFieldSettings($field ?? ''); + + $media = $field ? $this->getMediaField($field) : $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + throw new RuntimeException("Media for {$this->getFlexDirectory()->getFlexType()} doesn't support file uploads."); + } + + $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings); + $media->copyUploadedFile($uploadedFile, $filename, $settings); $this->clearMediaCache(); } + /** + * @param string $filename + * @return void + * @internal + */ + public function deleteMediaFile(string $filename): void + { + $media = $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + throw new RuntimeException("Media for {$this->getFlexDirectory()->getFlexType()} doesn't support file uploads."); + } + + $media->deleteFile($filename); + $this->clearMediaCache(); + } + + /** + * @return array + */ + public function __debugInfo() + { + return parent::__debugInfo() + [ + 'uploads:private' => $this->getUpdatedMedia() + ]; + } + /** * @param array $files + * @return void */ protected function setUpdatedMedia(array $files): void { + $media = $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + return; + } + + $filesystem = Filesystem::getInstance(false); + $list = []; foreach ($files as $field => $group) { - if ($field === '' || \strpos($field, '/', true)) { + $field = (string)$field; + // Ignore files without a field and resized images. + if ($field === '' || strpos($field, '/')) { continue; } + + // Load settings for the field. + $settings = $this->getMediaFieldSettings($field); foreach ($group as $filename => $file) { - $list[$filename] = $file; + if ($file) { + // File upload. + $filename = $file->getClientFilename(); + + /** @var FormFlashFile $file */ + $data = $file->jsonSerialize(); + unset($data['tmp_name'], $data['path']); + } else { + // File delete. + $data = null; + } + + if ($file) { + // Check file upload against media limits (except for max size). + $filename = $media->checkUploadedFile($file, $filename, ['filesize' => 0] + $settings); + } + + $self = $settings['self']; + if ($this->_loadMedia && $self) { + $filepath = $filename; + } else { + $filepath = "{$settings['destination']}/{$filename}"; + } + + // Calculate path without the retina scaling factor. + $realpath = $filesystem->pathname($filepath) . str_replace(['@3x', '@2x'], '', basename($filepath)); + + $list[$filename] = [$file, $settings]; + + $path = str_replace('.', "\n", $field); + if (null !== $data) { + $data['name'] = $filename; + $data['path'] = $filepath; + + $this->setNestedProperty("{$path}\n{$realpath}", $data, "\n"); + } else { + $this->unsetNestedProperty("{$path}\n{$realpath}", "\n"); + } } } + $this->clearMediaCache(); + $this->_uploads = $list; } /** - * @return array + * @param MediaCollectionInterface $media + */ + protected function addUpdatedMedia(MediaCollectionInterface $media): void + { + $updated = false; + foreach ($this->getUpdatedMedia() as $filename => $upload) { + if (is_array($upload)) { + // Uses new format with [UploadedFileInterface, array]. + $settings = $upload[1]; + if ($settings['destination'] === $media->getPath()) { + $upload = $upload[0]; + } else { + $upload = false; + } + } + if (false !== $upload) { + $medium = $upload ? MediumFactory::fromUploadedFile($upload) : null; + $updated = true; + if ($medium) { + $media->add($filename, $medium); + } elseif (is_callable([$media, 'hide'])) { + $media->hide($filename); + } + } + } + + if ($updated) { + $media->setTimestamps(); + } + } + + /** + * @return array */ protected function getUpdatedMedia(): array { return $this->_uploads ?? []; } + /** + * @return void + */ protected function saveUpdatedMedia(): void { + $media = $this->getMedia(); + if (!$media instanceof MediaUploadInterface) { + return; + } + + // Upload/delete altered files. /** * @var string $filename - * @var UploadedFileInterface $file + * @var UploadedFileInterface|array|null $file */ foreach ($this->getUpdatedMedia() as $filename => $file) { - if ($file) { - $this->uploadMediaFile($file, $filename); + if (is_array($file)) { + [$file, $settings] = $file; } else { - $this->deleteMediaFile($filename); + $settings = null; + } + if ($file instanceof UploadedFileInterface) { + $media->copyUploadedFile($file, $filename, $settings); + } else { + $media->deleteFile($filename, $settings); } } $this->setUpdatedMedia([]); + $this->clearMediaCache(); + } + + /** + * @return void + */ + protected function freeMedia(): void + { + $this->unsetObjectProperty('media'); } /** @@ -319,28 +456,64 @@ trait FlexMediaTrait $file = $uri && $locator->isStream($uri) ? $locator->findResource($uri) : $uri; - return $file && file_exists($file) ? MediumFactory::fromFile($file) : null; + return is_string($file) && file_exists($file) ? MediumFactory::fromFile($file) : null; } /** - * @return Cache + * @return CacheInterface */ protected function getMediaCache() { return $this->getCache('object'); } + /** + * @return MediaCollectionInterface + */ protected function offsetLoad_media() { return $this->getMedia(); } + /** + * @return null + */ protected function offsetSerialize_media() { return null; } + /** + * @return FlexDirectory + */ abstract public function getFlexDirectory(): FlexDirectory; + /** + * @return string + */ abstract public function getStorageKey(): string; + + /** + * @param string $filename + * @return void + * @deprecated 1.7 Use Media class that implements MediaUploadInterface instead. + */ + public function checkMediaFilename(string $filename) + { + user_error(__METHOD__ . '() is deprecated since Grav 1.7, use Media class that implements MediaUploadInterface instead', E_USER_DEPRECATED); + + // Check the file extension. + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + $grav = Grav::instance(); + + /** @var Config $config */ + $config = $grav['config']; + + // If not a supported type, return + if (!$extension || !$config->get("media.types.{$extension}")) { + $language = $grav['language']; + throw new RuntimeException($language->translate('PLUGIN_ADMIN.UNSUPPORTED_FILE_TYPE') . ': ' . $extension, 400); + } + } } diff --git a/system/src/Grav/Framework/Flex/Traits/FlexRelatedDirectoryTrait.php b/system/src/Grav/Framework/Flex/Traits/FlexRelatedDirectoryTrait.php new file mode 100644 index 0000000..9f7380c --- /dev/null +++ b/system/src/Grav/Framework/Flex/Traits/FlexRelatedDirectoryTrait.php @@ -0,0 +1,59 @@ +getRelatedDirectory($type); + $collection = $directory->getCollection(); + $list = $this->getNestedProperty($property) ?: []; + + /** @var FlexCollection $collection */ + $collection = $collection->filter(static function ($object) use ($list) { + return in_array($object->id, $list, true); + }); + + return $collection; + } + + /** + * @param string $type + * @return FlexDirectory + * @throws RuntimeException + */ + protected function getRelatedDirectory($type): FlexDirectory + { + $directory = $this->getFlexContainer()->getDirectory($type); + if (!$directory) { + throw new RuntimeException(ucfirst($type). ' directory does not exist!'); + } + + return $directory; + } +} diff --git a/system/src/Grav/Framework/Form/FormFlash.php b/system/src/Grav/Framework/Form/FormFlash.php index e2ba826..00aab28 100644 --- a/system/src/Grav/Framework/Form/FormFlash.php +++ b/system/src/Grav/Framework/Form/FormFlash.php @@ -3,12 +3,13 @@ /** * @package Grav\Framework\Form * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Form; +use Exception; use Grav\Common\Filesystem\Folder; use Grav\Common\Grav; use Grav\Common\User\Interfaces\UserInterface; @@ -17,7 +18,14 @@ use Grav\Framework\Form\Interfaces\FormFlashInterface; use Psr\Http\Message\UploadedFileInterface; use RocketTheme\Toolbox\File\YamlFile; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; +use RuntimeException; +use function func_get_args; +use function is_array; +/** + * Class FormFlash + * @package Grav\Framework\Form + */ class FormFlash implements FormFlashInterface { /** @var bool */ @@ -30,13 +38,13 @@ class FormFlash implements FormFlashInterface protected $formName; /** @var string */ protected $url; - /** @var array */ + /** @var array|null */ protected $user; /** @var int */ protected $createdTimestamp; /** @var int */ protected $updatedTimestamp; - /** @var array */ + /** @var array|null */ protected $data; /** @var array */ protected $files; @@ -62,6 +70,9 @@ class FormFlash implements FormFlashInterface 'unique_id' => $args[1] ?? null, 'form_name' => $args[2] ?? null, ]; + $config = array_filter($config, static function ($val) { + return $val !== null; + }); } $this->sessionId = $config['session_id'] ?? 'no-session'; @@ -73,30 +84,55 @@ class FormFlash implements FormFlashInterface $locator = Grav::instance()['locator']; $this->folder = $folder && $locator->isStream($folder) ? $locator->findResource($folder, true, true) : $folder; - $file = $this->getTmpIndex(); - $this->exists = $file->exists(); - if ($this->exists) { - try { - $data = (array)$file->content(); - } catch (\Exception $e) { - $data = []; - } - $this->formName = $content['form'] ?? $config['form_name'] ?? ''; + $this->init($this->loadStoredForm(), $config); + } + + /** + * @param array|null $data + * @param array $config + */ + protected function init(?array $data, array $config): void + { + if (null === $data) { + $this->exists = false; + $this->formName = $config['form_name'] ?? ''; + $this->url = ''; + $this->createdTimestamp = $this->updatedTimestamp = time(); + $this->files = []; + } else { + $this->exists = true; + $this->formName = $data['form'] ?? $config['form_name'] ?? ''; $this->url = $data['url'] ?? ''; $this->user = $data['user'] ?? null; $this->updatedTimestamp = $data['timestamps']['updated'] ?? time(); $this->createdTimestamp = $data['timestamps']['created'] ?? $this->updatedTimestamp; $this->data = $data['data'] ?? null; $this->files = $data['files'] ?? []; - } else { - $this->formName = $config['form_name'] ?? ''; - $this->url = ''; - $this->createdTimestamp = $this->updatedTimestamp = time(); - $this->files = []; } } + /** + * Load raw flex flash data from the filesystem. + * + * @return array|null + */ + protected function loadStoredForm(): ?array + { + $file = $this->getTmpIndex(); + $exists = $file->exists(); + + $data = null; + if ($exists) { + try { + $data = (array)$file->content(); + } catch (Exception $e) { + } + } + + return $data; + } + /** * @inheritDoc */ @@ -114,6 +150,7 @@ class FormFlash implements FormFlashInterface } /** + * @return string * @deprecated 1.6.11 Use '->getUniqueId()' method instead. */ public function getUniqieId(): string @@ -200,13 +237,13 @@ class FormFlash implements FormFlashInterface /** * @inheritDoc */ - public function save(): self + public function save(bool $force = false) { if (!($this->folder && $this->uniqueId)) { return $this; } - if ($this->data || $this->files) { + if ($force || $this->data || $this->files) { // Only save if there is data or files to be saved. $file = $this->getTmpIndex(); $file->save($this->jsonSerialize()); @@ -222,7 +259,7 @@ class FormFlash implements FormFlashInterface /** * @inheritDoc */ - public function delete(): self + public function delete() { if ($this->folder && $this->uniqueId) { $this->removeTmpDir(); @@ -273,6 +310,9 @@ class FormFlash implements FormFlashInterface $tmp_dir = $this->getTmpDir(); $tmp_name = Utils::generateRandomString(12); $name = $upload->getClientFilename(); + if (!$name) { + throw new RuntimeException('Uploaded file has no filename'); + } // Prepare upload data for later save $data = [ @@ -290,14 +330,13 @@ class FormFlash implements FormFlashInterface return $name; } - /** * @inheritDoc */ public function addFile(string $filename, string $field, array $crop = null): bool { if (!file_exists($filename)) { - throw new \RuntimeException("File not found: {$filename}"); + throw new RuntimeException("File not found: {$filename}"); } // Prepare upload data for later save @@ -457,8 +496,15 @@ class FormFlash implements FormFlashInterface } } + /** + * @return void + */ protected function removeTmpDir(): void { + // Make sure that index file cache gets always cleared. + $file = $this->getTmpIndex(); + $file->free(); + $tmpDir = $this->getTmpDir(); if ($tmpDir && file_exists($tmpDir)) { Folder::delete($tmpDir); @@ -466,15 +512,16 @@ class FormFlash implements FormFlashInterface } /** - * @param string $field + * @param string|null $field * @param string $name * @param array $data * @param array|null $crop + * @return void */ protected function addFileInternal(?string $field, string $name, array $data, array $crop = null): void { if (!($this->folder && $this->uniqueId)) { - throw new \RuntimeException('Cannot upload files: form flash folder not defined'); + throw new RuntimeException('Cannot upload files: form flash folder not defined'); } $field = $field ?: 'undefined'; diff --git a/system/src/Grav/Framework/Form/FormFlashFile.php b/system/src/Grav/Framework/Form/FormFlashFile.php index 9183689..dc510e2 100644 --- a/system/src/Grav/Framework/Form/FormFlashFile.php +++ b/system/src/Grav/Framework/Form/FormFlashFile.php @@ -3,23 +3,43 @@ /** * @package Grav\Framework\Form * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Form; use Grav\Framework\Psr7\Stream; +use InvalidArgumentException; use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UploadedFileInterface; +use RuntimeException; +use function copy; +use function fopen; +use function is_string; +use function sprintf; +/** + * Class FormFlashFile + * @package Grav\Framework\Form + */ class FormFlashFile implements UploadedFileInterface, \JsonSerializable { + /** @var string */ private $field; + /** @var bool */ private $moved = false; + /** @var array */ private $upload; + /** @var FormFlash */ private $flash; + /** + * FormFlashFile constructor. + * @param string $field + * @param array $upload + * @param FormFlash $flash + */ public function __construct(string $field, array $upload, FormFlash $flash) { $this->field = $field; @@ -43,54 +63,99 @@ class FormFlashFile implements UploadedFileInterface, \JsonSerializable { $this->validateActive(); - $resource = \fopen($this->getTmpFile(), 'rb'); + $tmpFile = $this->getTmpFile(); + if (null === $tmpFile) { + throw new RuntimeException('No temporary file'); + } + + $resource = fopen($tmpFile, 'rb'); + if (false === $resource) { + throw new RuntimeException('No temporary file'); + } return Stream::create($resource); } + /** + * @param string $targetPath + * @return void + */ public function moveTo($targetPath) { $this->validateActive(); - if (!\is_string($targetPath) || empty($targetPath)) { - throw new \InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string'); + if (!is_string($targetPath) || empty($targetPath)) { + throw new InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string'); + } + $tmpFile = $this->getTmpFile(); + if (null === $tmpFile) { + throw new RuntimeException('No temporary file'); } - $this->moved = \copy($this->getTmpFile(), $targetPath); + $this->moved = copy($tmpFile, $targetPath); if (false === $this->moved) { - throw new \RuntimeException(\sprintf('Uploaded file could not be moved to %s', $targetPath)); + throw new RuntimeException(sprintf('Uploaded file could not be moved to %s', $targetPath)); } - $this->flash->removeFile($this->getClientFilename(), $this->field); + $filename = $this->getClientFilename(); + if ($filename) { + $this->flash->removeFile($filename, $this->field); + } } + /** + * @return string + */ + public function getField(): string + { + return $this->field; + } + + /** + * @return int + */ public function getSize() { return $this->upload['size']; } + /** + * @return int + */ public function getError() { return $this->upload['error'] ?? \UPLOAD_ERR_OK; } + /** + * @return string + */ public function getClientFilename() { return $this->upload['name'] ?? 'unknown'; } + /** + * @return string + */ public function getClientMediaType() { return $this->upload['type'] ?? 'application/octet-stream'; } - public function isMoved() : bool + /** + * @return bool + */ + public function isMoved(): bool { return $this->moved; } - public function getMetaData() : array + /** + * @return array + */ + public function getMetaData(): array { if (isset($this->upload['crop'])) { return ['crop' => $this->upload['crop']]; @@ -99,17 +164,26 @@ class FormFlashFile implements UploadedFileInterface, \JsonSerializable return []; } + /** + * @return string + */ public function getDestination() { return $this->upload['path'] ?? ''; } + /** + * @return array + */ public function jsonSerialize() { return $this->upload; } - public function getTmpFile() : ?string + /** + * @return string|null + */ + public function getTmpFile(): ?string { $tmpName = $this->upload['tmp_name'] ?? null; @@ -122,6 +196,9 @@ class FormFlashFile implements UploadedFileInterface, \JsonSerializable return file_exists($tmpFile) ? $tmpFile : null; } + /** + * @return array + */ public function __debugInfo() { return [ @@ -132,20 +209,21 @@ class FormFlashFile implements UploadedFileInterface, \JsonSerializable } /** - * @throws \RuntimeException if is moved or not ok + * @return void + * @throws RuntimeException if is moved or not ok */ private function validateActive(): void { if (!$this->isOk()) { - throw new \RuntimeException('Cannot retrieve stream due to upload error'); + throw new RuntimeException('Cannot retrieve stream due to upload error'); } if ($this->moved) { - throw new \RuntimeException('Cannot retrieve stream after it has already been moved'); + throw new RuntimeException('Cannot retrieve stream after it has already been moved'); } if (!$this->getTmpFile()) { - throw new \RuntimeException('Cannot retrieve stream as the file is missing'); + throw new RuntimeException('Cannot retrieve stream as the file is missing'); } } diff --git a/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php b/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php index fb57db1..d21d4aa 100644 --- a/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php +++ b/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Form * - * @copyright Copyright (C) 2015 - 2018 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,6 +14,10 @@ namespace Grav\Framework\Form\Interfaces; use Grav\Common\Page\Interfaces\PageInterface; use Grav\Common\Page\Page; +/** + * Interface FormFactoryInterface + * @package Grav\Framework\Form\Interfaces + */ interface FormFactoryInterface { /** diff --git a/system/src/Grav/Framework/Form/Interfaces/FormFlashInterface.php b/system/src/Grav/Framework/Form/Interfaces/FormFlashInterface.php index 8eb8525..d51bd8d 100644 --- a/system/src/Grav/Framework/Form/Interfaces/FormFlashInterface.php +++ b/system/src/Grav/Framework/Form/Interfaces/FormFlashInterface.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Form * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,6 +11,10 @@ namespace Grav\Framework\Form\Interfaces; use Psr\Http\Message\UploadedFileInterface; +/** + * Interface FormFlashInterface + * @package Grav\Framework\Form\Interfaces + */ interface FormFlashInterface extends \JsonSerializable { /** @@ -86,6 +90,7 @@ interface FormFlashInterface extends \JsonSerializable * Set raw form data. * * @param array|null $data + * @return void */ public function setData(?array $data): void; @@ -105,6 +110,8 @@ interface FormFlashInterface extends \JsonSerializable /** * Delete this form flash. + * + * @return $this */ public function delete(); @@ -139,7 +146,7 @@ interface FormFlashInterface extends \JsonSerializable * * @param string $filename * @param string $field - * @param array $crop + * @param array|null $crop * @return bool */ public function addFile(string $filename, string $field, array $crop = null): bool; @@ -148,13 +155,15 @@ interface FormFlashInterface extends \JsonSerializable * Remove any file from form flash. * * @param string $name - * @param string $field + * @param string|null $field * @return bool */ public function removeFile(string $name, string $field = null): bool; /** * Clear form flash from all uploaded files. + * + * @return void */ public function clearFiles(); diff --git a/system/src/Grav/Framework/Form/Interfaces/FormInterface.php b/system/src/Grav/Framework/Form/Interfaces/FormInterface.php index d218e72..9815646 100644 --- a/system/src/Grav/Framework/Form/Interfaces/FormInterface.php +++ b/system/src/Grav/Framework/Form/Interfaces/FormInterface.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Form * - * @copyright Copyright (C) 2015 - 2018 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -122,6 +122,13 @@ interface FormInterface extends RenderInterface, \Serializable */ public function getValue(string $name); + /** + * Get form flash object. + * + * @return FormFlashInterface + */ + public function getFlash(); + /** * @param ServerRequestInterface $request * @return $this @@ -130,7 +137,7 @@ interface FormInterface extends RenderInterface, \Serializable /** * @param array $data - * @param UploadedFileInterface[] $files + * @param UploadedFileInterface[]|null $files * @return $this */ public function submit(array $data, array $files = null): FormInterface; @@ -157,6 +164,8 @@ interface FormInterface extends RenderInterface, \Serializable /** * Reset form. + * + * @return void */ public function reset(): void; diff --git a/system/src/Grav/Framework/Form/Traits/FormTrait.php b/system/src/Grav/Framework/Form/Traits/FormTrait.php index d475048..8dfed19 100644 --- a/system/src/Grav/Framework/Form/Traits/FormTrait.php +++ b/system/src/Grav/Framework/Form/Traits/FormTrait.php @@ -3,28 +3,40 @@ /** * @package Grav\Framework\Form * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Form\Traits; +use ArrayAccess; +use Exception; +use FilesystemIterator; use Grav\Common\Data\Blueprint; use Grav\Common\Data\Data; use Grav\Common\Data\ValidationException; +use Grav\Common\Debugger; use Grav\Common\Form\FormFlash; use Grav\Common\Grav; use Grav\Common\Twig\Twig; use Grav\Common\User\Interfaces\UserInterface; use Grav\Common\Utils; +use Grav\Framework\Compat\Serializable; use Grav\Framework\ContentBlock\HtmlBlock; +use Grav\Framework\Form\Interfaces\FormFlashInterface; use Grav\Framework\Form\Interfaces\FormInterface; use Grav\Framework\Session\SessionInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UploadedFileInterface; +use RuntimeException; +use SplFileInfo; use Twig\Error\LoaderError; use Twig\Error\SyntaxError; +use Twig\Template; use Twig\TemplateWrapper; +use function in_array; +use function is_array; +use function is_object; /** * Trait FormTrait @@ -32,9 +44,11 @@ use Twig\TemplateWrapper; */ trait FormTrait { + use Serializable; + /** @var string */ public $status = 'success'; - /** @var string */ + /** @var string|null */ public $message; /** @var string[] */ public $messages = []; @@ -45,72 +59,114 @@ trait FormTrait private $id; /** @var string */ private $uniqueid; + /** @var string */ + private $sessionid; /** @var bool */ private $submitted; - /** @var Data|object|null */ + /** @var ArrayAccess|Data|null */ private $data; /** @var array|UploadedFileInterface[] */ private $files; - /** @var FormFlash|null */ + /** @var FormFlashInterface|null */ private $flash; + /** @var string */ + private $flashFolder; /** @var Blueprint */ private $blueprint; + /** + * @return string + */ public function getId(): string { return $this->id; } + /** + * @param string $id + */ public function setId(string $id): void { $this->id = $id; } + /** + * @return string + */ public function getUniqueId(): string { return $this->uniqueid; } + /** + * @param string $uniqueId + * @return void + */ public function setUniqueId(string $uniqueId): void { $this->uniqueid = $uniqueId; } + /** + * @return string + */ public function getName(): string { return $this->name; } + /** + * @return string + */ public function getFormName(): string { return $this->name; } + /** + * @return string + */ public function getNonceName(): string { return 'form-nonce'; } + /** + * @return string + */ public function getNonceAction(): string { return 'form'; } + /** + * @return string + */ public function getNonce(): string { return Utils::getNonce($this->getNonceAction()); } + /** + * @return string + */ public function getAction(): string { return ''; } + /** + * @return string + */ public function getTask(): string { return $this->getBlueprint()->get('form/task') ?? ''; } + /** + * @param string|null $name + * @return mixed + */ public function getData(string $name = null) { return null !== $name ? $this->data[$name] : $this->data; @@ -124,11 +180,19 @@ trait FormTrait return $this->files ?? []; } + /** + * @param string $name + * @return mixed|null + */ public function getValue(string $name) { return $this->data[$name] ?? null; } + /** + * @param string $name + * @return mixed|null + */ public function getDefaultValue(string $name) { $path = explode('.', $name) ?: []; @@ -145,9 +209,9 @@ trait FormTrait while ($path) { $offset = array_shift($path); - if ((\is_array($current) || $current instanceof \ArrayAccess) && isset($current[$offset])) { + if ((is_array($current) || $current instanceof ArrayAccess) && isset($current[$offset])) { $current = $current[$offset]; - } elseif (\is_object($current) && isset($current->{$offset})) { + } elseif (is_object($current) && isset($current->{$offset})) { $current = $current->{$offset}; } else { return null; @@ -180,14 +244,17 @@ trait FormTrait /** @var Twig $twig */ $twig = $grav['twig']; $twig->twig_vars['form'] = $this; - } try { [$data, $files] = $this->parseRequest($request); $this->submit($data, $files); - } catch (\Exception $e) { + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addException($e); + $this->setError($e->getMessage()); } @@ -208,26 +275,41 @@ trait FormTrait return $this; } + /** + * @return bool + */ public function isValid(): bool { return $this->status === 'success'; } + /** + * @return string|null + */ public function getError(): ?string { return !$this->isValid() ? $this->message : null; } + /** + * @return array + */ public function getErrors(): array { return !$this->isValid() ? $this->messages : []; } + /** + * @return bool + */ public function isSubmitted(): bool { return $this->submitted; } + /** + * @return bool + */ public function validate(): bool { if (!$this->isValid()) { @@ -239,7 +321,11 @@ trait FormTrait $this->validateUploads($this->getFiles()); } catch (ValidationException $e) { $this->setErrors($e->getMessages()); - } catch (\Exception $e) { + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + $this->setError($e->getMessage()); } @@ -250,14 +336,14 @@ trait FormTrait /** * @param array $data - * @param UploadedFileInterface[] $files + * @param UploadedFileInterface[]|null $files * @return FormInterface|$this */ public function submit(array $data, array $files = null): FormInterface { try { if ($this->isSubmitted()) { - throw new \RuntimeException('Form has already been submitted'); + throw new RuntimeException('Form has already been submitted'); } $this->data = new Data($data, $this->getBlueprint()); @@ -270,13 +356,20 @@ trait FormTrait $this->doSubmit($this->data->toArray(), $this->files); $this->submitted = true; - } catch (\Exception $e) { + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + $this->setError($e->getMessage()); } return $this; } + /** + * @return void + */ public function reset(): void { // Make sure that the flash object gets deleted. @@ -291,51 +384,41 @@ trait FormTrait $this->flash = null; } + /** + * @return array + */ public function getFields(): array { return $this->getBlueprint()->fields(); } + /** + * @return array + */ public function getButtons(): array { return $this->getBlueprint()->get('form/buttons') ?? []; } + /** + * @return array + */ public function getTasks(): array { return $this->getBlueprint()->get('form/tasks') ?? []; } + /** + * @return Blueprint + */ abstract public function getBlueprint(): Blueprint; - /** - * Implements \Serializable::serialize(). - * - * @return string - */ - public function serialize(): string - { - return serialize($this->doSerialize()); - } - - /** - * Implements \Serializable::unserialize(). - * - * @param string $serialized - */ - public function unserialize($serialized): void - { - $data = unserialize($serialized, ['allowed_classes' => false]); - - $this->doUnserialize($data); - } - /** * Get form flash object. * - * @return FormFlash + * @return FormFlashInterface */ - public function getFlash(): FormFlash + public function getFlash() { if (null === $this->flash) { $grav = Grav::instance(); @@ -357,7 +440,7 @@ trait FormTrait /** * Get all available form flash objects for this form. * - * @return FormFlash[] + * @return FormFlashInterface[] */ public function getAllFlashes(): array { @@ -369,8 +452,8 @@ trait FormTrait $name = $this->getName(); $list = []; - /** @var \SplFileInfo $file */ - foreach (new \FilesystemIterator($folder) as $file) { + /** @var SplFileInfo $file */ + foreach (new FilesystemIterator($folder) as $file) { $uniqueId = $file->getFilename(); $config = [ 'session_id' => $this->getSessionId(), @@ -385,7 +468,6 @@ trait FormTrait } return $list; - } /** @@ -412,31 +494,79 @@ trait FormTrait return $block; } - protected function getSessionId(): string + /** + * @return array + */ + public function jsonSerialize(): array { - /** @var Grav $grav */ - $grav = Grav::instance(); - - /** @var SessionInterface $session */ - $session = $grav['session'] ?? null; - - return $session ? ($session->getId() ?? '') : ''; + return $this->doSerialize(); } + /** + * @return array + */ + final public function __serialize(): array + { + return $this->doSerialize(); + } + + /** + * @param array $data + * @return void + */ + final public function __unserialize(array $data): void + { + $this->doUnserialize($data); + } + + protected function getSessionId(): string + { + if (null === $this->sessionid) { + /** @var Grav $grav */ + $grav = Grav::instance(); + + /** @var SessionInterface|null $session */ + $session = $grav['session'] ?? null; + + $this->sessionid = $session ? ($session->getId() ?? '') : ''; + } + + return $this->sessionid; + } + + /** + * @param string $sessionId + * @return void + */ + protected function setSessionId(string $sessionId): void + { + $this->sessionid = $sessionId; + } + + /** + * @return void + */ protected function unsetFlash(): void { $this->flash = null; } + /** + * @return string|null + */ protected function getFlashFolder(): ?string { $grav = Grav::instance(); - /** @var UserInterface $user */ + /** @var UserInterface|null $user */ $user = $grav['user'] ?? null; - $userExists = $user && $user->exists(); - $username = $userExists ? $user->username : null; - $mediaFolder = $userExists ? $user->getMediaFolder() : null; + if (null !== $user && $user->exists()) { + $username = $user->username; + $mediaFolder = $user->getMediaFolder(); + } else { + $username = null; + $mediaFolder = null; + } $session = $grav['session'] ?? null; $sessionId = $session ? $session->getId() : null; @@ -449,18 +579,40 @@ trait FormTrait '[ACCOUNT]' => $mediaFolder ?? '!!' ]; - $flashFolder = $this->getBlueprint()->get('form/flash_folder', 'tmp://forms/[SESSIONID]'); + $flashLookupFolder = $this->getFlashLookupFolder(); - $path = str_replace(array_keys($dataMap), array_values($dataMap), $flashFolder); + $path = str_replace(array_keys($dataMap), array_values($dataMap), $flashLookupFolder); // Make sure we only return valid paths. return strpos($path, '!!') === false ? rtrim($path, '/') : null; } + /** + * @return string + */ + protected function getFlashLookupFolder(): string + { + if (null === $this->flashFolder) { + $this->flashFolder = $this->getBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]'; + } + + return $this->flashFolder; + } + + /** + * @param string $folder + * @return void + */ + protected function setFlashLookupFolder(string $folder): void + { + $this->flashFolder = $folder; + } + /** * Set a single error. * * @param string $error + * @return void */ protected function setError(string $error): void { @@ -472,6 +624,7 @@ trait FormTrait * Set all errors. * * @param array $errors + * @return void */ protected function setErrors(array $errors): void { @@ -481,7 +634,7 @@ trait FormTrait /** * @param string $layout - * @return TemplateWrapper + * @return Template|TemplateWrapper * @throws LoaderError * @throws SyntaxError */ @@ -509,8 +662,8 @@ trait FormTrait protected function parseRequest(ServerRequestInterface $request): array { $method = $request->getMethod(); - if (!\in_array($method, ['PUT', 'POST', 'PATCH'])) { - throw new \RuntimeException(sprintf('FlexForm: Bad HTTP method %s', $method)); + if (!in_array($method, ['PUT', 'POST', 'PATCH'])) { + throw new RuntimeException(sprintf('FlexForm: Bad HTTP method %s', $method)); } $body = $request->getParsedBody(); @@ -536,23 +689,15 @@ trait FormTrait ]; } - /** - * Form submit logic goes here. - * - * @param array $data - * @param array $files - * @return mixed - */ - abstract protected function doSubmit(array $data, array $files); - /** * Validate data and throw validation exceptions if validation fails. * - * @param \ArrayAccess $data + * @param ArrayAccess|Data|null $data + * @return void * @throws ValidationException - * @throws \Exception + * @throws Exception */ - protected function validateData(\ArrayAccess $data): void + protected function validateData($data = null): void { if ($data instanceof Data) { $data->validate(); @@ -562,9 +707,10 @@ trait FormTrait /** * Filter validated data. * - * @param \ArrayAccess $data + * @param ArrayAccess|Data|null $data + * @return void */ - protected function filterData(\ArrayAccess $data): void + protected function filterData($data = null): void { if ($data instanceof Data) { $data->filter(); @@ -575,6 +721,7 @@ trait FormTrait * Validate all uploaded files. * * @param array $files + * @return void */ protected function validateUploads(array $files): void { @@ -594,15 +741,16 @@ trait FormTrait * Validate uploaded file. * * @param UploadedFileInterface $file + * @return void */ protected function validateUpload(UploadedFileInterface $file): void { // Handle bad filenames. $filename = $file->getClientFilename(); - if (!Utils::checkFilename($filename)) { + if ($filename && !Utils::checkFilename($filename)) { $grav = Grav::instance(); - throw new \RuntimeException( + throw new RuntimeException( sprintf($grav['language']->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_UPLOAD', null, true), $filename, 'Bad filename') ); } @@ -616,13 +764,16 @@ trait FormTrait */ protected function decodeData($data): array { - if (!\is_array($data)) { + if (!is_array($data)) { return []; } // Decode JSON encoded fields and merge them to data. if (isset($data['_json'])) { $data = array_replace_recursive($data, $this->jsonDecode($data['_json'])); + if (null === $data) { + throw new RuntimeException(__METHOD__ . '(): Unexpected error'); + } unset($data['_json']); } @@ -638,7 +789,7 @@ trait FormTrait protected function jsonDecode(array $data): array { foreach ($data as $key => &$value) { - if (\is_array($value)) { + if (is_array($value)) { $value = $this->jsonDecode($value); } elseif (trim($value) === '') { unset($data[$key]); @@ -676,6 +827,7 @@ trait FormTrait /** * @param array $data + * @return void */ protected function doUnserialize(array $data): void { diff --git a/system/src/Grav/Framework/Interfaces/RenderInterface.php b/system/src/Grav/Framework/Interfaces/RenderInterface.php index ca6d9bc..7a7d9d3 100644 --- a/system/src/Grav/Framework/Interfaces/RenderInterface.php +++ b/system/src/Grav/Framework/Interfaces/RenderInterface.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Interfaces * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php b/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php index e834bc8..a3587d8 100644 --- a/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php +++ b/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Media * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/Media/Interfaces/MediaInterface.php b/system/src/Grav/Framework/Media/Interfaces/MediaInterface.php index b06817b..2c5a4b3 100644 --- a/system/src/Grav/Framework/Media/Interfaces/MediaInterface.php +++ b/system/src/Grav/Framework/Media/Interfaces/MediaInterface.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Media * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/Media/Interfaces/MediaManipulationInterface.php b/system/src/Grav/Framework/Media/Interfaces/MediaManipulationInterface.php index 0f609d2..fef475f 100644 --- a/system/src/Grav/Framework/Media/Interfaces/MediaManipulationInterface.php +++ b/system/src/Grav/Framework/Media/Interfaces/MediaManipulationInterface.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Media * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -17,16 +17,17 @@ use Psr\Http\Message\UploadedFileInterface; /** * Interface MediaManipulationInterface * @package Grav\Framework\Media\Interfaces + * @deprecated 1.7 Not used currently */ interface MediaManipulationInterface extends MediaInterface { /** * @param UploadedFileInterface $uploadedFile */ - public function uploadMediaFile(UploadedFileInterface $uploadedFile) : void; + public function uploadMediaFile(UploadedFileInterface $uploadedFile): void; /** * @param string $filename */ - public function deleteMediaFile(string $filename) : void; + public function deleteMediaFile(string $filename): void; } diff --git a/system/src/Grav/Framework/Media/Interfaces/MediaObjectInterface.php b/system/src/Grav/Framework/Media/Interfaces/MediaObjectInterface.php index 1fc46b7..dc7f148 100644 --- a/system/src/Grav/Framework/Media/Interfaces/MediaObjectInterface.php +++ b/system/src/Grav/Framework/Media/Interfaces/MediaObjectInterface.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Media * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php b/system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php index 1d43849..de5cc7a 100644 --- a/system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php +++ b/system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -42,6 +42,7 @@ trait ArrayAccessTrait * * @param mixed $offset The offset to assign the value to. * @param mixed $value The value to set. + * @return void */ public function offsetSet($offset, $value) { @@ -52,14 +53,10 @@ trait ArrayAccessTrait * Unsets an offset. * * @param mixed $offset The offset to unset. + * @return void */ public function offsetUnset($offset) { $this->unsetProperty($offset); } - - abstract public function hasProperty($property); - abstract public function getProperty($property, $default = null); - abstract public function setProperty($property, $value); - abstract public function unsetProperty($property); } diff --git a/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php b/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php index 858b391..e048565 100644 --- a/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php +++ b/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -42,6 +42,7 @@ trait NestedArrayAccessTrait * * @param mixed $offset The offset to assign the value to. * @param mixed $value The value to set. + * @return void */ public function offsetSet($offset, $value) { @@ -52,14 +53,10 @@ trait NestedArrayAccessTrait * Unsets an offset. * * @param mixed $offset The offset to unset. + * @return void */ public function offsetUnset($offset) { $this->unsetNestedProperty($offset); } - - abstract public function hasNestedProperty($property, $separator = null); - abstract public function getNestedProperty($property, $default = null, $separator = null); - abstract public function setNestedProperty($property, $value, $separator = null); - abstract public function unsetNestedProperty($property, $separator = null); } diff --git a/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php b/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php index 5ac0390..120c1a4 100644 --- a/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php +++ b/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -19,7 +19,7 @@ trait NestedPropertyCollectionTrait { /** * @param string $property Object property to be matched. - * @param string $separator Separator, defaults to '.' + * @param string|null $separator Separator, defaults to '.' * @return array Key/Value pairs of the properties. */ public function hasNestedProperty($property, $separator = null) @@ -37,7 +37,7 @@ trait NestedPropertyCollectionTrait /** * @param string $property Object property to be fetched. * @param mixed $default Default value if not set. - * @param string $separator Separator, defaults to '.' + * @param string|null $separator Separator, defaults to '.' * @return array Key/Value pairs of the properties. */ public function getNestedProperty($property, $default = null, $separator = null) @@ -54,8 +54,8 @@ trait NestedPropertyCollectionTrait /** * @param string $property Object property to be updated. - * @param string $value New value. - * @param string $separator Separator, defaults to '.' + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' * @return $this */ public function setNestedProperty($property, $value, $separator = null) @@ -70,7 +70,7 @@ trait NestedPropertyCollectionTrait /** * @param string $property Object property to be updated. - * @param string $separator Separator, defaults to '.' + * @param string|null $separator Separator, defaults to '.' * @return $this */ public function unsetNestedProperty($property, $separator = null) @@ -86,7 +86,7 @@ trait NestedPropertyCollectionTrait /** * @param string $property Object property to be updated. * @param string $default Default value. - * @param string $separator Separator, defaults to '.' + * @param string|null $separator Separator, defaults to '.' * @return $this */ public function defNestedProperty($property, $default, $separator = null) @@ -103,7 +103,7 @@ trait NestedPropertyCollectionTrait * Group items in the collection by a field. * * @param string $property Object property to be used to make groups. - * @param string $separator Separator, defaults to '.' + * @param string|null $separator Separator, defaults to '.' * @return array */ public function group($property, $separator = null) @@ -117,9 +117,4 @@ trait NestedPropertyCollectionTrait return $list; } - - /** - * @return \Traversable - */ - abstract public function getIterator(); } diff --git a/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php b/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php index 2200871..7409f4c 100644 --- a/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php +++ b/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php @@ -3,13 +3,17 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Object\Access; use Grav\Framework\Object\Interfaces\ObjectInterface; +use RuntimeException; +use stdClass; +use function is_array; +use function is_object; /** * Nested Property Object Trait @@ -19,20 +23,20 @@ trait NestedPropertyTrait { /** * @param string $property Object property name. - * @param string $separator Separator, defaults to '.' + * @param string|null $separator Separator, defaults to '.' * @return bool True if property has been defined (can be null). */ public function hasNestedProperty($property, $separator = null) { - $test = new \stdClass; + $test = new stdClass; return $this->getNestedProperty($property, $test, $separator) !== $test; } /** * @param string $property Object property to be fetched. - * @param mixed $default Default value if property has not been set. - * @param string $separator Separator, defaults to '.' + * @param mixed|null $default Default value if property has not been set. + * @param string|null $separator Separator, defaults to '.' * @return mixed Property value. */ public function getNestedProperty($property, $default = null, $separator = null) @@ -58,9 +62,9 @@ trait NestedPropertyTrait $offset = array_shift($path); - if ((\is_array($current) || is_a($current, 'ArrayAccess')) && isset($current[$offset])) { + if ((is_array($current) || is_a($current, 'ArrayAccess')) && isset($current[$offset])) { $current = $current[$offset]; - } elseif (\is_object($current) && isset($current->{$offset})) { + } elseif (is_object($current) && isset($current->{$offset})) { $current = $current->{$offset}; } else { return $default; @@ -73,10 +77,10 @@ trait NestedPropertyTrait /** * @param string $property Object property to be updated. - * @param string $value New value. - * @param string $separator Separator, defaults to '.' + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' * @return $this - * @throws \RuntimeException + * @throws RuntimeException */ public function setNestedProperty($property, $value, $separator = null) { @@ -98,12 +102,12 @@ trait NestedPropertyTrait // Handle arrays and scalars. if ($current === null) { $current = [$offset => []]; - } elseif (\is_array($current)) { + } elseif (is_array($current)) { if (!isset($current[$offset])) { $current[$offset] = []; } } else { - throw new \RuntimeException("Cannot set nested property {$property} on non-array value"); + throw new RuntimeException("Cannot set nested property {$property} on non-array value"); } $current = &$current[$offset]; @@ -116,9 +120,9 @@ trait NestedPropertyTrait /** * @param string $property Object property to be updated. - * @param string $separator Separator, defaults to '.' + * @param string|null $separator Separator, defaults to '.' * @return $this - * @throws \RuntimeException + * @throws RuntimeException */ public function unsetNestedProperty($property, $separator = null) { @@ -142,12 +146,12 @@ trait NestedPropertyTrait if ($current === null) { return $this; } - if (\is_array($current)) { + if (is_array($current)) { if (!isset($current[$offset])) { return $this; } } else { - throw new \RuntimeException("Cannot unset nested property {$property} on non-array value"); + throw new RuntimeException("Cannot unset nested property {$property} on non-array value"); } $current = &$current[$offset]; @@ -161,9 +165,9 @@ trait NestedPropertyTrait /** * @param string $property Object property to be updated. * @param mixed $default Default value. - * @param string $separator Separator, defaults to '.' + * @param string|null $separator Separator, defaults to '.' * @return $this - * @throws \RuntimeException + * @throws RuntimeException */ public function defNestedProperty($property, $default, $separator = null) { @@ -173,11 +177,4 @@ trait NestedPropertyTrait return $this; } - - - abstract public function hasProperty($property); - abstract public function getProperty($property, $default = null); - abstract public function setProperty($property, $value); - abstract public function unsetProperty($property); - abstract protected function &doGetProperty($property, $default = null, $doCreate = false); } diff --git a/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php b/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php index 029dbba..0f0f89f 100644 --- a/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php +++ b/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -42,6 +42,7 @@ trait OverloadedPropertyTrait * * @param mixed $offset The offset to assign the value to. * @param mixed $value The value to set. + * @return void */ public function __set($offset, $value) { @@ -52,14 +53,10 @@ trait OverloadedPropertyTrait * Magic method to unset the attribute * * @param mixed $offset The name value to unset + * @return void */ public function __unset($offset) { $this->unsetProperty($offset); } - - abstract public function hasProperty($property); - abstract public function getProperty($property, $default = null); - abstract public function setProperty($property, $value); - abstract public function unsetProperty($property); } diff --git a/system/src/Grav/Framework/Object/ArrayObject.php b/system/src/Grav/Framework/Object/ArrayObject.php index a7062e8..2bbe937 100644 --- a/system/src/Grav/Framework/Object/ArrayObject.php +++ b/system/src/Grav/Framework/Object/ArrayObject.php @@ -3,12 +3,13 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Object; +use ArrayAccess; use Grav\Framework\Object\Access\NestedArrayAccessTrait; use Grav\Framework\Object\Access\NestedPropertyTrait; use Grav\Framework\Object\Access\OverloadedPropertyTrait; @@ -19,7 +20,7 @@ use Grav\Framework\Object\Property\ArrayPropertyTrait; /** * Array Objects keep the data in private array property. */ -class ArrayObject implements NestedObjectInterface, \ArrayAccess +class ArrayObject implements NestedObjectInterface, ArrayAccess { use ObjectTrait; use ArrayPropertyTrait; diff --git a/system/src/Grav/Framework/Object/Base/ObjectCollectionTrait.php b/system/src/Grav/Framework/Object/Base/ObjectCollectionTrait.php index 7e71d91..fa0920b 100644 --- a/system/src/Grav/Framework/Object/Base/ObjectCollectionTrait.php +++ b/system/src/Grav/Framework/Object/Base/ObjectCollectionTrait.php @@ -3,13 +3,18 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Object\Base; +use Grav\Framework\Compat\Serializable; use Grav\Framework\Object\Interfaces\ObjectInterface; +use function call_user_func_array; +use function get_class; +use function is_callable; +use function is_object; /** * ObjectCollection Trait @@ -17,8 +22,187 @@ use Grav\Framework\Object\Interfaces\ObjectInterface; */ trait ObjectCollectionTrait { - use ObjectTrait { - setKey as public; + use Serializable; + + /** @var string */ + protected static $type; + + /** @var string */ + private $_key; + + /** + * @return string + */ + protected function getTypePrefix() + { + return ''; + } + + /** + * @param bool $prefix + * @return string + */ + public function getType($prefix = true) + { + $type = $prefix ? $this->getTypePrefix() : ''; + + if (static::$type) { + return $type . static::$type; + } + + $class = get_class($this); + return $type . strtolower(substr($class, strrpos($class, '\\') + 1)); + } + + /** + * @return string + */ + public function getKey() + { + return $this->_key ?: $this->getType() . '@@' . spl_object_hash($this); + } + + /** + * @return bool + */ + public function hasKey() + { + return !empty($this->_key); + } + + /** + * @param string $property Object property name. + * @return bool[] True if property has been defined (can be null). + */ + public function hasProperty($property) + { + return $this->doHasProperty($property); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @return mixed[] Property values. + */ + public function getProperty($property, $default = null) + { + return $this->doGetProperty($property, $default); + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return $this + */ + public function setProperty($property, $value) + { + $this->doSetProperty($property, $value); + + return $this; + } + + /** + * @param string $property Object property to be unset. + * @return $this + */ + public function unsetProperty($property) + { + $this->doUnsetProperty($property); + + return $this; + } + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @return $this + */ + public function defProperty($property, $default) + { + if (!$this->hasProperty($property)) { + $this->setProperty($property, $default); + } + + return $this; + } + + /** + * @return array + */ + final public function __serialize(): array + { + return $this->doSerialize(); + } + + /** + * @param array $data + * @return void + */ + final public function __unserialize(array $data): void + { + if (method_exists($this, 'initObjectProperties')) { + $this->initObjectProperties(); + } + + $this->doUnserialize($data); + } + + + /** + * @return array + */ + protected function doSerialize() + { + return [ + 'key' => $this->getKey(), + 'type' => $this->getType(), + 'elements' => $this->getElements() + ]; + } + + /** + * @param array $data + * @return void + */ + protected function doUnserialize(array $data) + { + if (!isset($data['key'], $data['type'], $data['elements']) || $data['type'] !== $this->getType()) { + throw new \InvalidArgumentException("Cannot unserialize '{$this->getType()}': Bad data"); + } + + $this->setKey($data['key']); + $this->setElements($data['elements']); + } + + /** + * Implements JsonSerializable interface. + * + * @return array + */ + public function jsonSerialize() + { + return $this->doSerialize(); + } + + /** + * Returns a string representation of this object. + * + * @return string + */ + public function __toString() + { + return $this->getKey(); + } + + /** + * @param string $key + * @return $this + */ + public function setKey($key) + { + $this->_key = (string) $key; + + return $this; } /** @@ -30,14 +214,14 @@ trait ObjectCollectionTrait { $list = []; foreach ($this->getIterator() as $key => $value) { - $list[$key] = \is_object($value) ? clone $value : $value; + $list[$key] = is_object($value) ? clone $value : $value; } return $this->createFrom($list); } /** - * @return array + * @return string[] */ public function getObjectKeys() { @@ -54,7 +238,7 @@ trait ObjectCollectionTrait /** @var ObjectInterface $element */ foreach ($this->getIterator() as $id => $element) { - $list[$id] = $element->hasProperty($property); + $list[$id] = (bool)$element->hasProperty($property); } return $list; @@ -63,9 +247,10 @@ trait ObjectCollectionTrait /** * @param string $property Object property to be fetched. * @param mixed $default Default value if not set. + * @param bool $doCreate Not being used. * @return mixed[] Key/Value pairs of the properties. */ - public function doGetProperty($property, $default = null) + public function &doGetProperty($property, $default = null, $doCreate = false) { $list = []; @@ -135,8 +320,8 @@ trait ObjectCollectionTrait * @var ObjectInterface $element */ foreach ($this->getIterator() as $id => $element) { - $callable = method_exists($element, $method) ? [$element, $method] : null; - $list[$id] = $callable ? \call_user_func_array($callable, $arguments) : null; + $callable = [$element, $method]; + $list[$id] = is_callable($callable) ? call_user_func_array($callable, $arguments) : null; } return $list; @@ -170,6 +355,7 @@ trait ObjectCollectionTrait { $collections = []; foreach ($this->group($property) as $id => $elements) { + /** @var static $collection */ $collection = $this->createFrom($elements); $collections[$id] = $collection; @@ -177,9 +363,4 @@ trait ObjectCollectionTrait return $collections; } - - /** - * @return \Traversable - */ - abstract public function getIterator(); } diff --git a/system/src/Grav/Framework/Object/Base/ObjectTrait.php b/system/src/Grav/Framework/Object/Base/ObjectTrait.php index baeeb2b..d4e324b 100644 --- a/system/src/Grav/Framework/Object/Base/ObjectTrait.php +++ b/system/src/Grav/Framework/Object/Base/ObjectTrait.php @@ -3,12 +3,16 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Object\Base; +use Grav\Framework\Compat\Serializable; +use InvalidArgumentException; +use function get_class; + /** * Object trait. * @@ -16,12 +20,12 @@ namespace Grav\Framework\Object\Base; */ trait ObjectTrait { - /** @var string */ - static protected $type; + use Serializable; - /** - * @var string - */ + /** @var string */ + protected static $type; + + /** @var string */ private $_key; /** @@ -44,7 +48,7 @@ trait ObjectTrait return $type . static::$type; } - $class = \get_class($this); + $class = get_class($this); return $type . strtolower(substr($class, strrpos($class, '\\') + 1)); } @@ -66,7 +70,7 @@ trait ObjectTrait /** * @param string $property Object property name. - * @return bool|bool[] True if property has been defined (can be null). + * @return bool True if property has been defined (can be null). */ public function hasProperty($property) { @@ -76,7 +80,7 @@ trait ObjectTrait /** * @param string $property Object property to be fetched. * @param mixed $default Default value if property has not been set. - * @return mixed|mixed[] Property value. + * @return mixed Property value. */ public function getProperty($property, $default = null) { @@ -121,25 +125,23 @@ trait ObjectTrait } /** - * Implements Serializable interface. - * - * @return string + * @return array */ - public function serialize() + final public function __serialize(): array { - return serialize($this->doSerialize()); + return $this->doSerialize(); } /** - * @param string $serialized + * @param array $data + * @return void */ - public function unserialize($serialized) + final public function __unserialize(array $data): void { - $data = unserialize($serialized); - if (method_exists($this, 'initObjectProperties')) { $this->initObjectProperties(); } + $this->doUnserialize($data); } @@ -153,11 +155,12 @@ trait ObjectTrait /** * @param array $serialized + * @return void */ protected function doUnserialize(array $serialized) { if (!isset($serialized['key'], $serialized['type'], $serialized['elements']) || $serialized['type'] !== $this->getType()) { - throw new \InvalidArgumentException("Cannot unserialize '{$this->getType()}': Bad data"); + throw new InvalidArgumentException("Cannot unserialize '{$this->getType()}': Bad data"); } $this->setKey($serialized['key']); @@ -194,11 +197,4 @@ trait ObjectTrait return $this; } - - abstract protected function doHasProperty($property); - abstract protected function &doGetProperty($property, $default = null, $doCreate = false); - abstract protected function doSetProperty($property, $value); - abstract protected function doUnsetProperty($property); - abstract protected function getElements(); - abstract protected function setElements(array $elements); } diff --git a/system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php b/system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php index 0eefb78..d8f1524 100644 --- a/system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php +++ b/system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php @@ -3,15 +3,26 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Object\Collection; +use Closure; use Doctrine\Common\Collections\Expr\ClosureExpressionVisitor; use Doctrine\Common\Collections\Expr\Comparison; +use RuntimeException; +use function in_array; +use function is_array; +use function is_callable; +use function is_string; +use function strlen; +/** + * Class ObjectExpressionVisitor + * @package Grav\Framework\Object\Collection + */ class ObjectExpressionVisitor extends ClosureExpressionVisitor { /** @@ -19,7 +30,6 @@ class ObjectExpressionVisitor extends ClosureExpressionVisitor * * @param object $object * @param string $field - * * @return mixed */ public static function getObjectFieldValue($object, $field) @@ -28,7 +38,7 @@ class ObjectExpressionVisitor extends ClosureExpressionVisitor $pos = strpos($field, '('); if (false !== $pos) { - list ($op, $field) = explode('(', $field, 2); + [$op, $field] = explode('(', $field, 2); $field = rtrim($field, ')'); } @@ -40,7 +50,7 @@ class ObjectExpressionVisitor extends ClosureExpressionVisitor foreach ($accessors as $accessor) { $accessor .= $field; - if (!method_exists($object, $accessor)) { + if (!is_callable([$object, $accessor])) { continue; } @@ -59,31 +69,55 @@ class ObjectExpressionVisitor extends ClosureExpressionVisitor return $value; } + /** + * @param string $str + * @return string + */ public static function filterLower($str) { return mb_strtolower($str); } + /** + * @param string $str + * @return string + */ public static function filterUpper($str) { return mb_strtoupper($str); } + /** + * @param string $str + * @return int + */ public static function filterLength($str) { return mb_strlen($str); } + /** + * @param string $str + * @return string + */ public static function filterLtrim($str) { return ltrim($str); } + /** + * @param string $str + * @return string + */ public static function filterRtrim($str) { return rtrim($str); } + /** + * @param string $str + * @return string + */ public static function filterTrim($str) { return trim($str); @@ -92,16 +126,18 @@ class ObjectExpressionVisitor extends ClosureExpressionVisitor /** * Helper for sorting arrays of objects based on multiple fields + orientations. * + * Comparison between two strings is natural and case insensitive. + * * @param string $name * @param int $orientation - * @param \Closure $next + * @param Closure|null $next * - * @return \Closure + * @return Closure */ - public static function sortByField($name, $orientation = 1, \Closure $next = null) + public static function sortByField($name, $orientation = 1, Closure $next = null) { if (!$next) { - $next = function($a, $b) { + $next = function ($a, $b) { return 0; }; } @@ -114,6 +150,11 @@ class ObjectExpressionVisitor extends ClosureExpressionVisitor return $next($a, $b); } + // For strings we use natural case insensitive sorting. + if (is_string($aValue) && is_string($bValue)) { + return strnatcasecmp($aValue, $bValue) * $orientation; + } + return (($aValue > $bValue) ? 1 : -1) * $orientation; }; } @@ -159,12 +200,12 @@ class ObjectExpressionVisitor extends ClosureExpressionVisitor case Comparison::IN: return function ($object) use ($field, $value) { - return \in_array(static::getObjectFieldValue($object, $field), $value, true); + return in_array(static::getObjectFieldValue($object, $field), $value, true); }; case Comparison::NIN: return function ($object) use ($field, $value) { - return !\in_array(static::getObjectFieldValue($object, $field), $value, true); + return !in_array(static::getObjectFieldValue($object, $field), $value, true); }; case Comparison::CONTAINS: @@ -175,10 +216,10 @@ class ObjectExpressionVisitor extends ClosureExpressionVisitor case Comparison::MEMBER_OF: return function ($object) use ($field, $value) { $fieldValues = static::getObjectFieldValue($object, $field); - if (!\is_array($fieldValues)) { + if (!is_array($fieldValues)) { $fieldValues = iterator_to_array($fieldValues); } - return \in_array($value, $fieldValues, true); + return in_array($value, $fieldValues, true); }; case Comparison::STARTS_WITH: @@ -191,9 +232,8 @@ class ObjectExpressionVisitor extends ClosureExpressionVisitor return $value === substr(static::getObjectFieldValue($object, $field), -strlen($value)); }; - default: - throw new \RuntimeException("Unknown comparison operator: " . $comparison->getOperator()); + throw new RuntimeException("Unknown comparison operator: " . $comparison->getOperator()); } } } diff --git a/system/src/Grav/Framework/Object/Interfaces/NestedObjectCollectionInterface.php b/system/src/Grav/Framework/Object/Interfaces/NestedObjectCollectionInterface.php new file mode 100644 index 0000000..0e2373e --- /dev/null +++ b/system/src/Grav/Framework/Object/Interfaces/NestedObjectCollectionInterface.php @@ -0,0 +1,64 @@ + + */ +interface NestedObjectCollectionInterface extends ObjectCollectionInterface +{ + /** + * @param string $property Object property name. + * @param string|null $separator Separator, defaults to '.' + * @return bool[] List of [key => bool] pairs. + */ + public function hasNestedProperty($property, $separator = null); + + /** + * @param string $property Object property to be fetched. + * @param mixed|null $default Default value if property has not been set. + * @param string|null $separator Separator, defaults to '.' + * @return mixed[] List of [key => value] pairs. + */ + public function getNestedProperty($property, $default = null, $separator = null); + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function setNestedProperty($property, $value, $separator = null); + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @param string|null $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function defNestedProperty($property, $default, $separator = null); + + /** + * @param string $property Object property to be unset. + * @param string|null $separator Separator, defaults to '.' + * @return $this + * @throws RuntimeException + */ + public function unsetNestedProperty($property, $separator = null); +} diff --git a/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php b/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php index 8d6025a..4d7a880 100644 --- a/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php +++ b/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php @@ -3,12 +3,14 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Object\Interfaces; +use RuntimeException; + /** * Common Interface for both Objects and Collections * @package Grav\Framework\Object @@ -35,7 +37,7 @@ interface NestedObjectInterface extends ObjectInterface * @param mixed $value New value. * @param string|null $separator Separator, defaults to '.' * @return $this - * @throws \RuntimeException + * @throws RuntimeException */ public function setNestedProperty($property, $value, $separator = null); @@ -44,7 +46,7 @@ interface NestedObjectInterface extends ObjectInterface * @param mixed $default Default value. * @param string|null $separator Separator, defaults to '.' * @return $this - * @throws \RuntimeException + * @throws RuntimeException */ public function defNestedProperty($property, $default, $separator = null); @@ -52,7 +54,7 @@ interface NestedObjectInterface extends ObjectInterface * @param string $property Object property to be unset. * @param string|null $separator Separator, defaults to '.' * @return $this - * @throws \RuntimeException + * @throws RuntimeException */ public function unsetNestedProperty($property, $separator = null); } diff --git a/system/src/Grav/Framework/Object/Interfaces/ObjectCollectionInterface.php b/system/src/Grav/Framework/Object/Interfaces/ObjectCollectionInterface.php index 7323294..3a43981 100644 --- a/system/src/Grav/Framework/Object/Interfaces/ObjectCollectionInterface.php +++ b/system/src/Grav/Framework/Object/Interfaces/ObjectCollectionInterface.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,19 +11,27 @@ namespace Grav\Framework\Object\Interfaces; use Doctrine\Common\Collections\Selectable; use Grav\Framework\Collection\CollectionInterface; +use Serializable; /** * ObjectCollection Interface * @package Grav\Framework\Collection + * @template TKey + * @template T + * @extends CollectionInterface + * @extends Selectable */ -interface ObjectCollectionInterface extends CollectionInterface, Selectable, ObjectInterface +interface ObjectCollectionInterface extends CollectionInterface, Selectable, Serializable { /** - * Create a copy from this collection by cloning all objects in the collection. - * - * @return static + * @return string */ - public function copy(); + public function getType(); + + /** + * @return string + */ + public function getKey(); /** * @param string $key @@ -31,6 +39,46 @@ interface ObjectCollectionInterface extends CollectionInterface, Selectable, Obj */ public function setKey($key); + /** + * @param string $property Object property name. + * @return bool[] List of [key => bool] pairs. + */ + public function hasProperty($property); + + /** + * @param string $property Object property to be fetched. + * @param mixed|null $default Default value if property has not been set. + * @return mixed[] List of [key => value] pairs. + */ + public function getProperty($property, $default = null); + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @return $this + */ + public function setProperty($property, $value); + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @return $this + */ + public function defProperty($property, $default); + + /** + * @param string $property Object property to be unset. + * @return $this + */ + public function unsetProperty($property); + + /** + * Create a copy from this collection by cloning all objects in the collection. + * + * @return static + */ + public function copy(); + /** * @return array */ @@ -41,7 +89,7 @@ interface ObjectCollectionInterface extends CollectionInterface, Selectable, Obj * @param array $arguments List of arguments passed to the function. * @return array Return values. */ - public function call($name, array $arguments); + public function call($name, array $arguments = []); /** * Group items in the collection by a field and return them as associated array. @@ -56,12 +104,14 @@ interface ObjectCollectionInterface extends CollectionInterface, Selectable, Obj * * @param string $property * @return static[] + * @phpstan-return array> */ public function collectionGroup($property); /** * @param array $ordering * @return ObjectCollectionInterface + * @phpstan-return static */ public function orderBy(array $ordering); @@ -69,6 +119,7 @@ interface ObjectCollectionInterface extends CollectionInterface, Selectable, Obj * @param int $start * @param int|null $limit * @return ObjectCollectionInterface + * @phpstan-return static */ public function limit($start, $limit = null); } diff --git a/system/src/Grav/Framework/Object/Interfaces/ObjectInterface.php b/system/src/Grav/Framework/Object/Interfaces/ObjectInterface.php index 88c80ab..e8ec2c1 100644 --- a/system/src/Grav/Framework/Object/Interfaces/ObjectInterface.php +++ b/system/src/Grav/Framework/Object/Interfaces/ObjectInterface.php @@ -3,17 +3,20 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Object\Interfaces; +use JsonSerializable; +use Serializable; + /** * Object Interface * @package Grav\Framework\Object */ -interface ObjectInterface extends \Serializable, \JsonSerializable +interface ObjectInterface extends Serializable, JsonSerializable { /** * @return string @@ -27,14 +30,14 @@ interface ObjectInterface extends \Serializable, \JsonSerializable /** * @param string $property Object property name. - * @return bool|bool[] True if property has been defined (can be null). + * @return bool True if property has been defined (property can be null). */ public function hasProperty($property); /** * @param string $property Object property to be fetched. * @param mixed|null $default Default value if property has not been set. - * @return mixed|mixed[] Property value. + * @return mixed Property value. */ public function getProperty($property, $default = null); diff --git a/system/src/Grav/Framework/Object/LazyObject.php b/system/src/Grav/Framework/Object/LazyObject.php index 1c0d9a8..309c0d6 100644 --- a/system/src/Grav/Framework/Object/LazyObject.php +++ b/system/src/Grav/Framework/Object/LazyObject.php @@ -3,12 +3,13 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Object; +use ArrayAccess; use Grav\Framework\Object\Access\NestedArrayAccessTrait; use Grav\Framework\Object\Access\NestedPropertyTrait; use Grav\Framework\Object\Access\OverloadedPropertyTrait; @@ -22,7 +23,7 @@ use Grav\Framework\Object\Property\LazyPropertyTrait; * * @package Grav\Framework\Object */ -class LazyObject implements NestedObjectInterface, \ArrayAccess +class LazyObject implements NestedObjectInterface, ArrayAccess { use ObjectTrait; use LazyPropertyTrait; diff --git a/system/src/Grav/Framework/Object/ObjectCollection.php b/system/src/Grav/Framework/Object/ObjectCollection.php index a386727..0d82434 100644 --- a/system/src/Grav/Framework/Object/ObjectCollection.php +++ b/system/src/Grav/Framework/Object/ObjectCollection.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,13 +15,19 @@ use Grav\Framework\Collection\ArrayCollection; use Grav\Framework\Object\Access\NestedPropertyCollectionTrait; use Grav\Framework\Object\Base\ObjectCollectionTrait; use Grav\Framework\Object\Collection\ObjectExpressionVisitor; -use Grav\Framework\Object\Interfaces\NestedObjectInterface; -use Grav\Framework\Object\Interfaces\ObjectCollectionInterface; +use Grav\Framework\Object\Interfaces\NestedObjectCollectionInterface; +use InvalidArgumentException; +use function array_slice; /** * Class contains a collection of objects. + * + * @template TKey + * @template T + * @extends ArrayCollection + * @implements NestedObjectCollectionInterface */ -class ObjectCollection extends ArrayCollection implements ObjectCollectionInterface, NestedObjectInterface +class ObjectCollection extends ArrayCollection implements NestedObjectCollectionInterface { use ObjectCollectionTrait; use NestedPropertyCollectionTrait { @@ -30,8 +36,8 @@ class ObjectCollection extends ArrayCollection implements ObjectCollectionInterf /** * @param array $elements - * @param string $key - * @throws \InvalidArgumentException + * @param string|null $key + * @throws InvalidArgumentException */ public function __construct(array $elements = [], $key = null) { @@ -42,7 +48,8 @@ class ObjectCollection extends ArrayCollection implements ObjectCollectionInterf /** * @param array $ordering - * @return Collection|static + * @return static + * @phpstan-return static */ public function orderBy(array $ordering) { @@ -55,6 +62,7 @@ class ObjectCollection extends ArrayCollection implements ObjectCollectionInterf * @param int $start * @param int|null $limit * @return static + * @phpstan-return static */ public function limit($start, $limit = null) { @@ -62,7 +70,9 @@ class ObjectCollection extends ArrayCollection implements ObjectCollectionInterf } /** - * {@inheritDoc} + * @param Criteria $criteria + * @return static + * @phpstan-return static */ public function matching(Criteria $criteria) { @@ -94,17 +104,24 @@ class ObjectCollection extends ArrayCollection implements ObjectCollectionInterf $length = $criteria->getMaxResults(); if ($offset || $length) { - $filtered = \array_slice($filtered, (int)$offset, $length); + $filtered = array_slice($filtered, (int)$offset, $length); } return $this->createFrom($filtered); } + /** + * @return array + */ protected function getElements() { return $this->toArray(); } + /** + * @param array $elements + * @return array + */ protected function setElements(array $elements) { return $elements; diff --git a/system/src/Grav/Framework/Object/ObjectIndex.php b/system/src/Grav/Framework/Object/ObjectIndex.php index 4ba9b75..2c13765 100644 --- a/system/src/Grav/Framework/Object/ObjectIndex.php +++ b/system/src/Grav/Framework/Object/ObjectIndex.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,8 +11,10 @@ namespace Grav\Framework\Object; use Doctrine\Common\Collections\Criteria; use Grav\Framework\Collection\AbstractIndexCollection; -use Grav\Framework\Object\Interfaces\NestedObjectInterface; +use Grav\Framework\Object\Interfaces\NestedObjectCollectionInterface; use Grav\Framework\Object\Interfaces\ObjectCollectionInterface; +use function get_class; +use function is_object; /** * Keeps index of objects instead of collection of objects. This class allows you to keep a list of objects and load @@ -20,15 +22,18 @@ use Grav\Framework\Object\Interfaces\ObjectCollectionInterface; * * This is an abstract class and has some protected abstract methods to load objects which you need to implement in * order to use the class. + * + * @template TKey + * @template T + * @extends AbstractIndexCollection + * @implements NestedObjectCollectionInterface */ -abstract class ObjectIndex extends AbstractIndexCollection implements ObjectCollectionInterface, NestedObjectInterface +abstract class ObjectIndex extends AbstractIndexCollection implements NestedObjectCollectionInterface { /** @var string */ - static protected $type; + protected static $type; - /** - * @var string - */ + /** @var string */ private $_key; /** @@ -43,7 +48,7 @@ abstract class ObjectIndex extends AbstractIndexCollection implements ObjectColl return $type . static::$type; } - $class = \get_class($this); + $class = get_class($this); return $type . strtolower(substr($class, strrpos($class, '\\') + 1)); } @@ -68,7 +73,7 @@ abstract class ObjectIndex extends AbstractIndexCollection implements ObjectColl /** * @param string $property Object property name. - * @return array True if property has been defined (can be null). + * @return bool[] True if property has been defined (can be null). */ public function hasProperty($property) { @@ -78,7 +83,7 @@ abstract class ObjectIndex extends AbstractIndexCollection implements ObjectColl /** * @param string $property Object property to be fetched. * @param mixed $default Default value if property has not been set. - * @return array Property values. + * @return mixed[] Property values. */ public function getProperty($property, $default = null) { @@ -116,8 +121,8 @@ abstract class ObjectIndex extends AbstractIndexCollection implements ObjectColl /** * @param string $property Object property name. - * @param string $separator Separator, defaults to '.' - * @return bool True if property has been defined (can be null). + * @param string|null $separator Separator, defaults to '.' + * @return bool[] True if property has been defined (can be null). */ public function hasNestedProperty($property, $separator = null) { @@ -127,8 +132,8 @@ abstract class ObjectIndex extends AbstractIndexCollection implements ObjectColl /** * @param string $property Object property to be fetched. * @param mixed $default Default value if property has not been set. - * @param string $separator Separator, defaults to '.' - * @return mixed Property value. + * @param string|null $separator Separator, defaults to '.' + * @return mixed[] Property values. */ public function getNestedProperty($property, $default = null, $separator = null) { @@ -137,8 +142,8 @@ abstract class ObjectIndex extends AbstractIndexCollection implements ObjectColl /** * @param string $property Object property to be updated. - * @param string $value New value. - * @param string $separator Separator, defaults to '.' + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' * @return ObjectCollectionInterface */ public function setNestedProperty($property, $value, $separator = null) @@ -149,7 +154,7 @@ abstract class ObjectIndex extends AbstractIndexCollection implements ObjectColl /** * @param string $property Object property to be defined. * @param mixed $default Default value. - * @param string $separator Separator, defaults to '.' + * @param string|null $separator Separator, defaults to '.' * @return ObjectCollectionInterface */ public function defNestedProperty($property, $default, $separator = null) @@ -159,6 +164,7 @@ abstract class ObjectIndex extends AbstractIndexCollection implements ObjectColl /** * @param string $property Object property to be unset. + * @param string|null $separator Separator, defaults to '.' * @return ObjectCollectionInterface */ public function unsetNestedProperty($property, $separator = null) @@ -175,7 +181,7 @@ abstract class ObjectIndex extends AbstractIndexCollection implements ObjectColl { $list = []; foreach ($this->getIterator() as $key => $value) { - $list[$key] = \is_object($value) ? clone $value : $value; + $list[$key] = is_object($value) ? clone $value : $value; } return $this->createFrom($list); @@ -199,7 +205,9 @@ abstract class ObjectIndex extends AbstractIndexCollection implements ObjectColl } /** - * {@inheritDoc} + * @param string $method + * @param array $arguments + * @return array|mixed */ public function call($method, array $arguments = []) { @@ -239,6 +247,11 @@ abstract class ObjectIndex extends AbstractIndexCollection implements ObjectColl return $collection->matching($criteria); } + /** + * @param string $name + * @param array $arguments + * @return mixed + */ abstract public function __call($name, $arguments); /** diff --git a/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php b/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php index 7092e15..fa3fed1 100644 --- a/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php +++ b/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php @@ -3,12 +3,15 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Object\Property; +use InvalidArgumentException; +use function array_key_exists; + /** * Array Property Trait * @@ -18,16 +21,13 @@ namespace Grav\Framework\Object\Property; */ trait ArrayPropertyTrait { - /** - * Properties of the object. - * @var array - */ + /** @var array Properties of the object. */ private $_elements; /** * @param array $elements - * @param string $key - * @throws \InvalidArgumentException + * @param string|null $key + * @throws InvalidArgumentException */ public function __construct(array $elements = [], $key = null) { @@ -66,6 +66,7 @@ trait ArrayPropertyTrait /** * @param string $property Object property to be updated. * @param mixed $value New value. + * @return void */ protected function doSetProperty($property, $value) { @@ -74,6 +75,7 @@ trait ArrayPropertyTrait /** * @param string $property Object property to be unset. + * @return void */ protected function doUnsetProperty($property) { @@ -95,11 +97,14 @@ trait ArrayPropertyTrait */ protected function getElements() { - return array_filter($this->_elements, function ($val) { return $val !== null; }); + return array_filter($this->_elements, function ($val) { + return $val !== null; + }); } /** * @param array $elements + * @return void */ protected function setElements(array $elements) { diff --git a/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php b/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php index 1066b99..d5baddb 100644 --- a/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php +++ b/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -53,6 +53,7 @@ trait LazyPropertyTrait /** * @param string $property Object property to be fetched. * @param mixed $default Default value if property has not been set. + * @param bool $doCreate * @return mixed Property value. */ protected function &doGetProperty($property, $default = null, $doCreate = false) @@ -69,7 +70,7 @@ trait LazyPropertyTrait /** * @param string $property Object property to be updated. * @param mixed $value New value. - * @return $this + * @return void */ protected function doSetProperty($property, $value) { @@ -78,20 +79,16 @@ trait LazyPropertyTrait } else { $this->setArrayProperty($property, $value); } - - return $this; } /** * @param string $property Object property to be unset. - * @return $this + * @return void */ protected function doUnsetProperty($property) { $this->hasObjectProperty($property) ? $this->unsetObjectProperty($property) : $this->unsetArrayProperty($property); - - return $this; } /** diff --git a/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php b/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php index 149ac40..584e812 100644 --- a/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php +++ b/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -54,6 +54,7 @@ trait MixedPropertyTrait /** * @param string $property Object property to be fetched. * @param mixed $default Default value if property has not been set. + * @param bool $doCreate * @return mixed Property value. */ protected function &doGetProperty($property, $default = null, $doCreate = false) @@ -68,26 +69,22 @@ trait MixedPropertyTrait /** * @param string $property Object property to be updated. * @param mixed $value New value. - * @return $this + * @return void */ protected function doSetProperty($property, $value) { $this->hasObjectProperty($property) ? $this->setObjectProperty($property, $value) : $this->setArrayProperty($property, $value); - - return $this; } /** * @param string $property Object property to be unset. - * @return $this + * @return void */ protected function doUnsetProperty($property) { $this->hasObjectProperty($property) ? $this->unsetObjectProperty($property) : $this->unsetArrayProperty($property); - - return $this; } /** @@ -114,6 +111,7 @@ trait MixedPropertyTrait /** * @param array $elements + * @return void */ protected function setElements(array $elements) { diff --git a/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php b/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php index 5f8fdf0..435220a 100644 --- a/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php +++ b/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php @@ -3,12 +3,17 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Object\Property; +use InvalidArgumentException; +use function array_key_exists; +use function get_object_vars; +use function is_callable; + /** * Object Property Trait * @@ -24,15 +29,13 @@ namespace Grav\Framework\Object\Property; */ trait ObjectPropertyTrait { - /** - * @var array - */ + /** @var array */ private $_definedProperties; /** * @param array $elements - * @param string $key - * @throws \InvalidArgumentException + * @param string|null $key + * @throws InvalidArgumentException */ public function __construct(array $elements = [], $key = null) { @@ -104,14 +107,14 @@ trait ObjectPropertyTrait protected function &doGetProperty($property, $default = null, $doCreate = false) { if (!array_key_exists($property, $this->_definedProperties)) { - throw new \InvalidArgumentException("Property '{$property}' does not exist in the object!"); + throw new InvalidArgumentException("Property '{$property}' does not exist in the object!"); } if (empty($this->_definedProperties[$property])) { if ($doCreate === true) { $this->_definedProperties[$property] = true; $this->{$property} = null; - } elseif (\is_callable($doCreate)) { + } elseif (is_callable($doCreate)) { $this->_definedProperties[$property] = true; $this->{$property} = $this->offsetLoad($property, $doCreate()); } else { @@ -125,12 +128,13 @@ trait ObjectPropertyTrait /** * @param string $property Object property to be updated. * @param mixed $value New value. - * @throws \InvalidArgumentException + * @return void + * @throws InvalidArgumentException */ protected function doSetProperty($property, $value) { if (!array_key_exists($property, $this->_definedProperties)) { - throw new \InvalidArgumentException("Property '{$property}' does not exist in the object!"); + throw new InvalidArgumentException("Property '{$property}' does not exist in the object!"); } $this->_definedProperties[$property] = true; @@ -139,6 +143,7 @@ trait ObjectPropertyTrait /** * @param string $property Object property to be unset. + * @return void */ protected function doUnsetProperty($property) { @@ -147,13 +152,16 @@ trait ObjectPropertyTrait } $this->_definedProperties[$property] = false; - unset($this->{$property}); + $this->{$property} = null; } + /** + * @return void + */ protected function initObjectProperties() { $this->_definedProperties = []; - foreach (\get_object_vars($this) as $property => $value) { + foreach (get_object_vars($this) as $property => $value) { if ($property[0] !== '_') { $this->_definedProperties[$property] = ($value !== null); } @@ -194,6 +202,7 @@ trait ObjectPropertyTrait /** * @param array $elements + * @return void */ protected function setElements(array $elements) { @@ -201,7 +210,4 @@ trait ObjectPropertyTrait $this->setProperty($property, $value); } } - - abstract public function setProperty($property, $value); - abstract protected function setKey($key); } diff --git a/system/src/Grav/Framework/Object/PropertyObject.php b/system/src/Grav/Framework/Object/PropertyObject.php index f740fc6..dbeb5c4 100644 --- a/system/src/Grav/Framework/Object/PropertyObject.php +++ b/system/src/Grav/Framework/Object/PropertyObject.php @@ -3,12 +3,13 @@ /** * @package Grav\Framework\Object * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Object; +use ArrayAccess; use Grav\Framework\Object\Access\NestedArrayAccessTrait; use Grav\Framework\Object\Access\NestedPropertyTrait; use Grav\Framework\Object\Access\OverloadedPropertyTrait; @@ -19,7 +20,7 @@ use Grav\Framework\Object\Property\ObjectPropertyTrait; /** * Property Objects keep their data in protected object properties. */ -class PropertyObject implements NestedObjectInterface, \ArrayAccess +class PropertyObject implements NestedObjectInterface, ArrayAccess { use ObjectTrait; use ObjectPropertyTrait; diff --git a/system/src/Grav/Framework/Pagination/AbstractPagination.php b/system/src/Grav/Framework/Pagination/AbstractPagination.php index acdad77..0be21ec 100644 --- a/system/src/Grav/Framework/Pagination/AbstractPagination.php +++ b/system/src/Grav/Framework/Pagination/AbstractPagination.php @@ -3,47 +3,43 @@ /** * @package Grav\Framework\Pagination * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Pagination; +use ArrayIterator; use Grav\Framework\Pagination\Interfaces\PaginationInterface; use Grav\Framework\Route\Route; +use function count; +/** + * Class AbstractPagination + * @package Grav\Framework\Pagination + */ class AbstractPagination implements PaginationInterface { /** @var Route Base rouse used for the pagination. */ protected $route; - /** @var int|null Current page. */ protected $page; - /** @var int|null The record number to start displaying from. */ protected $start; - /** @var int Number of records to display per page. */ protected $limit; - /** @var int Total number of records. */ protected $total; - /** @var array Pagination options */ protected $options; - /** @var bool View all flag. */ protected $viewAll; - /** @var int Total number of pages. */ protected $pages; - /** @var int Value pagination object begins at. */ protected $pagesStart; - /** @var int Value pagination object ends at .*/ protected $pagesStop; - /** @var array */ protected $defaultOptions = [ 'type' => 'page', @@ -51,37 +47,57 @@ class AbstractPagination implements PaginationInterface 'display' => 5, 'opening' => 0, 'ending' => 0, - 'url' => null + 'url' => null, + 'param' => null, + 'use_query_param' => false ]; - /** @var array */ private $items; + /** + * @return bool + */ public function isEnabled(): bool { return $this->count() > 1; } + /** + * @return array + */ public function getOptions(): array { return $this->options; } + /** + * @return Route|null + */ public function getRoute(): ?Route { return $this->route; } + /** + * @return int + */ public function getTotalPages(): int { return $this->pages; } + /** + * @return int + */ public function getPageNumber(): int { - return $this->page; + return $this->page ?? 1; } + /** + * @param int $count + * @return int|null + */ public function getPrevNumber(int $count = 1): ?int { $page = $this->page - $count; @@ -89,6 +105,10 @@ class AbstractPagination implements PaginationInterface return $page >= 1 ? $page : null; } + /** + * @param int $count + * @return int|null + */ public function getNextNumber(int $count = 1): ?int { $page = $this->page + $count; @@ -96,6 +116,11 @@ class AbstractPagination implements PaginationInterface return $page <= $this->pages ? $page : null; } + /** + * @param int $page + * @param string|null $label + * @return PaginationPage|null + */ public function getPage(int $page, string $label = null): ?PaginationPage { if ($page < 1 || $page > $this->pages) { @@ -103,14 +128,23 @@ class AbstractPagination implements PaginationInterface } $start = ($page - 1) * $this->limit; - if ($this->getOptions()['type'] === 'page') { - $name = 'page'; + $type = $this->getOptions()['type']; + $param = $this->getOptions()['param']; + $useQuery = $this->getOptions()['use_query_param']; + if ($type === 'page') { + $param = $param ?? 'page'; $offset = $page; } else { - $name = 'start'; + $param = $param ?? 'start'; $offset = $start; } + if ($useQuery) { + $route = $this->route->withQueryParam($param, $offset); + } else { + $route = $this->route->withGravParam($param, $offset); + } + return new PaginationPage( [ 'label' => $label ?? (string)$page, @@ -119,60 +153,98 @@ class AbstractPagination implements PaginationInterface 'offset_end' => min($start + $this->limit, $this->total) - 1, 'enabled' => $page !== $this->page || $this->viewAll, 'active' => $page === $this->page, - 'route' => $this->route->withGravParam($name, $offset) + 'route' => $route ] ); } + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ public function getFirstPage(string $label = null, int $count = 0): ?PaginationPage { return $this->getPage(1 + $count, $label ?? $this->getOptions()['label_first'] ?? null); } + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ public function getPrevPage(string $label = null, int $count = 1): ?PaginationPage { return $this->getPage($this->page - $count, $label ?? $this->getOptions()['label_prev'] ?? null); } + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ public function getNextPage(string $label = null, int $count = 1): ?PaginationPage { return $this->getPage($this->page + $count, $label ?? $this->getOptions()['label_next'] ?? null); } + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ public function getLastPage(string $label = null, int $count = 0): ?PaginationPage { return $this->getPage($this->pages - $count, $label ?? $this->getOptions()['label_last'] ?? null); } + /** + * @return int + */ public function getStart(): int { - return $this->start; + return $this->start ?? 0; } + /** + * @return int + */ public function getLimit(): int { return $this->limit; } + /** + * @return int + */ public function getTotal(): int { return $this->total; } + /** + * @return int + */ public function count(): int { $this->loadItems(); - return \count($this->items); + return count($this->items); } + /** + * @return ArrayIterator + */ public function getIterator() { $this->loadItems(); - return new \ArrayIterator($this->items); + return new ArrayIterator($this->items); } + /** + * @return array + */ public function getPages(): array { $this->loadItems(); @@ -180,6 +252,9 @@ class AbstractPagination implements PaginationInterface return $this->items; } + /** + * @return void + */ protected function loadItems() { $this->calculateRange(); @@ -197,6 +272,10 @@ class AbstractPagination implements PaginationInterface } } + /** + * @param Route $route + * @return $this + */ protected function setRoute(Route $route) { $this->route = $route; @@ -204,6 +283,10 @@ class AbstractPagination implements PaginationInterface return $this; } + /** + * @param array|null $options + * @return $this + */ protected function setOptions(array $options = null) { $this->options = $options ? array_merge($this->defaultOptions, $options) : $this->defaultOptions; @@ -211,6 +294,10 @@ class AbstractPagination implements PaginationInterface return $this; } + /** + * @param int|null $page + * @return $this + */ protected function setPage(int $page = null) { $this->page = (int)max($page, 1); @@ -220,7 +307,7 @@ class AbstractPagination implements PaginationInterface } /** - * @param int $start + * @param int|null $start * @return $this */ protected function setStart(int $start = null) @@ -256,6 +343,14 @@ class AbstractPagination implements PaginationInterface return $this; } + /** + * @param Route $route + * @param int $total + * @param int|null $pos + * @param int|null $limit + * @param array|null $options + * @return void + */ protected function initialize(Route $route, int $total, int $pos = null, int $limit = null, array $options = null) { $this->setRoute($route); @@ -270,6 +365,9 @@ class AbstractPagination implements PaginationInterface $this->calculateLimits(); } + /** + * @return void + */ protected function calculateLimits() { $limit = $this->limit; @@ -300,6 +398,9 @@ class AbstractPagination implements PaginationInterface $this->pages = (int)ceil($total / $limit); } + /** + * @return void + */ protected function calculateRange() { $options = $this->getOptions(); diff --git a/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php b/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php index 04f536b..95ff8af 100644 --- a/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php +++ b/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Pagination * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,41 +11,66 @@ namespace Grav\Framework\Pagination; use Grav\Framework\Pagination\Interfaces\PaginationPageInterface; +/** + * Class AbstractPaginationPage + * @package Grav\Framework\Pagination + */ abstract class AbstractPaginationPage implements PaginationPageInterface { /** @var array */ protected $options; + /** + * @return bool + */ public function isActive(): bool { return $this->options['active'] ?? false; } + /** + * @return bool + */ public function isEnabled(): bool { return $this->options['enabled'] ?? false; } + /** + * @return array + */ public function getOptions(): array { return $this->options ?? []; } + /** + * @return int|null + */ public function getNumber(): ?int { return $this->options['number'] ?? null; } + /** + * @return string + */ public function getLabel(): string { return $this->options['label'] ?? (string)$this->getNumber(); } + /** + * @return string|null + */ public function getUrl(): ?string { return $this->options['route'] ? (string)$this->options['route']->getUri() : null; } + /** + * @param array $options + */ protected function setOptions(array $options): void { $this->options = $options; diff --git a/system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php b/system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php index 0a14525..56d3361 100644 --- a/system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php +++ b/system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php @@ -3,41 +3,101 @@ /** * @package Grav\Framework\Pagination * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Pagination\Interfaces; +use Countable; use Grav\Framework\Pagination\PaginationPage; +use IteratorAggregate; -interface PaginationInterface extends \Countable, \IteratorAggregate +/** + * Interface PaginationInterface + * @package Grav\Framework\Pagination\Interfaces + */ +interface PaginationInterface extends Countable, IteratorAggregate { + /** + * @return int + */ public function getTotalPages(): int; + /** + * @return int + */ public function getPageNumber(): int; + /** + * @param int $count + * @return int|null + */ public function getPrevNumber(int $count = 1): ?int; + /** + * @param int $count + * @return int|null + */ public function getNextNumber(int $count = 1): ?int; + /** + * @return int + */ public function getStart(): int; + /** + * @return int + */ public function getLimit(): int; + /** + * @return int + */ public function getTotal(): int; + /** + * @return int + */ public function count(): int; + /** + * @return array + */ public function getOptions(): array; + /** + * @param int $page + * @param string|null $label + * @return PaginationPage|null + */ public function getPage(int $page, string $label = null): ?PaginationPage; + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ public function getFirstPage(string $label = null, int $count = 0): ?PaginationPage; + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ public function getPrevPage(string $label = null, int $count = 1): ?PaginationPage; + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ public function getNextPage(string $label = null, int $count = 1): ?PaginationPage; + /** + * @param string|null $label + * @param int $count + * @return PaginationPage|null + */ public function getLastPage(string $label = null, int $count = 0): ?PaginationPage; } diff --git a/system/src/Grav/Framework/Pagination/Interfaces/PaginationPageInterface.php b/system/src/Grav/Framework/Pagination/Interfaces/PaginationPageInterface.php index 12d1ecc..b90594d 100644 --- a/system/src/Grav/Framework/Pagination/Interfaces/PaginationPageInterface.php +++ b/system/src/Grav/Framework/Pagination/Interfaces/PaginationPageInterface.php @@ -3,23 +3,45 @@ /** * @package Grav\Framework\Pagination * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Pagination\Interfaces; +/** + * Interface PaginationPageInterface + * @package Grav\Framework\Pagination\Interfaces + */ interface PaginationPageInterface { + /** + * @return bool + */ public function isActive(): bool; + /** + * @return bool + */ public function isEnabled(): bool; + /** + * @return array + */ public function getOptions(): array; + /** + * @return int|null + */ public function getNumber(): ?int; + /** + * @return string + */ public function getLabel(): string; + /** + * @return string|null + */ public function getUrl(): ?string; } diff --git a/system/src/Grav/Framework/Pagination/Pagination.php b/system/src/Grav/Framework/Pagination/Pagination.php index 6e4ad67..e602872 100644 --- a/system/src/Grav/Framework/Pagination/Pagination.php +++ b/system/src/Grav/Framework/Pagination/Pagination.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Pagination * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,8 +11,20 @@ namespace Grav\Framework\Pagination; use Grav\Framework\Route\Route; +/** + * Class Pagination + * @package Grav\Framework\Pagination + */ class Pagination extends AbstractPagination { + /** + * Pagination constructor. + * @param Route $route + * @param int $total + * @param int|null $pos + * @param int|null $limit + * @param array|null $options + */ public function __construct(Route $route, int $total, int $pos = null, int $limit = null, array $options = null) { $this->initialize($route, $total, $pos, $limit, $options); diff --git a/system/src/Grav/Framework/Pagination/PaginationPage.php b/system/src/Grav/Framework/Pagination/PaginationPage.php index ca35a8d..63ceb5f 100644 --- a/system/src/Grav/Framework/Pagination/PaginationPage.php +++ b/system/src/Grav/Framework/Pagination/PaginationPage.php @@ -3,14 +3,22 @@ /** * @package Grav\Framework\Pagination * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Pagination; +/** + * Class PaginationPage + * @package Grav\Framework\Pagination + */ class PaginationPage extends AbstractPaginationPage { + /** + * PaginationPage constructor. + * @param array $options + */ public function __construct(array $options = []) { $this->setOptions($options); diff --git a/system/src/Grav/Framework/Parsedown/Parsedown.php b/system/src/Grav/Framework/Parsedown/Parsedown.php deleted file mode 100644 index 20b634d..0000000 --- a/system/src/Grav/Framework/Parsedown/Parsedown.php +++ /dev/null @@ -1,1554 +0,0 @@ -DefinitionData = array(); - - # standardize line breaks - $text = str_replace(array("\r\n", "\r"), "\n", $text); - - # remove surrounding line breaks - $text = trim($text, "\n"); - - # split text into lines - $lines = explode("\n", $text); - - # iterate through lines to identify blocks - $markup = $this->lines($lines); - - # trim line breaks - $markup = trim($markup, "\n"); - - return $markup; - } - - # - # Setters - # - - function setBreaksEnabled($breaksEnabled) - { - $this->breaksEnabled = $breaksEnabled; - - return $this; - } - - protected $breaksEnabled; - - function setMarkupEscaped($markupEscaped) - { - $this->markupEscaped = $markupEscaped; - - return $this; - } - - protected $markupEscaped; - - function setUrlsLinked($urlsLinked) - { - $this->urlsLinked = $urlsLinked; - - return $this; - } - - protected $urlsLinked = true; - - # - # Lines - # - - protected $BlockTypes = array( - '#' => array('Header'), - '*' => array('Rule', 'List'), - '+' => array('List'), - '-' => array('SetextHeader', 'Table', 'Rule', 'List'), - '0' => array('List'), - '1' => array('List'), - '2' => array('List'), - '3' => array('List'), - '4' => array('List'), - '5' => array('List'), - '6' => array('List'), - '7' => array('List'), - '8' => array('List'), - '9' => array('List'), - ':' => array('Table'), - '<' => array('Comment', 'Markup'), - '=' => array('SetextHeader'), - '>' => array('Quote'), - '[' => array('Reference'), - '_' => array('Rule'), - '`' => array('FencedCode'), - '|' => array('Table'), - '~' => array('FencedCode'), - ); - - # ~ - - protected $unmarkedBlockTypes = array( - 'Code', - ); - - # - # Blocks - # - - protected function lines(array $lines) - { - $CurrentBlock = null; - - foreach ($lines as $line) - { - if (chop($line) === '') - { - if (isset($CurrentBlock)) - { - $CurrentBlock['interrupted'] = true; - } - - continue; - } - - if (strpos($line, "\t") !== false) - { - $parts = explode("\t", $line); - - $line = $parts[0]; - - unset($parts[0]); - - foreach ($parts as $part) - { - $shortage = 4 - mb_strlen($line, 'utf-8') % 4; - - $line .= str_repeat(' ', $shortage); - $line .= $part; - } - } - - $indent = 0; - - while (isset($line[$indent]) and $line[$indent] === ' ') - { - $indent ++; - } - - $text = $indent > 0 ? substr($line, $indent) : $line; - - # ~ - - $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); - - # ~ - - if (isset($CurrentBlock['continuable'])) - { - $Block = $this->{'block'.$CurrentBlock['type'].'Continue'}($Line, $CurrentBlock); - - if (isset($Block)) - { - $CurrentBlock = $Block; - - continue; - } - else - { - if ($this->isBlockCompletable($CurrentBlock['type'])) - { - $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock); - } - } - } - - # ~ - - $marker = $text[0]; - - # ~ - - $blockTypes = $this->unmarkedBlockTypes; - - if (isset($this->BlockTypes[$marker])) - { - foreach ($this->BlockTypes[$marker] as $blockType) - { - $blockTypes []= $blockType; - } - } - - # - # ~ - - foreach ($blockTypes as $blockType) - { - $Block = $this->{'block'.$blockType}($Line, $CurrentBlock); - - if (isset($Block)) - { - $Block['type'] = $blockType; - - if ( ! isset($Block['identified'])) - { - $Blocks []= $CurrentBlock; - - $Block['identified'] = true; - } - - if ($this->isBlockContinuable($blockType)) - { - $Block['continuable'] = true; - } - - $CurrentBlock = $Block; - - continue 2; - } - } - - # ~ - - if (isset($CurrentBlock) and ! isset($CurrentBlock['type']) and ! isset($CurrentBlock['interrupted'])) - { - $CurrentBlock['element']['text'] .= "\n".$text; - } - else - { - $Blocks []= $CurrentBlock; - - $CurrentBlock = $this->paragraph($Line); - - $CurrentBlock['identified'] = true; - } - } - - # ~ - - if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) - { - $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock); - } - - # ~ - - $Blocks []= $CurrentBlock; - - unset($Blocks[0]); - - # ~ - - $markup = ''; - - foreach ($Blocks as $Block) - { - if (isset($Block['hidden'])) - { - continue; - } - - $markup .= "\n"; - $markup .= isset($Block['markup']) ? $Block['markup'] : $this->element($Block['element']); - } - - $markup .= "\n"; - - # ~ - - return $markup; - } - - protected function isBlockContinuable($Type) - { - return method_exists($this, 'block'.$Type.'Continue'); - } - - protected function isBlockCompletable($Type) - { - return method_exists($this, 'block'.$Type.'Complete'); - } - - # - # Code - - protected function blockCode($Line, $Block = null) - { - if (isset($Block) and ! isset($Block['type']) and ! isset($Block['interrupted'])) - { - return; - } - - if ($Line['indent'] >= 4) - { - $text = substr($Line['body'], 4); - - $Block = array( - 'element' => array( - 'name' => 'pre', - 'handler' => 'element', - 'text' => array( - 'name' => 'code', - 'text' => $text, - ), - ), - ); - - return $Block; - } - } - - protected function blockCodeContinue($Line, $Block) - { - if ($Line['indent'] >= 4) - { - if (isset($Block['interrupted'])) - { - $Block['element']['text']['text'] .= "\n"; - - unset($Block['interrupted']); - } - - $Block['element']['text']['text'] .= "\n"; - - $text = substr($Line['body'], 4); - - $Block['element']['text']['text'] .= $text; - - return $Block; - } - } - - protected function blockCodeComplete($Block) - { - $text = $Block['element']['text']['text']; - - $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8'); - - $Block['element']['text']['text'] = $text; - - return $Block; - } - - # - # Comment - - protected function blockComment($Line) - { - if ($this->markupEscaped) - { - return; - } - - if (isset($Line['text'][3]) and $Line['text'][3] === '-' and $Line['text'][2] === '-' and $Line['text'][1] === '!') - { - $Block = array( - 'markup' => $Line['body'], - ); - - if (preg_match('/-->$/', $Line['text'])) - { - $Block['closed'] = true; - } - - return $Block; - } - } - - protected function blockCommentContinue($Line, array $Block) - { - if (isset($Block['closed'])) - { - return; - } - - $Block['markup'] .= "\n" . $Line['body']; - - if (preg_match('/-->$/', $Line['text'])) - { - $Block['closed'] = true; - } - - return $Block; - } - - # - # Fenced Code - - protected function blockFencedCode($Line) - { - if (preg_match('/^['.$Line['text'][0].']{3,}[ ]*([\w-]+)?[ ]*$/', $Line['text'], $matches)) - { - $Element = array( - 'name' => 'code', - 'text' => '', - ); - - if (isset($matches[1])) - { - $class = 'language-'.$matches[1]; - - $Element['attributes'] = array( - 'class' => $class, - ); - } - - $Block = array( - 'char' => $Line['text'][0], - 'element' => array( - 'name' => 'pre', - 'handler' => 'element', - 'text' => $Element, - ), - ); - - return $Block; - } - } - - protected function blockFencedCodeContinue($Line, $Block) - { - if (isset($Block['complete'])) - { - return; - } - - if (isset($Block['interrupted'])) - { - $Block['element']['text']['text'] .= "\n"; - - unset($Block['interrupted']); - } - - if (preg_match('/^'.$Block['char'].'{3,}[ ]*$/', $Line['text'])) - { - $Block['element']['text']['text'] = substr($Block['element']['text']['text'], 1); - - $Block['complete'] = true; - - return $Block; - } - - $Block['element']['text']['text'] .= "\n".$Line['body']; - - return $Block; - } - - protected function blockFencedCodeComplete($Block) - { - $text = $Block['element']['text']['text']; - - $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8'); - - $Block['element']['text']['text'] = $text; - - return $Block; - } - - # - # Header - - protected function blockHeader($Line) - { - if (isset($Line['text'][1])) - { - $level = 1; - - while (isset($Line['text'][$level]) and $Line['text'][$level] === '#') - { - $level ++; - } - - if ($level > 6) - { - return; - } - - $text = trim($Line['text'], '# '); - - $Block = array( - 'element' => array( - 'name' => 'h' . min(6, $level), - 'text' => $text, - 'handler' => 'line', - ), - ); - - return $Block; - } - } - - # - # List - - protected function blockList($Line) - { - list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]+[.]'); - - if (preg_match('/^('.$pattern.'[ ]+)(.*)/', $Line['text'], $matches)) - { - $Block = array( - 'indent' => $Line['indent'], - 'pattern' => $pattern, - 'element' => array( - 'name' => $name, - 'handler' => 'elements', - ), - ); - - if($name === 'ol') - { - $listStart = stristr($matches[0], '.', true); - - if($listStart !== '1') - { - $Block['element']['attributes'] = array('start' => $listStart); - } - } - - $Block['li'] = array( - 'name' => 'li', - 'handler' => 'li', - 'text' => array( - $matches[2], - ), - ); - - $Block['element']['text'] []= & $Block['li']; - - return $Block; - } - } - - protected function blockListContinue($Line, array $Block) - { - if ($Block['indent'] === $Line['indent'] and preg_match('/^'.$Block['pattern'].'(?:[ ]+(.*)|$)/', $Line['text'], $matches)) - { - if (isset($Block['interrupted'])) - { - $Block['li']['text'] []= ''; - - unset($Block['interrupted']); - } - - unset($Block['li']); - - $text = isset($matches[1]) ? $matches[1] : ''; - - $Block['li'] = array( - 'name' => 'li', - 'handler' => 'li', - 'text' => array( - $text, - ), - ); - - $Block['element']['text'] []= & $Block['li']; - - return $Block; - } - - if ($Line['text'][0] === '[' and $this->blockReference($Line)) - { - return $Block; - } - - if ( ! isset($Block['interrupted'])) - { - $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']); - - $Block['li']['text'] []= $text; - - return $Block; - } - - if ($Line['indent'] > 0) - { - $Block['li']['text'] []= ''; - - $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']); - - $Block['li']['text'] []= $text; - - unset($Block['interrupted']); - - return $Block; - } - } - - # - # Quote - - protected function blockQuote($Line) - { - if (preg_match('/^>[ ]?(.*)/', $Line['text'], $matches)) - { - $Block = array( - 'element' => array( - 'name' => 'blockquote', - 'handler' => 'lines', - 'text' => (array) $matches[1], - ), - ); - - return $Block; - } - } - - protected function blockQuoteContinue($Line, array $Block) - { - if ($Line['text'][0] === '>' and preg_match('/^>[ ]?(.*)/', $Line['text'], $matches)) - { - if (isset($Block['interrupted'])) - { - $Block['element']['text'] []= ''; - - unset($Block['interrupted']); - } - - $Block['element']['text'] []= $matches[1]; - - return $Block; - } - - if ( ! isset($Block['interrupted'])) - { - $Block['element']['text'] []= $Line['text']; - - return $Block; - } - } - - # - # Rule - - protected function blockRule($Line) - { - if (preg_match('/^(['.$Line['text'][0].'])([ ]*\1){2,}[ ]*$/', $Line['text'])) - { - $Block = array( - 'element' => array( - 'name' => 'hr' - ), - ); - - return $Block; - } - } - - # - # Setext - - protected function blockSetextHeader($Line, array $Block = null) - { - if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted'])) - { - return; - } - - if (chop($Line['text'], $Line['text'][0]) === '') - { - $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; - - return $Block; - } - } - - # - # Markup - - protected function blockMarkup($Line) - { - if ($this->markupEscaped) - { - return; - } - - if (preg_match('/^<(\w*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches)) - { - $element = strtolower($matches[1]); - - if (in_array($element, $this->textLevelElements)) - { - return; - } - - $Block = array( - 'name' => $matches[1], - 'depth' => 0, - 'markup' => $Line['text'], - ); - - $length = strlen($matches[0]); - - $remainder = substr($Line['text'], $length); - - if (trim($remainder) === '') - { - if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) - { - $Block['closed'] = true; - - $Block['void'] = true; - } - } - else - { - if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) - { - return; - } - - if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder)) - { - $Block['closed'] = true; - } - } - - return $Block; - } - } - - protected function blockMarkupContinue($Line, array $Block) - { - if (isset($Block['closed'])) - { - return; - } - - if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open - { - $Block['depth'] ++; - } - - if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close - { - if ($Block['depth'] > 0) - { - $Block['depth'] --; - } - else - { - $Block['closed'] = true; - } - } - - if (isset($Block['interrupted'])) - { - $Block['markup'] .= "\n"; - - unset($Block['interrupted']); - } - - $Block['markup'] .= "\n".$Line['body']; - - return $Block; - } - - # - # Reference - - protected function blockReference($Line) - { - if (preg_match('/^\[(.+?)\]:[ ]*?(?:[ ]+["\'(](.+)["\')])?[ ]*$/', $Line['text'], $matches)) - { - $id = strtolower($matches[1]); - - $Data = array( - 'url' => $matches[2], - 'title' => null, - ); - - if (isset($matches[3])) - { - $Data['title'] = $matches[3]; - } - - $this->DefinitionData['Reference'][$id] = $Data; - - $Block = array( - 'hidden' => true, - ); - - return $Block; - } - } - - # - # Table - - protected function blockTable($Line, array $Block = null) - { - if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted'])) - { - return; - } - - if (strpos($Block['element']['text'], '|') !== false and chop($Line['text'], ' -:|') === '') - { - $alignments = array(); - - $divider = $Line['text']; - - $divider = trim($divider); - $divider = trim($divider, '|'); - - $dividerCells = explode('|', $divider); - - foreach ($dividerCells as $dividerCell) - { - $dividerCell = trim($dividerCell); - - if ($dividerCell === '') - { - continue; - } - - $alignment = null; - - if ($dividerCell[0] === ':') - { - $alignment = 'left'; - } - - if (substr($dividerCell, - 1) === ':') - { - $alignment = $alignment === 'left' ? 'center' : 'right'; - } - - $alignments []= $alignment; - } - - # ~ - - $HeaderElements = array(); - - $header = $Block['element']['text']; - - $header = trim($header); - $header = trim($header, '|'); - - $headerCells = explode('|', $header); - - foreach ($headerCells as $index => $headerCell) - { - $headerCell = trim($headerCell); - - $HeaderElement = array( - 'name' => 'th', - 'text' => $headerCell, - 'handler' => 'line', - ); - - if (isset($alignments[$index])) - { - $alignment = $alignments[$index]; - - $HeaderElement['attributes'] = array( - 'style' => 'text-align: '.$alignment.';', - ); - } - - $HeaderElements []= $HeaderElement; - } - - # ~ - - $Block = array( - 'alignments' => $alignments, - 'identified' => true, - 'element' => array( - 'name' => 'table', - 'handler' => 'elements', - ), - ); - - $Block['element']['text'] []= array( - 'name' => 'thead', - 'handler' => 'elements', - ); - - $Block['element']['text'] []= array( - 'name' => 'tbody', - 'handler' => 'elements', - 'text' => array(), - ); - - $Block['element']['text'][0]['text'] []= array( - 'name' => 'tr', - 'handler' => 'elements', - 'text' => $HeaderElements, - ); - - return $Block; - } - } - - protected function blockTableContinue($Line, array $Block) - { - if (isset($Block['interrupted'])) - { - return; - } - - if ($Line['text'][0] === '|' or strpos($Line['text'], '|')) - { - $Elements = array(); - - $row = $Line['text']; - - $row = trim($row); - $row = trim($row, '|'); - - preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]+`|`)+/', $row, $matches); - - foreach ($matches[0] as $index => $cell) - { - $cell = trim($cell); - - $Element = array( - 'name' => 'td', - 'handler' => 'line', - 'text' => $cell, - ); - - if (isset($Block['alignments'][$index])) - { - $Element['attributes'] = array( - 'style' => 'text-align: '.$Block['alignments'][$index].';', - ); - } - - $Elements []= $Element; - } - - $Element = array( - 'name' => 'tr', - 'handler' => 'elements', - 'text' => $Elements, - ); - - $Block['element']['text'][1]['text'] []= $Element; - - return $Block; - } - } - - # - # ~ - # - - protected function paragraph($Line) - { - $Block = array( - 'element' => array( - 'name' => 'p', - 'text' => $Line['text'], - 'handler' => 'line', - ), - ); - - return $Block; - } - - # - # Inline Elements - # - - protected $InlineTypes = array( - '"' => array('SpecialCharacter'), - '!' => array('Image'), - '&' => array('SpecialCharacter'), - '*' => array('Emphasis'), - ':' => array('Url'), - '<' => array('UrlTag', 'EmailTag', 'Markup', 'SpecialCharacter'), - '>' => array('SpecialCharacter'), - '[' => array('Link'), - '_' => array('Emphasis'), - '`' => array('Code'), - '~' => array('Strikethrough'), - '\\' => array('EscapeSequence'), - ); - - # ~ - - protected $inlineMarkerList = '!"*_&[:<>`~\\'; - - # - # ~ - # - - public function line($text) - { - $markup = ''; - - # $excerpt is based on the first occurrence of a marker - - while ($excerpt = strpbrk($text, $this->inlineMarkerList)) - { - $marker = $excerpt[0]; - - $markerPosition = strpos($text, $marker); - - $Excerpt = array('text' => $excerpt, 'context' => $text); - - foreach ($this->InlineTypes[$marker] as $inlineType) - { - $Inline = $this->{'inline'.$inlineType}($Excerpt); - - if ( ! isset($Inline)) - { - continue; - } - - # makes sure that the inline belongs to "our" marker - - if (isset($Inline['position']) and $Inline['position'] > $markerPosition) - { - continue; - } - - # sets a default inline position - - if ( ! isset($Inline['position'])) - { - $Inline['position'] = $markerPosition; - } - - # the text that comes before the inline - $unmarkedText = substr($text, 0, $Inline['position']); - - # compile the unmarked text - $markup .= $this->unmarkedText($unmarkedText); - - # compile the inline - $markup .= isset($Inline['markup']) ? $Inline['markup'] : $this->element($Inline['element']); - - # remove the examined text - $text = substr($text, $Inline['position'] + $Inline['extent']); - - continue 2; - } - - # the marker does not belong to an inline - - $unmarkedText = substr($text, 0, $markerPosition + 1); - - $markup .= $this->unmarkedText($unmarkedText); - - $text = substr($text, $markerPosition + 1); - } - - $markup .= $this->unmarkedText($text); - - return $markup; - } - - # - # ~ - # - - protected function inlineCode($Excerpt) - { - $marker = $Excerpt['text'][0]; - - if (preg_match('/^('.$marker.'+)[ ]*(.+?)[ ]*(? strlen($matches[0]), - 'element' => array( - 'name' => 'code', - 'text' => $text, - ), - ); - } - } - - protected function inlineEmailTag($Excerpt) - { - if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<((mailto:)?\S+?@\S+?)>/i', $Excerpt['text'], $matches)) - { - $url = $matches[1]; - - if ( ! isset($matches[2])) - { - $url = 'mailto:' . $url; - } - - return array( - 'extent' => strlen($matches[0]), - 'element' => array( - 'name' => 'a', - 'text' => $matches[1], - 'attributes' => array( - 'href' => $url, - ), - ), - ); - } - } - - protected function inlineEmphasis($Excerpt) - { - if ( ! isset($Excerpt['text'][1])) - { - return; - } - - $marker = $Excerpt['text'][0]; - - if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) - { - $emphasis = 'strong'; - } - elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) - { - $emphasis = 'em'; - } - else - { - return; - } - - return array( - 'extent' => strlen($matches[0]), - 'element' => array( - 'name' => $emphasis, - 'handler' => 'line', - 'text' => $matches[1], - ), - ); - } - - protected function inlineEscapeSequence($Excerpt) - { - if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) - { - return array( - 'markup' => $Excerpt['text'][1], - 'extent' => 2, - ); - } - } - - protected function inlineImage($Excerpt) - { - if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') - { - return; - } - - $Excerpt['text']= substr($Excerpt['text'], 1); - - $Link = $this->inlineLink($Excerpt); - - if ($Link === null) - { - return; - } - - $Inline = array( - 'extent' => $Link['extent'] + 1, - 'element' => array( - 'name' => 'img', - 'attributes' => array( - 'src' => $Link['element']['attributes']['href'], - 'alt' => $Link['element']['text'], - ), - ), - ); - - $Inline['element']['attributes'] += $Link['element']['attributes']; - - unset($Inline['element']['attributes']['href']); - - return $Inline; - } - - protected function inlineLink($Excerpt) - { - $Element = array( - 'name' => 'a', - 'handler' => 'line', - 'text' => null, - 'attributes' => array( - 'href' => null, - 'title' => null, - ), - ); - - $extent = 0; - - $remainder = $Excerpt['text']; - - if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) - { - $Element['text'] = $matches[1]; - - $extent += strlen($matches[0]); - - $remainder = substr($remainder, $extent); - } - else - { - return; - } - - if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*"|\'[^\']*\'))?\s*[)]/', $remainder, $matches)) - { - $Element['attributes']['href'] = $matches[1]; - - if (isset($matches[2])) - { - $Element['attributes']['title'] = substr($matches[2], 1, - 1); - } - - $extent += strlen($matches[0]); - } - else - { - if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) - { - $definition = strlen($matches[1]) ? $matches[1] : $Element['text']; - $definition = strtolower($definition); - - $extent += strlen($matches[0]); - } - else - { - $definition = strtolower($Element['text']); - } - - if ( ! isset($this->DefinitionData['Reference'][$definition])) - { - return; - } - - $Definition = $this->DefinitionData['Reference'][$definition]; - - $Element['attributes']['href'] = $Definition['url']; - $Element['attributes']['title'] = $Definition['title']; - } - - $Element['attributes']['href'] = str_replace(array('&', '<'), array('&', '<'), $Element['attributes']['href']); - - return array( - 'extent' => $extent, - 'element' => $Element, - ); - } - - protected function inlineMarkup($Excerpt) - { - if ($this->markupEscaped or strpos($Excerpt['text'], '>') === false) - { - return; - } - - if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w*[ ]*>/s', $Excerpt['text'], $matches)) - { - return array( - 'markup' => $matches[0], - 'extent' => strlen($matches[0]), - ); - } - - if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) - { - return array( - 'markup' => $matches[0], - 'extent' => strlen($matches[0]), - ); - } - - if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w*(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*\/?>/s', $Excerpt['text'], $matches)) - { - return array( - 'markup' => $matches[0], - 'extent' => strlen($matches[0]), - ); - } - } - - protected function inlineSpecialCharacter($Excerpt) - { - if ($Excerpt['text'][0] === '&' and ! preg_match('/^&#?\w+;/', $Excerpt['text'])) - { - return array( - 'markup' => '&', - 'extent' => 1, - ); - } - - $SpecialCharacter = array('>' => 'gt', '<' => 'lt', '"' => 'quot'); - - if (isset($SpecialCharacter[$Excerpt['text'][0]])) - { - return array( - 'markup' => '&'.$SpecialCharacter[$Excerpt['text'][0]].';', - 'extent' => 1, - ); - } - } - - protected function inlineStrikethrough($Excerpt) - { - if ( ! isset($Excerpt['text'][1])) - { - return; - } - - if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) - { - return array( - 'extent' => strlen($matches[0]), - 'element' => array( - 'name' => 'del', - 'text' => $matches[1], - 'handler' => 'line', - ), - ); - } - } - - protected function inlineUrl($Excerpt) - { - if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') - { - return; - } - - if (preg_match('/\bhttps?:[\/]{2}[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)) - { - $Inline = array( - 'extent' => strlen($matches[0][0]), - 'position' => $matches[0][1], - 'element' => array( - 'name' => 'a', - 'text' => $matches[0][0], - 'attributes' => array( - 'href' => $matches[0][0], - ), - ), - ); - - return $Inline; - } - } - - protected function inlineUrlTag($Excerpt) - { - if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w+:\/{2}[^ >]+)>/i', $Excerpt['text'], $matches)) - { - $url = str_replace(array('&', '<'), array('&', '<'), $matches[1]); - - return array( - 'extent' => strlen($matches[0]), - 'element' => array( - 'name' => 'a', - 'text' => $url, - 'attributes' => array( - 'href' => $url, - ), - ), - ); - } - } - - # ~ - - protected function unmarkedText($text) - { - if ($this->breaksEnabled) - { - $text = preg_replace('/[ ]*\n/', "
    \n", $text); - } - else - { - $text = preg_replace('/(?:[ ][ ]+|[ ]*\\\\)\n/', "
    \n", $text); - $text = str_replace(" \n", "\n", $text); - } - - return $text; - } - - # - # Handlers - # - - protected function element(array $Element) - { - $markup = '<'.$Element['name']; - - if (isset($Element['attributes'])) - { - foreach ($Element['attributes'] as $name => $value) - { - if ($value === null) - { - continue; - } - - $markup .= ' '.$name.'="'.$value.'"'; - } - } - - if (isset($Element['text'])) - { - $markup .= '>'; - - if (isset($Element['handler'])) - { - $markup .= $this->{$Element['handler']}($Element['text']); - } - else - { - $markup .= $Element['text']; - } - - $markup .= ''; - } - else - { - $markup .= ' />'; - } - - return $markup; - } - - protected function elements(array $Elements) - { - $markup = ''; - - foreach ($Elements as $Element) - { - $markup .= "\n" . $this->element($Element); - } - - $markup .= "\n"; - - return $markup; - } - - # ~ - - protected function li($lines) - { - $markup = $this->lines($lines); - - $trimmedMarkup = trim($markup); - - if ( ! in_array('', $lines) and substr($trimmedMarkup, 0, 3) === '

    ') - { - $markup = $trimmedMarkup; - $markup = substr($markup, 3); - - $position = strpos($markup, "

    "); - - $markup = substr_replace($markup, '', $position, 4); - } - - return $markup; - } - - # - # Deprecated Methods - # - - function parse($text) - { - $markup = $this->text($text); - - return $markup; - } - - # - # Static Methods - # - - static function instance($name = 'default') - { - if (isset(self::$instances[$name])) - { - return self::$instances[$name]; - } - - $instance = new static(); - - self::$instances[$name] = $instance; - - return $instance; - } - - private static $instances = array(); - - # - # Fields - # - - protected $DefinitionData; - - # - # Read-Only - - protected $specialCharacters = array( - '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', - ); - - protected $StrongRegex = array( - '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*[*])+?)[*]{2}(?![*])/s', - '_' => '/^__((?:\\\\_|[^_]|_[^_]*_)+?)__(?!_)/us', - ); - - protected $EmRegex = array( - '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', - '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', - ); - - protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:[^"\'=<>`\s]+|"[^"]*"|\'[^\']*\'))?'; - - protected $voidElements = array( - 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', - ); - - protected $textLevelElements = array( - 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont', - 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', - 'i', 'rp', 'del', 'code', 'strike', 'marquee', - 'q', 'rt', 'ins', 'font', 'strong', - 's', 'tt', 'kbd', 'mark', - 'u', 'xm', 'sub', 'nobr', - 'sup', 'ruby', - 'var', 'span', - 'wbr', 'time', - ); -} diff --git a/system/src/Grav/Framework/Parsedown/ParsedownExtra.php b/system/src/Grav/Framework/Parsedown/ParsedownExtra.php deleted file mode 100644 index 8219005..0000000 --- a/system/src/Grav/Framework/Parsedown/ParsedownExtra.php +++ /dev/null @@ -1,532 +0,0 @@ -BlockTypes[':'] []= 'DefinitionList'; - $this->BlockTypes['*'] []= 'Abbreviation'; - - # identify footnote definitions before reference definitions - array_unshift($this->BlockTypes['['], 'Footnote'); - - # identify footnote markers before before links - array_unshift($this->InlineTypes['['], 'FootnoteMarker'); - } - - # - # ~ - - function text($text) - { - $markup = parent::text($text); - - # merge consecutive dl elements - - $markup = preg_replace('/<\/dl>\s+
    \s+/', '', $markup); - - # add footnotes - - if (isset($this->DefinitionData['Footnote'])) - { - $Element = $this->buildFootnoteElement(); - - $markup .= "\n" . $this->element($Element); - } - - return $markup; - } - - # - # Blocks - # - - # - # Abbreviation - - protected function blockAbbreviation($Line) - { - if (preg_match('/^\*\[(.+?)\]:[ ]*(.+?)[ ]*$/', $Line['text'], $matches)) - { - $this->DefinitionData['Abbreviation'][$matches[1]] = $matches[2]; - - $Block = array( - 'hidden' => true, - ); - - return $Block; - } - } - - # - # Footnote - - protected function blockFootnote($Line) - { - if (preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $Line['text'], $matches)) - { - $Block = array( - 'label' => $matches[1], - 'text' => $matches[2], - 'hidden' => true, - ); - - return $Block; - } - } - - protected function blockFootnoteContinue($Line, $Block) - { - if ($Line['text'][0] === '[' and preg_match('/^\[\^(.+?)\]:/', $Line['text'])) - { - return; - } - - if (isset($Block['interrupted'])) - { - if ($Line['indent'] >= 4) - { - $Block['text'] .= "\n\n" . $Line['text']; - - return $Block; - } - } - else - { - $Block['text'] .= "\n" . $Line['text']; - - return $Block; - } - } - - protected function blockFootnoteComplete($Block) - { - $this->DefinitionData['Footnote'][$Block['label']] = array( - 'text' => $Block['text'], - 'count' => null, - 'number' => null, - ); - - return $Block; - } - - # - # Definition List - - protected function blockDefinitionList($Line, $Block) - { - if ( ! isset($Block) or isset($Block['type'])) - { - return; - } - - $Element = array( - 'name' => 'dl', - 'handler' => 'elements', - 'text' => array(), - ); - - $terms = explode("\n", $Block['element']['text']); - - foreach ($terms as $term) - { - $Element['text'] []= array( - 'name' => 'dt', - 'handler' => 'line', - 'text' => $term, - ); - } - - $Block['element'] = $Element; - - $Block = $this->addDdElement($Line, $Block); - - return $Block; - } - - protected function blockDefinitionListContinue($Line, array $Block) - { - if ($Line['text'][0] === ':') - { - $Block = $this->addDdElement($Line, $Block); - - return $Block; - } - else - { - if (isset($Block['interrupted']) and $Line['indent'] === 0) - { - return; - } - - if (isset($Block['interrupted'])) - { - $Block['dd']['handler'] = 'text'; - $Block['dd']['text'] .= "\n\n"; - - unset($Block['interrupted']); - } - - $text = substr($Line['body'], min($Line['indent'], 4)); - - $Block['dd']['text'] .= "\n" . $text; - - return $Block; - } - } - - # - # Header - - protected function blockHeader($Line) - { - $Block = parent::blockHeader($Line); - - if ($Block !== null && preg_match('/[ #]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['text'], $matches, PREG_OFFSET_CAPTURE)) - { - $attributeString = $matches[1][0]; - - $Block['element']['attributes'] = $this->parseAttributeData($attributeString); - - $Block['element']['text'] = substr($Block['element']['text'], 0, $matches[0][1]); - } - - return $Block; - } - - # - # Markup - - protected function blockMarkupComplete($Block) - { - if ( ! isset($Block['void'])) - { - $Block['markup'] = $this->processTag($Block['markup']); - } - - return $Block; - } - - # - # Setext - - protected function blockSetextHeader($Line, array $Block = null) - { - $Block = parent::blockSetextHeader($Line, $Block); - - if ($Block !== null && preg_match('/[ ]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['text'], $matches, PREG_OFFSET_CAPTURE)) - { - $attributeString = $matches[1][0]; - - $Block['element']['attributes'] = $this->parseAttributeData($attributeString); - - $Block['element']['text'] = substr($Block['element']['text'], 0, $matches[0][1]); - } - - return $Block; - } - - # - # Inline Elements - # - - # - # Footnote Marker - - protected function inlineFootnoteMarker($Excerpt) - { - if (preg_match('/^\[\^(.+?)\]/', $Excerpt['text'], $matches)) - { - $name = $matches[1]; - - if ( ! isset($this->DefinitionData['Footnote'][$name])) - { - return; - } - - $this->DefinitionData['Footnote'][$name]['count'] ++; - - if ( ! isset($this->DefinitionData['Footnote'][$name]['number'])) - { - $this->DefinitionData['Footnote'][$name]['number'] = ++ $this->footnoteCount; # » & - } - - $Element = array( - 'name' => 'sup', - 'attributes' => array('id' => 'fnref'.$this->DefinitionData['Footnote'][$name]['count'].':'.$name), - 'handler' => 'element', - 'text' => array( - 'name' => 'a', - 'attributes' => array('href' => '#fn:'.$name, 'class' => 'footnote-ref'), - 'text' => $this->DefinitionData['Footnote'][$name]['number'], - ), - ); - - return array( - 'extent' => strlen($matches[0]), - 'element' => $Element, - ); - } - } - - private $footnoteCount = 0; - - # - # Link - - protected function inlineLink($Excerpt) - { - $Link = parent::inlineLink($Excerpt); - - $remainder = $Link !== null ? substr($Excerpt['text'], $Link['extent']) : ''; - - if (preg_match('/^[ ]*{('.$this->regexAttribute.'+)}/', $remainder, $matches)) - { - $Link['element']['attributes'] += $this->parseAttributeData($matches[1]); - - $Link['extent'] += strlen($matches[0]); - } - - return $Link; - } - - # - # ~ - # - - protected function unmarkedText($text) - { - $text = parent::unmarkedText($text); - - if (isset($this->DefinitionData['Abbreviation'])) - { - foreach ($this->DefinitionData['Abbreviation'] as $abbreviation => $meaning) - { - $pattern = '/\b'.preg_quote($abbreviation, '/').'\b/'; - - $text = preg_replace($pattern, ''.$abbreviation.'', $text); - } - } - - return $text; - } - - # - # Util Methods - # - - protected function addDdElement(array $Line, array $Block) - { - $text = substr($Line['text'], 1); - $text = trim($text); - - unset($Block['dd']); - - $Block['dd'] = array( - 'name' => 'dd', - 'handler' => 'line', - 'text' => $text, - ); - - if (isset($Block['interrupted'])) - { - $Block['dd']['handler'] = 'text'; - - unset($Block['interrupted']); - } - - $Block['element']['text'] []= & $Block['dd']; - - return $Block; - } - - protected function buildFootnoteElement() - { - $Element = array( - 'name' => 'div', - 'attributes' => array('class' => 'footnotes'), - 'handler' => 'elements', - 'text' => array( - array( - 'name' => 'hr', - ), - array( - 'name' => 'ol', - 'handler' => 'elements', - 'text' => array(), - ), - ), - ); - - uasort($this->DefinitionData['Footnote'], 'self::sortFootnotes'); - - foreach ($this->DefinitionData['Footnote'] as $definitionId => $DefinitionData) - { - if ( ! isset($DefinitionData['number'])) - { - continue; - } - - $text = $DefinitionData['text']; - - $text = parent::text($text); - - $numbers = range(1, $DefinitionData['count']); - - $backLinksMarkup = ''; - - foreach ($numbers as $number) - { - $backLinksMarkup .= ' '; - } - - $backLinksMarkup = substr($backLinksMarkup, 1); - - if (substr($text, - 4) === '

    ') - { - $backLinksMarkup = ' '.$backLinksMarkup; - - $text = substr_replace($text, $backLinksMarkup.'

    ', - 4); - } - else - { - $text .= "\n".'

    '.$backLinksMarkup.'

    '; - } - - $Element['text'][1]['text'] []= array( - 'name' => 'li', - 'attributes' => array('id' => 'fn:'.$definitionId), - 'text' => "\n".$text."\n", - ); - } - - return $Element; - } - - # ~ - - protected function parseAttributeData($attributeString) - { - $Data = array(); - - $attributes = preg_split('/[ ]+/', $attributeString, - 1, PREG_SPLIT_NO_EMPTY); - - foreach ($attributes as $attribute) - { - if ($attribute[0] === '#') - { - $Data['id'] = substr($attribute, 1); - } - else # "." - { - $classes []= substr($attribute, 1); - } - } - - if (isset($classes)) - { - $Data['class'] = implode(' ', $classes); - } - - return $Data; - } - - # ~ - - protected function processTag($elementMarkup) # recursive - { - # http://stackoverflow.com/q/1148928/200145 - libxml_use_internal_errors(true); - - $DOMDocument = new \DOMDocument; - - # http://stackoverflow.com/q/11309194/200145 - $elementMarkup = mb_convert_encoding($elementMarkup, 'HTML-ENTITIES', 'UTF-8'); - - # http://stackoverflow.com/q/4879946/200145 - $DOMDocument->loadHTML($elementMarkup); - $DOMDocument->removeChild($DOMDocument->doctype); - $DOMDocument->replaceChild($DOMDocument->firstChild->firstChild->firstChild, $DOMDocument->firstChild); - - $elementText = ''; - - if ($DOMDocument->documentElement->getAttribute('markdown') === '1') - { - foreach ($DOMDocument->documentElement->childNodes as $Node) - { - $elementText .= $DOMDocument->saveHTML($Node); - } - - $DOMDocument->documentElement->removeAttribute('markdown'); - - $elementText = "\n".$this->text($elementText)."\n"; - } - else - { - foreach ($DOMDocument->documentElement->childNodes as $Node) - { - $nodeMarkup = $DOMDocument->saveHTML($Node); - - if ($Node instanceof \DOMElement and ! in_array($Node->nodeName, $this->textLevelElements)) - { - $elementText .= $this->processTag($nodeMarkup); - } - else - { - $elementText .= $nodeMarkup; - } - } - } - - # because we don't want for markup to get encoded - $DOMDocument->documentElement->nodeValue = 'placeholder\x1A'; - - $markup = $DOMDocument->saveHTML($DOMDocument->documentElement); - $markup = str_replace('placeholder\x1A', $elementText, $markup); - - return $markup; - } - - # ~ - - protected function sortFootnotes($A, $B) # callback - { - return $A['number'] - $B['number']; - } - - # - # Fields - # - - protected $regexAttribute = '(?:[#.][-\w]+[ ]*)'; -} diff --git a/system/src/Grav/Framework/Psr7/AbstractUri.php b/system/src/Grav/Framework/Psr7/AbstractUri.php index e0d46b6..85d802f 100644 --- a/system/src/Grav/Framework/Psr7/AbstractUri.php +++ b/system/src/Grav/Framework/Psr7/AbstractUri.php @@ -3,13 +3,14 @@ /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Psr7; use Grav\Framework\Uri\UriPartsFilter; +use InvalidArgumentException; use Psr\Http\Message\UriInterface; /** @@ -20,6 +21,7 @@ use Psr\Http\Message\UriInterface; */ abstract class AbstractUri implements UriInterface { + /** @var array */ protected static $defaultPorts = [ 'http' => 80, 'https' => 443 @@ -27,25 +29,18 @@ abstract class AbstractUri implements UriInterface /** @var string Uri scheme. */ private $scheme = ''; - /** @var string Uri user. */ private $user = ''; - /** @var string Uri password. */ private $password = ''; - /** @var string Uri host. */ private $host = ''; - /** @var int|null Uri port. */ private $port; - /** @var string Uri path. */ private $path = ''; - /** @var string Uri query string (without ?). */ private $query = ''; - /** @var string Uri fragment (without #). */ private $fragment = ''; @@ -156,7 +151,7 @@ abstract class AbstractUri implements UriInterface /** * @inheritdoc - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public function withUserInfo($user, $password = null) { @@ -249,7 +244,7 @@ abstract class AbstractUri implements UriInterface /** * @inheritdoc - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public function withFragment($fragment) { @@ -352,7 +347,8 @@ abstract class AbstractUri implements UriInterface /** * @param array $parts - * @throws \InvalidArgumentException + * @return void + * @throws InvalidArgumentException */ protected function initParts(array $parts) { @@ -370,26 +366,30 @@ abstract class AbstractUri implements UriInterface } /** - * @throws \InvalidArgumentException + * @return void + * @throws InvalidArgumentException */ private function validate() { if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) { - throw new \InvalidArgumentException('Uri with a scheme must have a host'); + throw new InvalidArgumentException('Uri with a scheme must have a host'); } if ($this->getAuthority() === '') { if (0 === strpos($this->path, '//')) { - throw new \InvalidArgumentException('The path of a URI without an authority must not start with two slashes \'//\''); + throw new InvalidArgumentException('The path of a URI without an authority must not start with two slashes \'//\''); } if ($this->scheme === '' && false !== strpos(explode('/', $this->path, 2)[0], ':')) { - throw new \InvalidArgumentException('A relative URI must not have a path beginning with a segment containing a colon'); + throw new InvalidArgumentException('A relative URI must not have a path beginning with a segment containing a colon'); } } elseif (isset($this->path[0]) && $this->path[0] !== '/') { - throw new \InvalidArgumentException('The path of a URI with an authority must start with a slash \'/\' or be empty'); + throw new InvalidArgumentException('The path of a URI with an authority must start with a slash \'/\' or be empty'); } } + /** + * @return bool + */ protected function isDefaultPort() { $scheme = $this->scheme; @@ -399,6 +399,9 @@ abstract class AbstractUri implements UriInterface || (isset(static::$defaultPorts[$scheme]) && $port === static::$defaultPorts[$scheme]); } + /** + * @return void + */ private function unsetDefaultPort() { if ($this->isDefaultPort()) { diff --git a/system/src/Grav/Framework/Psr7/Request.php b/system/src/Grav/Framework/Psr7/Request.php index 24ea3b6..cd65fd9 100644 --- a/system/src/Grav/Framework/Psr7/Request.php +++ b/system/src/Grav/Framework/Psr7/Request.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/Psr7/Response.php b/system/src/Grav/Framework/Psr7/Response.php index 960621d..6cb67e0 100644 --- a/system/src/Grav/Framework/Psr7/Response.php +++ b/system/src/Grav/Framework/Psr7/Response.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,6 +15,7 @@ use Grav\Framework\Psr7\Traits\ResponseDecoratorTrait; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; use RuntimeException; +use function in_array; /** * Class Response @@ -24,9 +25,7 @@ class Response implements ResponseInterface { use ResponseDecoratorTrait; - /** - * @var string EOL characters used for HTTP response. - */ + /** @var string EOL characters used for HTTP response. */ private const EOL = "\r\n"; /** @@ -50,7 +49,7 @@ class Response implements ResponseInterface * response to the client. * * @param mixed $data The data - * @param int $status The HTTP status code. + * @param int|null $status The HTTP status code. * @param int $options Json encoding options * @param int $depth Json encoding max depth * @return static @@ -112,7 +111,7 @@ class Response implements ResponseInterface */ public function isEmpty(): bool { - return \in_array($this->getResponse()->getStatusCode(), [204, 205, 304], true); + return in_array($this->getResponse()->getStatusCode(), [204, 205, 304], true); } @@ -137,7 +136,7 @@ class Response implements ResponseInterface */ public function isRedirect(): bool { - return \in_array($this->getResponse()->getStatusCode(), [301, 302, 303, 307, 308], true); + return in_array($this->getResponse()->getStatusCode(), [301, 302, 303, 307, 308], true); } /** diff --git a/system/src/Grav/Framework/Psr7/ServerRequest.php b/system/src/Grav/Framework/Psr7/ServerRequest.php index bb6b8b8..4084834 100644 --- a/system/src/Grav/Framework/Psr7/ServerRequest.php +++ b/system/src/Grav/Framework/Psr7/ServerRequest.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,6 +15,8 @@ use Grav\Framework\Psr7\Traits\ServerRequestDecoratorTrait; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; +use function is_array; +use function is_object; /** * Class ServerRequest @@ -159,7 +161,7 @@ class ServerRequest implements ServerRequestInterface * Note: This method is not part of the PSR-7 standard. * * @param string $key The parameter key. - * @param string $default The default value. + * @param string|null $default The default value. * * @return mixed The parameter value. */ diff --git a/system/src/Grav/Framework/Psr7/Stream.php b/system/src/Grav/Framework/Psr7/Stream.php index 9431052..a5a1043 100644 --- a/system/src/Grav/Framework/Psr7/Stream.php +++ b/system/src/Grav/Framework/Psr7/Stream.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,10 +14,28 @@ namespace Grav\Framework\Psr7; use Grav\Framework\Psr7\Traits\StreamDecoratorTrait; use Psr\Http\Message\StreamInterface; +/** + * Class Stream + * @package Grav\Framework\Psr7 + */ class Stream implements StreamInterface { use StreamDecoratorTrait; + /** + * @param string|resource|StreamInterface $body + * @return static + */ + public static function create($body = '') + { + return new static($body); + } + + /** + * Stream constructor. + * + * @param string|resource|StreamInterface $body + */ public function __construct($body = '') { $this->stream = \Nyholm\Psr7\Stream::create($body); diff --git a/system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php index 28ed1f5..39e3818 100644 --- a/system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php +++ b/system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php index ab1ffad..13f4c6f 100644 --- a/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php +++ b/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php index 32a1bfb..7b62c3c 100644 --- a/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php +++ b/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ diff --git a/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php index f9297dd..33c26e0 100644 --- a/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php +++ b/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,6 +13,10 @@ namespace Grav\Framework\Psr7\Traits; use Psr\Http\Message\ServerRequestInterface; +/** + * Trait ServerRequestDecoratorTrait + * @package Grav\Framework\Psr7\Traits + */ trait ServerRequestDecoratorTrait { use RequestDecoratorTrait; @@ -101,6 +105,10 @@ trait ServerRequestDecoratorTrait return $new; } + /** + * @param array $attributes + * @return ServerRequestInterface + */ public function withAttributes(array $attributes) { $new = clone $this; diff --git a/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php index f61ea4b..db1e746 100644 --- a/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php +++ b/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,6 +13,10 @@ namespace Grav\Framework\Psr7\Traits; use Psr\Http\Message\StreamInterface; +/** + * Trait StreamDecoratorTrait + * @package Grav\Framework\Psr7\Traits + */ trait StreamDecoratorTrait { /** @var StreamInterface */ @@ -26,6 +30,9 @@ trait StreamDecoratorTrait return $this->stream->__toString(); } + /** + * @return void + */ public function __destruct() { $this->stream->close(); diff --git a/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php index 677c4d2..e0c65bd 100644 --- a/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php +++ b/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -14,36 +14,58 @@ namespace Grav\Framework\Psr7\Traits; use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UploadedFileInterface; +/** + * Trait UploadedFileDecoratorTrait + * @package Grav\Framework\Psr7\Traits + */ trait UploadedFileDecoratorTrait { /** @var UploadedFileInterface */ protected $uploadedFile; + /** + * @return StreamInterface + */ public function getStream(): StreamInterface { return $this->uploadedFile->getStream(); } + /** + * @param string $targetPath + */ public function moveTo($targetPath): void { $this->uploadedFile->moveTo($targetPath); } + /** + * @return int|null + */ public function getSize(): ?int { return $this->uploadedFile->getSize(); } + /** + * @return int + */ public function getError(): int { return $this->uploadedFile->getError(); } + /** + * @return string|null + */ public function getClientFilename(): ?string { return $this->uploadedFile->getClientFilename(); } + /** + * @return string|null + */ public function getClientMediaType(): ?string { return $this->uploadedFile->getClientMediaType(); diff --git a/system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php b/system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php index 9e7946a..607d757 100644 --- a/system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php +++ b/system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,116 +13,176 @@ namespace Grav\Framework\Psr7\Traits; use Psr\Http\Message\UriInterface; +/** + * Trait UriDecorationTrait + * @package Grav\Framework\Psr7\Traits + */ trait UriDecorationTrait { /** @var UriInterface */ protected $uri; + /** + * @return string + */ public function __toString(): string { return $this->uri->__toString(); } + /** + * @return string + */ public function getScheme(): string { return $this->uri->getScheme(); } + /** + * @return string + */ public function getAuthority(): string { return $this->uri->getAuthority(); } + /** + * @return string + */ public function getUserInfo(): string { return $this->uri->getUserInfo(); } + /** + * @return string + */ public function getHost(): string { return $this->uri->getHost(); } + /** + * @return int|null + */ public function getPort(): ?int { return $this->uri->getPort(); } + /** + * @return string + */ public function getPath(): string { return $this->uri->getPath(); } + /** + * @return string + */ public function getQuery(): string { return $this->uri->getQuery(); } + /** + * @return string + */ public function getFragment(): string { return $this->uri->getFragment(); } + /** + * @param string $scheme + * @return UriInterface + */ public function withScheme($scheme): UriInterface { - /** @var UriInterface|UriDecorationTrait $new */ $new = clone $this; $new->uri = $this->uri->withScheme($scheme); + /** @var UriInterface $new */ return $new; } + /** + * @param string $user + * @param string|null $password + * @return UriInterface + */ public function withUserInfo($user, $password = null): UriInterface { - /** @var UriInterface|UriDecorationTrait $new */ $new = clone $this; $new->uri = $this->uri->withUserInfo($user, $password); + /** @var UriInterface $new */ return $new; } + /** + * @param string $host + * @return UriInterface + */ public function withHost($host): UriInterface { - /** @var UriInterface|UriDecorationTrait $new */ $new = clone $this; $new->uri = $this->uri->withHost($host); + /** @var UriInterface $new */ return $new; } + /** + * @param int|null $port + * @return UriInterface + */ public function withPort($port): UriInterface { - /** @var UriInterface|UriDecorationTrait $new */ $new = clone $this; $new->uri = $this->uri->withPort($port); + /** @var UriInterface $new */ return $new; } + /** + * @param string $path + * @return UriInterface + */ public function withPath($path): UriInterface { - /** @var UriInterface|UriDecorationTrait $new */ $new = clone $this; $new->uri = $this->uri->withPath($path); + /** @var UriInterface $new */ return $new; } + /** + * @param string $query + * @return UriInterface + */ public function withQuery($query): UriInterface { - /** @var UriInterface|UriDecorationTrait $new */ $new = clone $this; $new->uri = $this->uri->withQuery($query); + /** @var UriInterface $new */ return $new; } + /** + * @param string $fragment + * @return UriInterface + */ public function withFragment($fragment): UriInterface { - /** @var UriInterface|UriDecorationTrait $new */ $new = clone $this; $new->uri = $this->uri->withFragment($fragment); + /** @var UriInterface $new */ return $new; } } diff --git a/system/src/Grav/Framework/Psr7/UploadedFile.php b/system/src/Grav/Framework/Psr7/UploadedFile.php index d6b062f..f136742 100644 --- a/system/src/Grav/Framework/Psr7/UploadedFile.php +++ b/system/src/Grav/Framework/Psr7/UploadedFile.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -15,6 +15,10 @@ use Grav\Framework\Psr7\Traits\UploadedFileDecoratorTrait; use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UploadedFileInterface; +/** + * Class UploadedFile + * @package Grav\Framework\Psr7 + */ class UploadedFile implements UploadedFileInterface { use UploadedFileDecoratorTrait; diff --git a/system/src/Grav/Framework/Psr7/Uri.php b/system/src/Grav/Framework/Psr7/Uri.php index 28614ca..c920b33 100644 --- a/system/src/Grav/Framework/Psr7/Uri.php +++ b/system/src/Grav/Framework/Psr7/Uri.php @@ -5,7 +5,7 @@ declare(strict_types=1); /** * @package Grav\Framework\Psr7 * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -16,6 +16,10 @@ use Grav\Framework\Uri\UriFactory; use GuzzleHttp\Psr7\Uri as GuzzleUri; use Psr\Http\Message\UriInterface; +/** + * Class Uri + * @package Grav\Framework\Psr7 + */ class Uri implements UriInterface { use UriDecorationTrait; diff --git a/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php b/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php index 4cacd71..451e586 100644 --- a/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php +++ b/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\RequestHandler * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -13,11 +13,13 @@ namespace Grav\Framework\RequestHandler\Exception; use Throwable; +/** + * Class InvalidArgumentException + * @package Grav\Framework\RequestHandler\Exception + */ class InvalidArgumentException extends \InvalidArgumentException { - /** - * @var mixed|null - */ + /** @var mixed|null */ private $invalidMiddleware; /** diff --git a/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php b/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php index 5e8d22a..28a226a 100644 --- a/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php +++ b/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\RequestHandler * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -12,7 +12,13 @@ declare(strict_types=1); namespace Grav\Framework\RequestHandler\Exception; use Psr\Http\Message\ServerRequestInterface; +use Throwable; +use function in_array; +/** + * Class NotFoundException + * @package Grav\Framework\RequestHandler\Exception + */ class NotFoundException extends RequestException { /** @var ServerRequestInterface */ @@ -21,11 +27,11 @@ class NotFoundException extends RequestException /** * NotFoundException constructor. * @param ServerRequestInterface $request - * @param \Throwable|null $previous + * @param Throwable|null $previous */ - public function __construct(ServerRequestInterface $request, \Throwable $previous = null) + public function __construct(ServerRequestInterface $request, Throwable $previous = null) { - if (\in_array(strtoupper($request->getMethod()), ['PUT', 'PATCH', 'DELETE'])) { + if (in_array(strtoupper($request->getMethod()), ['PUT', 'PATCH', 'DELETE'])) { parent::__construct($request, 'Method Not Allowed', 405, $previous); } else { parent::__construct($request, 'Not Found', 404, $previous); diff --git a/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php b/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php index 6696013..1b18473 100644 --- a/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php +++ b/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\RequestHandler * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,6 +11,10 @@ declare(strict_types=1); namespace Grav\Framework\RequestHandler\Exception; +/** + * Class NotHandledException + * @package Grav\Framework\RequestHandler\Exception + */ class NotHandledException extends NotFoundException { } diff --git a/system/src/Grav/Framework/RequestHandler/Exception/PageExpiredException.php b/system/src/Grav/Framework/RequestHandler/Exception/PageExpiredException.php index 897e78a..747c0ca 100644 --- a/system/src/Grav/Framework/RequestHandler/Exception/PageExpiredException.php +++ b/system/src/Grav/Framework/RequestHandler/Exception/PageExpiredException.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\RequestHandler * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -12,15 +12,20 @@ declare(strict_types=1); namespace Grav\Framework\RequestHandler\Exception; use Psr\Http\Message\ServerRequestInterface; +use Throwable; +/** + * Class PageExpiredException + * @package Grav\Framework\RequestHandler\Exception + */ class PageExpiredException extends RequestException { /** * PageExpiredException constructor. * @param ServerRequestInterface $request - * @param \Throwable|null $previous + * @param Throwable|null $previous */ - public function __construct(ServerRequestInterface $request, \Throwable $previous = null) + public function __construct(ServerRequestInterface $request, Throwable $previous = null) { parent::__construct($request, 'Page Expired', 400, $previous); // 419 } diff --git a/system/src/Grav/Framework/RequestHandler/Exception/RequestException.php b/system/src/Grav/Framework/RequestHandler/Exception/RequestException.php index 02c613e..6079964 100644 --- a/system/src/Grav/Framework/RequestHandler/Exception/RequestException.php +++ b/system/src/Grav/Framework/RequestHandler/Exception/RequestException.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\RequestHandler * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -12,7 +12,12 @@ declare(strict_types=1); namespace Grav\Framework\RequestHandler\Exception; use Psr\Http\Message\ServerRequestInterface; +use Throwable; +/** + * Class RequestException + * @package Grav\Framework\RequestHandler\Exception + */ class RequestException extends \RuntimeException { /** @var array Map of standard HTTP status code/reason phrases */ @@ -66,9 +71,9 @@ class RequestException extends \RuntimeException * @param ServerRequestInterface $request * @param string $message * @param int $code - * @param \Throwable|null $previous + * @param Throwable|null $previous */ - public function __construct(ServerRequestInterface $request, string $message, int $code = 500, \Throwable $previous = null) + public function __construct(ServerRequestInterface $request, string $message, int $code = 500, Throwable $previous = null) { $this->request = $request; @@ -78,19 +83,19 @@ class RequestException extends \RuntimeException /** * @return ServerRequestInterface */ - public function getRequest() : ServerRequestInterface + public function getRequest(): ServerRequestInterface { return $this->request; } - public function getHttpCode() : int + public function getHttpCode(): int { $code = $this->getCode(); return isset(self::$phrases[$code]) ? $code : 500; } - public function getHttpReason() : ?string + public function getHttpReason(): ?string { return self::$phrases[$this->getCode()] ?? self::$phrases[500]; } diff --git a/system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php b/system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php index ec1de6d..a9935ee 100644 --- a/system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php +++ b/system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\RequestHandler * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,29 +11,44 @@ declare(strict_types=1); namespace Grav\Framework\RequestHandler\Middlewares; +use Grav\Common\Debugger; +use Grav\Common\Grav; use Grav\Framework\Psr7\Response; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Throwable; +use function get_class; +/** + * Class Exceptions + * @package Grav\Framework\RequestHandler\Middlewares + */ class Exceptions implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { try { return $handler->handle($request); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $response = [ 'error' => [ - 'type' => \get_class($exception), 'code' => $exception->getCode(), 'message' => $exception->getMessage(), + ] + ]; + + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + if ($debugger->enabled()) { + $response['error'] += [ + 'type' => get_class($exception), 'file' => $exception->getFile(), 'line' => $exception->getLine(), 'trace' => explode("\n", $exception->getTraceAsString()), - ] - ]; + ]; + } /** @var string $json */ $json = json_encode($response); diff --git a/system/src/Grav/Framework/RequestHandler/RequestHandler.php b/system/src/Grav/Framework/RequestHandler/RequestHandler.php index c4d5387..5b03805 100644 --- a/system/src/Grav/Framework/RequestHandler/RequestHandler.php +++ b/system/src/Grav/Framework/RequestHandler/RequestHandler.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\RequestHandler * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -16,6 +16,10 @@ use Psr\Container\ContainerInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +/** + * Class RequestHandler + * @package Grav\Framework\RequestHandler + */ class RequestHandler implements RequestHandlerInterface { use RequestHandlerTrait; @@ -63,4 +67,4 @@ class RequestHandler implements RequestHandlerInterface return $this; } -} \ No newline at end of file +} diff --git a/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php b/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php index 153d1a1..1b5a79e 100644 --- a/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php +++ b/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\RequestHandler * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -16,10 +16,15 @@ use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; +use function call_user_func; +/** + * Trait RequestHandlerTrait + * @package Grav\Framework\RequestHandler\Traits + */ trait RequestHandlerTrait { - /** @var array */ + /** @var array */ protected $middleware; /** @var callable */ @@ -38,7 +43,7 @@ trait RequestHandlerTrait // Use default callable if there is no middleware. if ($middleware === null) { - return \call_user_func($this->handler, $request); + return call_user_func($this->handler, $request); } if ($middleware instanceof MiddlewareInterface) { @@ -56,4 +61,4 @@ trait RequestHandlerTrait return $this->handle($request); } -} \ No newline at end of file +} diff --git a/system/src/Grav/Framework/Route/Route.php b/system/src/Grav/Framework/Route/Route.php index 541dc4f..f9a7fa7 100644 --- a/system/src/Grav/Framework/Route/Route.php +++ b/system/src/Grav/Framework/Route/Route.php @@ -3,13 +3,15 @@ /** * @package Grav\Framework\Route * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Route; use Grav\Framework\Uri\UriFactory; +use InvalidArgumentException; +use function array_slice; /** * Implements Grav Route. @@ -20,19 +22,14 @@ class Route { /** @var string */ private $root = ''; - /** @var string */ private $language = ''; - /** @var string */ private $route = ''; - /** @var string */ private $extension = ''; - /** @var array */ private $gravParams = []; - /** @var array */ private $queryParams = []; @@ -40,7 +37,7 @@ class Route * You can use `RouteFactory` functions to create new `Route` objects. * * @param array $parts - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public function __construct(array $parts = []) { @@ -74,6 +71,14 @@ class Route return $this->root; } + /** + * @return string + */ + public function getLanguage() + { + return $this->language; + } + /** * @return string */ @@ -82,6 +87,25 @@ class Route return $this->language !== '' ? '/' . $this->language : ''; } + /** + * @param string|null $language + * @return string + */ + public function getBase(string $language = null): string + { + $parts = [$this->root]; + + if (null === $language) { + $language = $this->language; + } + + if ($language !== '') { + $parts[] = $language; + } + + return implode('/', $parts); + } + /** * @param int $offset * @param int|null $length @@ -114,7 +138,7 @@ class Route $parts = explode('/', $this->route); if ($offset !== 0 || $length !== null) { - $parts = \array_slice($parts, $offset, $length); + $parts = array_slice($parts, $offset, $length); } return $parts; @@ -183,26 +207,40 @@ class Route * Allow the ability to set the route to something else * * @param string $route - * @return $this + * @return Route */ public function withRoute($route) { - $this->route = $route; + $new = $this->copy(); + $new->route = $route; - return $this; + return $new; } /** * Allow the ability to set the root to something else * * @param string $root - * @return $this + * @return Route */ public function withRoot($root) { - $this->root = $root; + $new = $this->copy(); + $new->root = $root; - return $this; + return $new; + } + + /** + * @param string|null $language + * @return Route + */ + public function withLanguage($language) + { + $new = $this->copy(); + $new->language = $language ?? ''; + + return $new; } /** @@ -211,9 +249,10 @@ class Route */ public function withAddedPath($path) { - $this->route .= '/' . ltrim($path, '/'); + $new = $this->copy(); + $new->route .= '/' . ltrim($path, '/'); - return $this; + return $new; } /** @@ -222,9 +261,10 @@ class Route */ public function withExtension($extension) { - $this->extension = $extension; + $new = $this->copy(); + $new->extension = $extension; - return $this; + return $new; } /** @@ -247,23 +287,34 @@ class Route return $this->withParam('queryParams', $param, $value); } + /** + * @return Route + */ public function withoutParams() { return $this->withoutGravParams()->withoutQueryParams(); } + /** + * @return Route + */ public function withoutGravParams() { - $this->gravParams = []; + $new = $this->copy(); + $new->gravParams = []; - return $this; + return $new; } + /** + * @return Route + */ public function withoutQueryParams() { - $this->queryParams = []; + $new = $this->copy(); + $new->queryParams = []; - return $this; + return $new; } /** @@ -304,7 +355,7 @@ class Route * @param string $type * @param string $param * @param mixed $value - * @return static + * @return Route */ protected function withParam($type, $param, $value) { @@ -327,6 +378,9 @@ class Route return $new; } + /** + * @return Route + */ protected function copy() { return clone $this; @@ -364,6 +418,7 @@ class Route /** * @param array $parts + * @return void */ protected function initParts(array $parts) { @@ -373,9 +428,8 @@ class Route $this->language = $gravParts['language']; $this->route = $gravParts['route']; $this->extension = $gravParts['extension'] ?? ''; - $this->gravParams = $gravParts['params'] ?: []; - $this->queryParams = $parts['query_params'] ?: []; - + $this->gravParams = $gravParts['params'] ?? []; + $this->queryParams = $parts['query_params'] ?? []; } else { $this->root = RouteFactory::getRoot(); $this->language = RouteFactory::getLanguage(); diff --git a/system/src/Grav/Framework/Route/RouteFactory.php b/system/src/Grav/Framework/Route/RouteFactory.php index 6b3414a..68baf55 100644 --- a/system/src/Grav/Framework/Route/RouteFactory.php +++ b/system/src/Grav/Framework/Route/RouteFactory.php @@ -3,12 +3,16 @@ /** * @package Grav\Framework\Route * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Route; +use Grav\Common\Uri; +use function dirname; +use function strlen; + /** * Class RouteFactory * @package Grav\Framework\Route @@ -17,21 +21,52 @@ class RouteFactory { /** @var string */ private static $root = ''; - /** @var string */ private static $language = ''; - /** @var string */ private static $delimiter = ':'; - public static function createFromParts($parts) + /** + * @param array $parts + * @return Route + */ + public static function createFromParts(array $parts): Route { return new Route($parts); } - public static function createFromString($path) + /** + * @param Uri $uri + * @return Route + */ + public static function createFromLegacyUri(Uri $uri): Route + { + $parts = $uri->toArray(); + $parts += [ + 'grav' => [] + ]; + $path = $parts['path'] ?? ''; + $parts['grav'] += [ + 'root' => self::$root, + 'language' => self::$language, + 'route' => trim($path, '/'), + 'params' => $parts['params'] ?? [], + ]; + + return static::createFromParts($parts); + } + + /** + * @param string $path + * @return Route + */ + public static function createFromString(string $path): Route { $path = ltrim($path, '/'); + if (self::$language && mb_strpos($path, self::$language) === 0) { + $path = ltrim(mb_substr($path, mb_strlen(self::$language)), '/'); + } + $parts = [ 'path' => $path, 'query' => '', @@ -39,39 +74,58 @@ class RouteFactory 'grav' => [ 'root' => self::$root, 'language' => self::$language, - 'route' => $path, - 'params' => '' + 'route' => static::trimParams($path), + 'params' => static::getParams($path) ], ]; + return new Route($parts); } - public static function getRoot() + /** + * @return string + */ + public static function getRoot(): string { return self::$root; } - public static function setRoot($root) + /** + * @param string $root + */ + public static function setRoot($root): void { self::$root = rtrim($root, '/'); } - public static function getLanguage() + /** + * @return string + */ + public static function getLanguage(): string { return self::$language; } - public static function setLanguage($language) + /** + * @param string $language + */ + public static function setLanguage(string $language): void { self::$language = trim($language, '/'); } - public static function getParamValueDelimiter() + /** + * @return string + */ + public static function getParamValueDelimiter(): string { return self::$delimiter; } - public static function setParamValueDelimiter($delimiter) + /** + * @param string $delimiter + */ + public static function setParamValueDelimiter(string $delimiter): void { self::$delimiter = $delimiter ?: ':'; } @@ -80,7 +134,7 @@ class RouteFactory * @param array $params * @return string */ - public static function buildParams(array $params) + public static function buildParams(array $params): string { if (!$params) { return ''; @@ -101,7 +155,7 @@ class RouteFactory * @param bool $decode * @return string */ - public static function stripParams($path, $decode = false) + public static function stripParams(string $path, bool $decode = false): string { $pos = strpos($path, self::$delimiter); @@ -109,7 +163,7 @@ class RouteFactory return $path; } - $path = \dirname(substr($path, 0, $pos)); + $path = dirname(substr($path, 0, $pos)); if ($path === '.') { return ''; } @@ -121,18 +175,42 @@ class RouteFactory * @param string $path * @return array */ - public static function getParams($path) + public static function getParams(string $path): array { - $params = ltrim(substr($path, \strlen(static::stripParams($path))), '/'); + $params = ltrim(substr($path, strlen(static::stripParams($path))), '/'); return $params !== '' ? static::parseParams($params) : []; } + /** + * @param string $str + * @return string + */ + public static function trimParams(string $str): string + { + if ($str === '') { + return $str; + } + + $delimiter = self::$delimiter; + + /** @var array $params */ + $params = explode('/', $str); + $list = []; + foreach ($params as $param) { + if (mb_strpos($param, $delimiter) === false) { + $list[] = $param; + } + } + + return implode('/', $list); + } + /** * @param string $str * @return array */ - public static function parseParams($str) + public static function parseParams(string $str): array { if ($str === '') { return []; @@ -142,16 +220,17 @@ class RouteFactory /** @var array $params */ $params = explode('/', $str); + $list = []; foreach ($params as &$param) { /** @var array $parts */ $parts = explode($delimiter, $param, 2); if (isset($parts[1])) { $var = rawurldecode($parts[0]); $val = rawurldecode($parts[1]); - $param = [$var => $val]; + $list[$var] = $val; } } - return $params; + return $list; } } diff --git a/system/src/Grav/Framework/Session/Exceptions/SessionException.php b/system/src/Grav/Framework/Session/Exceptions/SessionException.php index 4caab82..5ad948b 100644 --- a/system/src/Grav/Framework/Session/Exceptions/SessionException.php +++ b/system/src/Grav/Framework/Session/Exceptions/SessionException.php @@ -3,12 +3,18 @@ /** * @package Grav\Framework\Session * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ + namespace Grav\Framework\Session\Exceptions; -class SessionException extends \RuntimeException -{ +use RuntimeException; +/** + * Class SessionException + * @package Grav\Framework\Session\Exceptions + */ +class SessionException extends RuntimeException +{ } diff --git a/system/src/Grav/Framework/Session/Messages.php b/system/src/Grav/Framework/Session/Messages.php new file mode 100644 index 0000000..5d854a2 --- /dev/null +++ b/system/src/Grav/Framework/Session/Messages.php @@ -0,0 +1,134 @@ + $message, 'scope' => $scope]; + + // don't add duplicates + if (!array_key_exists($key, $this->messages)) { + $this->messages[$key] = $item; + } + + return $this; + } + + /** + * Clear message queue. + * + * @param string|null $scope + * @return $this + */ + public function clear(string $scope = null): Messages + { + if ($scope === null) { + if ($this->messages !== []) { + $this->isCleared = true; + $this->messages = []; + } + } else { + foreach ($this->messages as $key => $message) { + if ($message['scope'] === $scope) { + $this->isCleared = true; + unset($this->messages[$key]); + } + } + } + + return $this; + } + + /** + * @return bool + */ + public function isCleared(): bool + { + return $this->isCleared; + } + + /** + * Fetch all messages. + * + * @param string|null $scope + * @return array + */ + public function all(string $scope = null): array + { + if ($scope === null) { + return array_values($this->messages); + } + + $messages = []; + foreach ($this->messages as $message) { + if ($message['scope'] === $scope) { + $messages[] = $message; + } + } + + return $messages; + } + + /** + * Fetch and clear message queue. + * + * @param string|null $scope + * @return array + */ + public function fetch(string $scope = null): array + { + $messages = $this->all($scope); + $this->clear($scope); + + return $messages; + } + + /** + * @return array + */ + public function __serialize(): array + { + return [ + 'messages' => $this->messages + ]; + } + + /** + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $this->messages = $data['messages']; + } +} diff --git a/system/src/Grav/Framework/Session/Session.php b/system/src/Grav/Framework/Session/Session.php index 4fd2e97..ddab08d 100644 --- a/system/src/Grav/Framework/Session/Session.php +++ b/system/src/Grav/Framework/Session/Session.php @@ -3,14 +3,22 @@ /** * @package Grav\Framework\Session * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Session; +use ArrayIterator; +use Exception; +use Grav\Common\Debugger; +use Grav\Common\Grav; use Grav\Common\User\Interfaces\UserInterface; use Grav\Framework\Session\Exceptions\SessionException; +use RuntimeException; +use function is_array; +use function is_bool; +use function is_string; /** * Class Session @@ -20,7 +28,6 @@ class Session implements SessionInterface { /** @var array */ protected $options = []; - /** @var bool */ protected $started = false; @@ -33,12 +40,17 @@ class Session implements SessionInterface public static function getInstance() { if (null === self::$instance) { - throw new \RuntimeException("Session hasn't been initialized.", 500); + throw new RuntimeException("Session hasn't been initialized.", 500); } return self::$instance; } + /** + * Session constructor. + * + * @param array $options + */ public function __construct(array $options = []) { // Session is a singleton. @@ -49,7 +61,7 @@ class Session implements SessionInterface } if (null !== self::$instance) { - throw new \RuntimeException('Session has already been initialized.', 500); + throw new RuntimeException('Session has already been initialized.', 500); } // Destroy any existing sessions started with session.auto_start @@ -79,7 +91,7 @@ class Session implements SessionInterface */ public function getId() { - return session_id(); + return session_id() ?: null; } /** @@ -97,7 +109,7 @@ class Session implements SessionInterface */ public function getName() { - return session_name(); + return session_name() ?: null; } /** @@ -135,6 +147,7 @@ class Session implements SessionInterface 'use_strict_mode' => true, 'use_cookies' => true, 'use_only_cookies' => true, + 'cookie_samesite' => true, 'referer_check' => true, 'cache_limiter' => true, 'cache_expire' => true, @@ -153,7 +166,7 @@ class Session implements SessionInterface ]; foreach ($options as $key => $value) { - if (\is_array($value)) { + if (is_array($value)) { // Allow nested options. foreach ($value as $key2 => $value2) { $ckey = "{$key}.{$key2}"; @@ -190,18 +203,55 @@ class Session implements SessionInterface $options['read_and_close'] = '1'; } - $success = @session_start($options); - $user = $success ? $this->__get('user') : null; - if (!$success) { - $last = error_get_last(); - $error = $last ? $last['message'] : 'Unknown error'; + try { + $success = @session_start($options); + if (!$success) { + $last = error_get_last(); + $error = $last ? $last['message'] : 'Unknown error'; - throw new SessionException('Failed to start session: ' . $error, 500); + throw new RuntimeException($error); + } + + // Handle changing session id. + if ($this->__isset('session_destroyed')) { + $newId = $this->__get('session_new_id'); + if (!$newId || $this->__get('session_destroyed') < time() - 300) { + // Should not happen usually. This could be attack or due to unstable network. Destroy this session. + $this->invalidate(); + + throw new RuntimeException('Obsolete session access.', 500); + } + + // Not fully expired yet. Could be lost cookie by unstable network. Start session with new session id. + session_write_close(); + + // Start session with new session id. + $useStrictMode = $options['use_strict_mode'] ?? 0; + if ($useStrictMode) { + ini_set('session.use_strict_mode', '0'); + } + session_id($newId); + if ($useStrictMode) { + ini_set('session.use_strict_mode', '1'); + } + + $success = @session_start($options); + if (!$success) { + $last = error_get_last(); + $error = $last ? $last['message'] : 'Unknown error'; + + throw new RuntimeException($error); + } + } + } catch (Exception $e) { + throw new SessionException('Failed to start session: ' . $e->getMessage(), 500); } $this->started = true; + $this->onSessionStart(); - if ($user && (!$user instanceof UserInterface || !$user->isValid())) { + $user = $this->__get('user'); + if ($user && (!$user instanceof UserInterface || (method_exists($user, 'isValid') && !$user->isValid()))) { $this->invalidate(); throw new SessionException('Invalid User object, session destroyed.', 500); @@ -209,37 +259,104 @@ class Session implements SessionInterface // Extend the lifetime of the session. if ($sessionExists) { - $params = session_get_cookie_params(); - - setcookie( - $sessionName, - session_id(), - time() + $params['lifetime'], - $params['path'], - $params['domain'], - $params['secure'], - $params['httponly'] - ); + $this->setCookie(); } return $this; } + /** + * Regenerate session id but keep the current session information. + * + * Session id must be regenerated on login, logout or after long time has been passed. + * + * @return $this + * @since 1.7 + */ + public function regenerateId() + { + if (!$this->isSessionStarted()) { + return $this; + } + + // TODO: session_create_id() segfaults in PHP 7.3 (PHP bug #73461), remove phpstan rule when removing this one. + if (PHP_VERSION_ID < 70400) { + $newId = 0; + } else { + // Session id creation may fail with some session storages. + $newId = @session_create_id() ?: 0; + } + + // Set destroyed timestamp for the old session as well as pointer to the new id. + $this->__set('session_destroyed', time()); + $this->__set('session_new_id', $newId); + + // Keep the old session alive to avoid lost sessions by unstable network. + if (!$newId) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addMessage('Session fixation lost session detection is turned of due to server limitations.', 'warning'); + + session_regenerate_id(false); + } else { + session_write_close(); + + // Start session with new session id. + $useStrictMode = $this->options['use_strict_mode'] ?? 0; + if ($useStrictMode) { + ini_set('session.use_strict_mode', '0'); + } + session_id($newId); + if ($useStrictMode) { + ini_set('session.use_strict_mode', '1'); + } + + $this->removeCookie(); + + $success = @session_start($this->options); + if (!$success) { + $last = error_get_last(); + $error = $last ? $last['message'] : 'Unknown error'; + + throw new RuntimeException($error); + } + + $this->onSessionStart(); + } + + // New session does not have these. + $this->__unset('session_destroyed'); + $this->__unset('session_new_id'); + + return $this; + } + /** * @inheritdoc */ public function invalidate() { - $params = session_get_cookie_params(); - setcookie( - session_name(), - '', - time() - 42000, - $params['path'], - $params['domain'], - $params['secure'], - $params['httponly'] - ); + $name = $this->getName(); + if (null !== $name) { + $params = session_get_cookie_params(); + + $cookie_options = array ( + 'expires' => time() - 42000, + 'path' => $params['path'], + 'domain' => $params['domain'], + 'secure' => $params['secure'], + 'httponly' => $params['httponly'], + 'samesite' => $params['samesite'] + ); + + $this->removeCookie(); + + setcookie( + session_name(), + '', + $cookie_options + ); + } if ($this->isSessionStarted()) { session_unset(); @@ -288,7 +405,7 @@ class Session implements SessionInterface */ public function getIterator() { - return new \ArrayIterator($_SESSION); + return new ArrayIterator($_SESSION); } /** @@ -341,14 +458,74 @@ class Session implements SessionInterface return \PHP_SAPI !== 'cli' ? \PHP_SESSION_ACTIVE === session_status() : false; } + protected function onSessionStart(): void + { + } + + /** + * @return void + */ + protected function setCookie(): void + { + $params = session_get_cookie_params(); + + $cookie_options = array ( + 'expires' => time() + $params['lifetime'], + 'path' => $params['path'], + 'domain' => $params['domain'], + 'secure' => $params['secure'], + 'httponly' => $params['httponly'], + 'samesite' => $params['samesite'] + ); + + $this->removeCookie(); + + setcookie( + session_name(), + session_id(), + $cookie_options + ); + } + + protected function removeCookie(): void + { + $search = " {$this->getName()}="; + $cookies = []; + $found = false; + + foreach (headers_list() as $header) { + // Identify cookie headers + if (strpos($header, 'Set-Cookie:') === 0) { + // Add all but session cookie(s). + if (!str_contains($header, $search)) { + $cookies[] = $header; + } else { + $found = true; + } + } + } + + // Nothing to do. + if (false === $found) { + return; + } + + // Remove all cookies and put back all but session cookie. + header_remove('Set-Cookie'); + foreach($cookies as $cookie) { + header($cookie, false); + } + } + /** * @param string $key * @param mixed $value + * @return void */ protected function setOption($key, $value) { - if (!\is_string($value)) { - if (\is_bool($value)) { + if (!is_string($value)) { + if (is_bool($value)) { $value = $value ? '1' : '0'; } else { $value = (string)$value; diff --git a/system/src/Grav/Framework/Session/SessionInterface.php b/system/src/Grav/Framework/Session/SessionInterface.php index 75df0e9..43ca40f 100644 --- a/system/src/Grav/Framework/Session/SessionInterface.php +++ b/system/src/Grav/Framework/Session/SessionInterface.php @@ -3,23 +3,27 @@ /** * @package Grav\Framework\Session * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Session; +use ArrayIterator; +use IteratorAggregate; +use RuntimeException; + /** * Class Session * @package Grav\Framework\Session */ -interface SessionInterface extends \IteratorAggregate +interface SessionInterface extends IteratorAggregate { /** * Get current session instance. * * @return Session - * @throws \RuntimeException + * @throws RuntimeException */ public static function getInstance(); @@ -34,7 +38,6 @@ interface SessionInterface extends \IteratorAggregate * Set session ID * * @param string $id Session ID - * * @return $this */ public function setId($id); @@ -50,7 +53,6 @@ interface SessionInterface extends \IteratorAggregate * Set session name * * @param string $name - * * @return $this */ public function setName($name); @@ -59,7 +61,7 @@ interface SessionInterface extends \IteratorAggregate * Sets session.* ini variables. * * @param array $options - * + * @return void * @see http://php.net/session.configuration */ public function setOptions(array $options); @@ -69,7 +71,7 @@ interface SessionInterface extends \IteratorAggregate * * @param bool $readonly * @return $this - * @throws \RuntimeException + * @throws RuntimeException */ public function start($readonly = false); @@ -104,7 +106,7 @@ interface SessionInterface extends \IteratorAggregate /** * Retrieve an external iterator * - * @return \ArrayIterator Return an ArrayIterator of $_SESSION + * @return ArrayIterator Return an ArrayIterator of $_SESSION */ public function getIterator(); @@ -136,6 +138,7 @@ interface SessionInterface extends \IteratorAggregate * * @param string $name * @param mixed $value + * @return void */ public function __set($name, $value); @@ -143,6 +146,7 @@ interface SessionInterface extends \IteratorAggregate * Removes session variable. * * @param string $name + * @return void */ public function __unset($name); } diff --git a/system/src/Grav/Framework/Uri/Uri.php b/system/src/Grav/Framework/Uri/Uri.php index 124de6c..c43496e 100644 --- a/system/src/Grav/Framework/Uri/Uri.php +++ b/system/src/Grav/Framework/Uri/Uri.php @@ -3,7 +3,7 @@ /** * @package Grav\Framework\Uri * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ @@ -11,6 +11,7 @@ namespace Grav\Framework\Uri; use Grav\Framework\Psr7\AbstractUri; use GuzzleHttp\Psr7\Uri as GuzzleUri; +use InvalidArgumentException; use Psr\Http\Message\UriInterface; /** @@ -27,7 +28,8 @@ class Uri extends AbstractUri * You can use `UriFactory` functions to create new `Uri` objects. * * @param array $parts - * @throws \InvalidArgumentException + * @return void + * @throws InvalidArgumentException */ public function __construct(array $parts = []) { diff --git a/system/src/Grav/Framework/Uri/UriFactory.php b/system/src/Grav/Framework/Uri/UriFactory.php index e5cb1d3..f7724b7 100644 --- a/system/src/Grav/Framework/Uri/UriFactory.php +++ b/system/src/Grav/Framework/Uri/UriFactory.php @@ -3,12 +3,15 @@ /** * @package Grav\Framework\Uri * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Uri; +use InvalidArgumentException; +use function is_string; + /** * Class Uri * @package Grav\Framework\Uri @@ -18,7 +21,7 @@ class UriFactory /** * @param array $env * @return Uri - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public static function createFromEnvironment(array $env) { @@ -28,7 +31,7 @@ class UriFactory /** * @param string $uri * @return Uri - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public static function createFromString($uri) { @@ -40,7 +43,7 @@ class UriFactory * * @param array $parts * @return Uri - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public static function createFromParts(array $parts) { @@ -50,7 +53,7 @@ class UriFactory /** * @param array $env * @return array - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public static function parseUrlFromEnvironment(array $env) { @@ -112,23 +115,25 @@ class UriFactory * * @param string $url * @return array - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public static function parseUrl($url) { - if (!\is_string($url)) { - throw new \InvalidArgumentException('URL must be a string'); + if (!is_string($url)) { + throw new InvalidArgumentException('URL must be a string'); } $encodedUrl = preg_replace_callback( '%[^:/@?&=#]+%u', - function ($matches) { return rawurlencode($matches[0]); }, + function ($matches) { + return rawurlencode($matches[0]); + }, $url ); - $parts = \is_string($encodedUrl) ? parse_url($encodedUrl) : false; + $parts = is_string($encodedUrl) ? parse_url($encodedUrl) : false; if ($parts === false) { - throw new \InvalidArgumentException("Malformed URL: {$url}"); + throw new InvalidArgumentException("Malformed URL: {$url}"); } return $parts; diff --git a/system/src/Grav/Framework/Uri/UriPartsFilter.php b/system/src/Grav/Framework/Uri/UriPartsFilter.php index 51a971a..71a5135 100644 --- a/system/src/Grav/Framework/Uri/UriPartsFilter.php +++ b/system/src/Grav/Framework/Uri/UriPartsFilter.php @@ -3,12 +3,16 @@ /** * @package Grav\Framework\Uri * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Framework\Uri; +use InvalidArgumentException; +use function is_int; +use function is_string; + /** * Class Uri * @package Grav\Framework\Uri @@ -20,12 +24,12 @@ class UriPartsFilter /** * @param string $scheme * @return string - * @throws \InvalidArgumentException If the scheme is invalid. + * @throws InvalidArgumentException If the scheme is invalid. */ public static function filterScheme($scheme) { - if (!\is_string($scheme)) { - throw new \InvalidArgumentException('Uri scheme must be a string'); + if (!is_string($scheme)) { + throw new InvalidArgumentException('Uri scheme must be a string'); } return strtolower($scheme); @@ -36,12 +40,12 @@ class UriPartsFilter * * @param string $info The raw user or password. * @return string The percent-encoded user or password string. - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public static function filterUserInfo($info) { - if (!\is_string($info)) { - throw new \InvalidArgumentException('Uri user info must be a string'); + if (!is_string($info)) { + throw new InvalidArgumentException('Uri user info must be a string'); } return preg_replace_callback( @@ -56,18 +60,18 @@ class UriPartsFilter /** * @param string $host * @return string - * @throws \InvalidArgumentException If the host is invalid. + * @throws InvalidArgumentException If the host is invalid. */ public static function filterHost($host) { - if (!\is_string($host)) { - throw new \InvalidArgumentException('Uri host must be a string'); + if (!is_string($host)) { + throw new InvalidArgumentException('Uri host must be a string'); } if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { $host = '[' . $host . ']'; } elseif ($host && preg_match(static::HOSTNAME_REGEX, $host) !== 1) { - throw new \InvalidArgumentException('Uri host name validation failed'); + throw new InvalidArgumentException('Uri host name validation failed'); } return strtolower($host); @@ -80,15 +84,15 @@ class UriPartsFilter * * @param int|null $port * @return int|null - * @throws \InvalidArgumentException If the port is invalid. + * @throws InvalidArgumentException If the port is invalid. */ public static function filterPort($port = null) { - if (null === $port || (\is_int($port) && ($port >= 0 && $port <= 65535))) { + if (null === $port || (is_int($port) && ($port >= 0 && $port <= 65535))) { return $port; } - throw new \InvalidArgumentException('Uri port must be null or an integer between 0 and 65535'); + throw new InvalidArgumentException('Uri port must be null or an integer between 0 and 65535'); } /** @@ -99,13 +103,13 @@ class UriPartsFilter * * @param string $path The raw uri path. * @return string The RFC 3986 percent-encoded uri path. - * @throws \InvalidArgumentException If the path is invalid. + * @throws InvalidArgumentException If the path is invalid. * @link http://www.faqs.org/rfcs/rfc3986.html */ public static function filterPath($path) { - if (!\is_string($path)) { - throw new \InvalidArgumentException('Uri path must be a string'); + if (!is_string($path)) { + throw new InvalidArgumentException('Uri path must be a string'); } return preg_replace_callback( @@ -122,12 +126,12 @@ class UriPartsFilter * * @param string $query The raw uri query string. * @return string The percent-encoded query string. - * @throws \InvalidArgumentException If the query is invalid. + * @throws InvalidArgumentException If the query is invalid. */ public static function filterQueryOrFragment($query) { - if (!\is_string($query)) { - throw new \InvalidArgumentException('Uri query string and fragment must be a string'); + if (!is_string($query)) { + throw new InvalidArgumentException('Uri query string and fragment must be a string'); } return preg_replace_callback( diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index 5345ce3..4df9dcd 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -3,40 +3,49 @@ /** * @package Grav\Installer * - * @copyright Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved. + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. * @license MIT License; see LICENSE file for details. */ namespace Grav\Installer; use Composer\Autoload\ClassLoader; +use Exception; use Grav\Common\Cache; use Grav\Common\GPM\Installer; use Grav\Common\Grav; use Grav\Common\Plugins; +use RuntimeException; +use function class_exists; +use function dirname; +use function function_exists; +use function is_string; /** * Grav installer. * * NOTE: This class can be initialized during upgrade from an older version of Grav. Make sure it runs there! */ -class Install +final class Install { - private $requires = [ + /** @var int Installer version. */ + public $version = 1; + + /** @var array */ + public $requires = [ 'php' => [ 'name' => 'PHP', 'versions' => [ - '7.3' => '7.3.1', - '7.2' => '7.2.0', - '7.1' => '7.1.3', - '' => '7.2.14' + '7.4' => '7.4.0', + '7.3' => '7.3.6', + '' => '7.4.12' ] ], 'grav' => [ 'name' => 'Grav', 'versions' => [ - '1.5' => '1.5.0', - '' => '1.5.7' + '1.6' => '1.6.0', + '' => '1.6.28' ] ], 'plugins' => [ @@ -44,38 +53,42 @@ class Install 'name' => 'Admin', 'optional' => true, 'versions' => [ - '1.8' => '1.8.0', - '' => '1.8.16' + '1.9' => '1.9.0', + '' => '1.9.13' ] ], 'email' => [ 'name' => 'Email', 'optional' => true, 'versions' => [ - '2.7' => '2.7.0', - '' => '2.7.2' + '3.0' => '3.0.0', + '' => '3.0.10' ] ], 'form' => [ 'name' => 'Form', 'optional' => true, 'versions' => [ - '2.16' => '2.16.0', - '' => '2.16.4' + '4.1' => '4.1.0', + '4.0' => '4.0.0', + '3.0' => '3.0.0', + '' => '4.1.2' ] ], 'login' => [ 'name' => 'Login', 'optional' => true, 'versions' => [ - '2.8' => '2.8.0', - '' => '2.8.3' + '3.3' => '3.3.0', + '3.0' => '3.0.0', + '' => '3.3.6' ] ], ] ]; - private $ignores = [ + /** @var array */ + public $ignores = [ 'backup', 'cache', 'images', @@ -86,15 +99,30 @@ class Install 'robots.txt' ]; + /** @var array */ private $classMap = [ - // 'Grav\\Installer\\Test' => __DIR__ . '/Test.php', + InstallException::class => __DIR__ . '/InstallException.php', + Versions::class => __DIR__ . '/Versions.php', + VersionUpdate::class => __DIR__ . '/VersionUpdate.php', + VersionUpdater::class => __DIR__ . '/VersionUpdater.php', + YamlUpdater::class => __DIR__ . '/YamlUpdater.php', ]; + /** @var string|null */ private $zip; + + /** @var string|null */ private $location; + /** @var VersionUpdater */ + private $updater; + + /** @var static */ private static $instance; + /** + * @return static + */ public static function instance() { if (null === self::$instance) { @@ -108,13 +136,21 @@ class Install { } - public function setZip(string $zip) + /** + * @param string|null $zip + * @return $this + */ + public function setZip(?string $zip) { $this->zip = $zip; return $this; } + /** + * @param string|null $zip + * @return void + */ public function __invoke(?string $zip) { $this->zip = $zip; @@ -127,7 +163,19 @@ class Install $error[] = "{$req['title']} >= v{$req['minimum']} required, you have v{$req['installed']}"; } - throw new \RuntimeException(implode("
    \n", $error)); + $errors = implode("
    \n", $error); + if (\defined('GRAV_CLI') && GRAV_CLI) { + $errors = "\n\n" . strip_tags($errors) . "\n\n"; + $errors .= <<prepare(); @@ -144,7 +192,7 @@ class Install { $results = []; - $this->checkVersion($results, 'php','php', $this->requires['php'], PHP_VERSION); + $this->checkVersion($results, 'php', 'php', $this->requires['php'], PHP_VERSION); $this->checkVersion($results, 'grav', 'grav', $this->requires['grav'], GRAV_VERSION); $this->checkPlugins($results, $this->requires['plugins']); @@ -152,54 +200,66 @@ class Install } /** - * @throws \RuntimeException + * @return void + * @throws RuntimeException */ public function prepare(): void { // Locate the new Grav update and the target site from the filesystem. - $location = dirname(realpath(__DIR__), 4); - $target = dirname(realpath(GRAV_ROOT . '/index.php')); - if ($location === $target) { + $location = realpath(__DIR__); + $target = realpath(GRAV_ROOT . '/index.php'); + + if (!$location) { + throw new RuntimeException('Internal Error', 500); + } + + if ($target && dirname($location, 4) === dirname($target)) { // We cannot copy files into themselves, abort! - throw new \RuntimeException('Grav has already been installed here!', 400); + throw new RuntimeException('Grav has already been installed here!', 400); } - // Make sure that none of the Grav\Installer classes have been loaded, otherwise installation may fail! + // Load the installer classes. foreach ($this->classMap as $class_name => $path) { - if (\class_exists($class_name, false)) { - throw new \RuntimeException(sprintf('Cannot update Grav, class %s has already been loaded!', $class_name), 500); + // Make sure that none of the Grav\Installer classes have been loaded, otherwise installation may fail! + if (class_exists($class_name, false)) { + throw new RuntimeException(sprintf('Cannot update Grav, class %s has already been loaded!', $class_name), 500); } + + require $path; } - $grav = Grav::instance(); + $this->legacySupport(); - /** @var ClassLoader $loader */ - $loader = $grav['loader']; + $this->location = dirname($location, 4); - // Override Grav\Installer classes by using this version of Grav. - $loader->addClassMap($this->classMap); + $versions = Versions::instance(USER_DIR . 'config/versions.yaml'); + $this->updater = new VersionUpdater('core/grav', __DIR__ . '/updates', $this->getVersion(), $versions); - $this->location = $location; + $this->updater->preflight(); } /** - * @throws \RuntimeException + * @return void + * @throws RuntimeException */ public function install(): void { if (!$this->location) { - throw new \RuntimeException('Oops, installer was run without prepare()!', 500); + throw new RuntimeException('Oops, installer was run without prepare()!', 500); } try { + // Update user/config/version.yaml before copying the files to avoid frontend from setting the version schema. + $this->updater->install(); + Installer::install( - $this->zip, + $this->zip ?? '', GRAV_ROOT, ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores], $this->location, !($this->zip && is_file($this->zip)) ); - } catch (\Exception $e) { + } catch (Exception $e) { Installer::setError($e->getMessage()); } @@ -208,16 +268,26 @@ class Install $success = !(is_string($errorCode) || ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR))); if (!$success) { - throw new \RuntimeException(Installer::lastErrorMsg()); + throw new RuntimeException(Installer::lastErrorMsg()); } } /** - * @throws \RuntimeException + * @return void + * @throws RuntimeException */ public function finalize(): void { - Cache::clearCache(); + // Finalize can be run without installing Grav first. + if (!$this->updater) { + $versions = Versions::instance(USER_DIR . 'config/versions.yaml'); + $this->updater = new VersionUpdater('core/grav', __DIR__ . '/updates', GRAV_VERSION, $versions); + $this->updater->install(); + } + + $this->updater->postflight(); + + Cache::clearCache('all'); clearstatcache(); if (function_exists('opcache_reset')) { @@ -225,6 +295,14 @@ class Install } } + /** + * @param array $results + * @param string $type + * @param string $name + * @param array $check + * @param string|null $version + * @return void + */ protected function checkVersion(array &$results, $type, $name, array $check, $version): void { if (null === $version && !empty($check['optional'])) { @@ -234,11 +312,11 @@ class Install $major = $minor = 0; $versions = $check['versions'] ?? []; foreach ($versions as $major => $minor) { - if (!$major || version_compare($version, $major, '<')) { + if (!$major || version_compare($version ?? '0', $major, '<')) { continue; } - if (version_compare($version, $minor, '>=')) { + if (version_compare($version ?? '0', $minor, '>=')) { return; } @@ -265,9 +343,14 @@ class Install ]; } + /** + * @param array $results + * @param array $plugins + * @return void + */ protected function checkPlugins(array &$results, array $plugins): void { - if (!\class_exists('Plugins')) { + if (!class_exists('Plugins')) { return; } @@ -284,4 +367,26 @@ class Install $this->checkVersion($results, 'plugin', $name, $check, $version); } } -} \ No newline at end of file + + /** + * @return string + */ + protected function getVersion(): string + { + $definesFile = "{$this->location}/system/defines.php"; + $content = file_get_contents($definesFile); + if (false === $content) { + return ''; + } + + preg_match("/define\('GRAV_VERSION', '([^']+)'\);/mu", $content, $matches); + + return $matches[1] ?? ''; + } + + protected function legacySupport(): void + { + // Support install for Grav 1.6.0 - 1.6.20 by loading the original class from the older version of Grav. + class_exists(\Grav\Console\Cli\CacheCommand::class, true); + } +} diff --git a/system/src/Grav/Installer/InstallException.php b/system/src/Grav/Installer/InstallException.php new file mode 100644 index 0000000..145b7c9 --- /dev/null +++ b/system/src/Grav/Installer/InstallException.php @@ -0,0 +1,29 @@ +getCode(), $previous); + } +} diff --git a/system/src/Grav/Installer/VersionUpdate.php b/system/src/Grav/Installer/VersionUpdate.php new file mode 100644 index 0000000..faaf2a2 --- /dev/null +++ b/system/src/Grav/Installer/VersionUpdate.php @@ -0,0 +1,82 @@ +revision = $name; + [$this->version, $this->date, $this->patch] = explode('_', $name); + $this->updater = $updater; + $this->methods = require $file; + } + + public function getRevision(): string + { + return $this->revision; + } + + public function getVersion(): string + { + return $this->version; + } + + public function getDate(): string + { + return $this->date; + } + + public function getPatch(): string + { + return $this->date; + } + + public function getUpdater(): VersionUpdater + { + return $this->updater; + } + + /** + * Run right before installation. + */ + public function preflight(VersionUpdater $updater): void + { + $method = $this->methods['preflight'] ?? null; + if ($method instanceof Closure) { + $method->call($this); + } + } + + /** + * Runs right after installation. + */ + public function postflight(VersionUpdater $updater): void + { + $method = $this->methods['postflight'] ?? null; + if ($method instanceof Closure) { + $method->call($this); + } + } +} diff --git a/system/src/Grav/Installer/VersionUpdater.php b/system/src/Grav/Installer/VersionUpdater.php new file mode 100644 index 0000000..75a3b04 --- /dev/null +++ b/system/src/Grav/Installer/VersionUpdater.php @@ -0,0 +1,133 @@ +name = $name; + $this->path = $path; + $this->version = $version; + $this->versions = $versions; + + $this->loadUpdates(); + } + + /** + * Pre-installation method. + */ + public function preflight(): void + { + foreach ($this->updates as $revision => $update) { + $update->preflight($this); + } + } + + /** + * Install method. + */ + public function install(): void + { + $versions = $this->getVersions(); + $versions->updateVersion($this->name, $this->version); + $versions->save(); + } + + /** + * Post-installation method. + */ + public function postflight(): void + { + $versions = $this->getVersions(); + + foreach ($this->updates as $revision => $update) { + $update->postflight($this); + + $versions->setSchema($this->name, $revision); + $versions->save(); + } + } + + /** + * @return Versions + */ + public function getVersions(): Versions + { + return $this->versions; + } + + /** + * @param string|null $name + * @return string|null + */ + public function getExtensionVersion(string $name = null): ?string + { + return $this->versions->getVersion($name ?? $this->name); + } + + /** + * @param string|null $name + * @return string|null + */ + public function getExtensionSchema(string $name = null): ?string + { + return $this->versions->getSchema($name ?? $this->name); + } + + /** + * @param string|null $name + * @return array + */ + public function getExtensionHistory(string $name = null): array + { + return $this->versions->getHistory($name ?? $this->name); + } + + protected function loadUpdates(): void + { + $this->updates = []; + + $schema = $this->getExtensionSchema(); + $iterator = new DirectoryIterator($this->path); + foreach ($iterator as $item) { + if (!$item->isFile() || $item->getExtension() !== 'php') { + continue; + } + + $revision = $item->getBasename('.php'); + if (!$schema || version_compare($revision, $schema, '>')) { + $realPath = $item->getRealPath(); + if ($realPath) { + $this->updates[$revision] = new VersionUpdate($realPath, $this); + } + } + } + + uksort($this->updates, 'version_compare'); + } +} diff --git a/system/src/Grav/Installer/Versions.php b/system/src/Grav/Installer/Versions.php new file mode 100644 index 0000000..03f3b0b --- /dev/null +++ b/system/src/Grav/Installer/Versions.php @@ -0,0 +1,329 @@ +updated) { + return false; + } + + file_put_contents($this->filename, Yaml::dump($this->items, 4, 2)); + + $this->updated = false; + + return true; + } + + /** + * @return array + */ + public function getAll(): array + { + return $this->items; + } + + /** + * @return array|null + */ + public function getGrav(): ?array + { + return $this->get('core/grav'); + } + + /** + * @return array + */ + public function getPlugins(): array + { + return $this->get('plugins', []); + } + + /** + * @param string $name + * @return array|null + */ + public function getPlugin(string $name): ?array + { + return $this->get("plugins/{$name}"); + } + + /** + * @return array + */ + public function getThemes(): array + { + return $this->get('themes', []); + } + + /** + * @param string $name + * @return array|null + */ + public function getTheme(string $name): ?array + { + return $this->get("themes/{$name}"); + } + + /** + * @param string $extension + * @return array|null + */ + public function getExtension(string $extension): ?array + { + return $this->get($extension); + } + + /** + * @param string $extension + * @param array|null $value + */ + public function setExtension(string $extension, ?array $value): void + { + if (null !== $value) { + $this->set($extension, $value); + } else { + $this->undef($extension); + } + } + + /** + * @param string $extension + * @return string|null + */ + public function getVersion(string $extension): ?string + { + $version = $this->get("{$extension}/version", null); + + return is_string($version) ? $version : null; + } + + /** + * @param string $extension + * @param string|null $version + */ + public function setVersion(string $extension, ?string $version): void + { + $this->updateHistory($extension, $version); + } + + /** + * NOTE: Updates also history. + * + * @param string $extension + * @param string|null $version + */ + public function updateVersion(string $extension, ?string $version): void + { + $this->set("{$extension}/version", $version); + $this->updateHistory($extension, $version); + } + + /** + * @param string $extension + * @return string|null + */ + public function getSchema(string $extension): ?string + { + $version = $this->get("{$extension}/schema", null); + + return is_string($version) ? $version : null; + } + + /** + * @param string $extension + * @param string|null $schema + */ + public function setSchema(string $extension, ?string $schema): void + { + if (null !== $schema) { + $this->set("{$extension}/schema", $schema); + } else { + $this->undef("{$extension}/schema"); + } + } + + /** + * @param string $extension + * @return array + */ + public function getHistory(string $extension): array + { + $name = "{$extension}/history"; + $history = $this->get($name, []); + + // Fix for broken Grav 1.6 history + if ($extension === 'grav') { + $history = $this->fixHistory($history); + } + + return $history; + } + + /** + * @param string $extension + * @param string|null $version + */ + public function updateHistory(string $extension, ?string $version): void + { + $name = "{$extension}/history"; + $history = $this->getHistory($extension); + $history[] = ['version' => $version, 'date' => gmdate('Y-m-d H:i:s')]; + $this->set($name, $history); + } + + /** + * Clears extension history. Useful when creating skeletons. + * + * @param string $extension + */ + public function removeHistory(string $extension): void + { + $this->undef("{$extension}/history"); + } + + /** + * @param array $history + * @return array + */ + private function fixHistory(array $history): array + { + if (isset($history['version'], $history['date'])) { + $fix = [['version' => $history['version'], 'date' => $history['date']]]; + unset($history['version'], $history['date']); + $history = array_merge($fix, $history); + } + + return $history; + } + + /** + * Get value by using dot notation for nested arrays/objects. + * + * @param string $name Slash separated path to the requested value. + * @param mixed $default Default value (or null). + * @return mixed Value. + */ + private function get(string $name, $default = null) + { + $path = explode('/', $name); + $current = $this->items; + + foreach ($path as $field) { + if (is_array($current) && isset($current[$field])) { + $current = $current[$field]; + } else { + return $default; + } + } + + return $current; + } + + /** + * Set value by using dot notation for nested arrays/objects. + * + * @param string $name Slash separated path to the requested value. + * @param mixed $value New value. + */ + private function set(string $name, $value): void + { + $path = explode('/', $name); + $current = &$this->items; + + foreach ($path as $field) { + // Handle arrays and scalars. + if (!is_array($current)) { + $current = [$field => []]; + } elseif (!isset($current[$field])) { + $current[$field] = []; + } + $current = &$current[$field]; + } + + $current = $value; + $this->updated = true; + } + + /** + * Unset value by using dot notation for nested arrays/objects. + * + * @param string $name Dot separated path to the requested value. + */ + private function undef(string $name): void + { + $path = $name !== '' ? explode('/', $name) : []; + if (!$path) { + return; + } + + $var = array_pop($path); + $current = &$this->items; + + foreach ($path as $field) { + if (!is_array($current) || !isset($current[$field])) { + return; + } + $current = &$current[$field]; + } + + unset($current[$var]); + $this->updated = true; + } + + private function __construct(string $filename) + { + $this->filename = $filename; + $content = is_file($filename) ? file_get_contents($filename) : null; + if (false === $content) { + throw new \RuntimeException('Versions file cannot be read'); + } + $this->items = $content ? Yaml::parse($content) : []; + } +} diff --git a/system/src/Grav/Installer/YamlUpdater.php b/system/src/Grav/Installer/YamlUpdater.php new file mode 100644 index 0000000..9af2055 --- /dev/null +++ b/system/src/Grav/Installer/YamlUpdater.php @@ -0,0 +1,430 @@ +updated) { + return false; + } + + try { + if (!$this->isHandWritten()) { + $yaml = Yaml::dump($this->items, 5, 2); + } else { + $yaml = implode("\n", $this->lines); + + $items = Yaml::parse($yaml); + if ($items !== $this->items) { + throw new \RuntimeException('Failed saving the content'); + } + } + + file_put_contents($this->filename, $yaml); + + } catch (\Exception $e) { + throw new \RuntimeException('Failed to update ' . basename($this->filename) . ': ' . $e->getMessage()); + } + + return true; + } + + /** + * @return bool + */ + public function isHandWritten(): bool + { + return !empty($this->comments); + } + + /** + * @return array + */ + public function getComments(): array + { + $comments = []; + foreach ($this->lines as $i => $line) { + if ($this->isLineEmpty($line)) { + $comments[$i+1] = $line; + } elseif ($comment = $this->getInlineComment($line)) { + $comments[$i+1] = $comment; + } + } + + return $comments; + } + + /** + * @param string $variable + * @param mixed $value + */ + public function define(string $variable, $value): void + { + // If variable has already value, we're good. + if ($this->get($variable) !== null) { + return; + } + + // If one of the parents isn't array, we're good, too. + if (!$this->canDefine($variable)) { + return; + } + + $this->set($variable, $value); + if (!$this->isHandWritten()) { + return; + } + + $parts = explode('.', $variable); + + $lineNos = $this->findPath($this->lines, $parts); + $count = count($lineNos); + $last = array_key_last($lineNos); + + $value = explode("\n", trim(Yaml::dump([$last => $this->get(implode('.', array_keys($lineNos)))], max(0, 5-$count), 2))); + $currentLine = array_pop($lineNos) ?: 0; + $parentLine = array_pop($lineNos); + + if ($parentLine !== null) { + $c = $this->getLineIndentation($this->lines[$parentLine] ?? ''); + $n = $this->getLineIndentation($this->lines[$parentLine+1] ?? $this->lines[$parentLine] ?? ''); + $indent = $n > $c ? $n : $c + 2; + } else { + $indent = 0; + array_unshift($value, ''); + } + $spaces = str_repeat(' ', $indent); + foreach ($value as &$line) { + $line = $spaces . $line; + } + unset($line); + + array_splice($this->lines, abs($currentLine)+1, 0, $value); + } + + public function undefine(string $variable): void + { + // If variable does not have value, we're good. + if ($this->get($variable) === null) { + return; + } + + // If one of the parents isn't array, we're good, too. + if (!$this->canDefine($variable)) { + return; + } + + $this->undef($variable); + if (!$this->isHandWritten()) { + return; + } + + // TODO: support also removing property from handwritten configuration file. + } + + private function __construct(string $filename) + { + $content = is_file($filename) ? (string)file_get_contents($filename) : ''; + $content = rtrim(str_replace(["\r\n", "\r"], "\n", $content)); + + $this->filename = $filename; + $this->lines = explode("\n", $content); + $this->comments = $this->getComments(); + $this->items = $content ? Yaml::parse($content) : []; + } + + /** + * Return array of offsets for the parent nodes. Negative value means position, but not found. + * + * @param array $lines + * @param array $parts + * @return int[] + */ + private function findPath(array $lines, array $parts) + { + $test = true; + $indent = -1; + $current = array_shift($parts); + + $j = 1; + $found = []; + $space = ''; + foreach ($lines as $i => $line) { + if ($this->isLineEmpty($line)) { + if ($this->isLineComment($line) && $this->getLineIndentation($line) > $indent) { + $j = $i; + } + continue; + } + + if ($test === true) { + $test = false; + $spaces = strlen($line) - strlen(ltrim($line, ' ')); + if ($spaces <= $indent) { + $found[$current] = -$j; + + return $found; + } + + $indent = $spaces; + $space = $indent ? str_repeat(' ', $indent) : ''; + } + + + if (0 === \strncmp($line, $space, strlen($space))) { + $pattern = "/^{$space}(['\"]?){$current}\\1\:/"; + + if (preg_match($pattern, $line)) { + $found[$current] = $i; + $current = array_shift($parts); + if ($current === null) { + return $found; + } + $test = true; + } + } else { + $found[$current] = -$j; + + return $found; + } + + $j = $i; + } + + $found[$current] = -$j; + + return $found; + } + + /** + * Returns true if the current line is blank or if it is a comment line. + * + * @param string $line Contents of the line + * @return bool Returns true if the current line is empty or if it is a comment line, false otherwise + */ + private function isLineEmpty(string $line): bool + { + return $this->isLineBlank($line) || $this->isLineComment($line); + } + + /** + * Returns true if the current line is blank. + * + * @param string $line Contents of the line + * @return bool Returns true if the current line is blank, false otherwise + */ + private function isLineBlank(string $line): bool + { + return '' === trim($line, ' '); + } + + /** + * Returns true if the current line is a comment line. + * + * @param string $line Contents of the line + * @return bool Returns true if the current line is a comment line, false otherwise + */ + private function isLineComment(string $line): bool + { + //checking explicitly the first char of the trim is faster than loops or strpos + $ltrimmedLine = ltrim($line, ' '); + + return '' !== $ltrimmedLine && '#' === $ltrimmedLine[0]; + } + + /** + * @param string $line + * @return bool + */ + private function isInlineComment(string $line): bool + { + return $this->getInlineComment($line) !== null; + } + + /** + * @param string $line + * @return string|null + */ + private function getInlineComment(string $line): ?string + { + $pos = strpos($line, ' #'); + if (false === $pos) { + return null; + } + + $parts = explode(' #', $line); + $part = ''; + while ($part .= array_shift($parts)) { + // Remove quoted values. + $part = preg_replace('/(([\'"])[^\2]*\2)/', '', $part); + assert(null !== $part); + $part = preg_split('/[\'"]/', $part, 2); + assert(false !== $part); + if (!isset($part[1])) { + $part = $part[0]; + array_unshift($parts, str_repeat(' ', strlen($part) - strlen(trim($part, ' ')))); + break; + } + $part = $part[1]; + } + + + return implode(' #', $parts); + } + + /** + * Returns the current line indentation. + * + * @param string $line + * @return int The current line indentation + */ + private function getLineIndentation(string $line): int + { + return \strlen($line) - \strlen(ltrim($line, ' ')); + } + + /** + * Get value by using dot notation for nested arrays/objects. + * + * @param string $name Dot separated path to the requested value. + * @param mixed $default Default value (or null). + * @return mixed Value. + */ + private function get(string $name, $default = null) + { + $path = explode('.', $name); + $current = $this->items; + + foreach ($path as $field) { + if (is_array($current) && isset($current[$field])) { + $current = $current[$field]; + } else { + return $default; + } + } + + return $current; + } + + /** + * Set value by using dot notation for nested arrays/objects. + * + * @param string $name Dot separated path to the requested value. + * @param mixed $value New value. + */ + private function set(string $name, $value): void + { + $path = explode('.', $name); + $current = &$this->items; + + foreach ($path as $field) { + // Handle arrays and scalars. + if (!is_array($current)) { + $current = [$field => []]; + } elseif (!isset($current[$field])) { + $current[$field] = []; + } + $current = &$current[$field]; + } + + $current = $value; + $this->updated = true; + } + + /** + * Unset value by using dot notation for nested arrays/objects. + * + * @param string $name Dot separated path to the requested value. + */ + private function undef(string $name): void + { + $path = $name !== '' ? explode('.', $name) : []; + if (!$path) { + return; + } + + $var = array_pop($path); + $current = &$this->items; + + foreach ($path as $field) { + if (!is_array($current) || !isset($current[$field])) { + return; + } + $current = &$current[$field]; + } + + unset($current[$var]); + $this->updated = true; + } + + /** + * Get value by using dot notation for nested arrays/objects. + * + * @param string $name Dot separated path to the requested value. + * @return bool + */ + private function canDefine(string $name): bool + { + $path = explode('.', $name); + $current = $this->items; + + foreach ($path as $field) { + if (is_array($current)) { + if (!isset($current[$field])) { + return true; + } + $current = $current[$field]; + } else { + return false; + } + } + + return true; + } +} diff --git a/system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php b/system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php new file mode 100644 index 0000000..6120665 --- /dev/null +++ b/system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php @@ -0,0 +1,24 @@ + null, + 'postflight' => + function () { + /** @var VersionUpdate $this */ + try { + // Keep old defaults for backwards compatibility. + $yaml = YamlUpdater::instance(GRAV_ROOT . '/user/config/system.yaml'); + $yaml->define('twig.autoescape', false); + $yaml->define('strict_mode.yaml_compat', true); + $yaml->define('strict_mode.twig_compat', true); + $yaml->define('strict_mode.blueprint_compat', true); + $yaml->save(); + } catch (\Exception $e) { + throw new InstallException('Could not update system configuration to maintain backwards compatibility', $e); + } + } +]; diff --git a/system/templates/default.html.twig b/system/templates/default.html.twig new file mode 100644 index 0000000..f18206b --- /dev/null +++ b/system/templates/default.html.twig @@ -0,0 +1,4 @@ +{# Default output if no theme #} +

    ERROR: {{ page.template() ~'.'~ page.templateFormat() ~".twig" }} template not found for page: {{ page.route() }}

    +

    {{ page.title() }}

    +{{ page.content()|raw }} diff --git a/system/templates/external.html.twig b/system/templates/external.html.twig new file mode 100644 index 0000000..3fa3508 --- /dev/null +++ b/system/templates/external.html.twig @@ -0,0 +1 @@ +{# Default external template #} diff --git a/system/templates/flex/404.html.twig b/system/templates/flex/404.html.twig new file mode 100644 index 0000000..adf4f65 --- /dev/null +++ b/system/templates/flex/404.html.twig @@ -0,0 +1,4 @@ +{% set item = collection ?? object %} +{% set type = collection ? 'collection' : 'object' %} + +ERROR: Layout '{{ layout }}' for flex {{ type }} '{{ item.flexType() }}' was not found. \ No newline at end of file diff --git a/system/templates/flex/_default/collection/debug.html.twig b/system/templates/flex/_default/collection/debug.html.twig new file mode 100644 index 0000000..5a37835 --- /dev/null +++ b/system/templates/flex/_default/collection/debug.html.twig @@ -0,0 +1,5 @@ +

    {{ directory.getTitle() }} debug dump

    + +{% for object in collection %} + {% render object layout: layout %} +{% endfor %} diff --git a/system/templates/flex/_default/object/debug.html.twig b/system/templates/flex/_default/object/debug.html.twig new file mode 100644 index 0000000..dc961cd --- /dev/null +++ b/system/templates/flex/_default/object/debug.html.twig @@ -0,0 +1,4 @@ +
    +

    {{ object.key }}

    +
    {{ object.jsonSerialize()|yaml_encode }}
    +
    \ No newline at end of file diff --git a/system/templates/modular/default.html.twig b/system/templates/modular/default.html.twig new file mode 100644 index 0000000..f18206b --- /dev/null +++ b/system/templates/modular/default.html.twig @@ -0,0 +1,4 @@ +{# Default output if no theme #} +

    ERROR: {{ page.template() ~'.'~ page.templateFormat() ~".twig" }} template not found for page: {{ page.route() }}

    +

    {{ page.title() }}

    +{{ page.content()|raw }} diff --git a/system/templates/partials/metadata.html.twig b/system/templates/partials/metadata.html.twig index bf323e7..fcf1217 100644 --- a/system/templates/partials/metadata.html.twig +++ b/system/templates/partials/metadata.html.twig @@ -1,3 +1,3 @@ {% for meta in page.metadata %} - -{% endfor %} \ No newline at end of file + +{% endfor %} diff --git a/user/config/system.yaml b/user/config/system.yaml index b634652..14e0abf 100644 --- a/user/config/system.yaml +++ b/user/config/system.yaml @@ -151,3 +151,4 @@ accounts: strict_mode: yaml_compat: true twig_compat: true + blueprint_compat: true diff --git a/user/config/versions.yaml b/user/config/versions.yaml new file mode 100644 index 0000000..da95d04 --- /dev/null +++ b/user/config/versions.yaml @@ -0,0 +1,6 @@ +core: + grav: + version: 1.7.14 + history: + - { version: 1.7.14, date: '2021-05-12 08:31:39' } + schema: 1.7.0_2020-11-20_1 diff --git a/webserver-configs/Caddyfile b/webserver-configs/Caddyfile index a324132..3464b5b 100644 --- a/webserver-configs/Caddyfile +++ b/webserver-configs/Caddyfile @@ -1,33 +1,31 @@ -:8080 -gzip -fastcgi / 127.0.0.1:9000 php +# To use this file simply install caddy and run the command below from the root of your Grav site +# Once running it will redirect http://localhost to https://localhost (new default for Caddy2) +# More infromation here: https://caddyserver.com/docs/ +# +# $ caddy run --config webserver-configs/Caddyfile + +localhost +encode gzip +root * . +file_server + +php_fastcgi 127.0.0.1:9000 # Begin - Security # deny all direct access for these folders -rewrite { - r /(\.git|cache|bin|logs|backups|tests)/.*$ - to /403 -} -# deny running scripts inside core system folders -rewrite { - r /(system|vendor)/.*\.(txt|xml|md|html|yaml|yml|php|pl|py|cgi|twig|sh|bat)$ - to /403 -} -# deny running scripts inside user folder -rewrite { - r /user/.*\.(txt|md|yaml|yml|php|pl|py|cgi|twig|sh|bat)$ - to /403 -} -# deny access to specific files in the root folder -rewrite { - r /(LICENSE\.txt|composer\.lock|composer\.json|nginx\.conf|web\.config|htaccess\.txt|\.htaccess) - to /403 -} +rewrite /(\.git|cache|bin|logs|backups|tests)/.* /403 -status 403 /403 +# deny running scripts inside core system folders +rewrite /(system|vendor)/.*\.(txt|xml|md|html|yaml|yml|php|pl|py|cgi|twig|sh|bat)$ /403 + +# deny running scripts inside user folder +rewrite /user/.*\.(txt|md|yaml|yml|php|pl|py|cgi|twig|sh|bat)$ /403 + +# deny access to specific files in the root folder +rewrite /(LICENSE\.txt|composer\.lock|composer\.json|nginx\.conf|web\.config|htaccess\.txt|\.htaccess) /403 + +respond /403 403 ## End - Security # global rewrite should come last. -rewrite { - to {path} {path}/ /index.php?_url={uri}&{query} -} +try_files {path} {path}/ /index.php?_url={uri}&{query} diff --git a/webserver-configs/htaccess.txt b/webserver-configs/htaccess.txt index ef79a4b..83063ae 100644 --- a/webserver-configs/htaccess.txt +++ b/webserver-configs/htaccess.txt @@ -27,6 +27,9 @@ RewriteEngine On # If you experience problems on your site block out the operations listed below # This attempts to block the most common type of exploit `attempts` to Grav # +# Block out any script trying to use twig tags in URL. +RewriteCond %{REQUEST_URI} ({{|}}|{%|%}) [OR] +RewriteCond %{QUERY_STRING} ({{|}}|{%25|%25}) [OR] # Block out any script trying to base64_encode data within the URL. RewriteCond %{QUERY_STRING} base64_encode[^(]*\([^)]*\) [OR] # Block out any script that includes a