From 19ecb285abeeee50bdb69875bbb77e4a0b64911e Mon Sep 17 00:00:00 2001 From: bach Date: Thu, 27 May 2021 18:17:50 +0200 Subject: [PATCH] updated core to 1.7.15 --- .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 | 1370 ++++- CODE_OF_CONDUCT.md | 129 +- CONTRIBUTING.md | 5 +- LICENSE.txt | 2 +- README.md | 26 +- SECURITY.md | 21 + assets/.gitkeep | 1 + bin/composer.phar | Bin 1861877 -> 2205196 bytes bin/gpm | 63 +- bin/grav | 44 +- bin/plugin | 136 +- composer.json | 127 +- composer.lock | 4607 ++++++++++++--- index.php | 49 +- now.json | 4 + system/assets/debugger.css | 54 - system/assets/debugger/clockwork.css | 2 + system/assets/debugger/clockwork.js | 3 + system/assets/debugger/phpdebugbar.css | 70 + system/assets/grav.png | Bin 548 -> 1612 bytes system/assets/jquery/jquery-3.x.min.js | 4 +- system/blueprints/config/backups.yaml | 125 + system/blueprints/config/scheduler.yaml | 77 + system/blueprints/config/security.yaml | 20 + system/blueprints/config/site.yaml | 2 +- system/blueprints/config/system.yaml | 2684 +++++---- 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 | 217 +- system/blueprints/user/account_new.yaml | 2 + system/blueprints/user/group.yaml | 76 +- system/blueprints/user/group_new.yaml | 7 + system/config/backups.yaml | 15 + system/config/media.yaml | 10 +- system/config/permissions.yaml | 53 + system/config/security.yaml | 8 + system/config/site.yaml | 10 +- system/config/streams.yaml | 16 - system/config/system.yaml | 83 +- system/defines.php | 94 +- system/install.php | 15 + system/languages/ar.yaml | 155 +- system/languages/bg.yaml | 62 + system/languages/ca.yaml | 144 +- system/languages/cs.yaml | 226 +- system/languages/da.yaml | 163 +- system/languages/de.yaml | 234 +- system/languages/el.yaml | 164 +- system/languages/en.yaml | 220 +- system/languages/es.yaml | 195 +- system/languages/et.yaml | 104 + system/languages/eu.yaml | 62 + system/languages/fa.yaml | 62 + system/languages/fi.yaml | 192 +- system/languages/fr.yaml | 248 +- system/languages/gl.yaml | 144 + system/languages/he.yaml | 63 + system/languages/hr.yaml | 149 +- system/languages/hu.yaml | 233 +- system/languages/id.yaml | 97 + system/languages/is.yaml | 80 + system/languages/it.yaml | 207 +- system/languages/ja.yaml | 64 +- system/languages/ko.yaml | 63 + system/languages/lt.yaml | 145 +- system/languages/nb.yaml | 6 +- system/languages/nl.yaml | 206 +- system/languages/no.yaml | 173 +- system/languages/pl.yaml | 173 +- system/languages/pt.yaml | 221 +- system/languages/ro.yaml | 195 +- system/languages/ru.yaml | 183 +- system/languages/sk.yaml | 184 +- system/languages/sl.yaml | 62 + system/languages/sr.yaml | 144 + system/languages/sv.yaml | 160 +- system/languages/th.yaml | 129 +- system/languages/tr.yaml | 155 +- system/languages/uk.yaml | 136 +- system/languages/vi.yaml | 136 +- system/languages/zh-cn.yaml | 144 + system/languages/zh-tw.yaml | 62 + system/languages/zh.yaml | 144 + system/pages/notfound.md | 1 + system/router.php | 28 +- system/src/Grav/Common/Assets.php | 1497 +---- system/src/Grav/Common/Assets/BaseAsset.php | 258 + system/src/Grav/Common/Assets/Css.php | 52 + system/src/Grav/Common/Assets/InlineCss.php | 44 + system/src/Grav/Common/Assets/InlineJs.php | 44 + system/src/Grav/Common/Assets/Js.php | 48 + system/src/Grav/Common/Assets/Pipeline.php | 280 + .../Common/Assets/Traits/AssetUtilsTrait.php | 208 + .../Assets/Traits/LegacyAssetsTrait.php | 137 + .../Assets/Traits/TestingAssetsTrait.php | 341 ++ system/src/Grav/Common/Backup/Backups.php | 323 ++ system/src/Grav/Common/Backup/ZipBackup.php | 144 - system/src/Grav/Common/Browser.php | 26 +- system/src/Grav/Common/Cache.php | 354 +- system/src/Grav/Common/Composer.php | 19 +- .../src/Grav/Common/Config/CompiledBase.php | 91 +- .../Grav/Common/Config/CompiledBlueprints.php | 33 +- .../src/Grav/Common/Config/CompiledConfig.php | 45 +- .../Grav/Common/Config/CompiledLanguages.php | 30 +- system/src/Grav/Common/Config/Config.php | 60 +- .../Grav/Common/Config/ConfigFileFinder.php | 29 +- system/src/Grav/Common/Config/Languages.php | 56 +- system/src/Grav/Common/Config/Setup.php | 269 +- system/src/Grav/Common/Data/Blueprint.php | 395 +- .../src/Grav/Common/Data/BlueprintSchema.php | 320 +- system/src/Grav/Common/Data/Blueprints.php | 37 +- system/src/Grav/Common/Data/Data.php | 115 +- system/src/Grav/Common/Data/DataInterface.php | 23 +- system/src/Grav/Common/Data/Validation.php | 638 +- .../Grav/Common/Data/ValidationException.php | 25 +- system/src/Grav/Common/Debugger.php | 973 +++- system/src/Grav/Common/Errors/BareHandler.php | 18 +- system/src/Grav/Common/Errors/Errors.php | 50 +- .../Grav/Common/Errors/SimplePageHandler.php | 57 +- .../src/Grav/Common/Errors/SystemFacade.php | 13 +- system/src/Grav/Common/File/CompiledFile.php | 35 +- .../src/Grav/Common/File/CompiledJsonFile.php | 13 +- .../Grav/Common/File/CompiledMarkdownFile.php | 9 +- .../src/Grav/Common/File/CompiledYamlFile.php | 9 +- .../src/Grav/Common/Filesystem/Archiver.php | 108 + system/src/Grav/Common/Filesystem/Folder.php | 203 +- .../RecursiveDirectoryFilterIterator.php | 82 + .../RecursiveFolderFilterIterator.php | 34 +- .../Grav/Common/Filesystem/ZipArchiver.php | 136 + .../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 | 909 +++ system/src/Grav/Common/Form/FormFlash.php | 106 + .../Grav/Common/GPM/AbstractCollection.php | 23 +- .../GPM/Common/AbstractPackageCollection.php | 18 +- .../Common/GPM/Common/CachedCollection.php | 31 +- system/src/Grav/Common/GPM/Common/Package.php | 72 +- system/src/Grav/Common/GPM/GPM.php | 698 +-- system/src/Grav/Common/GPM/Installer.php | 214 +- system/src/Grav/Common/GPM/Licenses.php | 60 +- .../GPM/Local/AbstractPackageCollection.php | 16 +- system/src/Grav/Common/GPM/Local/Package.php | 26 +- system/src/Grav/Common/GPM/Local/Packages.php | 9 +- system/src/Grav/Common/GPM/Local/Plugins.php | 14 +- system/src/Grav/Common/GPM/Local/Themes.php | 18 +- .../GPM/Remote/AbstractPackageCollection.php | 42 +- .../src/Grav/Common/GPM/Remote/GravCore.php | 39 +- system/src/Grav/Common/GPM/Remote/Package.php | 54 +- .../src/Grav/Common/GPM/Remote/Packages.php | 14 +- system/src/Grav/Common/GPM/Remote/Plugins.php | 17 +- system/src/Grav/Common/GPM/Remote/Themes.php | 17 +- system/src/Grav/Common/GPM/Response.php | 447 +- system/src/Grav/Common/GPM/Upgrader.php | 35 +- system/src/Grav/Common/Getters.php | 77 +- system/src/Grav/Common/Grav.php | 743 ++- system/src/Grav/Common/GravTrait.php | 15 +- system/src/Grav/Common/Helpers/Base32.php | 102 +- system/src/Grav/Common/Helpers/Excerpts.php | 325 +- system/src/Grav/Common/Helpers/Exif.php | 29 +- system/src/Grav/Common/Helpers/LogViewer.php | 165 + system/src/Grav/Common/Helpers/Truncator.php | 210 +- system/src/Grav/Common/Helpers/YamlLinter.php | 121 + system/src/Grav/Common/Inflector.php | 216 +- system/src/Grav/Common/Iterator.php | 48 +- system/src/Grav/Common/Language/Language.php | 465 +- .../Grav/Common/Language/LanguageCodes.php | 58 +- system/src/Grav/Common/Markdown/Parsedown.php | 30 +- .../Grav/Common/Markdown/ParsedownExtra.php | 32 +- .../Common/Markdown/ParsedownGravTrait.php | 149 +- .../Media/Interfaces/AudioMediaInterface.php | 25 + .../Interfaces/ImageManipulateInterface.php | 120 + .../Media/Interfaces/ImageMediaInterface.php | 17 + .../Interfaces/MediaCollectionInterface.php | 108 +- .../Media/Interfaces/MediaFileInterface.php | 53 + .../Media/Interfaces/MediaInterface.php | 30 +- .../Media/Interfaces/MediaLinkInterface.php | 17 + .../Media/Interfaces/MediaObjectInterface.php | 229 +- .../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 | 93 +- .../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 | 288 +- system/src/Grav/Common/Page/Header.php | 30 +- .../Interfaces/PageCollectionInterface.php | 283 + .../Page/Interfaces/PageContentInterface.php | 257 + .../Page/Interfaces/PageFormInterface.php | 33 + .../Common/Page/Interfaces/PageInterface.php | 18 +- .../Page/Interfaces/PageLegacyInterface.php | 475 ++ .../Page/Interfaces/PageRoutableInterface.php | 180 + .../Interfaces/PageTranslateInterface.php | 33 + .../Page/Interfaces/PagesSourceInterface.php | 56 + .../Grav/Common/Page/Markdown/Excerpts.php | 343 ++ system/src/Grav/Common/Page/Media.php | 92 +- .../Grav/Common/Page/Medium/AbstractMedia.php | 195 +- .../Grav/Common/Page/Medium/AudioMedium.php | 145 +- .../Grav/Common/Page/Medium/GlobalMedia.php | 50 +- .../src/Grav/Common/Page/Medium/ImageFile.php | 147 +- .../Grav/Common/Page/Medium/ImageMedium.php | 555 +- system/src/Grav/Common/Page/Medium/Link.php | 71 +- system/src/Grav/Common/Page/Medium/Medium.php | 560 +- .../Grav/Common/Page/Medium/MediumFactory.php | 104 +- .../Common/Page/Medium/ParsedownHtmlTrait.php | 26 +- .../Page/Medium/RenderableInterface.php | 28 +- .../Common/Page/Medium/StaticImageMedium.php | 24 +- .../Common/Page/Medium/StaticResizeTrait.php | 29 +- .../Page/Medium/ThumbnailImageMedium.php | 129 +- .../Grav/Common/Page/Medium/VideoMedium.php | 134 +- system/src/Grav/Common/Page/Page.php | 1285 ++-- system/src/Grav/Common/Page/Pages.php | 1482 +++-- .../Grav/Common/Page/Traits/PageFormTrait.php | 126 + system/src/Grav/Common/Page/Types.php | 82 +- system/src/Grav/Common/Plugin.php | 205 +- system/src/Grav/Common/Plugins.php | 188 +- .../Common/Processors/AssetsProcessor.php | 30 +- .../Common/Processors/BackupsProcessor.php | 41 + .../Processors/ConfigurationProcessor.php | 21 - .../Processors/DebuggerAssetsProcessor.php | 28 +- .../Processors/DebuggerInitProcessor.php | 20 - .../Common/Processors/ErrorsProcessor.php | 20 - .../Processors/Events/RequestHandlerEvent.php | 82 + .../Common/Processors/InitializeProcessor.php | 441 +- .../Grav/Common/Processors/PagesProcessor.php | 59 +- .../Common/Processors/PluginsProcessor.php | 38 +- .../Grav/Common/Processors/ProcessorBase.php | 57 +- .../Common/Processors/ProcessorInterface.php | 14 +- .../Common/Processors/RenderProcessor.php | 72 +- .../Common/Processors/RequestProcessor.php | 65 + .../Common/Processors/SchedulerProcessor.php | 42 + .../Common/Processors/SiteSetupProcessor.php | 21 - .../Grav/Common/Processors/TasksProcessor.php | 60 +- .../Common/Processors/ThemesProcessor.php | 28 +- .../Grav/Common/Processors/TwigProcessor.php | 29 +- system/src/Grav/Common/Scheduler/Cron.php | 577 ++ .../Grav/Common/Scheduler/IntervalTrait.php | 404 ++ system/src/Grav/Common/Scheduler/Job.php | 564 ++ .../src/Grav/Common/Scheduler/Scheduler.php | 437 ++ system/src/Grav/Common/Security.php | 172 +- .../Service/AccountsServiceProvider.php | 157 + .../Common/Service/AssetsServiceProvider.php | 17 +- .../Common/Service/BackupsServiceProvider.php | 35 + .../Common/Service/ConfigServiceProvider.php | 59 +- .../Common/Service/ErrorServiceProvider.php | 16 +- .../Service/FilesystemServiceProvider.php | 32 + .../Common/Service/FlexServiceProvider.php | 117 + .../Service/InflectorServiceProvider.php | 32 + .../Common/Service/LoggerServiceProvider.php | 18 +- .../Common/Service/OutputServiceProvider.php | 17 +- .../Common/Service/PageServiceProvider.php | 99 - .../Common/Service/PagesServiceProvider.php | 139 + .../Common/Service/RequestServiceProvider.php | 103 + .../Service/SchedulerServiceProvider.php | 32 + .../Common/Service/SessionServiceProvider.php | 59 +- .../Common/Service/StreamsServiceProvider.php | 25 +- .../Common/Service/TaskServiceProvider.php | 32 +- system/src/Grav/Common/Session.php | 76 +- system/src/Grav/Common/Taxonomy.php | 83 +- system/src/Grav/Common/Theme.php | 61 +- system/src/Grav/Common/Themes.php | 130 +- .../Twig/Extension/FilesystemExtension.php | 387 ++ .../Common/Twig/Extension/GravExtension.php | 1612 ++++++ .../Grav/Common/Twig/Node/TwigNodeCache.php | 58 + .../Common/Twig/Node/TwigNodeMarkdown.php | 31 +- .../Grav/Common/Twig/Node/TwigNodeRender.php | 83 + .../Grav/Common/Twig/Node/TwigNodeScript.php | 108 +- .../Grav/Common/Twig/Node/TwigNodeStyle.php | 83 +- .../Grav/Common/Twig/Node/TwigNodeSwitch.php | 43 +- .../Grav/Common/Twig/Node/TwigNodeThrow.php | 52 + .../Common/Twig/Node/TwigNodeTryCatch.php | 53 +- .../Twig/TokenParser/TwigTokenParserCache.php | 71 + .../TokenParser/TwigTokenParserMarkdown.php | 28 +- .../TokenParser/TwigTokenParserRender.php | 74 + .../TokenParser/TwigTokenParserScript.php | 64 +- .../Twig/TokenParser/TwigTokenParserStyle.php | 60 +- .../TokenParser/TwigTokenParserSwitch.php | 57 +- .../Twig/TokenParser/TwigTokenParserThrow.php | 55 + .../TokenParser/TwigTokenParserTryCatch.php | 43 +- system/src/Grav/Common/Twig/Twig.php | 297 +- .../Common/Twig/TwigClockworkDataSource.php | 58 + .../Grav/Common/Twig/TwigClockworkDumper.php | 72 + .../src/Grav/Common/Twig/TwigEnvironment.php | 13 +- system/src/Grav/Common/Twig/TwigExtension.php | 1340 +---- .../Grav/Common/Twig/WriteCacheFileTrait.php | 22 +- system/src/Grav/Common/Uri.php | 409 +- system/src/Grav/Common/User/Access.php | 52 + .../src/Grav/Common/User/Authentication.php | 23 +- system/src/Grav/Common/User/DataUser/User.php | 325 ++ .../Common/User/DataUser/UserCollection.php | 162 + system/src/Grav/Common/User/Group.php | 48 +- .../User/Interfaces/AuthorizeInterface.php | 26 + .../Interfaces/UserCollectionInterface.php | 40 + .../User/Interfaces/UserGroupInterface.php | 18 + .../Common/User/Interfaces/UserInterface.php | 189 + .../src/Grav/Common/User/Traits/UserTrait.php | 187 + system/src/Grav/Common/User/User.php | 390 +- system/src/Grav/Common/Utils.php | 1515 ++++- system/src/Grav/Common/Yaml.php | 22 +- .../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 | 120 +- system/src/Grav/Console/Cli/CleanCommand.php | 238 +- .../Grav/Console/Cli/ClearCacheCommand.php | 94 +- .../src/Grav/Console/Cli/ComposerCommand.php | 56 +- .../src/Grav/Console/Cli/InstallCommand.php | 284 +- .../src/Grav/Console/Cli/LogViewerCommand.php | 96 + .../Grav/Console/Cli/NewProjectCommand.php | 30 +- .../Cli/PageSystemValidatorCommand.php | 299 + .../src/Grav/Console/Cli/SandboxCommand.php | 246 +- .../src/Grav/Console/Cli/SchedulerCommand.php | 225 + .../src/Grav/Console/Cli/SecurityCommand.php | 69 +- system/src/Grav/Console/Cli/ServerCommand.php | 154 + .../Grav/Console/Cli/YamlLinterCommand.php | 124 + system/src/Grav/Console/ConsoleCommand.php | 29 +- system/src/Grav/Console/ConsoleTrait.php | 288 +- .../Grav/Console/Gpm/DirectInstallCommand.php | 271 +- system/src/Grav/Console/Gpm/IndexCommand.php | 251 +- system/src/Grav/Console/Gpm/InfoCommand.php | 142 +- .../src/Grav/Console/Gpm/InstallCommand.php | 474 +- .../Grav/Console/Gpm/SelfupgradeCommand.php | 302 +- .../src/Grav/Console/Gpm/UninstallCommand.php | 216 +- system/src/Grav/Console/Gpm/UpdateCommand.php | 192 +- .../src/Grav/Console/Gpm/VersionCommand.php | 55 +- 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 | 13 +- 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 | 8 +- .../Framework/Cache/Adapter/ChainCache.php | 29 +- .../Framework/Cache/Adapter/DoctrineCache.php | 27 +- .../Framework/Cache/Adapter/FileCache.php | 69 +- .../Framework/Cache/Adapter/MemoryCache.php | 30 +- .../Framework/Cache/Adapter/SessionCache.php | 39 +- .../Grav/Framework/Cache/CacheInterface.php | 46 +- .../src/Grav/Framework/Cache/CacheTrait.php | 118 +- .../Cache/Exception/CacheException.php | 6 +- .../Exception/InvalidArgumentException.php | 3 +- .../Collection/AbstractFileCollection.php | 73 +- .../Collection/AbstractIndexCollection.php | 538 ++ .../Collection/AbstractLazyCollection.php | 37 +- .../Framework/Collection/ArrayCollection.php | 47 +- .../Collection/CollectionInterface.php | 35 +- .../Framework/Collection/FileCollection.php | 6 +- .../Collection/FileCollectionInterface.php | 13 +- .../Grav/Framework/Compat/Serializable.php | 47 + .../Framework/ContentBlock/ContentBlock.php | 102 +- .../ContentBlock/ContentBlockInterface.php | 12 +- .../Grav/Framework/ContentBlock/HtmlBlock.php | 30 +- .../ContentBlock/HtmlBlockInterface.php | 3 +- .../Traits/ControllerResponseTrait.php | 297 + system/src/Grav/Framework/DI/Container.php | 35 + .../src/Grav/Framework/File/AbstractFile.php | 441 ++ system/src/Grav/Framework/File/CsvFile.php | 31 + system/src/Grav/Framework/File/DataFile.php | 78 + system/src/Grav/Framework/File/File.php | 44 + .../File/Formatter/AbstractFormatter.php | 117 + .../Framework/File/Formatter/CsvFormatter.php | 169 + .../File/Formatter/FormatterInterface.php | 46 +- .../Framework/File/Formatter/IniFormatter.php | 64 +- .../File/Formatter/JsonFormatter.php | 171 +- .../File/Formatter/MarkdownFormatter.php | 126 +- .../File/Formatter/SerializeFormatter.php | 74 +- .../File/Formatter/YamlFormatter.php | 82 +- system/src/Grav/Framework/File/IniFile.php | 31 + .../Interfaces/FileFormatterInterface.php | 72 + .../File/Interfaces/FileInterface.php | 180 + system/src/Grav/Framework/File/JsonFile.php | 31 + .../src/Grav/Framework/File/MarkdownFile.php | 31 + system/src/Grav/Framework/File/YamlFile.php | 31 + .../Grav/Framework/Filesystem/Filesystem.php | 340 ++ .../Interfaces/FilesystemInterface.php | 82 + system/src/Grav/Framework/Flex/Flex.php | 332 ++ .../Grav/Framework/Flex/FlexCollection.php | 712 +++ .../src/Grav/Framework/Flex/FlexDirectory.php | 1041 ++++ .../Grav/Framework/Flex/FlexDirectoryForm.php | 482 ++ system/src/Grav/Framework/Flex/FlexForm.php | 566 ++ .../src/Grav/Framework/Flex/FlexFormFlash.php | 130 + system/src/Grav/Framework/Flex/FlexIndex.php | 901 +++ system/src/Grav/Framework/Flex/FlexObject.php | 1211 ++++ .../Interfaces/FlexAuthorizeInterface.php | 33 + .../Interfaces/FlexCollectionInterface.php | 144 + .../Flex/Interfaces/FlexCommonInterface.php | 79 + .../Interfaces/FlexDirectoryFormInterface.php | 27 + .../Interfaces/FlexDirectoryInterface.php | 223 + .../Flex/Interfaces/FlexFormInterface.php | 46 + .../Flex/Interfaces/FlexIndexInterface.php | 63 + .../Flex/Interfaces/FlexInterface.php | 98 + .../Interfaces/FlexObjectFormInterface.php | 27 + .../Flex/Interfaces/FlexObjectInterface.php | 210 + .../Flex/Interfaces/FlexStorageInterface.php | 138 + .../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 | 1119 ++++ .../Flex/Pages/Traits/PageRoutableTrait.php | 550 ++ .../Flex/Pages/Traits/PageTranslateTrait.php | 283 + .../Storage/AbstractFilesystemStorage.php | 228 + .../Framework/Flex/Storage/FileStorage.php | 159 + .../Framework/Flex/Storage/FolderStorage.php | 704 +++ .../Framework/Flex/Storage/SimpleStorage.php | 506 ++ .../Flex/Traits/FlexAuthorizeTrait.php | 126 + .../Framework/Flex/Traits/FlexMediaTrait.php | 520 ++ .../Flex/Traits/FlexRelatedDirectoryTrait.php | 59 + system/src/Grav/Framework/Form/FormFlash.php | 566 ++ .../src/Grav/Framework/Form/FormFlashFile.php | 237 + .../Form/Interfaces/FormFactoryInterface.php | 42 + .../Form/Interfaces/FormFlashInterface.php | 174 + .../Form/Interfaces/FormInterface.php | 187 + .../Grav/Framework/Form/Traits/FormTrait.php | 844 +++ .../Framework/Interfaces/RenderInterface.php | 38 + .../Interfaces/MediaCollectionInterface.php | 17 + .../Media/Interfaces/MediaInterface.php | 37 + .../Interfaces/MediaManipulationInterface.php | 33 + .../Media/Interfaces/MediaObjectInterface.php | 17 + .../Object/Access/ArrayAccessTrait.php | 10 +- .../Object/Access/NestedArrayAccessTrait.php | 10 +- .../Access/NestedPropertyCollectionTrait.php | 22 +- .../Object/Access/NestedPropertyTrait.php | 54 +- .../Object/Access/OverloadedPropertyTrait.php | 10 +- .../src/Grav/Framework/Object/ArrayObject.php | 16 +- .../Object/Base/ObjectCollectionTrait.php | 222 +- .../Framework/Object/Base/ObjectTrait.php | 59 +- .../Collection/ObjectExpressionVisitor.php | 67 +- .../NestedObjectCollectionInterface.php | 64 + .../Interfaces/NestedObjectInterface.php | 43 +- .../Interfaces/ObjectCollectionInterface.php | 86 +- .../Object/Interfaces/ObjectInterface.php | 28 +- .../src/Grav/Framework/Object/LazyObject.php | 15 +- .../Framework/Object/ObjectCollection.php | 72 +- .../src/Grav/Framework/Object/ObjectIndex.php | 264 + .../Object/Property/ArrayPropertyTrait.php | 24 +- .../Object/Property/LazyPropertyTrait.php | 12 +- .../Object/Property/MixedPropertyTrait.php | 13 +- .../Object/Property/ObjectPropertyTrait.php | 40 +- .../Grav/Framework/Object/PropertyObject.php | 16 +- .../Pagination/AbstractPagination.php | 427 ++ .../Pagination/AbstractPaginationPage.php | 78 + .../Interfaces/PaginationInterface.php | 103 + .../Interfaces/PaginationPageInterface.php | 47 + .../Grav/Framework/Pagination/Pagination.php | 32 + .../Framework/Pagination/PaginationPage.php | 26 + .../src/Grav/Framework/Psr7/AbstractUri.php | 41 +- system/src/Grav/Framework/Psr7/Request.php | 34 + system/src/Grav/Framework/Psr7/Response.php | 264 + .../src/Grav/Framework/Psr7/ServerRequest.php | 374 ++ system/src/Grav/Framework/Psr7/Stream.php | 43 + .../Psr7/Traits/MessageDecoratorTrait.php | 140 + .../Psr7/Traits/RequestDecoratorTrait.php | 112 + .../Psr7/Traits/ResponseDecoratorTrait.php | 82 + .../Traits/ServerRequestDecoratorTrait.php | 176 + .../Psr7/Traits/StreamDecoratorTrait.php | 152 + .../Traits/UploadedFileDecoratorTrait.php | 73 + .../Psr7/Traits/UriDecorationTrait.php | 188 + .../src/Grav/Framework/Psr7/UploadedFile.php | 37 + system/src/Grav/Framework/Psr7/Uri.php | 138 + .../Exception/InvalidArgumentException.php | 49 + .../Exception/NotFoundException.php | 45 + .../Exception/NotHandledException.php | 20 + .../Exception/PageExpiredException.php | 32 + .../Exception/RequestException.php | 102 + .../RequestHandler/Middlewares/Exceptions.php | 59 + .../RequestHandler/RequestHandler.php | 70 + .../Traits/RequestHandlerTrait.php | 64 + system/src/Grav/Framework/Route/Route.php | 218 +- .../src/Grav/Framework/Route/RouteFactory.php | 136 +- .../Session/Exceptions/SessionException.php | 20 + .../src/Grav/Framework/Session/Messages.php | 134 + system/src/Grav/Framework/Session/Session.php | 315 +- .../Framework/Session/SessionInterface.php | 23 +- system/src/Grav/Framework/Uri/Uri.php | 9 +- system/src/Grav/Framework/Uri/UriFactory.php | 44 +- .../src/Grav/Framework/Uri/UriPartsFilter.php | 41 +- system/src/Grav/Installer/Install.php | 392 ++ .../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 | 2 +- user/config/versions.yaml | 4 + webserver-configs/Caddyfile | 50 +- webserver-configs/htaccess.txt | 3 + webserver-configs/lighttpd.conf | 4 +- webserver-configs/nginx-ddev-site.conf | 118 - 552 files changed, 80743 insertions(+), 16675 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 create mode 100644 now.json 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/config/backups.yaml create mode 100644 system/blueprints/config/scheduler.yaml 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 create mode 100644 system/config/backups.yaml create mode 100644 system/config/permissions.yaml delete mode 100644 system/config/streams.yaml create mode 100644 system/install.php create mode 100644 system/languages/bg.yaml create mode 100644 system/languages/et.yaml create mode 100644 system/languages/eu.yaml create mode 100644 system/languages/fa.yaml create mode 100644 system/languages/gl.yaml create mode 100644 system/languages/he.yaml create mode 100644 system/languages/id.yaml create mode 100644 system/languages/is.yaml create mode 100644 system/languages/ko.yaml create mode 100644 system/languages/sl.yaml create mode 100644 system/languages/sr.yaml create mode 100644 system/languages/zh-cn.yaml create mode 100644 system/languages/zh-tw.yaml create mode 100644 system/languages/zh.yaml create mode 100644 system/src/Grav/Common/Assets/BaseAsset.php create mode 100644 system/src/Grav/Common/Assets/Css.php create mode 100644 system/src/Grav/Common/Assets/InlineCss.php create mode 100644 system/src/Grav/Common/Assets/InlineJs.php create mode 100644 system/src/Grav/Common/Assets/Js.php create mode 100644 system/src/Grav/Common/Assets/Pipeline.php create mode 100644 system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php create mode 100644 system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php create mode 100644 system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php create mode 100644 system/src/Grav/Common/Backup/Backups.php delete mode 100644 system/src/Grav/Common/Backup/ZipBackup.php create mode 100644 system/src/Grav/Common/Filesystem/Archiver.php create mode 100644 system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php create mode 100644 system/src/Grav/Common/Filesystem/ZipArchiver.php 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/Form/FormFlash.php create mode 100644 system/src/Grav/Common/Helpers/LogViewer.php create mode 100644 system/src/Grav/Common/Helpers/YamlLinter.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/PageContentInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageFormInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageLegacyInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PageTranslateInterface.php create mode 100644 system/src/Grav/Common/Page/Interfaces/PagesSourceInterface.php create mode 100644 system/src/Grav/Common/Page/Markdown/Excerpts.php create mode 100644 system/src/Grav/Common/Page/Traits/PageFormTrait.php create mode 100644 system/src/Grav/Common/Processors/BackupsProcessor.php delete mode 100644 system/src/Grav/Common/Processors/ConfigurationProcessor.php delete mode 100644 system/src/Grav/Common/Processors/DebuggerInitProcessor.php delete mode 100644 system/src/Grav/Common/Processors/ErrorsProcessor.php create mode 100644 system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php create mode 100644 system/src/Grav/Common/Processors/RequestProcessor.php create mode 100644 system/src/Grav/Common/Processors/SchedulerProcessor.php delete mode 100644 system/src/Grav/Common/Processors/SiteSetupProcessor.php create mode 100644 system/src/Grav/Common/Scheduler/Cron.php create mode 100644 system/src/Grav/Common/Scheduler/IntervalTrait.php create mode 100644 system/src/Grav/Common/Scheduler/Job.php create mode 100644 system/src/Grav/Common/Scheduler/Scheduler.php create mode 100644 system/src/Grav/Common/Service/AccountsServiceProvider.php create mode 100644 system/src/Grav/Common/Service/BackupsServiceProvider.php create mode 100644 system/src/Grav/Common/Service/FilesystemServiceProvider.php create mode 100644 system/src/Grav/Common/Service/FlexServiceProvider.php create mode 100644 system/src/Grav/Common/Service/InflectorServiceProvider.php delete mode 100644 system/src/Grav/Common/Service/PageServiceProvider.php create mode 100644 system/src/Grav/Common/Service/PagesServiceProvider.php create mode 100644 system/src/Grav/Common/Service/RequestServiceProvider.php create mode 100644 system/src/Grav/Common/Service/SchedulerServiceProvider.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/Node/TwigNodeRender.php create mode 100644 system/src/Grav/Common/Twig/Node/TwigNodeThrow.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserCache.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php create mode 100644 system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.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 create mode 100644 system/src/Grav/Common/User/DataUser/User.php create mode 100644 system/src/Grav/Common/User/DataUser/UserCollection.php create mode 100644 system/src/Grav/Common/User/Interfaces/AuthorizeInterface.php create mode 100644 system/src/Grav/Common/User/Interfaces/UserCollectionInterface.php create mode 100644 system/src/Grav/Common/User/Interfaces/UserGroupInterface.php create mode 100644 system/src/Grav/Common/User/Interfaces/UserInterface.php create mode 100644 system/src/Grav/Common/User/Traits/UserTrait.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/LogViewerCommand.php create mode 100644 system/src/Grav/Console/Cli/PageSystemValidatorCommand.php create mode 100644 system/src/Grav/Console/Cli/SchedulerCommand.php create mode 100644 system/src/Grav/Console/Cli/ServerCommand.php create mode 100644 system/src/Grav/Console/Cli/YamlLinterCommand.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/Collection/AbstractIndexCollection.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/DI/Container.php create mode 100644 system/src/Grav/Framework/File/AbstractFile.php create mode 100644 system/src/Grav/Framework/File/CsvFile.php create mode 100644 system/src/Grav/Framework/File/DataFile.php create mode 100644 system/src/Grav/Framework/File/File.php create mode 100644 system/src/Grav/Framework/File/Formatter/AbstractFormatter.php create mode 100644 system/src/Grav/Framework/File/Formatter/CsvFormatter.php create mode 100644 system/src/Grav/Framework/File/IniFile.php create mode 100644 system/src/Grav/Framework/File/Interfaces/FileFormatterInterface.php create mode 100644 system/src/Grav/Framework/File/Interfaces/FileInterface.php create mode 100644 system/src/Grav/Framework/File/JsonFile.php create mode 100644 system/src/Grav/Framework/File/MarkdownFile.php create mode 100644 system/src/Grav/Framework/File/YamlFile.php create mode 100644 system/src/Grav/Framework/Filesystem/Filesystem.php create mode 100644 system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php create mode 100644 system/src/Grav/Framework/Flex/Flex.php create mode 100644 system/src/Grav/Framework/Flex/FlexCollection.php create mode 100644 system/src/Grav/Framework/Flex/FlexDirectory.php create mode 100644 system/src/Grav/Framework/Flex/FlexDirectoryForm.php create mode 100644 system/src/Grav/Framework/Flex/FlexForm.php create mode 100644 system/src/Grav/Framework/Flex/FlexFormFlash.php create mode 100644 system/src/Grav/Framework/Flex/FlexIndex.php create mode 100644 system/src/Grav/Framework/Flex/FlexObject.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexCollectionInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.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/FlexFormInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexIndexInterface.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/FlexObjectInterface.php create mode 100644 system/src/Grav/Framework/Flex/Interfaces/FlexStorageInterface.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/Storage/AbstractFilesystemStorage.php create mode 100644 system/src/Grav/Framework/Flex/Storage/FileStorage.php create mode 100644 system/src/Grav/Framework/Flex/Storage/FolderStorage.php create mode 100644 system/src/Grav/Framework/Flex/Storage/SimpleStorage.php create mode 100644 system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php create mode 100644 system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php create mode 100644 system/src/Grav/Framework/Flex/Traits/FlexRelatedDirectoryTrait.php create mode 100644 system/src/Grav/Framework/Form/FormFlash.php create mode 100644 system/src/Grav/Framework/Form/FormFlashFile.php create mode 100644 system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php create mode 100644 system/src/Grav/Framework/Form/Interfaces/FormFlashInterface.php create mode 100644 system/src/Grav/Framework/Form/Interfaces/FormInterface.php create mode 100644 system/src/Grav/Framework/Form/Traits/FormTrait.php create mode 100644 system/src/Grav/Framework/Interfaces/RenderInterface.php create mode 100644 system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php create mode 100644 system/src/Grav/Framework/Media/Interfaces/MediaInterface.php create mode 100644 system/src/Grav/Framework/Media/Interfaces/MediaManipulationInterface.php create mode 100644 system/src/Grav/Framework/Media/Interfaces/MediaObjectInterface.php create mode 100644 system/src/Grav/Framework/Object/Interfaces/NestedObjectCollectionInterface.php create mode 100644 system/src/Grav/Framework/Object/ObjectIndex.php create mode 100644 system/src/Grav/Framework/Pagination/AbstractPagination.php create mode 100644 system/src/Grav/Framework/Pagination/AbstractPaginationPage.php create mode 100644 system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php create mode 100644 system/src/Grav/Framework/Pagination/Interfaces/PaginationPageInterface.php create mode 100644 system/src/Grav/Framework/Pagination/Pagination.php create mode 100644 system/src/Grav/Framework/Pagination/PaginationPage.php create mode 100644 system/src/Grav/Framework/Psr7/Request.php create mode 100644 system/src/Grav/Framework/Psr7/Response.php create mode 100644 system/src/Grav/Framework/Psr7/ServerRequest.php create mode 100644 system/src/Grav/Framework/Psr7/Stream.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php create mode 100644 system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php create mode 100644 system/src/Grav/Framework/Psr7/UploadedFile.php create mode 100644 system/src/Grav/Framework/Psr7/Uri.php create mode 100644 system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php create mode 100644 system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php create mode 100644 system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php create mode 100644 system/src/Grav/Framework/RequestHandler/Exception/PageExpiredException.php create mode 100644 system/src/Grav/Framework/RequestHandler/Exception/RequestException.php create mode 100644 system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php create mode 100644 system/src/Grav/Framework/RequestHandler/RequestHandler.php create mode 100644 system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php create mode 100644 system/src/Grav/Framework/Session/Exceptions/SessionException.php create mode 100644 system/src/Grav/Framework/Session/Messages.php create mode 100644 system/src/Grav/Installer/Install.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 8d1acb7..d300e71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,1371 @@ +# v1.7.15 +## 05/19/2021 + +1. [](#improved) + * Allow optional start date in page collections [#3350](https://github.com/getgrav/grav/pull/3350) + * Added `page` and `output` properties to `onOutputGenerated` and `onOutputRendered` events +1. [](#bugfix) + * Fixed twig deprecated TwigFilter messages [#3348](https://github.com/getgrav/grav/issues/3348) + * Fixed fatal error with some markdown links [getgrav/grav-premium-issues#95](https://github.com/getgrav/grav-premium-issues/issues/95) + * Fixed markdown media operations not working when using `image://` stream [#3333](https://github.com/getgrav/grav/issues/3333) [#3349](https://github.com/getgrav/grav/issues/3349) + * Fixed copying page without changing the slug [getgrav/grav-plugin-admin#2135](https://github.com/getgrav/grav-plugin-admin/issues/2139) + * Fixed missing and commonly used methods when using `system.twig.undefined_functions = false` [getgrav/grav-plugin-admin#2138](https://github.com/getgrav/grav-plugin-admin/issues/2138) + * Fixed uploading images into Flex Object if field destination is not set + +# 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 + +1. [](#improved) + * Right trim route for safety + * Use the proper ellipsis for summary [#2939](https://github.com/getgrav/grav/pull/2939) + * Left pad schedule times with zeros [#2921](https://github.com/getgrav/grav/pull/2921) + +# v1.6.26 +## 06/08/2020 + +1. [](#improved) + * Added new configuration option to control the supported attributes in markdown links [#2882](https://github.com/getgrav/grav/issues/2882) +1. [](#bugfix) + * Fixed blueprint for `system.pages.hide_empty_folders` [#1925](https://github.com/getgrav/grav/issues/2925) + * JSON Route of homepage with no ‘route’ set is valid + * Fix case-insensitive search of location header [form#425](https://github.com/getgrav/grav-plugin-form/issues/425) + +# v1.6.25 +## 05/14/2020 + +1. [](#improved) + * Added system configuration support for `HTTP_X_Forwarded` headers (host disabled by default) + * Updated `PHPUserAgentParser` to 1.0.0 + * Bump `Go` to version 1.13 in `travis.yaml` + +# v1.6.24 +## 04/27/2020 + +1. [](#improved) + * Added support for `X-Forwarded-Host` [#2891](https://github.com/getgrav/grav/pull/2891) + * Disable XDebug in Travis builds + +# v1.6.23 +## 03/19/2020 + +1. [](#new) + * Moved `Parsedown` 1.6 and `ParsedownExtra` 0.7 into `Grav\Framework\Parsedown` to allow fixes + * Added `aliases.php` with references to direct `\Parsedown` and `\ParsedownExtra` references +1. [](#improved) + * Upgraded `jQuery` to latest 3.4.1 version [#2859](https://github.com/getgrav/grav/issues/2859) +1. [](#bugfix) + * Fixed PHP 7.4 issue in ParsedownExtra [#2832](https://github.com/getgrav/grav/issues/2832) + * Fix for [user reported](https://twitter.com/OriginalSicksec) CVE path-based open redirect + * Fix for `stream_set_option` error with PHP 7.4 via Toolbox#28 [#2850](https://github.com/getgrav/grav/issues/2850) + +# v1.6.22 +## 03/05/2020 + +1. [](#new) + * Added `Pages::reset()` method +1. [](#improved) + * Updated Negotiation library to address issues [#2513](https://github.com/getgrav/grav/issues/2513) +1. [](#bugfix) + * Fixed issue with search plugins not being able to switch between page translations + * Fixed issues with `Pages::baseRoute()` not picking up active language reliably + * Reverted `validation: strict` fix as it breaks sites, see [#1273](https://github.com/getgrav/grav/issues/1273) + +# v1.6.21 +## 02/11/2020 + +1. [](#new) + * Added `ConsoleCommand::setLanguage()` method to set language to be used from CLI + * Added `ConsoleCommand::initializeGrav()` method to properly set up Grav instance to be used from CLI + * Added `ConsoleCommand::initializePlugins()`method to properly set up all plugins to be used from CLI + * Added `ConsoleCommand::initializeThemes()`method to properly set up current theme to be used from CLI + * Added `ConsoleCommand::initializePages()` method to properly set up pages to be used from CLI +1. [](#improved) + * Vendor updates +1. [](#bugfix) + * Fixed `bin/plugin` CLI calling `$themes->init()` way too early (removed it, use above methods instead) + * Fixed call to `$grav['page']` crashing CLI + * Fixed encoding problems when PHP INI setting `default_charset` is not `utf-8` [#2154](https://github.com/getgrav/grav/issues/2154) + +# v1.6.20 +## 02/03/2020 + +1. [](#bugfix) + * Fixed incorrect routing caused by `str_replace()` in `Uri::init()` [#2754](https://github.com/getgrav/grav/issues/2754) + * Fixed session cookie is being set twice in the HTTP header [#2745](https://github.com/getgrav/grav/issues/2745) + * Fixed session not restarting if user was invalid (downgrading from Grav 1.7) + * Fixed filesystem iterator calls with non-existing folders + * Fixed `checkbox` field not being saved, requires also Form v4.0.2 [#1225](https://github.com/getgrav/grav/issues/1225) + * Fixed `validation: strict` not working in blueprints [#1273](https://github.com/getgrav/grav/issues/1273) + * Fixed `Data::filter()` removing empty fields (such as empty list) by default [#2805](https://github.com/getgrav/grav/issues/2805) + * Fixed fatal error with non-integer page param value [#2803](https://github.com/getgrav/grav/issues/2803) + * Fixed `Assets::addInlineJs()` parameter type mismatch between v1.5 and v1.6 [#2659](https://github.com/getgrav/grav/issues/2659) + * Fixed `site.metadata` saving issues [#2615](https://github.com/getgrav/grav/issues/2615) + +# v1.6.19 +## 12/04/2019 + +1. [](#new) + * Catch PHP 7.4 deprecation messages and report them in debugbar instead of throwing fatal error +1. [](#bugfix) + * Fixed fatal error when calling `{{ grav.undefined }}` + * Fixed multiple issues when there are no pages in the site + * PHP 7.4 fix for [#2750](https://github.com/getgrav/grav/issues/2750) + +# v1.6.18 +## 12/02/2019 + +1. [](#bugfix) + * PHP 7.4 fix in `Pages::buildSort()` + * Updated vendor libraries for PHP 7.4 fixes in Twig and other libraries + * Fixed fatal error when `$page->id()` is null [#2731](https://github.com/getgrav/grav/pull/2731) + * Fixed cache conflicts on pages with no set id + * Fix rewrite rule for for `lighttpd` default config [#721](https://github.com/getgrav/grav/pull/2721) + +# v1.6.17 +## 11/06/2019 + +1. [](#new) + * Added working ETag (304 Not Modified) support based on the final rendered HTML +1. [](#improved) + * Safer file handling + customizable null char replacement in `CsvFormatter::decode()` + * Change of Behavior: `Inflector::hyphenize` will now automatically trim dashes at beginning and end of a string. + * Change in Behavior for `Folder::all()` so no longer fails if trying to copy non-existent dot file [#2581](https://github.com/getgrav/grav/pull/2581) + * renamed composer `test-plugins` script to `phpstan-plugins` to be more explicit [#2637](https://github.com/getgrav/grav/pull/2637) +1. [](#bugfix) + * Fixed PHP 7.1 bug in FlexMedia + * Fix cache image generation when using cropResize [#2639](https://github.com/getgrav/grav/pull/2639) + * Fix `array_merge()` exception with non-array page header metadata [#2701](https://github.com/getgrav/grav/pull/2701) + +# v1.6.16 +## 09/19/2019 + +1. [](#bugfix) + * Fixed Flex user creation if file storage is being used [#2444](https://github.com/getgrav/grav/issues/2444) + * Fixed `Badly encoded JSON data` warning when uploading files [#2663](https://github.com/getgrav/grav/issues/2663) + +# v1.6.15 +## 08/20/2019 + +1. [](#improved) + * Improved robots.txt [#2632](https://github.com/getgrav/grav/issues/2632) +1. [](#bugfix) + * Fixed broken markdown Twig tag [#2635](https://github.com/getgrav/grav/issues/2635) + * Force Symfony 4.2 in Grav 1.6 to remove a bunch of deprecated messages + +# v1.6.14 +## 08/18/2019 + +1. [](#bugfix) + * Actually include fix for `system\router.php` [#2627](https://github.com/getgrav/grav/issues/2627) + +# v1.6.13 +## 08/16/2019 + +1. [](#bugfix) + * Regression fix for `system\router.php` [#2627](https://github.com/getgrav/grav/issues/2627) + +# v1.6.12 +## 08/14/2019 + +1. [](#new) + * Added support for custom `FormFlash` save locations + * Added a new `Utils::arrayLower()` method for lowercasing arrays + * Support new GRAV_BASEDIR environment variable [#2541](https://github.com/getgrav/grav/pull/2541) + * Allow users to override plugin handler priorities [#2165](https://github.com/getgrav/grav/pull/2165) +1. [](#improved) + * Use new `Utils::getSupportedPageTypes()` to enforce `html,htm` at the front of the list [#2531](https://github.com/getgrav/grav/issues/2531) + * Updated vendor libraries + * Markdown filter is now page-aware so that it works with modular references [admin#1731](https://github.com/getgrav/grav-plugin-admin/issues/1731) + * Check of `GRAV_USER_INSTANCE` constant is already defined [#2621](https://github.com/getgrav/grav/pull/2621) +1. [](#bugfix) + * Fixed some potential issues when `$grav['user']` is not set + * Fixed error when calling `Media::add($name, null)` + * Fixed `url()` returning wrong path if using stream with grav root path in it, eg: `user-data://shop` when Grav is in `/shop` + * Fixed `url()` not returning a path to non-existing file (`user-data://shop` => `/user/data/shop`) if it is set to fail gracefully + * Fixed `url()` returning false on unknown streams, such as `ftp://domain.com`, they should be treated as external URL + * Fixed Flex User to have permissions to save and delete his own user + * Fixed new Flex User creation not being possible because of username could not be given + * Fixed fatal error 'Expiration date must be an integer, a DateInterval or null, "double" given' [#2529](https://github.com/getgrav/grav/issues/2529) + * Fixed non-existing Flex object having a bad media folder + * Fixed collections using `page@.self:` should allow modular pages if requested + * Fixed an error when trying to delete a file from non-existing Flex Object + * Fixed `FlexObject::exists()` failing sometimes just after the object has been saved + * Fixed CSV formatter not encoding strings with `"` and `,` properly + * Fixed var order in `Validation.php` [#2610](https://github.com/getgrav/grav/issues/2610) + +# v1.6.11 +## 06/21/2019 + +1. [](#new) + * Added `FormTrait::getAllFlashes()` method to get all the available form flash objects for the form + * Added creation and update timestamps to `FormFlash` objects +1. [](#improved) + * Added `FormFlashInterface`, changed constructor to take `$config` array +1. [](#bugfix) + * Fixed error in `ImageMedium::url()` if the image cache folder does not exist + * Fixed empty form flash name after file upload or form state update + * Fixed a bug in `Route::withParam()` method + * Fixed issue with `FormFlash` objects when there is no session initialized + +# v1.6.10 +## 06/14/2019 + +1. [](#improved) + * Added **page blueprints** to `YamlLinter` CLI and Admin reports + * Removed `Gitter` and `Slack` [#2502](https://github.com/getgrav/grav/issues/2502) + * Optimizations for Plugin/Theme loading + * Generalized markdown classes so they can be used outside of `Page` scope with a custom `Excerpts` class instance + * Change minimal port number to 0 (unix socket) [#2452](https://github.com/getgrav/grav/issues/2452) +1. [](#bugfix) + * Force question to install demo content in theme update [#2493](https://github.com/getgrav/grav/issues/2493) + * Fixed GPM errors from blueprints not being logged [#2505](https://github.com/getgrav/grav/issues/2505) + * Don't error when IP is invalid [#2507](https://github.com/getgrav/grav/issues/2507) + * Fixed regression with `bin/plugin` not listing the plugins available (1c725c0) + * Fixed bitwise operator in `TwigExtension::exifFunc()` [#2518](https://github.com/getgrav/grav/issues/2518) + * Fixed issue with lang prefix incorrectly identifying as admin [#2511](https://github.com/getgrav/grav/issues/2511) + * Fixed issue with `U0ils::pathPrefixedBYLanguageCode()` and trailing slash [#2510](https://github.com/getgrav/grav/issues/2511) + * Fixed regresssion issue of `Utils::Url()` not returning `false` on failure. Added new optional `fail_gracefully` 3rd attribute to return string that caused failure [#2524](https://github.com/getgrav/grav/issues/2524) + +# v1.6.9 +## 05/09/2019 + +1. [](#new) + * Added `Route::withoutParams()` methods + * Added `Pages::setCheckMethod()` method to override page configuration in Admin Plugin + * Added `Cache::clearCache('invalidate')` parameter for just invalidating the cache without deleting any cached files + * Made `UserCollectionInderface` to extend `Countable` to get the count of existing users +1. [](#improved) + * Flex admin: added default search options for flex objects + * Flex collection and object now fall back to the default template if template file doesn't exist + * Updated Vendor libraries including Twig 1.40.1 + * Updated language files from `https://crowdin.com/project/grav-core` +1. [](#bugfix) + * Fixed `$grav['route']` from being modified when the route instance gets modified + * Fixed Assets options array mixed with standalone priority [#2477](https://github.com/getgrav/grav/issues/2477) + * Fix for `avatar_url` provided by 3rd party providers + * Fixed non standard `lang` code lengths in `Utils` and `Session` detection + * Fixed saving a new object in Flex `SimpleStorage` + * Fixed exception in `Flex::getDirectories()` if the first parameter is set + * Output correct "Last Updated" in `bin/gpm info` command + * Checkbox getting interpreted as string, so created new `Validation::filterCheckbox()` + * Fixed backwards compatibility to `select` field with `selectize.create` set to true [git-sync#141](https://github.com/trilbymedia/grav-plugin-git-sync/issues/141) + * Fixed `YamlFormatter::decode()` to always return array [#2494](https://github.com/getgrav/grav/pull/2494) + * Fixed empty `$grav['request']->getAttribute('route')->getExtension()` + +# v1.6.8 +## 04/23/2019 + +1. [](#new) + * Added `FlexCollection::filterBy()` method +1. [](#bugfix) + * Revert `Use Null Coalesce Operator` [#2466](https://github.com/getgrav/grav/pull/2466) + * Fixed `FormTrait::render()` not providing config variable + * Updated `bin/grav clean` to clear `cache/compiled` and `user/config/security.yaml` + +# v1.6.7 +## 04/22/2019 + +1. [](#new) + * Added a new `bin/grav yamllinter` CLI command to find YAML Linting issues [#2468](https://github.com/getgrav/grav/issues/2468#issuecomment-485151681) +1. [](#improved) + * Improve `FormTrait` backwards compatibility with existing forms + * Added a new `Utils::getSubnet()` function for IPv4/IPv6 parsing [#2465](https://github.com/getgrav/grav/pull/2465) +1. [](#bugfix) + * Remove disabled fields from the form schema + * Fix issue when excluding `inlineJs` and `inlineCss` from Assets pipeline [#2468](https://github.com/getgrav/grav/issues/2468) + * Fix for manually set position on external URLs [#2470](https://github.com/getgrav/grav/issues/2470) + +# v1.6.6 +## 04/17/2019 + +1. [](#new) + * `FormInterface` now implements `RenderInterface` + * Added new `FormInterface::getTask()` method which reads the task from `form.task` in the blueprint +1. [](#improved) + * Updated vendor libraries to latest +1. [](#bugfix) + * Rollback `redirect_default_route` logic as it has issues with multi-lang [#2459](https://github.com/getgrav/grav/issues/2459) + * Fix potential issue with `|contains` Twig filter on PHP 7.3 + * Fixed bug in text field filtering: return empty string if value isn't a string or number [#2460](https://github.com/getgrav/grav/issues/2460) + * Force Asset `priority` to be an integer and not throw error if invalid string passed [#2461](https://github.com/getgrav/grav/issues/2461) + * Fixed bug in text field filtering: return empty string if value isn't a string or number + * Fixed `FlexForm` missing getter methods for defining form variables + +# v1.6.5 +## 04/15/2019 + +1. [](#bugfix) + * Backwards compatiblity with old `Uri::__toString()` output + +# v1.6.4 +## 04/15/2019 + +1. [](#bugfix) + * Improved `redirect_default_route` logic as well as `Uri::toArray()` to take into account `root_path` and `extension` + * Rework logic to pull out excluded files from pipeline more reliably [#2445](https://github.com/getgrav/grav/issues/2445) + * Better logic in `Utils::normalizePath` to handle externals properly [#2216](https://github.com/getgrav/grav/issues/2216) + * Fixed to force all `Page::taxonomy` to be treated as strings [#2446](https://github.com/getgrav/grav/issues/2446) + * Fixed issue with `Grav['user']` not being available [form#332](https://github.com/getgrav/grav-plugin-form/issues/332) + * Updated rounding logic for `Utils::parseSize()` [#2394](https://github.com/getgrav/grav/issues/2394) + * Fixed Flex simple storage not being properly initialized if used with caching + +# v1.6.3 +## 04/12/2019 + +1. [](#new) + * Added `Blueprint::addDynamicHandler()` method to allow custom dynamic handlers, for example `custom-options@: getCustomOptions` +1. [](#bugfix) + * Missed a `CacheCommand` reference in `bin/grav` [#2442](https://github.com/getgrav/grav/issues/2442) + * Fixed issue with `Utils::normalizePath` messing with external URLs [#2216](https://github.com/getgrav/grav/issues/2216) + * Fix for `vUndefined` versions when upgrading + +# v1.6.2 +## 04/11/2019 + +1. [](#bugfix) + * Revert renaming of `ClearCacheCommand` to ensure CLI GPM upgrades go smoothly + +# v1.6.1 +## 04/11/2019 + +1. [](#improved) + * Improved CSS for the bottom filter bar of DebugBar +1. [](#bugfix) + * Fixed issue with `@import` not being added to top of pipelined css [#2440](https://github.com/getgrav/grav/issues/2440) + +# v1.6.0 +## 04/11/2019 + +1. [](#new) + * Set minimum requirements to [PHP 7.1.3](https://getgrav.org/blog/raising-php-requirements-2018) + * New `Scheduler` functionality for periodic jobs + * New `Backup` functionality with multiple backup profiles and scheduler integration + * Refactored `Assets Manager` to be more powerful and flexible + * Updated Doctrine Collections to 1.6 + * Updated Doctrine Cache to 1.8 + * Updated Symfony Components to 4.2 + * Added new Cache purge functionality old cache manually via CLI/Admin as well as scheduler integration + * Added new `{% throw 404 'Not Found' %}` twig tag (with custom code/message) + * Added `Grav\Framework\File` classes for handling YAML, Markdown, JSON, INI and PHP serialized files + * Added `Grav\Framework\Collection\AbstractIndexCollection` class + * Added `Grav\Framework\Object\ObjectIndex` class + * Added `Grav\Framework\Flex` classes + * Added support for hiding form fields in blueprints by using dynamic property like `security@: admin.foobar`, `scope@: object` or `scope-ignore@: object` to any field + * New experimental **FlexObjects** powered `Users` for increased performance and capability (**disabled** by default) + * Added PSR-7 and PSR-15 classes + * Added `Grav\Framework\DI\Container` class + * Added `Grav\Framework\RequestHandler\RequestHandler` class + * Added `Page::httpResponseCode()` and `Page::httpHeaders()` methods + * Added `Grav\Framework\Form\Interfaces\FormInterface` + * Added `Grav\Framework\Form\Interfaces\FormFactoryInterface` + * Added `Grav\Framework\Form\FormTrait` + * Added `Page::forms()` method to get normalized list of all form headers defined in the page + * Added `onPageAction`, `onPageTask`, `onPageAction.{$action}` and `onPageTask.{$task}` events + * Added `Blueprint::processForm()` method to filter form inputs + * Move `processMarkdown()` method from `TwigExtension` to more general `Utils` class + * Added support to include extra files into `Media` (such as uploaded files) + * Added form preview support for `FlexObject`, including a way to render newly uploaded files before saving them + * Added `FlexObject::getChanges()` to determine what fields change during an update + * Added `arrayDiffMultidimensional`, `arrayIsAssociative`, `arrayCombine` Util functions + * New `$grav['users']` service to allow custom user classes implementing `UserInterface` + * Added `LogViewer` helper class and CLI command: `bin/grav logviewer` + * Added `select()` and `unselect()` methods to `CollectionInterface` and its base classes + * Added `orderBy()` and `limit()` methods to `ObjectCollectionInterface` and its base classes + * Added `user-data://` which is a writable stream (`user://data` is not and should be avoided) + * Added support for `/action:{$action}` (like task but used without nonce when only receiving data) + * Added `onAction.{$action}` event + * Added `Grav\Framework\Form\FormFlash` class to contain AJAX uploaded files in more reliable way + * Added `Grav\Framework\Form\FormFlashFile` class which implements `UploadedFileInterface` from PSR-7 + * Added `Grav\Framework\Filesystem\Filesystem` class with methods to manipulate stream URLs + * Added new `$grav['filesystem']` service using an instance of the new `Filesystem` object + * Added `{% render object layout: 'default' with { variable: true } %}` for Flex objects and collections + * Added `$grav->setup()` to simplify CLI and custom access points + * Added `CsvFormatter` and `CsvFile` classes + * Added new system config option to `pages.hide_empty_folders` if a folder has no valid `.md` file available. Default behavior is `false` for compatibility. + * Added new system config option for `languages.pages_fallback_only` forcing only 'fallback' to find page content through supported languages, default behavior is to display any language found if active language is missing + * Added `Utils::arrayFlattenDotNotation()` and `Utils::arrayUnflattenDotNotation()` helper methods +1. [](#improved) + * Add the page to onMarkdownInitialized event [#2412](https://github.com/getgrav/grav/issues/2412) + * Doctrine filecache is now namespaced with prefix to support purging + * Register all page types into `blueprint://pages` stream + * Removed `apc` and `xcache` support, made `apc` alias of `apcu` + * Support admin and regular translations via the `|t` twig filter and `t()` twig function + * Improved Grav Core installer/updater to run installer script + * Updated vendor libraries including Symfony `4.2.3` + * Renamed old `User` class to `Grav\Common\User\DataUser\User` with multiple improvements and small fixes + * `User` class now acts as a compatibility layer to older versions of Grav + * Deprecated `new User()`, `User::load()`, `User::find()` and `User::delete()` in favor of `$grav['users']` service + * `Media` constructor has now support to not to initialize the media objects + * Cleanly handle session corruption due to changing Flex object types + * Added `FlexObjectInterface::getDefaultValue()` and `FormInterface::getDefaultValue()` + * Added new `onPageContent()` event for every call to `Page::content()` + * Added phpstan: PHP Static Analysis Tool [#2393](https://github.com/getgrav/grav/pull/2393) + * Added `composer test-plugins` to test plugin issues with the current version of Grav + * Added `Flex::getObjects()` and `Flex::getMixedCollection()` methods for co-mingled collections + * Added support to use single Flex key parameter in `Flex::getObject()` method + * Added `FlexObjectInterface::search()` and `FlexCollectionInterface::search()` methods + * Override `system.media.upload_limit` with PHP's `post_max_size` or `upload_max_filesize` + * Class `Grav\Common\Page\Medium\AbstractMedia` now use array traits instead of extending `Grav\Common\Getters` + * Implemented `Grav\Framework\Psr7` classes as `Nyholm/psr7` decorators + * Added a new `cache-clear` scheduled job to go along with `cache-purge` + * Renamed `Grav\Framework\File\Formatter\FormatterInterface` to `Grav\Framework\File\Interfaces\FileFormatterInterface` + * Improved `File::save()` to use a temporary file if file isn't locked + * Improved `|t` filter to better support admin `|tu` style filter if in admin + * Update all classes to rely on `PageInterface` instead of `Page` class + * Better error checking in `bin/plugin` for existence and enabled + * Removed `media.upload_limit` references + * Twig `nicenumber`: do not use 0 + string casting hack + * Converted Twig tags to use namespaced Twig classes + * Site shows error on page rather than hard-crash when page has invalid frontmatter [#2343](https://github.com/getgrav/grav/issues/2343) + * Added `languages.default_lang` option to override the default lang (usually first supported language) + * Added `Content-Type: application/json` body support for PSR-7 `ServerRequest` + * Remove PHP time limit in `ZipArchive` + * DebugBar: Resolve twig templates in deprecated backtraces in order to help locating Twig issues + * Added `$grav['cache']->getSimpleCache()` method for getting PSR-16 compatible cache + * MediaTrait: Use PSR-16 cache + * Improved `Utils::normalizePath()` to support non-protocol URLs + * Added ability to reset `Page::metadata` to allow rebuilding from automatically generated values + * Added back missing `page.types` field in system content configuration [admin#1612](https://github.com/getgrav/grav-plugin-admin/issues/1612) + * Console commands: add method for invalidating cache + * Updated languages + * Improved `$page->forms()` call, added `$page->addForms()` + * Updated languages from crowdin + * Fixed `ImageMedium` constructor warning when file does not exist + * Improved `Grav\Common\User` class; added `$user->update()` method + * Added trim support for text input fields `validate: trim: true` + * Improved `Grav\Framework\File\Formatter` classes to have abstract parent class and some useful methods + * Support negotiated content types set via the Request `Accept:` header + * Support negotiated language types set via the Request `Accept-Language:` header + * Cleaned up and sorted the Service `idMap` + * Updated `Grav` container object to implement PSR-11 `ContainerInterface` + * Updated Grav `Processor` classes to implement PSR-15 `MiddlewareInterface` + * Make `Data` class to extend `JsonSerializable` + * Modified debugger icon to use retina space-dude version + * Added missing `Video::preload()` method + * Set session name based on `security.salt` rather than `GRAV_ROOT` [#2242](https://github.com/getgrav/grav/issues/2242) + * Added option to configure list of `xss_invalid_protocols` in `Security` config [#2250](https://github.com/getgrav/grav/issues/2250) + * Smarter `security.salt` checking now we use `security.yaml` for other options + * Added apcu autoloader optimization + * Additional helper methods in `Language`, `Languages`, and `LanguageCodes` classes + * Call `onFatalException` event also on internal PHP errors + * Built-in PHP Webserver: log requests before handling them + * Added support for syslog and syslog facility logging (default: 'file') + * Improved usability of `System` configuration blueprint with side-tabs + 1. [](#bugfix) + * Fixed issue with `Truncator::truncateWords` and `Truncator::truncateLetters` when string not wrapped in tags [#2432](https://github.com/getgrav/grav/issues/2432) + * Fixed `Undefined method closure::fields()` when getting avatar for user, thanks @Romarain [#2422](https://github.com/getgrav/grav/issues/2422) + * Fixed cached images not being updated when source image is modified + * Fixed deleting last list item in the form + * Fixed issue with `Utils::url()` method would append extra `base_url` if URL already included it + * Fixed `mkdir(...)` race condition + * Fixed `Obtaining write lock failed on file...` + * Fixed potential undefined property in `onPageNotFound` event handling + * Fixed some potential issues/bugs found by phpstan + * Fixed regression in GPM packages casted to Array (ref, getgrav/grav-plugin-admin@e3fc4ce) + * Fixed session_start(): Setting option 'session.name' failed [#2408](https://github.com/getgrav/grav/issues/2408) + * Fixed validation for select field type with selectize + * Fixed validation for boolean toggles + * Fixed non-namespaced exceptions in scheduler + * Fixed trailing slash redirect in multlang environment [#2350](https://github.com/getgrav/grav/issues/2350) + * Fixed some issues related to Medium objects losing query string attributes + * Broke out Medium timestamp so it's not cleared on `reset()`s + * Fixed issue with `redirect_trailing_slash` losing query string [#2269](https://github.com/getgrav/grav/issues/2269) + * Fixed failed login if user attempts to log in with upper case non-english letters + * Removed extra authenticated/authorized fields when saving existing user from a form + * Fixed `Grav\Framework\Route::__toString()` returning relative URL, not relative route + * Fixed handling of `append_url_extension` inside of `Page::templateFormat()` [#2264](https://github.com/getgrav/grav/issues/2264) + * Fixed a broken language string [#2261](https://github.com/getgrav/grav/issues/2261) + * Fixed clearing cache having no effect on Doctrine cache + * Fixed `Medium::relativePath()` for streams + * Fixed `Object` serialization breaking if overriding `jsonSerialize()` method + * Fixed `YamlFormatter::decode()` when calling `init_set()` with integer + * Fixed session throwing error in CLI if initialized + * Fixed `Uri::hasStandardPort()` to support reverse proxy configurations [#1786](https://github.com/getgrav/grav/issues/1786) + * Use `append_url_extension` from page header to set template format if set [#2604](https://github.com/getgrav/grav/pull/2064) + * Fixed some bugs in Grav environment selection logic + * Use login provider User avatar if set + * Fixed `Folder::doDelete($folder, false)` removing symlink when it should not + * Fixed asset manager to not add empty assets when they don't exist in the filesystem + * Update `script` and `style` Twig tags to use the new `Assets` classes + * Fixed asset pipeline to rewrite remote URLs as well as local [#2216](https://github.com/getgrav/grav/issues/2216) + # v1.5.10 ## 03/21/2019 @@ -35,7 +1403,7 @@ * Updated vendor libraries 1. [](#bugfix) * Support spaces with filenames in responsive images [#2300](https://github.com/getgrav/grav/pull/2300) - + # v1.5.6 ## 12/14/2018 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/CONTRIBUTING.md b/CONTRIBUTING.md index da8896f..112fe4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ The issue tracker is the preferred channel for [bug reports](#bugs), requests](#pull-requests), but please respect the following restrictions: * Please **do not** use the issue tracker for support requests. Use - [the Forum](http://getgrav.org/forum) or [the Gitter chat](https://gitter.im/getgrav/grav). + [the Forum](http://getgrav.org/forum) or [the Chat](https://chat.getgrav.org/). @@ -110,7 +110,8 @@ Good pull requests - patches, improvements, new features - are a fantastic help. They should remain focused in scope and avoid containing unrelated commits. -**Please ask first** in [Slack](https://getgrav.org/slack) or in the Forum before embarking on any significant pull request (e.g. +**Please ask first** in [the Forum](http://getgrav.org/forum) or [the Chat](https://chat.getgrav.org/) +before embarking on any significant pull request (e.g. implementing features, refactoring code..), otherwise you risk spending a lot of time working on something that the project's developers might not want to merge into the project. 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 b59a482..562349f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # ![](https://avatars1.githubusercontent.com/u/8237355?v=2&s=50) Grav -[![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) +[![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) + [![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. @@ -18,9 +21,13 @@ The underlying architecture of Grav is designed to use well-established and _bes # Requirements -- PHP 5.6.4 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: @@ -81,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. @@ -103,6 +115,7 @@ If you discover a possible security issue related to Grav or one of its plugins, * Dive into more [advanced](https://learn.getgrav.org/advanced) functions * Learn about the [Grav CLI](https://learn.getgrav.org/cli-console/grav-cli) * Review examples in the [Grav Cookbook](https://learn.getgrav.org/cookbook) +* More [Awesome Grav Stuff](https://github.com/getgrav/awesome-grav) # Backers Support Grav with a monthly donation to help us continue development. [[Become a backer](https://opencollective.com/grav#backer)] @@ -124,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 041775a1274ffc0a74952eec9ceedfc062644408..3791ce36ed26e033e485226ed288050ead25032b 100755 GIT binary patch delta 473167 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

gNW6|LZt#`C~5@Q+$MMrC2w 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 ldg5mGlv7T*p!cHQ{{gIKV)g(4 delta 233158 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 diff --git a/bin/gpm b/bin/gpm index d4932a2..a2eebc6 100755 --- a/bin/gpm +++ b/bin/gpm @@ -1,26 +1,29 @@ #!/usr/bin/env php arguments->add([ - 'environment' => [ - 'prefix' => 'e', - 'longPrefix' => 'env', - 'description' => 'Configuration Environment', - 'defaultValue' => 'localhost' - ] -]); -$climate->arguments->parse(); -$environment = $climate->arguments->get('environment'); - -// Set up environment based on params. -Setup::$environment = $environment; - $grav = Grav::instance(array('loader' => $autoload)); -$grav['uri']->init(); -$grav['config']->init(); -$grav['streams']; - -$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 e36f0cc..f3f77ef 100755 --- a/bin/grav +++ b/bin/grav @@ -1,24 +1,29 @@ #!/usr/bin/env php $autoload)); + +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\SecurityCommand(), -)); +$app = new GravApplication('Grav CLI Application', GRAV_VERSION); $app->run(); diff --git a/bin/plugin b/bin/plugin index a8b089a..b401c03 100755 --- a/bin/plugin +++ b/bin/plugin @@ -1,30 +1,29 @@ #!/usr/bin/env php arguments->add([ - 'environment' => [ - 'prefix' => 'e', - 'longPrefix' => 'env', - 'description' => 'Configuration Environment', - 'defaultValue' => 'localhost' - ] -]); -$climate->arguments->parse(); -$environment = $climate->arguments->get('environment'); - -// Set up environment based on params. -Setup::$environment = $environment; - +// Bootstrap Grav container. $grav = Grav::instance(array('loader' => $autoload)); -$grav['uri']->init(); -$grav['config']->init(); -$grav['streams']; -$grav['plugins']->init(); -$grav['themes']->init(); - -$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); - -$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]); - - 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); - - if (in_array($entry, $available)) { - continue; - } - - $available[] = $entry; - $output->writeln(' ' . $index . ". " . str_pad($entry, 15) . " ${bin} ${entry} list"); - } - } - - exit; -} - -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 9172088..c3fb23e 100644 --- a/composer.json +++ b/composer.json @@ -2,71 +2,114 @@ "name": "getgrav/grav", "type": "project", "description": "Modern, Crazy Fast, Ridiculously Easy and Amazingly Powerful Flat-File CMS", - "keywords": ["cms","flat-file cms","flat cms","flatfile cms","php"], + "keywords": [ + "cms", + "flat-file cms", + "flat cms", + "flatfile cms", + "php" + ], "homepage": "https://getgrav.org", "license": "MIT", "require": { - "php": ">=5.6.4", - "twig/twig": "~1.24", - "erusev/parsedown": "1.6.4", - "erusev/parsedown-extra": "~0.7", - "symfony/yaml": "~3.4", - "symfony/console": "~3.4", - "symfony/event-dispatcher": "~3.4", - "symfony/var-dumper": "~3.4", - "symfony/polyfill-iconv": "~1.0", - "doctrine/cache": "^1.6", - "doctrine/collections": "^1.4", - "psr/simple-cache": "^1.0", - "psr/http-message": "^1.0", - "guzzlehttp/psr7": "^1.4", - "filp/whoops": "~2.0", - "matthiasmullie/minify": "^1.3", - "monolog/monolog": "~1.0", - "gregwar/image": "2.*", - "donatj/phpuseragentparser": "~0.3", - "pimple/pimple": "~3.2", - "rockettheme/toolbox": "1.4.2", - "maximebf/debugbar": "~1.10", - "ext-mbstring": "*", + "php": "^7.3.6 || ^8.0", + "ext-json": "*", "ext-openssl": "*", "ext-curl": "*", "ext-zip": "*", - "ext-json": "*", - "league/climate": "^3.2", + "ext-dom": "*", + "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", + "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.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.3", - "composer/ca-bundle": "^1.0", - "phive/twig-extensions-deferred": "^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": "^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.1", - "phpunit/php-code-coverage": "~2.0", - "fzaninotto/faker": "^1.5", - "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", + "ext-memcached": "Needed to support Memcached servers", + "ext-redis": "Needed to support Redis servers" }, "config": { + "apcu-autoloader": true, "platform": { - "php": "5.6.4" + "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"] + "files": [ + "system/defines.php" + ] }, "archive": { - "exclude": ["VERSION"] + "exclude": [ + "VERSION" + ] }, "scripts": { + "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 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 5653657..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": "655f3e16c1c33d89e1baf6383b2efb26", + "content-hash": "36375d8a5daf3aef0341f9a2b023f4e9", "packages": [ { "name": "antoligy/dom-string-iterators", @@ -48,36 +48,41 @@ } ], "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.1.4", + "version": "1.2.9", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "558f321c52faeb4828c03e7dc0cfe39a09e09a2d" + "reference": "78a0e288fdcebf92aa2318a8d3656168da6ac1a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/558f321c52faeb4828c03e7dc0cfe39a09e09a2d", - "reference": "558f321c52faeb4828c03e7dc0cfe39a09e09a2d", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/78a0e288fdcebf92aa2318a8d3656168da6ac1a5", + "reference": "78a0e288fdcebf92aa2318a8d3656168da6ac1a5", "shasum": "" }, "require": { "ext-openssl": "*", "ext-pcre": "*", - "php": "^5.3.2 || ^7.0" + "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5", + "phpstan/phpstan": "^0.12.55", "psr/log": "^1.0", - "symfony/process": "^2.5 || ^3.0 || ^4.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,39 +109,142 @@ "ssl", "tls" ], - "time": "2019-01-28T09:30:10+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": "v1.6.2", + "name": "composer/semver", + "version": "1.7.2", "source": { "type": "git", - "url": "https://github.com/doctrine/cache.git", - "reference": "eb152c5100571c7a45470ff2a35095ab3f3b900b" + "url": "https://github.com/composer/semver.git", + "reference": "647490bbcaf7fc4891c58f47b825eb99d19c377a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/cache/zipball/eb152c5100571c7a45470ff2a35095ab3f3b900b", - "reference": "eb152c5100571c7a45470ff2a35095ab3f3b900b", + "url": "https://api.github.com/repos/composer/semver/zipball/647490bbcaf7fc4891c58f47b825eb99d19c377a", + "reference": "647490bbcaf7fc4891c58f47b825eb99d19c377a", "shasum": "" }, "require": { - "php": "~5.5|~7.0" - }, - "conflict": { - "doctrine/common": ">2.2,<2.4" + "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "~4.8|~5.0", - "predis/predis": "~1.0", - "satooshi/php-coveralls": "~0.6" + "phpunit/phpunit": "^4.5 || ^5.0.5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.6.x-dev" + "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", + "psr/cache": ">=3" + }, + "require-dev": { + "alcaeus/mongo-php-adapter": "^1.1", + "cache/integration-tests": "dev-master", + "doctrine/coding-standard": "^8.0", + "mongodb/mongodb": "^1.1", + "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", "autoload": { "psr-4": { "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" @@ -147,6 +255,10 @@ "MIT" ], "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, { "name": "Roman Borschel", "email": "roman@code-factory.org" @@ -155,10 +267,6 @@ "name": "Benjamin Eberlei", "email": "kontakt@beberlei.de" }, - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, { "name": "Jonathan Wage", "email": "jonwage@gmail.com" @@ -168,44 +276,66 @@ "email": "schmittjoh@gmail.com" } ], - "description": "Caching library offering an object-oriented API for many cache backends", - "homepage": "http://www.doctrine-project.org", + "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.", + "homepage": "https://www.doctrine-project.org/projects/cache.html", "keywords": [ + "abstraction", + "apcu", "cache", - "caching" + "caching", + "couchdb", + "memcached", + "php", + "redis", + "xcache" ], - "time": "2017-07-22T12:49:21+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": "v1.4.0", + "version": "1.6.7", "source": { "type": "git", "url": "https://github.com/doctrine/collections.git", - "reference": "1a4fb7e902202c33cce8c55989b945612943c2ba" + "reference": "55f8b799269a1a472457bd1a41b4f379d4cfba4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/1a4fb7e902202c33cce8c55989b945612943c2ba", - "reference": "1a4fb7e902202c33cce8c55989b945612943c2ba", + "url": "https://api.github.com/repos/doctrine/collections/zipball/55f8b799269a1a472457bd1a41b4f379d4cfba4a", + "reference": "55f8b799269a1a472457bd1a41b4f379d4cfba4a", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0" + "php": "^7.1.3 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "~0.1@dev", - "phpunit/phpunit": "^5.7" + "doctrine/coding-standard": "^6.0", + "phpstan/phpstan-shim": "^0.9.2", + "phpunit/phpunit": "^7.0", + "vimeo/psalm": "^3.8.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.3.x-dev" - } - }, "autoload": { - "psr-0": { - "Doctrine\\Common\\Collections\\": "lib/" + "psr-4": { + "Doctrine\\Common\\Collections\\": "lib/Doctrine/Common/Collections" } }, "notification-url": "https://packagist.org/downloads/", @@ -213,6 +343,10 @@ "MIT" ], "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, { "name": "Roman Borschel", "email": "roman@code-factory.org" @@ -221,10 +355,6 @@ "name": "Benjamin Eberlei", "email": "kontakt@beberlei.de" }, - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, { "name": "Jonathan Wage", "email": "jonwage@gmail.com" @@ -234,42 +364,50 @@ "email": "schmittjoh@gmail.com" } ], - "description": "Collections Abstraction library", - "homepage": "http://www.doctrine-project.org", + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", "keywords": [ "array", "collections", - "iterator" + "iterators", + "php" ], - "time": "2017-01-03T10:49:41+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": "v0.13.0", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/donatj/PhpUserAgent.git", - "reference": "5f2da266d2a386f9b231d4344ae37baf7a467c2d" + "reference": "246c1cf0a44f07168c702203bf30d5f48f17bab0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/donatj/PhpUserAgent/zipball/5f2da266d2a386f9b231d4344ae37baf7a467c2d", - "reference": "5f2da266d2a386f9b231d4344ae37baf7a467c2d", + "url": "https://api.github.com/repos/donatj/PhpUserAgent/zipball/246c1cf0a44f07168c702203bf30d5f48f17bab0", + "reference": "246c1cf0a44f07168c702203bf30d5f48f17bab0", "shasum": "" }, "require": { "php": ">=5.3.0" }, "require-dev": { - "camspiers/json-pretty": "0.1.*", + "camspiers/json-pretty": "~1.0", "donatj/drop": "*", - "phpunit/phpunit": "~4.8" + "phpunit/phpunit": "~4.8|~9" }, "type": "library", "autoload": { "files": [ "src/UserAgentParser.php" - ] + ], + "psr-4": { + "donatj\\UserAgent\\": "src/UserAgent" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -279,12 +417,12 @@ { "name": "Jesse G. Donat", "email": "donatj@gmail.com", - "homepage": "http://donatstudios.com", + "homepage": "https://donatstudios.com", "role": "Developer" } ], "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", @@ -292,23 +430,130 @@ "user agent", "useragent" ], - "time": "2019-03-08T20:52:23+00:00" + "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/5", + "type": "custom" + }, + { + "url": "https://github.com/donatj", + "type": "github" + } + ], + "time": "2021-03-16T16:25:14+00:00" }, { - "name": "erusev/parsedown", - "version": "1.6.4", + "name": "dragonmantank/cron-expression", + "version": "v1.2.1", "source": { "type": "git", - "url": "https://github.com/erusev/parsedown.git", - "reference": "fbe3fe878f4fe69048bb8a52783a09802004f548" + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "9504fa9ea681b586028adaaa0877db4aecf32bad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/erusev/parsedown/zipball/fbe3fe878f4fe69048bb8a52783a09802004f548", - "reference": "fbe3fe878f4fe69048bb8a52783a09802004f548", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/9504fa9ea681b586028adaaa0877db4aecf32bad", + "reference": "9504fa9ea681b586028adaaa0877db4aecf32bad", "shasum": "" }, "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.0|~5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "source": "https://github.com/dragonmantank/cron-expression/tree/v1.2.0" + }, + "time": "2017-01-23T04:29:33+00:00" + }, + { + "name": "enshrined/svg-sanitize", + "version": "0.14.0", + "source": { + "type": "git", + "url": "https://github.com/darylldoyle/svg-sanitizer.git", + "reference": "beff89576a72540ee99476aeb9cfe98222e76fb8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/darylldoyle/svg-sanitizer/zipball/beff89576a72540ee99476aeb9cfe98222e76fb8", + "reference": "beff89576a72540ee99476aeb9cfe98222e76fb8", + "shasum": "" + }, + "require": { + "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": { @@ -337,24 +582,31 @@ "markdown", "parser" ], - "time": "2017-11-14T20:44:03+00:00" + "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.7.1", + "version": "0.8.1", "source": { "type": "git", "url": "https://github.com/erusev/parsedown-extra.git", - "reference": "0db5cce7354e4b76f155d092ab5eb3981c21258c" + "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/erusev/parsedown-extra/zipball/0db5cce7354e4b76f155d092ab5eb3981c21258c", - "reference": "0db5cce7354e4b76f155d092ab5eb3981c21258c", + "url": "https://api.github.com/repos/erusev/parsedown-extra/zipball/91ac3ff98f0cea243bdccc688df43810f044dcef", + "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef", "shasum": "" }, "require": { - "erusev/parsedown": "~1.4" + "erusev/parsedown": "^1.7.4" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35" }, "type": "library", "autoload": { @@ -381,30 +633,34 @@ "parsedown", "parser" ], - "time": "2015-11-01T10:19:22+00:00" + "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.3.1", + "version": "2.12.1", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "bc0fd11bc455cc20ee4b5edabc63ebbf859324c7" + "reference": "c13c0be93cff50f88bbd70827d993026821914dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/bc0fd11bc455cc20ee4b5edabc63ebbf859324c7", - "reference": "bc0fd11bc455cc20ee4b5edabc63ebbf859324c7", + "url": "https://api.github.com/repos/filp/whoops/zipball/c13c0be93cff50f88bbd70827d993026821914dd", + "reference": "c13c0be93cff50f88bbd70827d993026821914dd", "shasum": "" }, "require": { - "php": "^5.5.9 || ^7.0", + "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", - "symfony/var-dumper": "^2.6 || ^3.0 || ^4.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": { "symfony/var-dumper": "Pretty print complex values better with var-dumper available", @@ -413,7 +669,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.2-dev" + "dev-master": "2.7-dev" } }, "autoload": { @@ -442,21 +698,31 @@ "throwable", "whoops" ], - "time": "2018-10-23T09: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": { @@ -476,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", @@ -485,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.24", + "name": "getgrav/image", + "version": "v3.0.0", "target-dir": "Gregwar/Image", "source": { "type": "git", - "url": "https://github.com/Gregwar/Image.git", - "reference": "52145816255dd20cb4bb115d0f9e1030c6287994" + "url": "https://github.com/getgrav/Image.git", + "reference": "02c1bb2c179dd894c4f6610c9c49da364ee7d264" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Gregwar/Image/zipball/52145816255dd20cb4bb115d0f9e1030c6287994", - "reference": "52145816255dd20cb4bb115d0f9e1030c6287994", + "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", @@ -529,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", @@ -537,37 +816,44 @@ "gd", "image" ], - "time": "2019-01-27T15:10:06+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.5.2", + "version": "1.8.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "9f83dded91781a01c63574e387eaa769be769115" + "reference": "35ea11d335fd638b5882ff1725228b3d35496ab1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/9f83dded91781a01c63574e387eaa769be769115", - "reference": "9f83dded91781a01c63574e387eaa769be769115", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/35ea11d335fd638b5882ff1725228b3d35496ab1", + "reference": "35ea11d335fd638b5882ff1725228b3d35496ab1", "shasum": "" }, "require": { "php": ">=5.4.0", "psr/http-message": "~1.0", - "ralouphie/getallheaders": "^2.0.5" + "ralouphie/getallheaders": "^2.0.5 || ^3.0.0" }, "provide": { "psr/http-message-implementation": "1.0" }, "require-dev": { - "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8" + "ext-zlib": "*", + "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.10" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.5-dev" + "dev-master": "1.7-dev" } }, "autoload": { @@ -604,31 +890,104 @@ "uri", "url" ], - "time": "2018-12-04T20:46:45+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": "league/climate", - "version": "3.4.1", + "name": "itsgoingd/clockwork", + "version": "v5.0.7", "source": { "type": "git", - "url": "https://github.com/thephpleague/climate.git", - "reference": "d657a19837c1f79a891381fb128b755aa3386381" + "url": "https://github.com/itsgoingd/clockwork.git", + "reference": "e41ee368ff4dcc30d3f4563fe8bd80ed72b293b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/climate/zipball/d657a19837c1f79a891381fb128b755aa3386381", - "reference": "d657a19837c1f79a891381fb128b755aa3386381", + "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/e41ee368ff4dcc30d3f4563fe8bd80ed72b293b4", + "reference": "e41ee368ff4dcc30d3f4563fe8bd80ed72b293b4", "shasum": "" }, "require": { - "php": "^5.6|^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": { + "Clockwork\\": "Clockwork/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "itsgoingd", + "email": "itsgoingd@luzer.sk", + "homepage": "https://twitter.com/itsgoingd" + } + ], + "description": "php dev tools in your browser", + "homepage": "https://underground.works/clockwork", + "keywords": [ + "Devtools", + "debugging", + "laravel", + "logging", + "lumen", + "profiling", + "slim" + ], + "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.7.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/climate.git", + "reference": "5c717c3032c5118be7ad2395dbe0813d9894e8c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/climate/zipball/5c717c3032c5118be7ad2395dbe0813d9894e8c7", + "reference": "5c717c3032c5118be7ad2395dbe0813d9894e8c7", + "shasum": "" + }, + "require": { + "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" @@ -644,17 +1003,17 @@ "MIT" ], "authors": [ - { - "name": "Craig Duncan", - "email": "git@duncanc.co.uk", - "homepage": "https://github.com/duncan3dc", - "role": "Developer" - }, { "name": "Joe Tannenbaum", "email": "hey@joe.codes", "homepage": "http://joe.codes/", "role": "Developer" + }, + { + "name": "Craig Duncan", + "email": "git@duncanc.co.uk", + "homepage": "https://github.com/duncan3dc", + "role": "Developer" } ], "description": "PHP's best friend for the terminal. CLImate allows you to easily output colored text, special formats, and more.", @@ -665,20 +1024,24 @@ "php", "terminal" ], - "time": "2018-04-29T16:43:54+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.61", + "version": "1.3.66", "source": { "type": "git", "url": "https://github.com/matthiasmullie/minify.git", - "reference": "d5acb8ce5b6acb7d11bafe97cecc533f6e4fd751" + "reference": "45fd3b0f1dfa2c965857c6d4a470bea52adc31a6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/matthiasmullie/minify/zipball/d5acb8ce5b6acb7d11bafe97cecc533f6e4fd751", - "reference": "d5acb8ce5b6acb7d11bafe97cecc533f6e4fd751", + "url": "https://api.github.com/repos/matthiasmullie/minify/zipball/45fd3b0f1dfa2c965857c6d4a470bea52adc31a6", + "reference": "45fd3b0f1dfa2c965857c6d4a470bea52adc31a6", "shasum": "" }, "require": { @@ -688,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" @@ -725,20 +1088,38 @@ "minifier", "minify" ], - "time": "2018-11-26T23:10:39+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", - "version": "1.1.2", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/matthiasmullie/path-converter.git", - "reference": "5e4b121c8b9f97c80835c1d878b0812ba1d607c9" + "reference": "e7d13b2c7e2f2268e1424aaed02085518afa02d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/matthiasmullie/path-converter/zipball/5e4b121c8b9f97c80835c1d878b0812ba1d607c9", - "reference": "5e4b121c8b9f97c80835c1d878b0812ba1d607c9", + "url": "https://api.github.com/repos/matthiasmullie/path-converter/zipball/e7d13b2c7e2f2268e1424aaed02085518afa02d9", + "reference": "e7d13b2c7e2f2268e1424aaed02085518afa02d9", "shasum": "" }, "require": { @@ -774,29 +1155,33 @@ "paths", "relative" ], - "time": "2018-10-25T15:19:41+00:00" + "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.15.0", + "version": "v1.16.5", "source": { "type": "git", "url": "https://github.com/maximebf/php-debugbar.git", - "reference": "30e7d60937ee5f1320975ca9bc7bcdd44d500f07" + "reference": "6d51ee9e94cff14412783785e79a4e7ef97b9d62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/30e7d60937ee5f1320975ca9bc7bcdd44d500f07", - "reference": "30e7d60937ee5f1320975ca9bc7bcdd44d500f07", + "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/6d51ee9e94cff14412783785e79a4e7ef97b9d62", + "reference": "6d51ee9e94cff14412783785e79a4e7ef97b9d62", "shasum": "" }, "require": { - "php": ">=5.3.0", + "php": "^7.1|^8", "psr/log": "^1.0", - "symfony/var-dumper": "^2.6|^3.0|^4.0" + "symfony/var-dumper": "^2.6|^3|^4|^5" }, "require-dev": { - "phpunit/phpunit": "^4.0|^5.0" + "phpunit/phpunit": "^7.5.20 || ^9.4.2" }, "suggest": { "kriswallsmith/assetic": "The best way to manage assets", @@ -806,7 +1191,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.14-dev" + "dev-master": "1.16-dev" } }, "autoload": { @@ -835,28 +1220,33 @@ "debug", "debugbar" ], - "time": "2017-12-15T11:13:46+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", - "version": "v0.6.4", + "version": "v0.6.5", "source": { "type": "git", "url": "https://github.com/PHPExif/php-exif.git", - "reference": "361c15b8bc7d5ef26a9492fe537f09c920fe6511" + "reference": "41f23db39d7b48e4af0e134c2e80e577c1782ac9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPExif/php-exif/zipball/361c15b8bc7d5ef26a9492fe537f09c920fe6511", - "reference": "361c15b8bc7d5ef26a9492fe537f09c920fe6511", + "url": "https://api.github.com/repos/PHPExif/php-exif/zipball/41f23db39d7b48e4af0e134c2e80e577c1782ac9", + "reference": "41f23db39d7b48e4af0e134c2e80e577c1782ac9", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=5.4" }, "require-dev": { + "jakub-onderka/php-parallel-lint": "^1.0", "phpmd/phpmd": "~2.2", - "phpunit/phpunit": "3.7.*", + "phpunit/phpunit": ">=4.0 <6.0", "satooshi/php-coveralls": "~0.6", "sebastian/phpcpd": "1.4.*@stable", "squizlabs/php_codesniffer": "1.4.*@stable" @@ -890,20 +1280,24 @@ "jpeg", "tiff" ], - "time": "2018-03-27T10:41:55+00:00" + "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.24.0", + "version": "1.26.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "bfc9ebb28f97e7a24c45bdc3f0ff482e47bb0266" + "reference": "2209ddd84e7ef1256b7af205d0717fb62cfc9c33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/bfc9ebb28f97e7a24c45bdc3f0ff482e47bb0266", - "reference": "bfc9ebb28f97e7a24c45bdc3f0ff482e47bb0266", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/2209ddd84e7ef1256b7af205d0717fb62cfc9c33", + "reference": "2209ddd84e7ef1256b7af205d0717fb62cfc9c33", "shasum": "" }, "require": { @@ -917,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" @@ -940,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" @@ -968,19 +1356,176 @@ "logging", "psr-3" ], - "time": "2018-11-05T09:00:11+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.4.0", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "23ae1f00fbc6a886cbe3062ca682391b9cc7c37b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/23ae1f00fbc6a886cbe3062ca682391b9cc7c37b", + "reference": "23ae1f00fbc6a886cbe3062ca682391b9cc7c37b", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "php-http/message-factory": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "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.4-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "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": "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" + ], + "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": "" }, @@ -1011,33 +1556,97 @@ "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" }, { - "name": "pimple/pimple", - "version": "v3.2.3", + "name": "php-http/message-factory", + "version": "v1.0.2", "source": { "type": "git", - "url": "https://github.com/silexphp/Pimple.git", - "reference": "9e403941ef9d65d20cba7d54e29fe906db42cf32" + "url": "https://github.com/php-http/message-factory.git", + "reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/silexphp/Pimple/zipball/9e403941ef9d65d20cba7d54e29fe906db42cf32", - "reference": "9e403941ef9d65d20cba7d54e29fe906db42cf32", + "url": "https://api.github.com/repos/php-http/message-factory/zipball/a478cb11f66a6ac48d8954216cfed9aa06a501a1", + "reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1", "shasum": "" }, "require": { - "php": ">=5.3.0", - "psr/container": "^1.0" - }, - "require-dev": { - "symfony/phpunit-bridge": "^3.2" + "php": ">=5.4", + "psr/http-message": "^1.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2.x-dev" + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Factory interfaces for PSR-7 HTTP Message", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "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.3.1", + "source": { + "type": "git", + "url": "https://github.com/silexphp/Pimple.git", + "reference": "21e45061c3429b1e06233475cc0e1f6fc774d5b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/silexphp/Pimple/zipball/21e45061c3429b1e06233475cc0e1f6fc774d5b0", + "reference": "21e45061c3429b1e06233475cc0e1f6fc774d5b0", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/container": "^1.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.3.x-dev" } }, "autoload": { @@ -1056,12 +1665,64 @@ } ], "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", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/master" + }, + "time": "2016-08-06T20:24:11+00:00" }, { "name": "psr/container", @@ -1110,8 +1771,67 @@ "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" }, + { + "name": "psr/http-factory", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory/tree/master" + }, + "time": "2019-04-30T12:38:16+00:00" + }, { "name": "psr/http-message", "version": "1.0.1", @@ -1160,20 +1880,137 @@ "request", "response" ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/master" + }, "time": "2016-08-06T14:39:51+00:00" }, { - "name": "psr/log", - "version": "1.1.0", + "name": "psr/http-server-handler", + "version": "1.0.1", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd" + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", - "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "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" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "2296f45510945530b9dceb8bcedb5cb84d40c5f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/2296f45510945530b9dceb8bcedb5cb84d40c5f5", + "reference": "2296f45510945530b9dceb8bcedb5cb84d40c5f5", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "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.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc", "shasum": "" }, "require": { @@ -1182,7 +2019,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { @@ -1207,7 +2044,10 @@ "psr", "psr-3" ], - "time": "2018-11-20T15:27:04+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", @@ -1255,28 +2095,31 @@ "psr-16", "simple-cache" ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/master" + }, "time": "2017-10-23T01:57:42+00:00" }, { "name": "ralouphie/getallheaders", - "version": "2.0.5", + "version": "3.0.3", "source": { "type": "git", "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "5601c8a83fbba7ef674a7369456d12f1e0d0eafa" + "reference": "120b605dfeb996808c31b6477290a714d356e822" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/5601c8a83fbba7ef674a7369456d12f1e0d0eafa", - "reference": "5601c8a83fbba7ef674a7369456d12f1e0d0eafa", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", "shasum": "" }, "require": { - "php": ">=5.3" + "php": ">=5.6" }, "require-dev": { - "phpunit/phpunit": "~3.7.0", - "satooshi/php-coveralls": ">=1.0" + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" }, "type": "library", "autoload": { @@ -1295,30 +2138,37 @@ } ], "description": "A polyfill for getallheaders.", - "time": "2016-02-11T07:05:27+00:00" + "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.2", + "version": "1.5.9", "source": { "type": "git", "url": "https://github.com/rockettheme/toolbox.git", - "reference": "93f5c3d5e173cee7419df20eed52711471abbc3e" + "reference": "2d6693235aaca2efaadb61c84dac927aaf4eabfa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rockettheme/toolbox/zipball/93f5c3d5e173cee7419df20eed52711471abbc3e", - "reference": "93f5c3d5e173cee7419df20eed52711471abbc3e", + "url": "https://api.github.com/repos/rockettheme/toolbox/zipball/2d6693235aaca2efaadb61c84dac927aaf4eabfa", + "reference": "2d6693235aaca2efaadb61c84dac927aaf4eabfa", "shasum": "" }, "require": { - "php": ">=5.4.0", + "ext-json": "*", + "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": "~5.1.5" + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpunit/phpunit": "~7.0" }, "type": "library", "autoload": { @@ -1344,25 +2194,32 @@ "php", "rockettheme" ], - "time": "2018-08-08T18:03:32+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": { @@ -1392,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": "v3.4.23", + "version": "v4.4.21", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "71ce77f37af0c5ffb9590e43cc4f70e426945c5e" + "reference": "1ba4560dbbb9fcf5ae28b61f71f49c678086cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/71ce77f37af0c5ffb9590e43cc4f70e426945c5e", - "reference": "71ce77f37af0c5ffb9590e43cc4f70e426945c5e", + "url": "https://api.github.com/repos/symfony/console/zipball/1ba4560dbbb9fcf5ae28b61f71f49c678086cf23", + "reference": "1ba4560dbbb9fcf5ae28b61f71f49c678086cf23", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/debug": "~2.8|~3.0|~4.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": { @@ -1422,11 +2287,12 @@ }, "require-dev": { "psr/log": "~1.0", - "symfony/config": "~3.3|~4.0", - "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~2.8|~3.0|~4.0", - "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.3|~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", @@ -1435,11 +2301,6 @@ "symfony/process": "" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Console\\": "" @@ -1462,46 +2323,76 @@ "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-02-23T15:06:07+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/debug", - "version": "v3.4.23", + "name": "symfony/contracts", + "version": "v1.1.10", "source": { "type": "git", - "url": "https://github.com/symfony/debug.git", - "reference": "8d8a9e877b3fcdc50ddecf8dcea146059753f782" + "url": "https://github.com/symfony/contracts.git", + "reference": "011c20407c4b99d454f44021d023fb39ce23b73d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/8d8a9e877b3fcdc50ddecf8dcea146059753f782", - "reference": "8d8a9e877b3fcdc50ddecf8dcea146059753f782", + "url": "https://api.github.com/repos/symfony/contracts/zipball/011c20407c4b99d454f44021d023fb39ce23b73d", + "reference": "011c20407c4b99d454f44021d023fb39ce23b73d", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "psr/log": "~1.0" + "php": ">=7.1.3", + "psr/cache": "^1.0", + "psr/container": "^1.0" }, - "conflict": { - "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" + "replace": { + "symfony/cache-contracts": "self.version", + "symfony/event-dispatcher-contracts": "self.version", + "symfony/http-client-contracts": "self.version", + "symfony/service-contracts": "self.version", + "symfony/translation-contracts": "self.version" }, "require-dev": { - "symfony/http-kernel": "~2.8|~3.0|~4.0" + "symfony/polyfill-intl-idn": "^1.10" + }, + "suggest": { + "psr/event-dispatcher": "When using the EventDispatcher contracts", + "symfony/cache-implementation": "", + "symfony/event-dispatcher-implementation": "", + "symfony/http-client-implementation": "", + "symfony/service-implementation": "", + "symfony/translation-implementation": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "1.1-dev" } }, "autoload": { "psr-4": { - "Symfony\\Component\\Debug\\": "" + "Symfony\\Contracts\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "**/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1510,55 +2401,83 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Debug Component", + "description": "A set of abstractions extracted out of the Symfony components", "homepage": "https://symfony.com", - "time": "2019-02-24T15:45:11+00:00" + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "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": "v3.4.23", + "version": "v4.4.20", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "ec625e2fff7f584eeb91754821807317b2e79236" + "reference": "c352647244bd376bf7d31efbd5401f13f50dad0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ec625e2fff7f584eeb91754821807317b2e79236", - "reference": "ec625e2fff7f584eeb91754821807317b2e79236", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c352647244bd376bf7d31efbd5401f13f50dad0c", + "reference": "c352647244bd376bf7d31efbd5401f13f50dad0c", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": ">=7.1.3", + "symfony/event-dispatcher-contracts": "^1.1" }, "conflict": { - "symfony/dependency-injection": "<3.3" + "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": "~2.8|~3.0|~4.0", - "symfony/dependency-injection": "~3.3|~4.0", - "symfony/expression-language": "~2.8|~3.0|~4.0", - "symfony/stopwatch": "~2.8|~3.0|~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": "3.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\EventDispatcher\\": "" @@ -1581,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-02-23T15:06:07+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.10.0", + "name": "symfony/http-client", + "version": "v4.4.21", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + "url": "https://github.com/symfony/http-client.git", + "reference": "911177e186b82e5b9a9f41c13af53699b6745657" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "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" @@ -1608,7 +2624,11 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" } }, "autoload": { @@ -1625,12 +2645,12 @@ ], "authors": [ { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" }, { - "name": "Gert de Pagter", - "email": "backendtea@gmail.com" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], "description": "Symfony polyfill for ctype functions", @@ -1641,24 +2661,41 @@ "polyfill", "portable" ], - "time": "2018-08-06T14:22:27+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.10.0", + "version": "v1.22.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-iconv.git", - "reference": "97001cfc283484c9691769f51cdf25259037eba2" + "reference": "06fb361659649bcfd6a208a0f1fcaf4e827ad342" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/97001cfc283484c9691769f51cdf25259037eba2", - "reference": "97001cfc283484c9691769f51cdf25259037eba2", + "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" @@ -1666,7 +2703,11 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" } }, "autoload": { @@ -1700,24 +2741,41 @@ "portable", "shim" ], - "time": "2018-09-21T06:26:08+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.10.0", + "version": "v1.22.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494" + "reference": "5232de97ee3b75b0360528dae24e73db49566ab1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/c79c051f5b3a46be09205c73b80b346e4153e494", - "reference": "c79c051f5b3a46be09205c73b80b346e4153e494", + "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" @@ -1725,7 +2783,11 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" } }, "autoload": { @@ -1759,44 +2821,288 @@ "portable", "shim" ], - "time": "2018-09-21T13:07:52+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/var-dumper", - "version": "v3.4.23", + "name": "symfony/polyfill-php74", + "version": "v1.22.1", "source": { "type": "git", - "url": "https://github.com/symfony/var-dumper.git", - "reference": "d34d10236300876d14291e9df85c6ef3d3bb9066" + "url": "https://github.com/symfony/polyfill-php74.git", + "reference": "577e147350331efeb816897e004d85e6e765daaf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/d34d10236300876d14291e9df85c6ef3d3bb9066", - "reference": "d34d10236300876d14291e9df85c6ef3d3bb9066", + "url": "https://api.github.com/repos/symfony/polyfill-php74/zipball/577e147350331efeb816897e004d85e6e765daaf", + "reference": "577e147350331efeb816897e004d85e6e765daaf", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/polyfill-mbstring": "~1.0" - }, - "conflict": { - "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0" - }, - "require-dev": { - "ext-iconv": "*", - "twig/twig": "~1.34|~2.4" - }, - "suggest": { - "ext-iconv": "To convert non-UTF-8 strings to UTF-8 (or symfony/polyfill-iconv in case ext-iconv cannot be used).", - "ext-intl": "To show region name in time zone dump", - "ext-symfony_debug": "" + "php": ">=7.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" } }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php74\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "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-php80", + "version": "v1.22.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dc3063ba22c2a1fd2f45ed856374d79114998f91", + "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "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.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "7e950b6366d4da90292c2e7fa820b3c1842b965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/7e950b6366d4da90292c2e7fa820b3c1842b965a", + "reference": "7e950b6366d4da90292c2e7fa820b3c1842b965a", + "shasum": "" + }, + "require": { + "php": ">=7.1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "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.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "0da0e174f728996f5d5072d6a9f0a42259dbc806" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0da0e174f728996f5d5072d6a9f0a42259dbc806", + "reference": "0da0e174f728996f5d5072d6a9f0a42259dbc806", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php72": "~1.5", + "symfony/polyfill-php80": "^1.15" + }, + "conflict": { + "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0", + "symfony/console": "<3.4" + }, + "require-dev": { + "ext-iconv": "*", + "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).", + "ext-intl": "To show region name in time zone dump", + "symfony/console": "To use the ServerDumpCommand and/or the bin/var-dump-server script" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", "autoload": { "files": [ "Resources/functions/dump.php" @@ -1822,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-02-23T15:06:07+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": "v3.4.23", + "version": "v4.4.21", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "57f1ce82c997f5a8701b89ef970e36bb657fd09c" + "reference": "3871c720871029f008928244e56cf43497da7e9d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/57f1ce82c997f5a8701b89ef970e36bb657fd09c", - "reference": "57f1ce82c997f5a8701b89ef970e36bb657fd09c", + "url": "https://api.github.com/repos/symfony/yaml/zipball/3871c720871029f008928244e56cf43497da7e9d", + "reference": "3871c720871029f008928244e56cf43497da7e9d", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "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": "3.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Yaml\\": "" @@ -1885,37 +3203,53 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Yaml Component", + "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", - "time": "2019-02-23T15:06:07+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.38.2", + "version": "v1.44.2", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "874adbd9222f928f6998732b25b01b41dff15b0c" + "reference": "138c493c5b8ee7cff3821f80b8896d371366b5fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/874adbd9222f928f6998732b25b01b41dff15b0c", - "reference": "874adbd9222f928f6998732b25b01b41dff15b0c", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/138c493c5b8ee7cff3821f80b8896d371366b5fe", + "reference": "138c493c5b8ee7cff3821f80b8896d371366b5fe", "shasum": "" }, "require": { - "php": ">=5.4.0", + "php": ">=7.2.5", "symfony/polyfill-ctype": "^1.8" }, "require-dev": { "psr/container": "^1.0", - "symfony/debug": "^2.7", - "symfony/phpunit-bridge": "^3.4.19|^4.1.8" + "symfony/phpunit-bridge": "^4.4.9|^5.0.9" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.38-dev" + "dev-master": "1.44-dev" } }, "autoload": { @@ -1937,15 +3271,14 @@ "homepage": "http://fabien.potencier.org", "role": "Lead Developer" }, + { + "name": "Twig Team", + "role": "Contributors" + }, { "name": "Armin Ronacher", "email": "armin.ronacher@active-4.com", "role": "Project Founder" - }, - { - "name": "Twig Team", - "homepage": "https://twig.symfony.com/contributors", - "role": "Contributors" } ], "description": "Twig, the flexible, fast, and secure template language for PHP", @@ -1953,31 +3286,102 @@ "keywords": [ "templating" ], - "time": "2019-03-12T18:45:24+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": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/willdurand/Negotiation.git", + "reference": "04e14f38d4edfcc974114a07d2777d90c98f3d9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/willdurand/Negotiation/zipball/04e14f38d4edfcc974114a07d2777d90c98f3d9c", + "reference": "04e14f38d4edfcc974114a07d2777d90c98f3d9c", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Negotiation\\": "src/Negotiation" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "William Durand", + "email": "will+git@drnd.me" + } + ], + "description": "Content Negotiation tools for PHP provided as a standalone library.", + "homepage": "http://williamdurand.fr/Negotiation/", + "keywords": [ + "accept", + "content", + "format", + "header", + "negotiation" + ], + "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.0", + "version": "v4.8.0", "source": { "type": "git", "url": "https://github.com/Behat/Gherkin.git", - "reference": "ab0a02ea14893860bca00f225f5621d351a3ad07" + "reference": "2391482cd003dfdc36b679b27e9f5326bd656acd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Behat/Gherkin/zipball/ab0a02ea14893860bca00f225f5621d351a3ad07", - "reference": "ab0a02ea14893860bca00f225f5621d351a3ad07", + "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" @@ -2004,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", @@ -2014,62 +3418,59 @@ "gherkin", "parser" ], - "time": "2019-01-16T14:22:17+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.4", + "version": "4.1.20", "source": { "type": "git", "url": "https://github.com/Codeception/Codeception.git", - "reference": "a2ecfe2f3ad36cc29904d2d566b0d7280854e6c9" + "reference": "d8b16e13e1781dbc3a7ae8292117d520c89a9c5a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/Codeception/zipball/a2ecfe2f3ad36cc29904d2d566b0d7280854e6c9", - "reference": "a2ecfe2f3ad36cc29904d2d566b0d7280854e6c9", + "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" }, @@ -2106,39 +3507,276 @@ "functional testing", "unit testing" ], - "time": "2019-02-20T20:45:25+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": "6.0.10", + "name": "codeception/lib-asserts", + "version": "1.13.2", "source": { "type": "git", - "url": "https://github.com/Codeception/phpunit-wrapper.git", - "reference": "7057e599d97b02b4efb009681a43b327dbce138a" + "url": "https://github.com/Codeception/lib-asserts.git", + "reference": "184231d5eab66bc69afd6b9429344d80c67a33b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/phpunit-wrapper/zipball/7057e599d97b02b4efb009681a43b327dbce138a", - "reference": "7057e599d97b02b4efb009681a43b327dbce138a", + "url": "https://api.github.com/repos/Codeception/lib-asserts/zipball/184231d5eab66bc69afd6b9429344d80c67a33b6", + "reference": "184231d5eab66bc69afd6b9429344d80c67a33b6", "shasum": "" }, "require": { - "phpunit/php-code-coverage": ">=2.2.4 <6.0", - "phpunit/phpunit": ">=4.8.28 <5.0.0 || >=5.6.3 <7.0", - "sebastian/comparator": ">1.1 <3.0", - "sebastian/diff": ">=1.4 <4.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" }, - "replace": { - "codeception/phpunit-wrapper": "*" + "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": "*", - "vlucas/phpdotenv": "^2.4" + "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/", @@ -2149,27 +3787,34 @@ { "name": "Davert", "email": "davert.php@resend.cc" + }, + { + "name": "Naktibalda" } ], "description": "PHPUnit classes used by Codeception", - "time": "2018-06-20T20:08:14+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.0.4", + "version": "3.7.0", "source": { "type": "git", "url": "https://github.com/Codeception/Stub.git", - "reference": "f50bc271f392a2836ff80690ce0c058efe1ae03e" + "reference": "468dd5fe659f131fc997f5196aad87512f9b1304" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/Stub/zipball/f50bc271f392a2836ff80690ce0c058efe1ae03e", - "reference": "f50bc271f392a2836ff80690ce0c058efe1ae03e", + "url": "https://api.github.com/repos/Codeception/Stub/zipball/468dd5fe659f131fc997f5196aad87512f9b1304", + "reference": "468dd5fe659f131fc997f5196aad87512f9b1304", "shasum": "" }, "require": { - "phpunit/phpunit": ">=4.8 <8.0" + "phpunit/phpunit": "^8.4 | ^9.0" }, "type": "library", "autoload": { @@ -2182,38 +3827,39 @@ "MIT" ], "description": "Flexible Stub wrapper for PHPUnit's Mock Builder", - "time": "2018-07-26T11:55:37+00:00" + "support": { + "issues": "https://github.com/Codeception/Stub/issues", + "source": "https://github.com/Codeception/Stub/tree/3.7.0" + }, + "time": "2020-07-03T15:54:43+00:00" }, { "name": "doctrine/instantiator", - "version": "1.0.5", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d" + "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/d56bf6102915de5702778fe20f2de3b2fe570b5b", + "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b", "shasum": "" }, "require": { - "php": ">=5.3,<8.0-DEV" + "php": "^7.1 || ^8.0" }, "require-dev": { - "athletic/athletic": "~0.1.8", + "doctrine/coding-standard": "^8.0", "ext-pdo": "*", "ext-phar": "*", - "phpunit/phpunit": "~4.0", - "squizlabs/php_codesniffer": "~2.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.0.x-dev" - } - }, "autoload": { "psr-4": { "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" @@ -2227,108 +3873,63 @@ { "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", - "homepage": "https://github.com/doctrine/instantiator", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", "keywords": [ "constructor", "instantiate" ], - "time": "2015-06-14T21:17:01+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.6.0", + "name": "getgrav/markdowndocs", + "version": "2.0.1", "source": { "type": "git", - "url": "https://github.com/facebook/php-webdriver.git", - "reference": "bd8c740097eb9f2fc3735250fc1912bc811a954e" + "url": "https://github.com/getgrav/PHP-Markdown-Documentation-Generator.git", + "reference": "4a24d1b64a88da17e8f1696dc64969f5ca769064" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/facebook/php-webdriver/zipball/bd8c740097eb9f2fc3735250fc1912bc811a954e", - "reference": "bd8c740097eb9f2fc3735250fc1912bc811a954e", + "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" - ], - "time": "2018-05-16T17:37:13+00:00" - }, - { - "name": "fzaninotto/faker", - "version": "v1.8.0", - "source": { - "type": "git", - "url": "https://github.com/fzaninotto/Faker.git", - "reference": "f72816b43e74063c8b10357394b6bba8cb1c10de" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/fzaninotto/Faker/zipball/f72816b43e74063c8b10357394b6bba8cb1c10de", - "reference": "f72816b43e74063c8b10357394b6bba8cb1c10de", - "shasum": "" - }, - "require": { - "php": "^5.3.3 || ^7.0" - }, - "require-dev": { - "ext-intl": "*", - "phpunit/phpunit": "^4.8.35 || ^5.7", - "squizlabs/php_codesniffer": "^1.5" - }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.8-dev" - } - }, "autoload": { - "psr-4": { - "Faker\\": "src/Faker/" + "psr-0": { + "PHPDocsMD": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2337,57 +3938,71 @@ ], "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": "2018-07-12T10:23:15+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.3.3", + "version": "7.3.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba" + "reference": "7008573787b430c1c1f650e3722d9bba59967628" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/407b0cb880ace85c9b63c5f9551db498cb2d50ba", - "reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7008573787b430c1c1f650e3722d9bba59967628", + "reference": "7008573787b430c1c1f650e3722d9bba59967628", "shasum": "" }, "require": { - "guzzlehttp/promises": "^1.0", - "guzzlehttp/psr7": "^1.4", - "php": ">=5.5" + "ext-json": "*", + "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", - "psr/log": "^1.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.3-dev" + "dev-master": "7.3-dev" } }, "autoload": { - "files": [ - "src/functions_include.php" - ], "psr-4": { "GuzzleHttp\\": "src/" - } + }, + "files": [ + "src/functions_include.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2398,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", @@ -2408,30 +4028,54 @@ "framework", "http", "http client", + "psr-18", + "psr-7", "rest", "web service" ], - "time": "2018-04-22T15:46:56+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": { @@ -2462,39 +4106,263 @@ "keywords": [ "promise" ], - "time": "2016-12-20T10:07:11+00:00" + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/1.4.1" + }, + "time": "2021-03-07T09:25:29+00:00" }, { - "name": "phpdocumentor/reflection-common", - "version": "1.0.1", + "name": "myclabs/deep-copy", + "version": "1.10.2", "source": { "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6" + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6", - "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/776f831124e9c62e1a2c601ecc52e776d8bb7220", + "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220", "shasum": "" }, "require": { - "php": ">=5.5" + "php": "^7.1 || ^8.0" + }, + "replace": { + "myclabs/deep-copy": "self.version" }, "require-dev": { - "phpunit/phpunit": "^4.6" + "doctrine/collections": "^1.0", + "doctrine/common": "^2.6", + "phpunit/phpunit": "^7.1" }, "type": "library", + "autoload": { + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + }, + "files": [ + "src/DeepCopy/deep_copy.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.10.2" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2020-11-13T09:40:50+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v4.10.4", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "c6d052fc58cb876152f89f532b95a8d7907e7f0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/c6d052fc58cb876152f89f532b95a8d7907e7f0e", + "reference": "c6d052fc58cb876152f89f532b95a8d7907e7f0e", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=7.0" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "4.9-dev" } }, "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": [ - "src" - ] + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v4.10.4" + }, + "time": "2020-12-20T10:01:03+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "85265efd3af7ba3ca4b2a2c34dbfc5788dd29133" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/85265efd3af7ba3ca4b2a2c34dbfc5788dd29133", + "reference": "85265efd3af7ba3ca4b2a2c34dbfc5788dd29133", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "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": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "bae7c545bef187884426f042434e561ab1ddb182" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/bae7c545bef187884426f042434e561ab1ddb182", + "reference": "bae7c545bef187884426f042434e561ab1ddb182", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "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.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2516,38 +4384,45 @@ "reflection", "static analysis" ], - "time": "2017-09-11T18:02:19+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": "3.3.2", + "version": "5.2.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "bf329f6c1aadea3299f08ee804682b7c45b326a2" + "reference": "069a785b2141f5bcf49f3e353548dc1cce6df556" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/bf329f6c1aadea3299f08ee804682b7c45b326a2", - "reference": "bf329f6c1aadea3299f08ee804682b7c45b326a2", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/069a785b2141f5bcf49f3e353548dc1cce6df556", + "reference": "069a785b2141f5bcf49f3e353548dc1cce6df556", "shasum": "" }, "require": { - "php": "^5.6 || ^7.0", - "phpdocumentor/reflection-common": "^1.0.0", - "phpdocumentor/type-resolver": "^0.4.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": { - "mockery/mockery": "^0.9.4", - "phpunit/phpunit": "^4.4" + "mockery/mockery": "~1.3.2" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": [ - "src/" - ] + "phpDocumentor\\Reflection\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2558,44 +4433,49 @@ { "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": "2017-11-10T14:09:06+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": "0.4.0", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7" + "reference": "6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/9c977708995954784726e25d0cd1dddf4e65b0f7", - "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0", + "reference": "6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0", "shasum": "" }, "require": { - "php": "^5.5 || ^7.0", - "phpdocumentor/reflection-common": "^1.0" + "php": "^7.2 || ^8.0", + "phpdocumentor/reflection-common": "^2.0" }, "require-dev": { - "mockery/mockery": "^0.9.4", - "phpunit/phpunit": "^5.2||^4.8.24" + "ext-tokenizer": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-1.x": "1.x-dev" } }, "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": [ - "src/" - ] + "phpDocumentor\\Reflection\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2608,42 +4488,47 @@ "email": "me@mikevanriel.com" } ], - "time": "2017-07-14T14:27:02+00:00" + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "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": "1.8.0", + "version": "1.13.0", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06" + "reference": "be1996ed8adc35c3fd795488a653f4b518be70ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/4ba436b55987b4bf311cb7c6ba82aa528aac0a06", - "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06", + "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", - "sebastian/comparator": "^1.1|^2.0|^3.0", - "sebastian/recursion-context": "^1.0|^2.0|^3.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.8.x-dev" + "dev-master": "1.11.x-dev" } }, "autoload": { - "psr-0": { - "Prophecy\\": "src/" + "psr-4": { + "Prophecy\\": "src/Prophecy" } }, "notification-url": "https://packagist.org/downloads/", @@ -2671,43 +4556,163 @@ "spy", "stub" ], - "time": "2018-08-05T17:53:17+00:00" + "support": { + "issues": "https://github.com/phpspec/prophecy/issues", + "source": "https://github.com/phpspec/prophecy/tree/1.13.0" + }, + "time": "2021-03-17T13:42:18+00:00" }, { - "name": "phpunit/php-code-coverage", - "version": "2.2.4", + "name": "phpstan/phpstan", + "version": "0.12.84", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "eabf68b476ac7d0f73793aada060f1c1a9bf8979" + "url": "https://github.com/phpstan/phpstan.git", + "reference": "9c43f15da8798c8f30a4b099e6a94530a558cfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/eabf68b476ac7d0f73793aada060f1c1a9bf8979", - "reference": "eabf68b476ac7d0f73793aada060f1c1a9bf8979", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c43f15da8798c8f30a4b099e6a94530a558cfd5", + "reference": "9c43f15da8798c8f30a4b099e6a94530a558cfd5", "shasum": "" }, "require": { - "php": ">=5.3.3", - "phpunit/php-file-iterator": "~1.3", - "phpunit/php-text-template": "~1.2", - "phpunit/php-token-stream": "~1.3", - "sebastian/environment": "^1.3.2", - "sebastian/version": "~1.0" + "php": "^7.1|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.12-dev" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "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.12.6", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", + "reference": "46dbd43c2db973d2876d6653e53f5c2cc3a01fbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/46dbd43c2db973d2876d6653e53f5c2cc3a01fbb", + "reference": "46dbd43c2db973d2876d6653e53f5c2cc3a01fbb", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "phpstan/phpstan": "^0.12.60" }, "require-dev": { - "ext-xdebug": ">=2.1.4", - "phpunit/phpunit": "~4" + "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.12-dev" + }, + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", + "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": "9.2.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "f6293e1b30a2354e8428e004689671b83871edde" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f6293e1b30a2354e8428e004689671b83871edde", + "reference": "f6293e1b30a2354e8428e004689671b83871edde", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "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": "^9.3" }, "suggest": { - "ext-dom": "*", - "ext-xdebug": ">=2.2.1", - "ext-xmlwriter": "*" + "ext-pcov": "*", + "ext-xdebug": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.2.x-dev" + "dev-master": "9.2-dev" } }, "autoload": { @@ -2722,7 +4727,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -2733,29 +4738,42 @@ "testing", "xunit" ], - "time": "2015-10-06T15:47:00+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": "1.4.5", + "version": "3.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4" + "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/730b01bc3e867237eaac355e06a36b85dd93a8b4", - "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/aa4be8575f26070b100fccb67faabb28f21f66f8", + "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4.x-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -2770,7 +4788,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -2780,26 +4798,107 @@ "filesystem", "iterator" ], - "time": "2017-11-27T13:52:08+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/" @@ -2821,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": "1.0.9", + "version": "5.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f" + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", - "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -2861,7 +4970,7 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], @@ -2870,94 +4979,69 @@ "keywords": [ "timer" ], - "time": "2017-02-26T11:10:40+00:00" - }, - { - "name": "phpunit/php-token-stream", - "version": "1.4.12", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "1ce90ba27c42e4e44e6d8458241466380b51fa16" + "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/1ce90ba27c42e4e44e6d8458241466380b51fa16", - "reference": "1ce90ba27c42e4e44e6d8458241466380b51fa16", - "shasum": "" - }, - "require": { - "ext-tokenizer": "*", - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4-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": "2017-12-04T08:55:13+00:00" + "time": "2020-10-26T13:16:10+00:00" }, { "name": "phpunit/phpunit", - "version": "4.8.36", + "version": "9.5.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "46023de9a91eec7dfb06cc56cb4e260017298517" + "reference": "c73c6737305e779771147af66c96ca6a7ed8a741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/46023de9a91eec7dfb06cc56cb4e260017298517", - "reference": "46023de9a91eec7dfb06cc56cb4e260017298517", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c73c6737305e779771147af66c96ca6a7ed8a741", + "reference": "c73c6737305e779771147af66c96ca6a7ed8a741", "shasum": "" }, "require": { + "doctrine/instantiator": "^1.3.1", "ext-dom": "*", "ext-json": "*", - "ext-pcre": "*", - "ext-reflection": "*", - "ext-spl": "*", - "php": ">=5.3.3", - "phpspec/prophecy": "^1.3.1", - "phpunit/php-code-coverage": "~2.1", - "phpunit/php-file-iterator": "~1.4", - "phpunit/php-text-template": "~1.2", - "phpunit/php-timer": "^1.0.6", - "phpunit/phpunit-mock-objects": "~2.3", - "sebastian/comparator": "~1.2.2", - "sebastian/diff": "~1.2", - "sebastian/environment": "~1.3", - "sebastian/exporter": "~1.2", - "sebastian/global-state": "~1.0", - "sebastian/version": "~1.0", - "symfony/yaml": "~2.1|~3.0" + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "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": "*", + "phpspec/prophecy-phpunit": "^2.0.1" }, "suggest": { - "phpunit/php-invoker": "~1.1" + "ext-soap": "*", + "ext-xdebug": "*" }, "bin": [ "phpunit" @@ -2965,12 +5049,15 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.8.x-dev" + "dev-master": "9.5-dev" } }, "autoload": { "classmap": [ "src/" + ], + "files": [ + "src/Framework/Assert/Functions.php" ] }, "notification-url": "https://packagist.org/downloads/", @@ -2991,38 +5078,98 @@ "testing", "xunit" ], - "time": "2017-06-21T08:07:12+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": "phpunit/phpunit-mock-objects", - "version": "2.3.8", + "name": "psr/http-client", + "version": "1.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "ac8e7a3db35738d56ee9a76e78a4e03d97628983" + "url": "https://github.com/php-fig/http-client.git", + "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/ac8e7a3db35738d56ee9a76e78a4e03d97628983", - "reference": "ac8e7a3db35738d56ee9a76e78a4e03d97628983", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", + "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.0.2", - "php": ">=5.3.3", - "phpunit/php-text-template": "~1.2", - "sebastian/exporter": "~1.2" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "suggest": { - "ext-soap": "*" + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.3.x-dev" + "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": { @@ -3037,45 +5184,48 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", + "email": "sebastian@phpunit.de", "role": "lead" } ], - "description": "Mock Object library for PHPUnit", - "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", - "keywords": [ - "mock", - "xunit" + "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" + } ], - "abandoned": true, - "time": "2015-10-02T06:51:40+00:00" + "time": "2020-09-28T06:08:49+00:00" }, { - "name": "sebastian/comparator", - "version": "1.2.4", + "name": "sebastian/code-unit", + "version": "1.0.8", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "2b7424b55f5047b47ac6e5ccb20b2aea4011d9be" + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2b7424b55f5047b47ac6e5ccb20b2aea4011d9be", - "reference": "2b7424b55f5047b47ac6e5ccb20b2aea4011d9be", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", "shasum": "" }, "require": { - "php": ">=5.3.3", - "sebastian/diff": "~1.2", - "sebastian/exporter": "~1.2 || ~2.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "~4.4" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.2.x-dev" + "dev-master": "1.0-dev" } }, "autoload": { @@ -3088,6 +5238,123 @@ "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/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "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": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55f4261989e546dc112258c7a75935a81a7ce382" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55f4261989e546dc112258c7a75935a81a7ce382", + "reference": "55f4261989e546dc112258c7a75935a81a7ce382", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "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": "Jeff Welch", "email": "whatthejeff@gmail.com" @@ -3099,45 +5366,52 @@ { "name": "Bernhard Schussek", "email": "bschussek@2bepublished.at" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" } ], "description": "Provides the functionality to compare PHP values for equality", - "homepage": "http://www.github.com/sebastianbergmann/comparator", + "homepage": "https://github.com/sebastianbergmann/comparator", "keywords": [ "comparator", "compare", "equality" ], - "time": "2017-01-29T09:50:25+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": "1.4.3", + "name": "sebastian/complexity", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7f066a26a962dbe58ddea9f72a4e82874a3975a4" + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "739b35e53379900cc9ac327b2147867b8b6efd88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7f066a26a962dbe58ddea9f72a4e82874a3975a4", - "reference": "7f066a26a962dbe58ddea9f72a4e82874a3975a4", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88", + "reference": "739b35e53379900cc9ac327b2147867b8b6efd88", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0" + "nikic/php-parser": "^4.7", + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -3151,45 +5425,118 @@ ], "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", "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ - "diff" + "diff", + "udiff", + "unidiff", + "unified diff" ], - "time": "2017-05-22T07:24:03+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": "1.3.8", + "version": "5.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "be2c607e43ce4c89ecd60e75c6a85c126e754aea" + "reference": "388b6ced16caa751030f6a69e588299fa09200ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/be2c607e43ce4c89ecd60e75c6a85c126e754aea", - "reference": "be2c607e43ce4c89ecd60e75c6a85c126e754aea", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/388b6ced16caa751030f6a69e588299fa09200ac", + "reference": "388b6ced16caa751030f6a69e588299fa09200ac", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^4.8 || ^5.0" + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.3.x-dev" + "dev-master": "5.1-dev" } }, "autoload": { @@ -3214,34 +5561,44 @@ "environment", "hhvm" ], - "time": "2016-08-18T05:49:44+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": "1.2.2", + "version": "4.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "42c4c2eec485ee3e159ec9884f95b431287edde4" + "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/42c4c2eec485ee3e159ec9884f95b431287edde4", - "reference": "42c4c2eec485ee3e159ec9884f95b431287edde4", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/d89cc98761b8cb5a1a235a6b703ae50d34080e65", + "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65", "shasum": "" }, "require": { - "php": ">=5.3.3", - "sebastian/recursion-context": "~1.0" + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" }, "require-dev": { "ext-mbstring": "*", - "phpunit/phpunit": "~4.4" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.3.x-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -3254,6 +5611,10 @@ "BSD-3-Clause" ], "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, { "name": "Jeff Welch", "email": "whatthejeff@gmail.com" @@ -3262,17 +5623,13 @@ "name": "Volker Dusch", "email": "github@wallbash.com" }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, { "name": "Adam Harvey", "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" } ], "description": "Provides the functionality to export PHP variables for visualization", @@ -3281,27 +5638,40 @@ "export", "exporter" ], - "time": "2016-06-17T09:04:28+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": "1.1.1", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4" + "reference": "a90ccbddffa067b51f574dea6eb25d5680839455" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bc37d50fea7d017d3d340f230811c9f1d7280af4", - "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/a90ccbddffa067b51f574dea6eb25d5680839455", + "reference": "a90ccbddffa067b51f574dea6eb25d5680839455", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" }, "require-dev": { - "phpunit/phpunit": "~4.2" + "ext-dom": "*", + "phpunit/phpunit": "^9.3" }, "suggest": { "ext-uopz": "*" @@ -3309,7 +5679,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -3332,32 +5702,43 @@ "keywords": [ "global state" ], - "time": "2015-10-12T03:26:01+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/recursion-context", - "version": "1.0.5", + "name": "sebastian/lines-of-code", + "version": "1.0.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "b19cc3298482a335a95f3016d2f8a6950f0fbcd7" + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/b19cc3298482a335a95f3016d2f8a6950f0fbcd7", - "reference": "b19cc3298482a335a95f3016d2f8a6950f0fbcd7", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc", "shasum": "" }, "require": { - "php": ">=5.3.3" + "nikic/php-parser": "^4.6", + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "~4.4" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.0-dev" } }, "autoload": { @@ -3371,12 +5752,180 @@ ], "authors": [ { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, + "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": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "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": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-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/", + "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/recursion-context", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172", + "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "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": "Jeff Welch", + "email": "whatthejeff@gmail.com" }, { "name": "Adam Harvey", @@ -3385,23 +5934,152 @@ ], "description": "Provides functionality to recursively process PHP variables", "homepage": "http://www.github.com/sebastianbergmann/recursion-context", - "time": "2016-10-03T07:41:43+00:00" + "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/version", - "version": "1.0.6", + "name": "sebastian/resource-operations", + "version": "3.0.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6" + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/58b3a85e7999757d6ad81c787a1fbf5ff6c628c6", - "reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6", + "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": { "classmap": [ "src/" @@ -3420,39 +6098,46 @@ ], "description": "Library that helps with managing the version number of Git-hosted PHP projects", "homepage": "https://github.com/sebastianbergmann/version", - "time": "2015-06-21T13:59:46+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": "v3.4.23", + "version": "v5.2.4", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "c0fadd368c1031109e996316e53ffeb886d37ea1" + "reference": "3ca3a57ce9860318b20a924fec5daf5c6db44d93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/c0fadd368c1031109e996316e53ffeb886d37ea1", - "reference": "c0fadd368c1031109e996316e53ffeb886d37ea1", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/3ca3a57ce9860318b20a924fec5daf5c6db44d93", + "reference": "3ca3a57ce9860318b20a924fec5daf5c6db44d93", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/dom-crawler": "~2.8|~3.0|~4.0" + "php": ">=7.2.5", + "symfony/dom-crawler": "^4.4|^5.0" }, "require-dev": { - "symfony/css-selector": "~2.8|~3.0|~4.0", - "symfony/process": "~2.8|~3.0|~4.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": "3.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\BrowserKit\\": "" @@ -3475,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": "2019-02-23T15:06:07+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": "v3.4.23", + "version": "v5.2.4", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "8ca29297c29b64fb3a1a135e71cb25f67f9fdccf" + "reference": "f65f217b3314504a1ec99c2d6ef69016bb13490f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/8ca29297c29b64fb3a1a135e71cb25f67f9fdccf", - "reference": "8ca29297c29b64fb3a1a135e71cb25f67f9fdccf", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/f65f217b3314504a1ec99c2d6ef69016bb13490f", + "reference": "f65f217b3314504a1ec99c2d6ef69016bb13490f", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": ">=7.2.5" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\CssSelector\\": "" @@ -3515,54 +6212,71 @@ "MIT" ], "authors": [ - { - "name": "Jean-François Simon", - "email": "jeanfrancois.simon@sensiolabs.com" - }, { "name": "Fabien Potencier", "email": "fabien@symfony.com" }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony CssSelector Component", + "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", - "time": "2019-01-16T09:39:14+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": "v3.4.23", + "version": "v5.2.4", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "d40023c057393fb25f7ca80af2a56ed948c45a09" + "reference": "400e265163f65aceee7e904ef532e15228de674b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/d40023c057393fb25f7ca80af2a56ed948c45a09", - "reference": "d40023c057393fb25f7ca80af2a56ed948c45a09", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/400e265163f65aceee7e904ef532e15228de674b", + "reference": "400e265163f65aceee7e904ef532e15228de674b", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", + "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": { - "symfony/css-selector": "~2.8|~3.0|~4.0" + "masterminds/html5": "^2.6", + "symfony/css-selector": "^4.4|^5.0" }, "suggest": { "symfony/css-selector": "" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\DomCrawler\\": "" @@ -3585,33 +6299,45 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony DomCrawler Component", + "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", - "time": "2019-02-23T15:06:07+00:00" + "support": { + "source": "https://github.com/symfony/dom-crawler/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-15T18:55:04+00:00" }, { "name": "symfony/finder", - "version": "v3.4.23", + "version": "v5.2.4", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "fcdde4aa38f48190ce70d782c166f23930084f9b" + "reference": "0d639a0943822626290d169965804f79400e6a04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/fcdde4aa38f48190ce70d782c166f23930084f9b", - "reference": "fcdde4aa38f48190ce70d782c166f23930084f9b", + "url": "https://api.github.com/repos/symfony/finder/zipball/0d639a0943822626290d169965804f79400e6a04", + "reference": "0d639a0943822626290d169965804f79400e6a04", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": ">=7.2.5" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.4-dev" - } - }, "autoload": { "psr-4": { "Symfony\\Component\\Finder\\": "" @@ -3634,131 +6360,106 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Finder Component", + "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", - "time": "2019-02-22T14:44:53+00:00" + "support": { + "source": "https://github.com/symfony/finder/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-15T18:55:04+00:00" }, { - "name": "symfony/process", - "version": "v3.4.23", + "name": "theseer/tokenizer", + "version": "1.2.0", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "009f8dda80930e89e8344a4e310b08f9ff07dd2e" + "url": "https://github.com/theseer/tokenizer.git", + "reference": "75a63c33a8577608444246075ea0af0d052e452a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/009f8dda80930e89e8344a4e310b08f9ff07dd2e", - "reference": "009f8dda80930e89e8344a4e310b08f9ff07dd2e", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/75a63c33a8577608444246075ea0af0d052e452a", + "reference": "75a63c33a8577608444246075ea0af0d052e452a", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.4-dev" - } - }, "autoload": { - "psr-4": { - "Symfony\\Component\\Process\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" } ], - "description": "Symfony Process Component", - "homepage": "https://symfony.com", - "time": "2019-01-16T13:27:11+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", + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "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.4.0", + "version": "1.10.0", "source": { "type": "git", - "url": "https://github.com/webmozart/assert.git", - "reference": "83e253c8e0be5b0257b881e1827274667c5c17a9" + "url": "https://github.com/webmozarts/assert.git", + "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozart/assert/zipball/83e253c8e0be5b0257b881e1827274667c5c17a9", - "reference": "83e253c8e0be5b0257b881e1827274667c5c17a9", + "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": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" + }, "require-dev": { - "phpunit/phpunit": "^4.6", - "sebastian/version": "^1.0.1" + "phpunit/phpunit": "^8.5.13" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.3-dev" + "dev-master": "1.10-dev" } }, "autoload": { @@ -3782,26 +6483,30 @@ "check", "validate" ], - "time": "2018-12-25T11:19:39+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": { - "victorjonsson/markdowndocs": 20 - }, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=5.6.4", - "ext-mbstring": "*", + "php": "^7.3.6 || ^8.0", + "ext-json": "*", "ext-openssl": "*", "ext-curl": "*", "ext-zip": "*", - "ext-json": "*" + "ext-dom": "*", + "ext-libxml": "*" }, "platform-dev": [], "platform-overrides": { - "php": "5.6.4" - } + "php": "7.3.6" + }, + "plugin-api-version": "2.0.0" } diff --git a/index.php b/index.php index 75d0cfe..9842952 100644 --- a/index.php +++ b/index.php @@ -3,45 +3,49 @@ /** * @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_PHP_MIN', '5.6.4'); - -// Ensure vendor libraries exist -$autoload = __DIR__ . '/vendor/autoload.php'; -if (!is_file($autoload)) { - die("Please run: bin/grav install"); -} - -if (PHP_SAPI === 'cli-server') { - if (!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
"); - } -} - -use Grav\Common\Grav; -use RocketTheme\Toolbox\Event\Event; +\define('GRAV_REQUEST_TIME', microtime(true)); +\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)); } -// Register the auto-loader. -$loader = require $autoload; +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 date_default_timezone_set(@date_default_timezone_get()); -// Set internal encoding if mbstring loaded -if (!extension_loaded('mbstring')) { +// Set internal encoding. +if (!\extension_loaded('mbstring')) { die("'mbstring' extension is not loaded. This is required for Grav to run correctly"); } +@ini_set('default_charset', 'UTF-8'); mb_internal_encoding('UTF-8'); +// Ensure vendor libraries exist +$autoload = __DIR__ . '/vendor/autoload.php'; +if (!is_file($autoload)) { + die('Please run: bin/grav install'); +} + +// Register the auto-loader. +$loader = require $autoload; + +use Grav\Common\Grav; +use RocketTheme\Toolbox\Event\Event; + // Get the Grav instance $grav = Grav::instance( array( @@ -52,6 +56,9 @@ $grav = Grav::instance( // Process the page try { $grav->process(); +} catch (\Error $e) { + $grav->fireEvent('onFatalException', new Event(array('exception' => $e))); + throw $e; } catch (\Exception $e) { $grav->fireEvent('onFatalException', new Event(array('exception' => $e))); throw $e; diff --git a/now.json b/now.json new file mode 100644 index 0000000..d2e629c --- /dev/null +++ b/now.json @@ -0,0 +1,4 @@ +{ + "version": 2, + "builds": [{ "src": "*.php", "use": "@now/php" }] +} diff --git a/system/assets/debugger.css b/system/assets/debugger.css deleted file mode 100644 index 556da6a..0000000 --- a/system/assets/debugger.css +++ /dev/null @@ -1,54 +0,0 @@ -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(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 { - padding-left: 5px; -} - -.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,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)} 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 3379934675630df9f249bb6740691f1480d13e78..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 548 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+3?vf;>QaFebFq_W2nPqp?T7vkfZU1zpAgso z|NsB~{rmIh&#zv+di3bgy?ghrUAuPa(xp?UP8~gZbno81TeogqyLRn@1qN}=VxbUr>CbUCnv|p$45m)g@uLr`T2QydAYf{ zIXOAm+1XiHS(%!e8X6kv>gob5y?LQs1?UEik|4ieAeR`xuy2)~1W*rWfk$L90|Vb2 z5N2eb5_}gZC{yAZQ4*Y=R#Ki=l*-_klAn~S;FejGTAp8&U98|7Z1!T$rXHX=5s*6P zqSVBa%=|oskj&gv26KH&eM6HKs@j%7`Qx50jv*3Lb0^#_YcddMaSxmrbkL0LsJzi^ z-cSGXU$EVZ`(hz(X?fo>EnV#Lnp5o$RzK~lnX?*6-$d`l z8H2YcuH+|Z?dyHZQ+ez**XsVNX0?fRPNFMRyN+9Y@;;**@bP0l+XkKVe0xy diff --git a/system/assets/jquery/jquery-3.x.min.js b/system/assets/jquery/jquery-3.x.min.js index 4d9b3a2..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.3.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(e,t){"use strict";var n=[],r=e.document,i=Object.getPrototypeOf,o=n.slice,a=n.concat,s=n.push,u=n.indexOf,l={},c=l.toString,f=l.hasOwnProperty,p=f.toString,d=p.call(Object),h={},g=function e(t){return"function"==typeof t&&"number"!=typeof t.nodeType},y=function e(t){return null!=t&&t===t.window},v={type:!0,src:!0,noModule:!0};function m(e,t,n){var i,o=(t=t||r).createElement("script");if(o.text=e,n)for(i in v)n[i]&&(o[i]=n[i]);t.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?l[c.call(e)]||"object":typeof e}var b="3.3.1",w=function(e,t){return new w.fn.init(e,t)},T=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;w.fn=w.prototype={jquery:"3.3.1",constructor:w,length:0,toArray:function(){return o.call(this)},get:function(e){return null==e?o.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=w.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return w.each(this,e)},map:function(e){return this.pushStack(w.map(this,function(t,n){return e.call(t,n,t)}))},slice:function(){return this.pushStack(o.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(n>=0&&n0&&t-1 in e)}var E=function(e){var t,n,r,i,o,a,s,u,l,c,f,p,d,h,g,y,v,m,x,b="sizzle"+1*new Date,w=e.document,T=0,C=0,E=ae(),k=ae(),S=ae(),D=function(e,t){return e===t&&(f=!0),0},N={}.hasOwnProperty,A=[],j=A.pop,q=A.push,L=A.push,H=A.slice,O=function(e,t){for(var n=0,r=e.length;n+~]|"+M+")"+M+"*"),z=new RegExp("="+M+"*([^\\]'\"]*?)"+M+"*\\]","g"),X=new RegExp(W),U=new RegExp("^"+R+"$"),V={ID:new RegExp("^#("+R+")"),CLASS:new RegExp("^\\.("+R+")"),TAG:new RegExp("^("+R+"|[*])"),ATTR:new RegExp("^"+I),PSEUDO:new RegExp("^"+W),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("^(?:"+P+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},G=/^(?:input|select|textarea|button)$/i,Y=/^h\d$/i,Q=/^[^{]+\{\s*\[native \w/,J=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,K=/[+~]/,Z=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ee=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)},te=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ne=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},re=function(){p()},ie=me(function(e){return!0===e.disabled&&("form"in e||"label"in e)},{dir:"parentNode",next:"legend"});try{L.apply(A=H.call(w.childNodes),w.childNodes),A[w.childNodes.length].nodeType}catch(e){L={apply:A.length?function(e,t){q.apply(e,H.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function oe(e,t,r,i){var o,s,l,c,f,h,v,m=t&&t.ownerDocument,T=t?t.nodeType:9;if(r=r||[],"string"!=typeof e||!e||1!==T&&9!==T&&11!==T)return r;if(!i&&((t?t.ownerDocument||t:w)!==d&&p(t),t=t||d,g)){if(11!==T&&(f=J.exec(e)))if(o=f[1]){if(9===T){if(!(l=t.getElementById(o)))return r;if(l.id===o)return r.push(l),r}else if(m&&(l=m.getElementById(o))&&x(t,l)&&l.id===o)return r.push(l),r}else{if(f[2])return L.apply(r,t.getElementsByTagName(e)),r;if((o=f[3])&&n.getElementsByClassName&&t.getElementsByClassName)return L.apply(r,t.getElementsByClassName(o)),r}if(n.qsa&&!S[e+" "]&&(!y||!y.test(e))){if(1!==T)m=t,v=e;else if("object"!==t.nodeName.toLowerCase()){(c=t.getAttribute("id"))?c=c.replace(te,ne):t.setAttribute("id",c=b),s=(h=a(e)).length;while(s--)h[s]="#"+c+" "+ve(h[s]);v=h.join(","),m=K.test(e)&&ge(t.parentNode)||t}if(v)try{return L.apply(r,m.querySelectorAll(v)),r}catch(e){}finally{c===b&&t.removeAttribute("id")}}}return u(e.replace(B,"$1"),t,r,i)}function ae(){var e=[];function t(n,i){return e.push(n+" ")>r.cacheLength&&delete t[e.shift()],t[n+" "]=i}return t}function se(e){return e[b]=!0,e}function ue(e){var t=d.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function le(e,t){var n=e.split("|"),i=n.length;while(i--)r.attrHandle[n[i]]=t}function ce(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 fe(e){return function(t){return"input"===t.nodeName.toLowerCase()&&t.type===e}}function pe(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function de(e){return function(t){return"form"in t?t.parentNode&&!1===t.disabled?"label"in t?"label"in t.parentNode?t.parentNode.disabled===e:t.disabled===e:t.isDisabled===e||t.isDisabled!==!e&&ie(t)===e:t.disabled===e:"label"in t&&t.disabled===e}}function he(e){return se(function(t){return t=+t,se(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}function ge(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}n=oe.support={},o=oe.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return!!t&&"HTML"!==t.nodeName},p=oe.setDocument=function(e){var t,i,a=e?e.ownerDocument||e:w;return a!==d&&9===a.nodeType&&a.documentElement?(d=a,h=d.documentElement,g=!o(d),w!==d&&(i=d.defaultView)&&i.top!==i&&(i.addEventListener?i.addEventListener("unload",re,!1):i.attachEvent&&i.attachEvent("onunload",re)),n.attributes=ue(function(e){return e.className="i",!e.getAttribute("className")}),n.getElementsByTagName=ue(function(e){return e.appendChild(d.createComment("")),!e.getElementsByTagName("*").length}),n.getElementsByClassName=Q.test(d.getElementsByClassName),n.getById=ue(function(e){return h.appendChild(e).id=b,!d.getElementsByName||!d.getElementsByName(b).length}),n.getById?(r.filter.ID=function(e){var t=e.replace(Z,ee);return function(e){return e.getAttribute("id")===t}},r.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&g){var n=t.getElementById(e);return n?[n]:[]}}):(r.filter.ID=function(e){var t=e.replace(Z,ee);return function(e){var n="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return n&&n.value===t}},r.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&g){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[]}}),r.find.TAG=n.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):n.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},r.find.CLASS=n.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&g)return t.getElementsByClassName(e)},v=[],y=[],(n.qsa=Q.test(d.querySelectorAll))&&(ue(function(e){h.appendChild(e).innerHTML="
",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+P+")"),e.querySelectorAll("[id~="+b+"-]").length||y.push("~="),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+b+"+*").length||y.push(".#.+[+~]")}),ue(function(e){e.innerHTML="";var t=d.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),h.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.push(",.*:")})),(n.matchesSelector=Q.test(m=h.matches||h.webkitMatchesSelector||h.mozMatchesSelector||h.oMatchesSelector||h.msMatchesSelector))&&ue(function(e){n.disconnectedMatch=m.call(e,"*"),m.call(e,"[s!='']:x"),v.push("!=",W)}),y=y.length&&new RegExp(y.join("|")),v=v.length&&new RegExp(v.join("|")),t=Q.test(h.compareDocumentPosition),x=t||Q.test(h.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 f=!0,0;var r=!e.compareDocumentPosition-!t.compareDocumentPosition;return r||(1&(r=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!n.sortDetached&&t.compareDocumentPosition(e)===r?e===d||e.ownerDocument===w&&x(w,e)?-1:t===d||t.ownerDocument===w&&x(w,t)?1:c?O(c,e)-O(c,t):0:4&r?-1:1)}:function(e,t){if(e===t)return f=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===d?-1:t===d?1:i?-1:o?1:c?O(c,e)-O(c,t):0;if(i===o)return ce(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?ce(a[r],s[r]):a[r]===w?-1:s[r]===w?1:0},d):d},oe.matches=function(e,t){return oe(e,null,null,t)},oe.matchesSelector=function(e,t){if((e.ownerDocument||e)!==d&&p(e),t=t.replace(z,"='$1']"),n.matchesSelector&&g&&!S[t+" "]&&(!v||!v.test(t))&&(!y||!y.test(t)))try{var r=m.call(e,t);if(r||n.disconnectedMatch||e.document&&11!==e.document.nodeType)return r}catch(e){}return oe(t,d,null,[e]).length>0},oe.contains=function(e,t){return(e.ownerDocument||e)!==d&&p(e),x(e,t)},oe.attr=function(e,t){(e.ownerDocument||e)!==d&&p(e);var i=r.attrHandle[t.toLowerCase()],o=i&&N.call(r.attrHandle,t.toLowerCase())?i(e,t,!g):void 0;return void 0!==o?o:n.attributes||!g?e.getAttribute(t):(o=e.getAttributeNode(t))&&o.specified?o.value:null},oe.escape=function(e){return(e+"").replace(te,ne)},oe.error=function(e){throw new Error("Syntax error, unrecognized expression: "+e)},oe.uniqueSort=function(e){var t,r=[],i=0,o=0;if(f=!n.detectDuplicates,c=!n.sortStable&&e.slice(0),e.sort(D),f){while(t=e[o++])t===e[o]&&(i=r.push(o));while(i--)e.splice(r[i],1)}return c=null,e},i=oe.getText=function(e){var t,n="",r=0,o=e.nodeType;if(o){if(1===o||9===o||11===o){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=i(e)}else if(3===o||4===o)return e.nodeValue}else while(t=e[r++])n+=i(t);return n},(r=oe.selectors={cacheLength:50,createPseudo:se,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(Z,ee),e[3]=(e[3]||e[4]||e[5]||"").replace(Z,ee),"~="===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]||oe.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]&&oe.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return V.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=a(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(Z,ee).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=E[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&E(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=oe.attr(r,e);return null==i?"!="===t:!t||(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i.replace($," ")+" ").indexOf(n)>-1:"|="===t&&(i===n||i.slice(0,n.length+1)===n+"-"))}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,f,p,d,h,g=o!==a?"nextSibling":"previousSibling",y=t.parentNode,v=s&&t.nodeName.toLowerCase(),m=!u&&!s,x=!1;if(y){if(o){while(g){p=t;while(p=p[g])if(s?p.nodeName.toLowerCase()===v:1===p.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?y.firstChild:y.lastChild],a&&m){x=(d=(l=(c=(f=(p=y)[b]||(p[b]={}))[p.uniqueID]||(f[p.uniqueID]={}))[e]||[])[0]===T&&l[1])&&l[2],p=d&&y.childNodes[d];while(p=++d&&p&&p[g]||(x=d=0)||h.pop())if(1===p.nodeType&&++x&&p===t){c[e]=[T,d,x];break}}else if(m&&(x=d=(l=(c=(f=(p=t)[b]||(p[b]={}))[p.uniqueID]||(f[p.uniqueID]={}))[e]||[])[0]===T&&l[1]),!1===x)while(p=++d&&p&&p[g]||(x=d=0)||h.pop())if((s?p.nodeName.toLowerCase()===v:1===p.nodeType)&&++x&&(m&&((c=(f=p[b]||(p[b]={}))[p.uniqueID]||(f[p.uniqueID]={}))[e]=[T,x]),p===t))break;return(x-=i)===r||x%r==0&&x/r>=0}}},PSEUDO:function(e,t){var n,i=r.pseudos[e]||r.setFilters[e.toLowerCase()]||oe.error("unsupported pseudo: "+e);return i[b]?i(t):i.length>1?(n=[e,e,"",t],r.setFilters.hasOwnProperty(e.toLowerCase())?se(function(e,n){var r,o=i(e,t),a=o.length;while(a--)e[r=O(e,o[a])]=!(n[r]=o[a])}):function(e){return i(e,0,n)}):i}},pseudos:{not:se(function(e){var t=[],n=[],r=s(e.replace(B,"$1"));return r[b]?se(function(e,t,n,i){var o,a=r(e,null,i,[]),s=e.length;while(s--)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),t[0]=null,!n.pop()}}),has:se(function(e){return function(t){return oe(e,t).length>0}}),contains:se(function(e){return e=e.replace(Z,ee),function(t){return(t.textContent||t.innerText||i(t)).indexOf(e)>-1}}),lang:se(function(e){return U.test(e||"")||oe.error("unsupported lang: "+e),e=e.replace(Z,ee).toLowerCase(),function(t){var n;do{if(n=g?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return(n=n.toLowerCase())===e||0===n.indexOf(e+"-")}while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===h},focus:function(e){return e===d.activeElement&&(!d.hasFocus||d.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:de(!1),disabled:de(!0),checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,!0===e.selected},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeType<6)return!1;return!0},parent:function(e){return!r.pseudos.empty(e)},header:function(e){return Y.test(e.nodeName)},input:function(e){return G.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||"text"===t.toLowerCase())},first:he(function(){return[0]}),last:he(function(e,t){return[t-1]}),eq:he(function(e,t,n){return[n<0?n+t:n]}),even:he(function(e,t){for(var n=0;n=0;)e.push(r);return e}),gt:he(function(e,t,n){for(var r=n<0?n+t:n;++r1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function be(e,t,n){for(var r=0,i=t.length;r-1&&(o[l]=!(a[l]=f))}}else v=we(v===a?v.splice(h,v.length):v),i?i(null,a,v,u):L.apply(a,v)})}function Ce(e){for(var t,n,i,o=e.length,a=r.relative[e[0].type],s=a||r.relative[" "],u=a?1:0,c=me(function(e){return e===t},s,!0),f=me(function(e){return O(t,e)>-1},s,!0),p=[function(e,n,r){var i=!a&&(r||n!==l)||((t=n).nodeType?c(e,n,r):f(e,n,r));return t=null,i}];u1&&xe(p),u>1&&ve(e.slice(0,u-1).concat({value:" "===e[u-2].type?"*":""})).replace(B,"$1"),n,u0,i=e.length>0,o=function(o,a,s,u,c){var f,h,y,v=0,m="0",x=o&&[],b=[],w=l,C=o||i&&r.find.TAG("*",c),E=T+=null==w?1:Math.random()||.1,k=C.length;for(c&&(l=a===d||a||c);m!==k&&null!=(f=C[m]);m++){if(i&&f){h=0,a||f.ownerDocument===d||(p(f),s=!g);while(y=e[h++])if(y(f,a||d,s)){u.push(f);break}c&&(T=E)}n&&((f=!y&&f)&&v--,o&&x.push(f))}if(v+=m,n&&m!==v){h=0;while(y=t[h++])y(x,b,a,s);if(o){if(v>0)while(m--)x[m]||b[m]||(b[m]=j.call(u));b=we(b)}L.apply(u,b),c&&!o&&b.length>0&&v+t.length>1&&oe.uniqueSort(u)}return c&&(T=E,l=w),x};return n?se(o):o}return s=oe.compile=function(e,t){var n,r=[],i=[],o=S[e+" "];if(!o){t||(t=a(e)),n=t.length;while(n--)(o=Ce(t[n]))[b]?r.push(o):i.push(o);(o=S(e,Ee(i,r))).selector=e}return o},u=oe.select=function(e,t,n,i){var o,u,l,c,f,p="function"==typeof e&&e,d=!i&&a(e=p.selector||e);if(n=n||[],1===d.length){if((u=d[0]=d[0].slice(0)).length>2&&"ID"===(l=u[0]).type&&9===t.nodeType&&g&&r.relative[u[1].type]){if(!(t=(r.find.ID(l.matches[0].replace(Z,ee),t)||[])[0]))return n;p&&(t=t.parentNode),e=e.slice(u.shift().value.length)}o=V.needsContext.test(e)?0:u.length;while(o--){if(l=u[o],r.relative[c=l.type])break;if((f=r.find[c])&&(i=f(l.matches[0].replace(Z,ee),K.test(u[0].type)&&ge(t.parentNode)||t))){if(u.splice(o,1),!(e=i.length&&ve(u)))return L.apply(n,i),n;break}}}return(p||s(e,d))(i,t,!g,n,!t||K.test(e)&&ge(t.parentNode)||t),n},n.sortStable=b.split("").sort(D).join("")===b,n.detectDuplicates=!!f,p(),n.sortDetached=ue(function(e){return 1&e.compareDocumentPosition(d.createElement("fieldset"))}),ue(function(e){return e.innerHTML="","#"===e.firstChild.getAttribute("href")})||le("type|href|height|width",function(e,t,n){if(!n)return e.getAttribute(t,"type"===t.toLowerCase()?1:2)}),n.attributes&&ue(function(e){return e.innerHTML="",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")})||le("value",function(e,t,n){if(!n&&"input"===e.nodeName.toLowerCase())return e.defaultValue}),ue(function(e){return null==e.getAttribute("disabled")})||le(P,function(e,t,n){var r;if(!n)return!0===e[t]?t.toLowerCase():(r=e.getAttributeNode(t))&&r.specified?r.value:null}),oe}(e);w.find=E,w.expr=E.selectors,w.expr[":"]=w.expr.pseudos,w.uniqueSort=w.unique=E.uniqueSort,w.text=E.getText,w.isXMLDoc=E.isXML,w.contains=E.contains,w.escapeSelector=E.escape;var k=function(e,t,n){var r=[],i=void 0!==n;while((e=e[t])&&9!==e.nodeType)if(1===e.nodeType){if(i&&w(e).is(n))break;r.push(e)}return r},S=function(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n},D=w.expr.match.needsContext;function N(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}var A=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,t,n){return g(t)?w.grep(e,function(e,r){return!!t.call(e,r,e)!==n}):t.nodeType?w.grep(e,function(e){return e===t!==n}):"string"!=typeof t?w.grep(e,function(e){return u.call(t,e)>-1!==n}):w.filter(t,e,n)}w.filter=function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?w.find.matchesSelector(r,e)?[r]:[]:w.find.matches(e,w.grep(t,function(e){return 1===e.nodeType}))},w.fn.extend({find:function(e){var t,n,r=this.length,i=this;if("string"!=typeof e)return this.pushStack(w(e).filter(function(){for(t=0;t1?w.uniqueSort(n):n},filter:function(e){return this.pushStack(j(this,e||[],!1))},not:function(e){return this.pushStack(j(this,e||[],!0))},is:function(e){return!!j(this,"string"==typeof e&&D.test(e)?w(e):e||[],!1).length}});var q,L=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(w.fn.init=function(e,t,n){var i,o;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(i="<"===e[0]&&">"===e[e.length-1]&&e.length>=3?[null,e,null]:L.exec(e))||!i[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(i[1]){if(t=t instanceof w?t[0]:t,w.merge(this,w.parseHTML(i[1],t&&t.nodeType?t.ownerDocument||t:r,!0)),A.test(i[1])&&w.isPlainObject(t))for(i in t)g(this[i])?this[i](t[i]):this.attr(i,t[i]);return this}return(o=r.getElementById(i[2]))&&(this[0]=o,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):g(e)?void 0!==n.ready?n.ready(e):e(w):w.makeArray(e,this)}).prototype=w.fn,q=w(r);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};w.fn.extend({has:function(e){var t=w(e,this),n=t.length;return this.filter(function(){for(var e=0;e-1:1===n.nodeType&&w.find.matchesSelector(n,e))){o.push(n);break}return this.pushStack(o.length>1?w.uniqueSort(o):o)},index:function(e){return e?"string"==typeof e?u.call(w(e),this[0]):u.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){return this.pushStack(w.uniqueSort(w.merge(this.get(),w(e,t))))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}});function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}w.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return k(e,"parentNode")},parentsUntil:function(e,t,n){return k(e,"parentNode",n)},next:function(e){return P(e,"nextSibling")},prev:function(e){return P(e,"previousSibling")},nextAll:function(e){return k(e,"nextSibling")},prevAll:function(e){return k(e,"previousSibling")},nextUntil:function(e,t,n){return k(e,"nextSibling",n)},prevUntil:function(e,t,n){return k(e,"previousSibling",n)},siblings:function(e){return S((e.parentNode||{}).firstChild,e)},children:function(e){return S(e.firstChild)},contents:function(e){return N(e,"iframe")?e.contentDocument:(N(e,"template")&&(e=e.content||e),w.merge([],e.childNodes))}},function(e,t){w.fn[e]=function(n,r){var i=w.map(this,t,n);return"Until"!==e.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=w.filter(r,i)),this.length>1&&(O[e]||w.uniqueSort(i),H.test(e)&&i.reverse()),this.pushStack(i)}});var M=/[^\x20\t\r\n\f]+/g;function R(e){var t={};return w.each(e.match(M)||[],function(e,n){t[n]=!0}),t}w.Callbacks=function(e){e="string"==typeof e?R(e):w.extend({},e);var t,n,r,i,o=[],a=[],s=-1,u=function(){for(i=i||e.once,r=t=!0;a.length;s=-1){n=a.shift();while(++s-1)o.splice(n,1),n<=s&&s--}),this},has:function(e){return e?w.inArray(e,o)>-1:o.length>0},empty:function(){return o&&(o=[]),this},disable:function(){return i=a=[],o=n="",this},disabled:function(){return!o},lock:function(){return i=a=[],n||t||(o=n=""),this},locked:function(){return!!i},fireWith:function(e,n){return i||(n=[e,(n=n||[]).slice?n.slice():n],a.push(n),t||u()),this},fire:function(){return l.fireWith(this,arguments),this},fired:function(){return!!r}};return l};function I(e){return e}function W(e){throw e}function $(e,t,n,r){var i;try{e&&g(i=e.promise)?i.call(e).done(t).fail(n):e&&g(i=e.then)?i.call(e,t,n):t.apply(void 0,[e].slice(r))}catch(e){n.apply(void 0,[e])}}w.extend({Deferred:function(t){var n=[["notify","progress",w.Callbacks("memory"),w.Callbacks("memory"),2],["resolve","done",w.Callbacks("once memory"),w.Callbacks("once memory"),0,"resolved"],["reject","fail",w.Callbacks("once memory"),w.Callbacks("once memory"),1,"rejected"]],r="pending",i={state:function(){return r},always:function(){return o.done(arguments).fail(arguments),this},"catch":function(e){return i.then(null,e)},pipe:function(){var e=arguments;return w.Deferred(function(t){w.each(n,function(n,r){var i=g(e[r[4]])&&e[r[4]];o[r[1]](function(){var e=i&&i.apply(this,arguments);e&&g(e.promise)?e.promise().progress(t.notify).done(t.resolve).fail(t.reject):t[r[0]+"With"](this,i?[e]:arguments)})}),e=null}).promise()},then:function(t,r,i){var o=0;function a(t,n,r,i){return function(){var s=this,u=arguments,l=function(){var e,l;if(!(t=o&&(r!==W&&(s=void 0,u=[e]),n.rejectWith(s,u))}};t?c():(w.Deferred.getStackHook&&(c.stackTrace=w.Deferred.getStackHook()),e.setTimeout(c))}}return w.Deferred(function(e){n[0][3].add(a(0,e,g(i)?i:I,e.notifyWith)),n[1][3].add(a(0,e,g(t)?t:I)),n[2][3].add(a(0,e,g(r)?r:W))}).promise()},promise:function(e){return null!=e?w.extend(e,i):i}},o={};return w.each(n,function(e,t){var a=t[2],s=t[5];i[t[1]]=a.add,s&&a.add(function(){r=s},n[3-e][2].disable,n[3-e][3].disable,n[0][2].lock,n[0][3].lock),a.add(t[3].fire),o[t[0]]=function(){return o[t[0]+"With"](this===o?void 0:this,arguments),this},o[t[0]+"With"]=a.fireWith}),i.promise(o),t&&t.call(o,o),o},when:function(e){var t=arguments.length,n=t,r=Array(n),i=o.call(arguments),a=w.Deferred(),s=function(e){return function(n){r[e]=this,i[e]=arguments.length>1?o.call(arguments):n,--t||a.resolveWith(r,i)}};if(t<=1&&($(e,a.done(s(n)).resolve,a.reject,!t),"pending"===a.state()||g(i[n]&&i[n].then)))return a.then();while(n--)$(i[n],s(n),a.reject);return a.promise()}});var B=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;w.Deferred.exceptionHook=function(t,n){e.console&&e.console.warn&&t&&B.test(t.name)&&e.console.warn("jQuery.Deferred exception: "+t.message,t.stack,n)},w.readyException=function(t){e.setTimeout(function(){throw t})};var F=w.Deferred();w.fn.ready=function(e){return F.then(e)["catch"](function(e){w.readyException(e)}),this},w.extend({isReady:!1,readyWait:1,ready:function(e){(!0===e?--w.readyWait:w.isReady)||(w.isReady=!0,!0!==e&&--w.readyWait>0||F.resolveWith(r,[w]))}}),w.ready.then=F.then;function _(){r.removeEventListener("DOMContentLoaded",_),e.removeEventListener("load",_),w.ready()}"complete"===r.readyState||"loading"!==r.readyState&&!r.documentElement.doScroll?e.setTimeout(w.ready):(r.addEventListener("DOMContentLoaded",_),e.addEventListener("load",_));var z=function(e,t,n,r,i,o,a){var s=0,u=e.length,l=null==n;if("object"===x(n)){i=!0;for(s in n)z(e,t,s,n[s],!0,o,a)}else if(void 0!==r&&(i=!0,g(r)||(a=!0),l&&(a?(t.call(e,r),t=null):(l=t,t=function(e,t,n){return l.call(w(e),n)})),t))for(;s1,null,!0)},removeData:function(e){return this.each(function(){K.remove(this,e)})}}),w.extend({queue:function(e,t,n){var r;if(e)return t=(t||"fx")+"queue",r=J.get(e,t),n&&(!r||Array.isArray(n)?r=J.access(e,t,w.makeArray(n)):r.push(n)),r||[]},dequeue:function(e,t){t=t||"fx";var n=w.queue(e,t),r=n.length,i=n.shift(),o=w._queueHooks(e,t),a=function(){w.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return J.get(e,n)||J.access(e,n,{empty:w.Callbacks("once memory").add(function(){J.remove(e,[t+"queue",n])})})}}),w.fn.extend({queue:function(e,t){var n=2;return"string"!=typeof e&&(t=e,e="fx",n--),arguments.length\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,"",""]};ge.optgroup=ge.option,ge.tbody=ge.tfoot=ge.colgroup=ge.caption=ge.thead,ge.th=ge.td;function ye(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&N(e,t)?w.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n-1)i&&i.push(o);else if(l=w.contains(o.ownerDocument,o),a=ye(f.appendChild(o),"script"),l&&ve(a),n){c=0;while(o=a[c++])he.test(o.type||"")&&n.push(o)}return f}!function(){var e=r.createDocumentFragment().appendChild(r.createElement("div")),t=r.createElement("input");t.setAttribute("type","radio"),t.setAttribute("checked","checked"),t.setAttribute("name","t"),e.appendChild(t),h.checkClone=e.cloneNode(!0).cloneNode(!0).lastChild.checked,e.innerHTML="",h.noCloneChecked=!!e.cloneNode(!0).lastChild.defaultValue}();var be=r.documentElement,we=/^key/,Te=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ce=/^([^.]*)(?:\.(.+)|)/;function Ee(){return!0}function ke(){return!1}function Se(){try{return r.activeElement}catch(e){}}function De(e,t,n,r,i,o){var a,s;if("object"==typeof t){"string"!=typeof n&&(r=r||n,n=void 0);for(s in t)De(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=ke;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return w().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=w.guid++)),e.each(function(){w.event.add(this,t,i,r,n)})}w.event={global:{},add:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,y=J.get(e);if(y){n.handler&&(n=(o=n).handler,i=o.selector),i&&w.find.matchesSelector(be,i),n.guid||(n.guid=w.guid++),(u=y.events)||(u=y.events={}),(a=y.handle)||(a=y.handle=function(t){return"undefined"!=typeof w&&w.event.triggered!==t.type?w.event.dispatch.apply(e,arguments):void 0}),l=(t=(t||"").match(M)||[""]).length;while(l--)d=g=(s=Ce.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=w.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=w.event.special[d]||{},c=w.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&w.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(e,r,h,a)||e.addEventListener&&e.addEventListener(d,a)),f.add&&(f.add.call(e,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),w.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,y=J.hasData(e)&&J.get(e);if(y&&(u=y.events)){l=(t=(t||"").match(M)||[""]).length;while(l--)if(s=Ce.exec(t[l])||[],d=g=s[1],h=(s[2]||"").split(".").sort(),d){f=w.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,y.handle)||w.removeEvent(e,d,y.handle),delete u[d])}else for(d in u)w.event.remove(e,d+t[l],n,r,!0);w.isEmptyObject(u)&&J.remove(e,"handle events")}},dispatch:function(e){var t=w.event.fix(e),n,r,i,o,a,s,u=new Array(arguments.length),l=(J.get(this,"events")||{})[t.type]||[],c=w.event.special[t.type]||{};for(u[0]=t,n=1;n=1))for(;l!==this;l=l.parentNode||this)if(1===l.nodeType&&("click"!==e.type||!0!==l.disabled)){for(o=[],a={},n=0;n-1:w.find(i,this,null,[l]).length),a[i]&&o.push(r);o.length&&s.push({elem:l,handlers:o})}return l=this,u\x20\t\r\n\f]*)[^>]*)\/>/gi,Ae=/\s*$/g;function Le(e,t){return N(e,"table")&&N(11!==t.nodeType?t:t.firstChild,"tr")?w(e).children("tbody")[0]||e:e}function He(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Oe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Pe(e,t){var n,r,i,o,a,s,u,l;if(1===t.nodeType){if(J.hasData(e)&&(o=J.access(e),a=J.set(t,o),l=o.events)){delete a.handle,a.events={};for(i in l)for(n=0,r=l[i].length;n1&&"string"==typeof y&&!h.checkClone&&je.test(y))return e.each(function(i){var o=e.eq(i);v&&(t[0]=y.call(this,i,o.html())),Re(o,t,n,r)});if(p&&(i=xe(t,e[0].ownerDocument,!1,e,r),o=i.firstChild,1===i.childNodes.length&&(i=o),o||r)){for(u=(s=w.map(ye(i,"script"),He)).length;f")},clone:function(e,t,n){var r,i,o,a,s=e.cloneNode(!0),u=w.contains(e.ownerDocument,e);if(!(h.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||w.isXMLDoc(e)))for(a=ye(s),r=0,i=(o=ye(e)).length;r0&&ve(a,!u&&ye(e,"script")),s},cleanData:function(e){for(var t,n,r,i=w.event.special,o=0;void 0!==(n=e[o]);o++)if(Y(n)){if(t=n[J.expando]){if(t.events)for(r in t.events)i[r]?w.event.remove(n,r):w.removeEvent(n,r,t.handle);n[J.expando]=void 0}n[K.expando]&&(n[K.expando]=void 0)}}}),w.fn.extend({detach:function(e){return Ie(this,e,!0)},remove:function(e){return Ie(this,e)},text:function(e){return z(this,function(e){return void 0===e?w.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)})},null,e,arguments.length)},append:function(){return Re(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||Le(this,e).appendChild(e)})},prepend:function(){return Re(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=Le(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(w.cleanData(ye(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return w.clone(this,e,t)})},html:function(e){return z(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!Ae.test(e)&&!ge[(de.exec(e)||["",""])[1].toLowerCase()]){e=w.htmlPrefilter(e);try{for(;n=0&&(u+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-o-u-s-.5))),u}function et(e,t,n){var r=$e(e),i=Fe(e,t,r),o="border-box"===w.css(e,"boxSizing",!1,r),a=o;if(We.test(i)){if(!n)return i;i="auto"}return a=a&&(h.boxSizingReliable()||i===e.style[t]),("auto"===i||!parseFloat(i)&&"inline"===w.css(e,"display",!1,r))&&(i=e["offset"+t[0].toUpperCase()+t.slice(1)],a=!0),(i=parseFloat(i)||0)+Ze(e,t,n||(o?"border":"content"),a,r,i)+"px"}w.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Fe(e,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,s=G(t),u=Xe.test(t),l=e.style;if(u||(t=Je(s)),a=w.cssHooks[t]||w.cssHooks[s],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];"string"==(o=typeof n)&&(i=ie.exec(n))&&i[1]&&(n=ue(e,t,i),o="number"),null!=n&&n===n&&("number"===o&&(n+=i&&i[3]||(w.cssNumber[s]?"":"px")),h.clearCloneStyle||""!==n||0!==t.indexOf("background")||(l[t]="inherit"),a&&"set"in a&&void 0===(n=a.set(e,n,r))||(u?l.setProperty(t,n):l[t]=n))}},css:function(e,t,n,r){var i,o,a,s=G(t);return Xe.test(t)||(t=Je(s)),(a=w.cssHooks[t]||w.cssHooks[s])&&"get"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=Fe(e,t,r)),"normal"===i&&t in Ve&&(i=Ve[t]),""===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),w.each(["height","width"],function(e,t){w.cssHooks[t]={get:function(e,n,r){if(n)return!ze.test(w.css(e,"display"))||e.getClientRects().length&&e.getBoundingClientRect().width?et(e,t,r):se(e,Ue,function(){return et(e,t,r)})},set:function(e,n,r){var i,o=$e(e),a="border-box"===w.css(e,"boxSizing",!1,o),s=r&&Ze(e,t,r,a,o);return a&&h.scrollboxSize()===o.position&&(s-=Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-parseFloat(o[t])-Ze(e,t,"border",!1,o)-.5)),s&&(i=ie.exec(n))&&"px"!==(i[3]||"px")&&(e.style[t]=n,n=w.css(e,t)),Ke(e,n,s)}}}),w.cssHooks.marginLeft=_e(h.reliableMarginLeft,function(e,t){if(t)return(parseFloat(Fe(e,"marginLeft"))||e.getBoundingClientRect().left-se(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),w.each({margin:"",padding:"",border:"Width"},function(e,t){w.cssHooks[e+t]={expand:function(n){for(var r=0,i={},o="string"==typeof n?n.split(" "):[n];r<4;r++)i[e+oe[r]+t]=o[r]||o[r-2]||o[0];return i}},"margin"!==e&&(w.cssHooks[e+t].set=Ke)}),w.fn.extend({css:function(e,t){return z(this,function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=$e(e),i=t.length;a1)}});function tt(e,t,n,r,i){return new tt.prototype.init(e,t,n,r,i)}w.Tween=tt,tt.prototype={constructor:tt,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||w.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(w.cssNumber[n]?"":"px")},cur:function(){var e=tt.propHooks[this.prop];return e&&e.get?e.get(this):tt.propHooks._default.get(this)},run:function(e){var t,n=tt.propHooks[this.prop];return this.options.duration?this.pos=t=w.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):tt.propHooks._default.set(this),this}},tt.prototype.init.prototype=tt.prototype,tt.propHooks={_default:{get:function(e){var t;return 1!==e.elem.nodeType||null!=e.elem[e.prop]&&null==e.elem.style[e.prop]?e.elem[e.prop]:(t=w.css(e.elem,e.prop,""))&&"auto"!==t?t:0},set:function(e){w.fx.step[e.prop]?w.fx.step[e.prop](e):1!==e.elem.nodeType||null==e.elem.style[w.cssProps[e.prop]]&&!w.cssHooks[e.prop]?e.elem[e.prop]=e.now:w.style(e.elem,e.prop,e.now+e.unit)}}},tt.propHooks.scrollTop=tt.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},w.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2},_default:"swing"},w.fx=tt.prototype.init,w.fx.step={};var nt,rt,it=/^(?:toggle|show|hide)$/,ot=/queueHooks$/;function at(){rt&&(!1===r.hidden&&e.requestAnimationFrame?e.requestAnimationFrame(at):e.setTimeout(at,w.fx.interval),w.fx.tick())}function st(){return e.setTimeout(function(){nt=void 0}),nt=Date.now()}function ut(e,t){var n,r=0,i={height:e};for(t=t?1:0;r<4;r+=2-t)i["margin"+(n=oe[r])]=i["padding"+n]=e;return t&&(i.opacity=i.width=e),i}function lt(e,t,n){for(var r,i=(pt.tweeners[t]||[]).concat(pt.tweeners["*"]),o=0,a=i.length;o1)},removeAttr:function(e){return this.each(function(){w.removeAttr(this,e)})}}),w.extend({attr:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return"undefined"==typeof e.getAttribute?w.prop(e,t,n):(1===o&&w.isXMLDoc(e)||(i=w.attrHooks[t.toLowerCase()]||(w.expr.match.bool.test(t)?dt:void 0)),void 0!==n?null===n?void w.removeAttr(e,t):i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:(e.setAttribute(t,n+""),n):i&&"get"in i&&null!==(r=i.get(e,t))?r:null==(r=w.find.attr(e,t))?void 0:r)},attrHooks:{type:{set:function(e,t){if(!h.radioValue&&"radio"===t&&N(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},removeAttr:function(e,t){var n,r=0,i=t&&t.match(M);if(i&&1===e.nodeType)while(n=i[r++])e.removeAttribute(n)}}),dt={set:function(e,t,n){return!1===t?w.removeAttr(e,n):e.setAttribute(n,n),n}},w.each(w.expr.match.bool.source.match(/\w+/g),function(e,t){var n=ht[t]||w.find.attr;ht[t]=function(e,t,r){var i,o,a=t.toLowerCase();return r||(o=ht[a],ht[a]=i,i=null!=n(e,t,r)?a:null,ht[a]=o),i}});var gt=/^(?:input|select|textarea|button)$/i,yt=/^(?:a|area)$/i;w.fn.extend({prop:function(e,t){return z(this,w.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[w.propFix[e]||e]})}}),w.extend({prop:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return 1===o&&w.isXMLDoc(e)||(t=w.propFix[t]||t,i=w.propHooks[t]),void 0!==n?i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=w.find.attr(e,"tabindex");return t?parseInt(t,10):gt.test(e.nodeName)||yt.test(e.nodeName)&&e.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),h.optSelected||(w.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),w.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){w.propFix[this.toLowerCase()]=this});function vt(e){return(e.match(M)||[]).join(" ")}function mt(e){return e.getAttribute&&e.getAttribute("class")||""}function xt(e){return Array.isArray(e)?e:"string"==typeof e?e.match(M)||[]:[]}w.fn.extend({addClass:function(e){var t,n,r,i,o,a,s,u=0;if(g(e))return this.each(function(t){w(this).addClass(e.call(this,t,mt(this)))});if((t=xt(e)).length)while(n=this[u++])if(i=mt(n),r=1===n.nodeType&&" "+vt(i)+" "){a=0;while(o=t[a++])r.indexOf(" "+o+" ")<0&&(r+=o+" ");i!==(s=vt(r))&&n.setAttribute("class",s)}return this},removeClass:function(e){var t,n,r,i,o,a,s,u=0;if(g(e))return this.each(function(t){w(this).removeClass(e.call(this,t,mt(this)))});if(!arguments.length)return this.attr("class","");if((t=xt(e)).length)while(n=this[u++])if(i=mt(n),r=1===n.nodeType&&" "+vt(i)+" "){a=0;while(o=t[a++])while(r.indexOf(" "+o+" ")>-1)r=r.replace(" "+o+" "," ");i!==(s=vt(r))&&n.setAttribute("class",s)}return this},toggleClass:function(e,t){var n=typeof e,r="string"===n||Array.isArray(e);return"boolean"==typeof t&&r?t?this.addClass(e):this.removeClass(e):g(e)?this.each(function(n){w(this).toggleClass(e.call(this,n,mt(this),t),t)}):this.each(function(){var t,i,o,a;if(r){i=0,o=w(this),a=xt(e);while(t=a[i++])o.hasClass(t)?o.removeClass(t):o.addClass(t)}else void 0!==e&&"boolean"!==n||((t=mt(this))&&J.set(this,"__className__",t),this.setAttribute&&this.setAttribute("class",t||!1===e?"":J.get(this,"__className__")||""))})},hasClass:function(e){var t,n,r=0;t=" "+e+" ";while(n=this[r++])if(1===n.nodeType&&(" "+vt(mt(n))+" ").indexOf(t)>-1)return!0;return!1}});var bt=/\r/g;w.fn.extend({val:function(e){var t,n,r,i=this[0];{if(arguments.length)return r=g(e),this.each(function(n){var i;1===this.nodeType&&(null==(i=r?e.call(this,n,w(this).val()):e)?i="":"number"==typeof i?i+="":Array.isArray(i)&&(i=w.map(i,function(e){return null==e?"":e+""})),(t=w.valHooks[this.type]||w.valHooks[this.nodeName.toLowerCase()])&&"set"in t&&void 0!==t.set(this,i,"value")||(this.value=i))});if(i)return(t=w.valHooks[i.type]||w.valHooks[i.nodeName.toLowerCase()])&&"get"in t&&void 0!==(n=t.get(i,"value"))?n:"string"==typeof(n=i.value)?n.replace(bt,""):null==n?"":n}}}),w.extend({valHooks:{option:{get:function(e){var t=w.find.attr(e,"value");return null!=t?t:vt(w.text(e))}},select:{get:function(e){var t,n,r,i=e.options,o=e.selectedIndex,a="select-one"===e.type,s=a?null:[],u=a?o+1:i.length;for(r=o<0?u:a?o:0;r-1)&&(n=!0);return n||(e.selectedIndex=-1),o}}}}),w.each(["radio","checkbox"],function(){w.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=w.inArray(w(e).val(),t)>-1}},h.checkOn||(w.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})}),h.focusin="onfocusin"in e;var wt=/^(?:focusinfocus|focusoutblur)$/,Tt=function(e){e.stopPropagation()};w.extend(w.event,{trigger:function(t,n,i,o){var a,s,u,l,c,p,d,h,v=[i||r],m=f.call(t,"type")?t.type:t,x=f.call(t,"namespace")?t.namespace.split("."):[];if(s=h=u=i=i||r,3!==i.nodeType&&8!==i.nodeType&&!wt.test(m+w.event.triggered)&&(m.indexOf(".")>-1&&(m=(x=m.split(".")).shift(),x.sort()),c=m.indexOf(":")<0&&"on"+m,t=t[w.expando]?t:new w.Event(m,"object"==typeof t&&t),t.isTrigger=o?2:3,t.namespace=x.join("."),t.rnamespace=t.namespace?new RegExp("(^|\\.)"+x.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=void 0,t.target||(t.target=i),n=null==n?[t]:w.makeArray(n,[t]),d=w.event.special[m]||{},o||!d.trigger||!1!==d.trigger.apply(i,n))){if(!o&&!d.noBubble&&!y(i)){for(l=d.delegateType||m,wt.test(l+m)||(s=s.parentNode);s;s=s.parentNode)v.push(s),u=s;u===(i.ownerDocument||r)&&v.push(u.defaultView||u.parentWindow||e)}a=0;while((s=v[a++])&&!t.isPropagationStopped())h=s,t.type=a>1?l:d.bindType||m,(p=(J.get(s,"events")||{})[t.type]&&J.get(s,"handle"))&&p.apply(s,n),(p=c&&s[c])&&p.apply&&Y(s)&&(t.result=p.apply(s,n),!1===t.result&&t.preventDefault());return t.type=m,o||t.isDefaultPrevented()||d._default&&!1!==d._default.apply(v.pop(),n)||!Y(i)||c&&g(i[m])&&!y(i)&&((u=i[c])&&(i[c]=null),w.event.triggered=m,t.isPropagationStopped()&&h.addEventListener(m,Tt),i[m](),t.isPropagationStopped()&&h.removeEventListener(m,Tt),w.event.triggered=void 0,u&&(i[c]=u)),t.result}},simulate:function(e,t,n){var r=w.extend(new w.Event,n,{type:e,isSimulated:!0});w.event.trigger(r,null,t)}}),w.fn.extend({trigger:function(e,t){return this.each(function(){w.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];if(n)return w.event.trigger(e,t,n,!0)}}),h.focusin||w.each({focus:"focusin",blur:"focusout"},function(e,t){var n=function(e){w.event.simulate(t,e.target,w.event.fix(e))};w.event.special[t]={setup:function(){var r=this.ownerDocument||this,i=J.access(r,t);i||r.addEventListener(e,n,!0),J.access(r,t,(i||0)+1)},teardown:function(){var r=this.ownerDocument||this,i=J.access(r,t)-1;i?J.access(r,t,i):(r.removeEventListener(e,n,!0),J.remove(r,t))}}});var Ct=e.location,Et=Date.now(),kt=/\?/;w.parseXML=function(t){var n;if(!t||"string"!=typeof t)return null;try{n=(new e.DOMParser).parseFromString(t,"text/xml")}catch(e){n=void 0}return n&&!n.getElementsByTagName("parsererror").length||w.error("Invalid XML: "+t),n};var St=/\[\]$/,Dt=/\r?\n/g,Nt=/^(?:submit|button|image|reset|file)$/i,At=/^(?:input|select|textarea|keygen)/i;function jt(e,t,n,r){var i;if(Array.isArray(t))w.each(t,function(t,i){n||St.test(e)?r(e,i):jt(e+"["+("object"==typeof i&&null!=i?t:"")+"]",i,n,r)});else if(n||"object"!==x(t))r(e,t);else for(i in t)jt(e+"["+i+"]",t[i],n,r)}w.param=function(e,t){var n,r=[],i=function(e,t){var n=g(t)?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(Array.isArray(e)||e.jquery&&!w.isPlainObject(e))w.each(e,function(){i(this.name,this.value)});else for(n in e)jt(n,e[n],t,i);return r.join("&")},w.fn.extend({serialize:function(){return w.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=w.prop(this,"elements");return e?w.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!w(this).is(":disabled")&&At.test(this.nodeName)&&!Nt.test(e)&&(this.checked||!pe.test(e))}).map(function(e,t){var n=w(this).val();return null==n?null:Array.isArray(n)?w.map(n,function(e){return{name:t.name,value:e.replace(Dt,"\r\n")}}):{name:t.name,value:n.replace(Dt,"\r\n")}}).get()}});var qt=/%20/g,Lt=/#.*$/,Ht=/([?&])_=[^&]*/,Ot=/^(.*?):[ \t]*([^\r\n]*)$/gm,Pt=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Mt=/^(?:GET|HEAD)$/,Rt=/^\/\//,It={},Wt={},$t="*/".concat("*"),Bt=r.createElement("a");Bt.href=Ct.href;function Ft(e){return function(t,n){"string"!=typeof t&&(n=t,t="*");var r,i=0,o=t.toLowerCase().match(M)||[];if(g(n))while(r=o[i++])"+"===r[0]?(r=r.slice(1)||"*",(e[r]=e[r]||[]).unshift(n)):(e[r]=e[r]||[]).push(n)}}function _t(e,t,n,r){var i={},o=e===Wt;function a(s){var u;return i[s]=!0,w.each(e[s]||[],function(e,s){var l=s(t,n,r);return"string"!=typeof l||o||i[l]?o?!(u=l):void 0:(t.dataTypes.unshift(l),a(l),!1)}),u}return a(t.dataTypes[0])||!i["*"]&&a("*")}function zt(e,t){var n,r,i=w.ajaxSettings.flatOptions||{};for(n in t)void 0!==t[n]&&((i[n]?e:r||(r={}))[n]=t[n]);return r&&w.extend(!0,e,r),e}function Xt(e,t,n){var r,i,o,a,s=e.contents,u=e.dataTypes;while("*"===u[0])u.shift(),void 0===r&&(r=e.mimeType||t.getResponseHeader("Content-Type"));if(r)for(i in s)if(s[i]&&s[i].test(r)){u.unshift(i);break}if(u[0]in n)o=u[0];else{for(i in n){if(!u[0]||e.converters[i+" "+u[0]]){o=i;break}a||(a=i)}o=o||a}if(o)return o!==u[0]&&u.unshift(o),n[o]}function Ut(e,t,n,r){var i,o,a,s,u,l={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)l[a.toLowerCase()]=e.converters[a];o=c.shift();while(o)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!u&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u=o,o=c.shift())if("*"===o)o=u;else if("*"!==u&&u!==o){if(!(a=l[u+" "+o]||l["* "+o]))for(i in l)if((s=i.split(" "))[1]===o&&(a=l[u+" "+s[0]]||l["* "+s[0]])){!0===a?a=l[i]:!0!==l[i]&&(o=s[0],c.unshift(s[1]));break}if(!0!==a)if(a&&e["throws"])t=a(t);else try{t=a(t)}catch(e){return{state:"parsererror",error:a?e:"No conversion from "+u+" to "+o}}}return{state:"success",data:t}}w.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Ct.href,type:"GET",isLocal:Pt.test(Ct.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":$t,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":w.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?zt(zt(e,w.ajaxSettings),t):zt(w.ajaxSettings,e)},ajaxPrefilter:Ft(It),ajaxTransport:Ft(Wt),ajax:function(t,n){"object"==typeof t&&(n=t,t=void 0),n=n||{};var i,o,a,s,u,l,c,f,p,d,h=w.ajaxSetup({},n),g=h.context||h,y=h.context&&(g.nodeType||g.jquery)?w(g):w.event,v=w.Deferred(),m=w.Callbacks("once memory"),x=h.statusCode||{},b={},T={},C="canceled",E={readyState:0,getResponseHeader:function(e){var t;if(c){if(!s){s={};while(t=Ot.exec(a))s[t[1].toLowerCase()]=t[2]}t=s[e.toLowerCase()]}return null==t?null:t},getAllResponseHeaders:function(){return c?a:null},setRequestHeader:function(e,t){return null==c&&(e=T[e.toLowerCase()]=T[e.toLowerCase()]||e,b[e]=t),this},overrideMimeType:function(e){return null==c&&(h.mimeType=e),this},statusCode:function(e){var t;if(e)if(c)E.always(e[E.status]);else for(t in e)x[t]=[x[t],e[t]];return this},abort:function(e){var t=e||C;return i&&i.abort(t),k(0,t),this}};if(v.promise(E),h.url=((t||h.url||Ct.href)+"").replace(Rt,Ct.protocol+"//"),h.type=n.method||n.type||h.method||h.type,h.dataTypes=(h.dataType||"*").toLowerCase().match(M)||[""],null==h.crossDomain){l=r.createElement("a");try{l.href=h.url,l.href=l.href,h.crossDomain=Bt.protocol+"//"+Bt.host!=l.protocol+"//"+l.host}catch(e){h.crossDomain=!0}}if(h.data&&h.processData&&"string"!=typeof h.data&&(h.data=w.param(h.data,h.traditional)),_t(It,h,n,E),c)return E;(f=w.event&&h.global)&&0==w.active++&&w.event.trigger("ajaxStart"),h.type=h.type.toUpperCase(),h.hasContent=!Mt.test(h.type),o=h.url.replace(Lt,""),h.hasContent?h.data&&h.processData&&0===(h.contentType||"").indexOf("application/x-www-form-urlencoded")&&(h.data=h.data.replace(qt,"+")):(d=h.url.slice(o.length),h.data&&(h.processData||"string"==typeof h.data)&&(o+=(kt.test(o)?"&":"?")+h.data,delete h.data),!1===h.cache&&(o=o.replace(Ht,"$1"),d=(kt.test(o)?"&":"?")+"_="+Et+++d),h.url=o+d),h.ifModified&&(w.lastModified[o]&&E.setRequestHeader("If-Modified-Since",w.lastModified[o]),w.etag[o]&&E.setRequestHeader("If-None-Match",w.etag[o])),(h.data&&h.hasContent&&!1!==h.contentType||n.contentType)&&E.setRequestHeader("Content-Type",h.contentType),E.setRequestHeader("Accept",h.dataTypes[0]&&h.accepts[h.dataTypes[0]]?h.accepts[h.dataTypes[0]]+("*"!==h.dataTypes[0]?", "+$t+"; q=0.01":""):h.accepts["*"]);for(p in h.headers)E.setRequestHeader(p,h.headers[p]);if(h.beforeSend&&(!1===h.beforeSend.call(g,E,h)||c))return E.abort();if(C="abort",m.add(h.complete),E.done(h.success),E.fail(h.error),i=_t(Wt,h,n,E)){if(E.readyState=1,f&&y.trigger("ajaxSend",[E,h]),c)return E;h.async&&h.timeout>0&&(u=e.setTimeout(function(){E.abort("timeout")},h.timeout));try{c=!1,i.send(b,k)}catch(e){if(c)throw e;k(-1,e)}}else k(-1,"No Transport");function k(t,n,r,s){var l,p,d,b,T,C=n;c||(c=!0,u&&e.clearTimeout(u),i=void 0,a=s||"",E.readyState=t>0?4:0,l=t>=200&&t<300||304===t,r&&(b=Xt(h,E,r)),b=Ut(h,b,E,l),l?(h.ifModified&&((T=E.getResponseHeader("Last-Modified"))&&(w.lastModified[o]=T),(T=E.getResponseHeader("etag"))&&(w.etag[o]=T)),204===t||"HEAD"===h.type?C="nocontent":304===t?C="notmodified":(C=b.state,p=b.data,l=!(d=b.error))):(d=C,!t&&C||(C="error",t<0&&(t=0))),E.status=t,E.statusText=(n||C)+"",l?v.resolveWith(g,[p,C,E]):v.rejectWith(g,[E,C,d]),E.statusCode(x),x=void 0,f&&y.trigger(l?"ajaxSuccess":"ajaxError",[E,h,l?p:d]),m.fireWith(g,[E,C]),f&&(y.trigger("ajaxComplete",[E,h]),--w.active||w.event.trigger("ajaxStop")))}return E},getJSON:function(e,t,n){return w.get(e,t,n,"json")},getScript:function(e,t){return w.get(e,void 0,t,"script")}}),w.each(["get","post"],function(e,t){w[t]=function(e,n,r,i){return g(n)&&(i=i||r,r=n,n=void 0),w.ajax(w.extend({url:e,type:t,dataType:i,data:n,success:r},w.isPlainObject(e)&&e))}}),w._evalUrl=function(e){return w.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,"throws":!0})},w.fn.extend({wrapAll:function(e){var t;return this[0]&&(g(e)&&(e=e.call(this[0])),t=w(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstElementChild)e=e.firstElementChild;return e}).append(this)),this},wrapInner:function(e){return g(e)?this.each(function(t){w(this).wrapInner(e.call(this,t))}):this.each(function(){var t=w(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=g(e);return this.each(function(n){w(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){w(this).replaceWith(this.childNodes)}),this}}),w.expr.pseudos.hidden=function(e){return!w.expr.pseudos.visible(e)},w.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},w.ajaxSettings.xhr=function(){try{return new e.XMLHttpRequest}catch(e){}};var Vt={0:200,1223:204},Gt=w.ajaxSettings.xhr();h.cors=!!Gt&&"withCredentials"in Gt,h.ajax=Gt=!!Gt,w.ajaxTransport(function(t){var n,r;if(h.cors||Gt&&!t.crossDomain)return{send:function(i,o){var a,s=t.xhr();if(s.open(t.type,t.url,t.async,t.username,t.password),t.xhrFields)for(a in t.xhrFields)s[a]=t.xhrFields[a];t.mimeType&&s.overrideMimeType&&s.overrideMimeType(t.mimeType),t.crossDomain||i["X-Requested-With"]||(i["X-Requested-With"]="XMLHttpRequest");for(a in i)s.setRequestHeader(a,i[a]);n=function(e){return function(){n&&(n=r=s.onload=s.onerror=s.onabort=s.ontimeout=s.onreadystatechange=null,"abort"===e?s.abort():"error"===e?"number"!=typeof s.status?o(0,"error"):o(s.status,s.statusText):o(Vt[s.status]||s.status,s.statusText,"text"!==(s.responseType||"text")||"string"!=typeof s.responseText?{binary:s.response}:{text:s.responseText},s.getAllResponseHeaders()))}},s.onload=n(),r=s.onerror=s.ontimeout=n("error"),void 0!==s.onabort?s.onabort=r:s.onreadystatechange=function(){4===s.readyState&&e.setTimeout(function(){n&&r()})},n=n("abort");try{s.send(t.hasContent&&t.data||null)}catch(e){if(n)throw e}},abort:function(){n&&n()}}}),w.ajaxPrefilter(function(e){e.crossDomain&&(e.contents.script=!1)}),w.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return w.globalEval(e),e}}}),w.ajaxPrefilter("script",function(e){void 0===e.cache&&(e.cache=!1),e.crossDomain&&(e.type="GET")}),w.ajaxTransport("script",function(e){if(e.crossDomain){var t,n;return{send:function(i,o){t=w("' . "\n"); - - if ($this->js_pipeline_before_excludes && $pipeline_result) { - if ($inlineGroup) { - $inline_js .= $pipeline_result; - } - else { - $output .= $pipeline_html; - } - } - foreach ($this->js_no_pipeline as $file) { - if ($group && $file['group'] == $group) { - if ($file['loading'] === 'inline') { - $inline_js .= $this->gatherLinks([$file], JS_ASSET) . "\n"; - } - else { - $output .= '' . "\n"; - } - } - } - if (!$this->js_pipeline_before_excludes && $pipeline_result) { - if ($inlineGroup) { - $inline_js .= $pipeline_result; - } - else { - $output .= $pipeline_html; - } - } - } else { - foreach ($this->js as $file) { - if ($group && $file['group'] == $group) { - if ($inlineGroup || $file['loading'] === 'inline') { - $inline_js .= $this->gatherLinks([$file], JS_ASSET) . "\n"; - } - else { - $output .= '' . "\n"; - } - } - } - } - - // Render Inline JS - foreach ($this->inline_js as $inline) { - if ($group && $inline['group'] == $group) { - $inline_js .= $inline['asset'] . "\n"; - } - } - - if ($inline_js) { - $attribute_string = isset($inline) && $inline['type'] ? " type=\"" . $inline['type'] . "\"" : ''; - $output .= "\n\n" . $inline_js . "\n\n"; - } - - return $output; - } - - /** - * Minify and concatenate CSS - * - * @param string $group - * @param bool $returnURL true if pipeline should return the URL, otherwise the content - * - * @return bool|string URL or generated content if available, else false - */ - protected function pipelineCss($group = 'head', $returnURL = true) - { - // temporary list of assets to pipeline - $temp_css = []; - - // clear no-pipeline assets lists - $this->css_no_pipeline = []; - - // Compute uid based on assets and timestamp - $uid = md5(json_encode($this->css) . $this->css_minify . $this->css_rewrite . $group); - $file = $uid . '.css'; - $inline_file = $uid . '-inline.css'; - - $relative_path = "{$this->base_url}{$this->assets_url}/{$file}"; - - // If inline files exist set them on object - if (file_exists($this->assets_dir . $inline_file)) { - $this->css_no_pipeline = json_decode(file_get_contents($this->assets_dir . $inline_file), true); - } - - // If pipeline exist return its URL or content - if (file_exists($this->assets_dir . $file)) { - if ($returnURL) { - return $relative_path . $this->getTimestamp(); - } - else { - return file_get_contents($this->assets_dir . $file) . "\n"; - } - } - - // Remove any non-pipeline files - foreach ($this->css as $id => $asset) { - if ($asset['group'] == $group) { - if (!$asset['pipeline'] || - ($asset['remote'] && $this->css_pipeline_include_externals === false)) { - $this->css_no_pipeline[$id] = $asset; + // 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 : $this::CSS; + $excludes = strtolower($exclude_type . '_pipeline_before_excludes'); + if ($this->{$excludes}) { + $default = 'after'; } else { - $temp_css[$id] = $asset; + $default = 'before'; } + + $options['position'] = $options['position'] ?? $default; } + + unset($options['pipeline']); } - //if nothing found get out of here! - if (count($temp_css) == 0) { - return false; + // Add timestamp + $options['timestamp'] = $this->timestamp; + + // Set order + $group = $options['group'] ?? 'head'; + $position = $options['position'] ?? 'pipeline'; + + $orderKey = "{$type}|{$group}|{$position}"; + if (!isset($this->order[$orderKey])) { + $this->order[$orderKey] = 0; } - // Write non-pipeline files out - if (!empty($this->css_no_pipeline)) { - file_put_contents($this->assets_dir . $inline_file, json_encode($this->css_no_pipeline)); + $options['order'] = $this->order[$orderKey]++; + + // Create asset of correct type + $asset_object = new $type(); + + // If exists + if ($asset_object->init($asset, $options)) { + $this->$collection[md5($asset)] = $asset_object; } - - $css_minify = $this->css_minify; - - // If this is a Windows server, and minify_windows is false (default value) skip the - // minification process because it will cause Apache to die/crash due to insufficient - // ThreadStackSize in httpd.conf - See: https://bugs.php.net/bug.php?id=47689 - if (strtoupper(substr(php_uname('s'), 0, 3)) === 'WIN' && !$this->css_minify_windows) { - $css_minify = false; - } - - // Concatenate files - $buffer = $this->gatherLinks($temp_css, CSS_ASSET); - if ($css_minify) { - $minifier = new \MatthiasMullie\Minify\CSS(); - $minifier->add($buffer); - $buffer = $minifier->minify(); - } - - // Write file - if (strlen(trim($buffer)) > 0) { - file_put_contents($this->assets_dir . $file, $buffer); - - if ($returnURL) { - return $relative_path . $this->getTimestamp(); - } - else { - return $buffer . "\n"; - } - } else { - return false; - } + return $this; } /** - * Minify and concatenate JS files. + * Add a CSS asset or a collection of assets. * - * @param string $group - * @param bool $returnURL true if pipeline should return the URL, otherwise the content - * - * @return bool|string URL or generated content if available, else false + * @return $this */ - protected function pipelineJs($group = 'head', $returnURL = true) + public function addCss($asset) { - // temporary list of assets to pipeline - $temp_js = []; - - // clear no-pipeline assets lists - $this->js_no_pipeline = []; - - // Compute uid based on assets and timestamp - $uid = md5(json_encode($this->js) . $this->js_minify . $group); - $file = $uid . '.js'; - $inline_file = $uid . '-inline.js'; - - $relative_path = "{$this->base_url}{$this->assets_url}/{$file}"; - - // If inline files exist set them on object - if (file_exists($this->assets_dir . $inline_file)) { - $this->js_no_pipeline = json_decode(file_get_contents($this->assets_dir . $inline_file), true); - } - - // If pipeline exist return its URL or content - if (file_exists($this->assets_dir . $file)) { - if ($returnURL) { - return $relative_path . $this->getTimestamp(); - } - else { - return file_get_contents($this->assets_dir . $file) . "\n"; - } - } - - // Remove any non-pipeline files - foreach ($this->js as $id => $asset) { - if ($asset['group'] == $group) { - if (!$asset['pipeline'] || - ($asset['remote'] && $this->js_pipeline_include_externals === false)) { - $this->js_no_pipeline[] = $asset; - } else { - $temp_js[$id] = $asset; - } - } - } - - //if nothing found get out of here! - if (count($temp_js) == 0) { - return false; - } - - // Write non-pipeline files out - if (!empty($this->js_no_pipeline)) { - file_put_contents($this->assets_dir . $inline_file, json_encode($this->js_no_pipeline)); - } - - // Concatenate files - $buffer = $this->gatherLinks($temp_js, JS_ASSET); - if ($this->js_minify) { - $minifier = new \MatthiasMullie\Minify\JS(); - $minifier->add($buffer); - $buffer = $minifier->minify(); - } - - // Write file - if (strlen(trim($buffer)) > 0) { - file_put_contents($this->assets_dir . $file, $buffer); - - if ($returnURL) { - return $relative_path . $this->getTimestamp(); - } - else { - return $buffer . "\n"; - } - } else { - return false; - } + return $this->addType($this::CSS_COLLECTION, $this::CSS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::CSS_TYPE)); } /** - * Return the array of all the registered CSS assets - * If a $key is provided, it will try to return only that asset - * else it will return null + * Add an Inline CSS asset or a collection of assets. * - * @param null|string $key the asset key - * @return array + * @return $this */ - public function getCss($key = null) + public function addInlineCss($asset) { - if (!empty($key)) { - $asset_key = md5($key); - if (isset($this->css[$asset_key])) { - return $this->css[$asset_key]; - } else { - return null; - } - } - - return $this->css; + return $this->addType($this::CSS_COLLECTION, $this::INLINE_CSS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::INLINE_CSS_TYPE)); } /** - * Return the array of all the registered JS assets - * If a $key is provided, it will try to return only that asset - * else it will return null + * Add a JS asset or a collection of assets. * - * @param null|string $key the asset key - * @return array + * @return $this */ - public function getJs($key = null) + public function addJs($asset) { - if (!empty($key)) { - $asset_key = md5($key); - if (isset($this->js[$asset_key])) { - return $this->js[$asset_key]; - } else { - return null; - } - } - - return $this->js; + return $this->addType($this::JS_COLLECTION, $this::JS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::JS_TYPE)); } /** - * Set the whole array of CSS assets + * Add an Inline JS asset or a collection of assets. * - * @param $css + * @return $this */ - public function setCss($css) + public function addInlineJs($asset) { - $this->css = $css; + return $this->addType($this::JS_COLLECTION, $this::INLINE_JS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::INLINE_JS_TYPE)); } - /** - * Set the whole array of JS assets - * - * @param $js - */ - public function setJs($js) - { - $this->js = $js; - } - - /** - * Removes an item from the CSS array if set - * - * @param string $key The asset key - */ - public function removeCss($key) - { - $asset_key = md5($key); - if (isset($this->css[$asset_key])) { - unset($this->css[$asset_key]); - } - } - - /** - * Removes an item from the JS array if set - * - * @param string $key The asset key - */ - public function removeJs($key) - { - $asset_key = md5($key); - if (isset($this->js[$asset_key])) { - unset($this->js[$asset_key]); - } - } - - /** - * Return the array of all the registered collections - * - * @return array - */ - public function getCollections() - { - return $this->collections; - } - - /** - * Set the array of collections explicitly - * - * @param $collections - */ - public function setCollection($collections) - { - $this->collections = $collections; - } - - /** - * Determines if an asset exists as a collection, CSS or JS reference - * - * @param $asset - * - * @return bool - */ - public function exists($asset) - { - if (isset($this->collections[$asset]) || isset($this->css[$asset]) || isset($this->js[$asset])) { - return true; - } else { - return false; - } - } /** * 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; @@ -1001,444 +314,124 @@ class Assets } /** - * Reset all assets. - * - * @return $this + * @param array $assets + * @param string $key + * @param string $value + * @param bool $sort + * @return array|false */ - public function reset() + protected function filterAssets($assets, $key, $value, $sort = false) { - return $this->resetCss()->resetJs(); - } + $results = array_filter($assets, function ($asset) use ($key, $value) { - /** - * Reset JavaScript assets. - * - * @return $this - */ - public function resetJs() - { - $this->js = []; - $this->inline_js = []; + if ($key === 'position' && $value === 'pipeline') { + $type = $asset->getType(); - return $this; - } - - /** - * Reset CSS assets. - * - * @return $this - */ - public function resetCss() - { - $this->css = []; - $this->inline_css = []; - - return $this; - } - - /** - * 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) - { - return $this->addDir($directory, self::JS_REGEX); - } - - /** - * 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) - { - return $this->addDir($directory, self::CSS_REGEX); - } - - /** - * Add all assets matching $pattern within $directory. - * - * @param string $directory Relative to the Grav root path, or a stream identifier - * @param string $pattern (regex) - * - * @return $this - * @throws Exception - */ - public function addDir($directory, $pattern = self::DEFAULT_REGEX) - { - $root_dir = rtrim(ROOT_DIR, '/'); - - // Check if $directory is a stream. - if (strpos($directory, '://')) { - $directory = Grav::instance()['locator']->findResource($directory, null); - } - - // Get files - $files = $this->rglob($root_dir . DIRECTORY_SEPARATOR . $directory, $pattern, $root_dir . '/'); - - // No luck? Nothing to do - if (!$files) { - return $this; - } - - // Add CSS files - if ($pattern === self::CSS_REGEX) { - foreach ($files as $file) { - $this->addCss($file); + 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; + } } - return $this; - } - - // Add JavaScript files - if ($pattern === self::JS_REGEX) { - foreach ($files as $file) { - $this->addJs($file); + if ($asset[$key] === $value) { + return true; } - - return $this; - } - - // Unknown pattern. - foreach ($files as $asset) { - $this->add($asset); - } - - return $this; - } - - /** - * Determine whether a link is local or remote. - * - * Understands both "http://" and "https://" as well as protocol agnostic links "//" - * - * @param string $link - * - * @return bool - */ - protected function isRemoteLink($link) - { - $base = Grav::instance()['uri']->rootUrl(true); - - // sanity check for local URLs with absolute URL's enabled - if (Utils::startsWith($link, $base)) { return false; + }); + + if ($sort && !empty($results)) { + $results = $this->sortAssets($results); } - return ('http://' === substr($link, 0, 7) || 'https://' === substr($link, 0, 8) || '//' === substr($link, 0, - 2)); + + return $results; } /** - * Build local links including grav asset shortcodes - * - * @param string $asset the asset string reference - * @param bool $absolute build absolute asset link - * - * @return string the final link url to the asset - */ - protected function buildLocalLink($asset, $absolute = false) - { - try { - $asset = Grav::instance()['locator']->findResource($asset, $absolute); - } catch (\Exception $e) { - } - - $uri = $absolute ? $asset : $this->base_url . ltrim($asset, '/'); - return $asset ? $uri : false; - } - - /** - * Get the last modification time of asset - * - * @param string $asset the asset string reference - * - * @return string the last modifcation time or false on error - */ - protected function getLastModificationTime($asset) - { - $file = GRAV_ROOT . $asset; - if (Grav::instance()['locator']->isStream($asset)) { - $file = $this->buildLocalLink($asset, true); - } - - return file_exists($file) ? filemtime($file) : false; - } - - /** - * Build an HTML attribute string from an array. - * - * @param array $attributes - * - * @return string - */ - protected function attributes(array $attributes) - { - $html = ''; - $no_key = ['loading']; - - foreach ($attributes as $key => $value) { - // For numeric keys we will assume that the key and the value are the same - // as this will convert HTML attributes such as "required" to a correct - // form like required="required" instead of using incorrect numerics. - if (is_numeric($key)) { - $key = $value; - } - if (is_array($value)) { - $value = implode(' ', $value); - } - - if (in_array($key, $no_key)) { - $element = htmlentities($value, ENT_QUOTES, 'UTF-8', false); - } else { - $element = $key . '="' . htmlentities($value, ENT_QUOTES, 'UTF-8', false) . '"'; - } - - $html .= ' ' . $element; - } - - return $html; - } - - /** - * Download and concatenate the content of several links. - * - * @param array $links - * @param bool $css - * - * @return string - */ - protected function gatherLinks(array $links, $css = true) - { - $buffer = ''; - - - foreach ($links as $asset) { - $relative_dir = ''; - $local = true; - - $link = $asset['asset']; - $relative_path = $link; - - if ($this->isRemoteLink($link)) { - $local = false; - if ('//' === substr($link, 0, 2)) { - $link = 'http:' . $link; - } - } else { - // Fix to remove relative dir if grav is in one - if (($this->base_url != '/') && (strpos($this->base_url, $link) == 0)) { - $base_url = '#' . preg_quote($this->base_url, '#') . '#'; - $relative_path = ltrim(preg_replace($base_url, '/', $link, 1), '/'); - } - - $relative_dir = dirname($relative_path); - $link = ROOT_DIR . $relative_path; - } - - $file = ($this->fetch_command instanceof Closure) ? @$this->fetch_command->__invoke($link) : @file_get_contents($link); - - // No file found, skip it... - if ($file === false) { - continue; - } - - // Double check last character being - if (!$css) { - $file = rtrim($file, ' ;') . ';'; - } - - // If this is CSS + the file is local + rewrite enabled - if ($css && $local && $this->css_rewrite) { - $file = $this->cssRewrite($file, $relative_dir); - } - - $file = rtrim($file) . PHP_EOL; - $buffer .= $file; - } - - // Pull out @imports and move to top - if ($css) { - $buffer = $this->moveImports($buffer); - } - - return $buffer; - } - - /** - * Finds relative CSS urls() and rewrites the URL with an absolute one - * - * @param string $file the css source file - * @param string $relative_path relative path to the css file - * - * @return mixed - */ - protected function cssRewrite($file, $relative_path) - { - // Strip any sourcemap comments - $file = preg_replace(self::CSS_SOURCEMAP_REGEX, '', $file); - - // Find any css url() elements, grab the URLs and calculate an absolute path - // Then replace the old url with the new one - $file = preg_replace_callback(self::CSS_URL_REGEX, function ($matches) use ($relative_path) { - - $old_url = $matches[2]; - - // Ensure link is not rooted to webserver, a data URL, or to a remote host - if (Utils::startsWith($old_url, '/') || Utils::startsWith($old_url, 'data:') || $this->isRemoteLink($old_url)) { - return $matches[0]; - } - - $new_url = $this->base_url . ltrim(Utils::normalizePath($relative_path . '/' . $old_url), '/'); - - return str_replace($old_url, $new_url, $matches[0]); - }, $file); - - return $file; - } - - /** - * 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) - { - $this->imports = []; - - $file = preg_replace_callback(self::CSS_IMPORT_REGEX, function ($matches) { - $this->imports[] = $matches[0]; - - return ''; - }, $file); - - return implode("\n", $this->imports) . "\n\n" . $file; - } - - /** - * Recursively get files matching $pattern within $directory. - * - * @param string $directory - * @param string $pattern (regex) - * @param string $ltrim Will be trimmed from the left of the file path - * + * @param array $assets * @return array */ - protected function rglob($directory, $pattern, $ltrim = null) + protected function sortAssets($assets) { - $iterator = new RegexIterator(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, - FilesystemIterator::SKIP_DOTS)), $pattern); - $offset = strlen($ltrim); - $files = []; + uasort($assets, static function ($a, $b) { + return $b['priority'] <=> $a['priority'] ?: $a['order'] <=> $b['order']; + }); - foreach ($iterator as $file) { - $files[] = substr($file->getPathname(), $offset); - } - - return $files; + return $assets; } /** - * Sets the state of CSS Pipeline - * - * @param boolean $value - */ - public function setCssPipeline($value) - { - $this->css_pipeline = (bool)$value; - } - - /** - * Sets the state of JS Pipeline - * - * @param boolean $value - */ - public function setJsPipeline($value) - { - $this->js_pipeline = (bool)$value; - } - - /** - * Explicitly set's a timestamp for assets - * - * @param $value - */ - public function setTimestamp($value) - { - $this->timestamp = $value; - } - - /** - * Get the timestamp for assets - * + * @param string $type + * @param string $group + * @param array $attributes * @return string */ - public function getTimestamp($include_join = true) + public function render($type, $group = 'head', $attributes = []) { - if ($this->timestamp) { - $timestamp = $include_join ? '?' . $this->timestamp : $this->timestamp; - return $timestamp; - } - return; - } + $before_output = ''; + $pipeline_output = ''; + $after_output = ''; - /** - * - * - * @param $asset - * @return string - */ - public function getQuerystring($asset) - { - $querystring = ''; + $assets = 'assets_' . $type; + $pipeline_enabled = $type . '_pipeline'; + $render_pipeline = 'render' . ucfirst($type); - if (!empty($asset['query'])) { - if (Utils::contains($asset['asset'], '?')) { - $querystring .= '&' . $asset['query']; - } else { - $querystring .= '?' . $asset['query']; + $group_assets = $this->filterAssets($this->$assets, 'group', $group); + $pipeline_assets = $this->filterAssets($group_assets, 'position', 'pipeline', true); + $before_assets = $this->filterAssets($group_assets, 'position', 'before', true); + $after_assets = $this->filterAssets($group_assets, 'position', 'after', true); + + // Pipeline + if ($this->{$pipeline_enabled}) { + $options = array_merge($this->pipeline_options, ['timestamp' => $this->timestamp]); + + $pipeline = new Pipeline($options); + $pipeline_output = $pipeline->$render_pipeline($pipeline_assets, $group, $attributes); + } else { + foreach ($pipeline_assets as $asset) { + $pipeline_output .= $asset->render(); } } - if ($this->timestamp) { - if (Utils::contains($asset['asset'], '?') || $querystring) { - $querystring .= '&' . $this->timestamp; - } else { - $querystring .= '?' . $this->timestamp; - } + // Before Pipeline + foreach ($before_assets as $asset) { + $before_output .= $asset->render(); } - return $querystring; + // After Pipeline + foreach ($after_assets as $asset) { + $after_output .= $asset->render(); + } + + return $before_output . $pipeline_output . $after_output; } + /** + * Build the CSS link tags. + * + * @param string $group name of the group + * @param array $attributes * @return string */ - public function __toString() + public function css($group = 'head', $attributes = []) { - return ''; + return $this->render('css', $group, $attributes); } /** - * @param $a - * @param $b + * Build the JavaScript script tags. * - * @return mixed + * @param string $group name of the group + * @param array $attributes + * @return string */ - protected function sortAssetsByPriorityThenOrder($a, $b) + public function js($group = 'head', $attributes = []) { - if ($a['priority'] == $b['priority']) { - return $a['order'] - $b['order']; - } - - return $b['priority'] - $a['priority']; + return $this->render('js', $group, $attributes); } - } diff --git a/system/src/Grav/Common/Assets/BaseAsset.php b/system/src/Grav/Common/Assets/BaseAsset.php new file mode 100644 index 0000000..3e07974 --- /dev/null +++ b/system/src/Grav/Common/Assets/BaseAsset.php @@ -0,0 +1,258 @@ + 'head', + 'position' => 'pipeline', + 'priority' => 10, + 'modified' => null, + 'asset' => null + ]; + + // Merge base defaults + $elements = array_merge($base_config, $elements); + + parent::__construct($elements, $key); + } + + /** + * @param string|false $asset + * @param array $options + * @return $this|false + */ + public function init($asset, $options) + { + $config = Grav::instance()['config']; + $uri = Grav::instance()['uri']; + + // set attributes + foreach ($options as $key => $value) { + if ($this->hasProperty($key)) { + $this->setProperty($key, $value); + } else { + $this->attributes[$key] = $value; + } + } + + // Force priority to be an int + $this->priority = (int) $this->priority; + + // Do some special stuff for CSS/JS (not inline) + if (!Utils::startsWith($this->getType(), 'inline')) { + $this->base_url = rtrim($uri->rootUrl($config->get('system.absolute_urls')), '/') . '/'; + $this->remote = static::isRemoteLink($asset); + + // Move this to render? + if (!$this->remote) { + $asset_parts = parse_url($asset); + if (isset($asset_parts['query'])) { + $this->query = $asset_parts['query']; + unset($asset_parts['query']); + $asset = Uri::buildUrl($asset_parts); + } + + $locator = Grav::instance()['locator']; + + if ($locator->isStream($asset)) { + $path = $locator->findResource($asset, true); + } else { + $path = GRAV_WEBROOT . $asset; + } + + // If local file is missing return + if ($path === false) { + return false; + } + + $file = new SplFileInfo($path); + + $asset = $this->buildLocalLink($file->getPathname()); + + $this->modified = $file->isFile() ? $file->getMTime() : false; + } + } + + $this->asset = $asset; + + 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 ''; + } + + + /** + * + * Get the last modification time of asset + * + * @param string $asset the asset string reference + * + * @return string the last modifcation time or false on error + */ +// protected function getLastModificationTime($asset) +// { +// $file = GRAV_WEBROOT . $asset; +// if (Grav::instance()['locator']->isStream($asset)) { +// $file = $this->buildLocalLink($asset, true); +// } +// +// return file_exists($file) ? filemtime($file) : false; +// } + + /** + * + * Build local links including grav asset shortcodes + * + * @param string $asset the asset string reference + * + * @return string|false the final link url to the asset + */ + protected function buildLocalLink($asset) + { + if ($asset) { + return $this->base_url . ltrim(Utils::replaceFirstOccurrence(GRAV_WEBROOT, '', $asset), '/'); + } + return false; + } + + + /** + * Implements JsonSerializable interface. + * + * @return array + */ + public function jsonSerialize() + { + return ['type' => $this->getType(), 'elements' => $this->getElements()]; + } + + /** + * Placeholder for AssetUtilsTrait method + * + * @param string $file + * @param string $dir + * @param bool $local + * @return string + */ + protected function cssRewrite($file, $dir, $local) + { + return; + } +} diff --git a/system/src/Grav/Common/Assets/Css.php b/system/src/Grav/Common/Assets/Css.php new file mode 100644 index 0000000..4c6a9c9 --- /dev/null +++ b/system/src/Grav/Common/Assets/Css.php @@ -0,0 +1,52 @@ + 'css', + 'attributes' => [ + 'type' => 'text/css', + 'rel' => 'stylesheet' + ] + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + 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); + return "\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 new file mode 100644 index 0000000..f024a95 --- /dev/null +++ b/system/src/Grav/Common/Assets/InlineCss.php @@ -0,0 +1,44 @@ + 'css', + 'position' => 'after' + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + 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 new file mode 100644 index 0000000..6787608 --- /dev/null +++ b/system/src/Grav/Common/Assets/InlineJs.php @@ -0,0 +1,44 @@ + 'js', + 'position' => 'after' + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + 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 new file mode 100644 index 0000000..fc2a472 --- /dev/null +++ b/system/src/Grav/Common/Assets/Js.php @@ -0,0 +1,48 @@ + 'js', + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + 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); + return 'renderAttributes() . ">\n" . trim($buffer) . "\n\n"; + } + + return '\n"; + } +} diff --git a/system/src/Grav/Common/Assets/Pipeline.php b/system/src/Grav/Common/Assets/Pipeline.php new file mode 100644 index 0000000..7aef0e1 --- /dev/null +++ b/system/src/Grav/Common/Assets/Pipeline.php @@ -0,0 +1,280 @@ +base_url = rtrim($uri->rootUrl($config->get('system.absolute_urls')), '/') . '/'; + $this->assets_dir = $locator->findResource('asset://') . DS; + $this->assets_url = $locator->findResource('asset://', false); + } + + /** + * Minify and concatenate CSS + * + * @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 = []) + { + // temporary list of assets to pipeline + $inline_group = false; + + if (array_key_exists('loading', $attributes) && $attributes['loading'] === 'inline') { + $inline_group = true; + unset($attributes['loading']); + } + + // Store Attributes + $this->attributes = array_merge(['type' => 'text/css', 'rel' => 'stylesheet'], $attributes); + + // Compute uid based on assets and timestamp + $json_assets = json_encode($assets); + $uid = md5($json_assets . $this->css_minify . $this->css_rewrite . $group); + $file = $uid . '.css'; + $relative_path = "{$this->base_url}{$this->assets_url}/{$file}"; + + $buffer = null; + + if (file_exists($this->assets_dir . $file)) { + $buffer = file_get_contents($this->assets_dir . $file) . "\n"; + } else { + //if nothing found get out of here! + if (empty($assets)) { + return false; + } + + // Concatenate files + $buffer = $this->gatherLinks($assets, self::CSS_ASSET); + + // Minify if required + if ($this->shouldMinify('css')) { + $minifier = new CSS(); + $minifier->add($buffer); + $buffer = $minifier->minify(); + } + + // Write file + if (trim($buffer) !== '') { + file_put_contents($this->assets_dir . $file, $buffer); + } + } + + if ($inline_group) { + $output = "\n"; + } else { + $this->asset = $relative_path; + $output = 'renderAttributes() . BaseAsset::integrityHash($this->asset) . ">\n"; + } + + return $output; + } + + /** + * Minify and concatenate JS files. + * + * @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 = []) + { + // temporary list of assets to pipeline + $inline_group = false; + + if (array_key_exists('loading', $attributes) && $attributes['loading'] === 'inline') { + $inline_group = true; + unset($attributes['loading']); + } + + // Store Attributes + $this->attributes = $attributes; + + // Compute uid based on assets and timestamp + $json_assets = json_encode($assets); + $uid = md5($json_assets . $this->js_minify . $group); + $file = $uid . '.js'; + $relative_path = "{$this->base_url}{$this->assets_url}/{$file}"; + + $buffer = null; + + if (file_exists($this->assets_dir . $file)) { + $buffer = file_get_contents($this->assets_dir . $file) . "\n"; + } else { + //if nothing found get out of here! + if (empty($assets)) { + return false; + } + + // Concatenate files + $buffer = $this->gatherLinks($assets, self::JS_ASSET); + + // Minify if required + if ($this->shouldMinify('js')) { + $minifier = new JS(); + $minifier->add($buffer); + $buffer = $minifier->minify(); + } + + // Write file + if (trim($buffer) !== '') { + file_put_contents($this->assets_dir . $file, $buffer); + } + } + + if ($inline_group) { + $output = 'renderAttributes(). ">\n" . $buffer . "\n\n"; + } else { + $this->asset = $relative_path; + $output = '\n"; + } + + return $output; + } + + + /** + * Finds relative CSS urls() and rewrites the URL with an absolute one + * + * @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 string + */ + protected function cssRewrite($file, $dir, $local) + { + // Strip any sourcemap comments + $file = preg_replace(self::CSS_SOURCEMAP_REGEX, '', $file); + + // Find any css url() elements, grab the URLs and calculate an absolute path + // Then replace the old url with the new one + $file = (string)preg_replace_callback(self::CSS_URL_REGEX, function ($matches) use ($dir, $local) { + + $old_url = $matches[2]; + + // Ensure link is not rooted to web server, a data URL, or to a remote host + if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url) || Utils::startsWith($old_url, 'data:') || $this->isRemoteLink($old_url)) { + return $matches[0]; + } + + // clean leading / + $old_url = Utils::normalizePath($dir . '/' . $old_url); + if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url)) { + $old_url = ltrim($old_url, '/'); + } + + $new_url = ($local ? $this->base_url: '') . $old_url; + + 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'; + $win_check = $type . '_minify_windows'; + + $minify = (bool) $this->$check; + + // If this is a Windows server, and minify_windows is false (default value) skip the + // minification process because it will cause Apache to die/crash due to insufficient + // ThreadStackSize in httpd.conf - See: https://bugs.php.net/bug.php?id=47689 + if (stripos(php_uname('s'), 'WIN') === 0 && !$this->{$win_check}) { + $minify = false; + } + + return $minify; + } +} diff --git a/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php b/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php new file mode 100644 index 0000000..902a7c5 --- /dev/null +++ b/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php @@ -0,0 +1,208 @@ +rootUrl(true); + + // Sanity check for local URLs with absolute URL's enabled + if (Utils::startsWith($link, $base)) { + return false; + } + + return (0 === strpos($link, 'http://') || 0 === strpos($link, 'https://') || 0 === strpos($link, '//')); + } + + /** + * Download and concatenate the content of several links. + * + * @param array $assets + * @param bool $css + * @return string + */ + protected function gatherLinks(array $assets, $css = true) + { + $buffer = ''; + + + foreach ($assets as $id => $asset) { + $local = true; + + $link = $asset->getAsset(); + $relative_path = $link; + + if (static::isRemoteLink($link)) { + $local = false; + if (0 === strpos($link, '//')) { + $link = 'http:' . $link; + } + $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)) { + $base_url = '#' . preg_quote($this->base_url, '#') . '#'; + $relative_path = ltrim(preg_replace($base_url, '/', $link, 1), '/'); + } + + $relative_dir = dirname($relative_path); + $link = GRAV_ROOT . '/' . $relative_path; + } + + // 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) { + continue; + } + + // Double check last character being + if (!$css) { + $file = rtrim($file, ' ;') . ';'; + } + + // If this is CSS + the file is local + rewrite enabled + if ($css && $this->css_rewrite) { + $file = $this->cssRewrite($file, $relative_dir, $local); + } + + $file = rtrim($file) . PHP_EOL; + $buffer .= $file; + } + + // Pull out @imports and move to top + if ($css) { + $buffer = $this->moveImports($buffer); + } + + return $buffer; + } + + /** + * 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($regex, function ($matches) use (&$imports) { + $imports[] = $matches[0]; + + return ''; + }, $file); + + return implode("\n", $imports) . "\n\n" . $file; + } + + /** + * + * Build an HTML attribute string from an array. + * + * @return string + */ + protected function renderAttributes() + { + $html = ''; + $no_key = ['loading']; + + foreach ($this->attributes as $key => $value) { + if (is_numeric($key)) { + $key = $value; + } + if (is_array($value)) { + $value = implode(' ', $value); + } + + 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) . '"'; + } + + $html .= ' ' . $element; + } + + return $html; + } + + /** + * Render Querystring + * + * @param string|null $asset + * @return string + */ + protected function renderQueryString($asset = null) + { + $querystring = ''; + + $asset = $asset ?? $this->asset; + + if (!empty($this->query)) { + if (Utils::contains($asset, '?')) { + $querystring .= '&' . $this->query; + } else { + $querystring .= '?' . $this->query; + } + } + + if ($this->timestamp) { + if (Utils::contains($asset, '?') || $querystring) { + $querystring .= '&' . $this->timestamp; + } else { + $querystring .= '?' . $this->timestamp; + } + } + + return $querystring; + } +} diff --git a/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php b/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php new file mode 100644 index 0000000..36b2152 --- /dev/null +++ b/system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php @@ -0,0 +1,137 @@ + null, 'pipeline' => true, 'loading' => null, 'group' => null]; + $arguments = $this->createArgumentsFromLegacy($args, $defaults); + break; + + case (Assets::INLINE_JS_TYPE): + $defaults = ['priority' => null, 'group' => null, 'attributes' => null]; + $arguments = $this->createArgumentsFromLegacy($args, $defaults); + + // special case to handle old attributes being passed in + if (isset($arguments['attributes'])) { + $old_attributes = $arguments['attributes']; + if (is_array($old_attributes)) { + $arguments = array_merge($arguments, $old_attributes); + } else { + $arguments['type'] = $old_attributes; + } + } + unset($arguments['attributes']); + + break; + + case (Assets::INLINE_CSS_TYPE): + $defaults = ['priority' => null, 'group' => null]; + $arguments = $this->createArgumentsFromLegacy($args, $defaults); + break; + + default: + case (Assets::CSS_TYPE): + $defaults = ['priority' => null, 'pipeline' => true, 'group' => null, 'loading' => null]; + $arguments = $this->createArgumentsFromLegacy($args, $defaults); + } + + return $arguments; + } + + /** + * @param array $args + * @param array $defaults + * @return array + */ + protected function createArgumentsFromLegacy(array $args, array $defaults) + { + // Remove arguments with old default values. + $arguments = []; + foreach ($args as $arg) { + $default = current($defaults); + if ($arg !== $default) { + $arguments[key($defaults)] = $arg; + } + next($defaults); + } + + return $arguments; + } + + /** + * Convenience wrapper for async loading of JavaScript + * + * @param string|array $asset + * @param int $priority + * @param bool $pipeline + * @param string $group name of the group + * @return Assets + * @deprecated Please use dynamic method with ['loading' => 'async']. + */ + public function addAsyncJs($asset, $priority = 10, $pipeline = true, $group = 'head') + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use dynamic method with [\'loading\' => \'async\']', E_USER_DEPRECATED); + + return $this->addJs($asset, $priority, $pipeline, 'async', $group); + } + + /** + * Convenience wrapper for deferred loading of JavaScript + * + * @param string|array $asset + * @param int $priority + * @param bool $pipeline + * @param string $group name of the group + * @return Assets + * @deprecated Please use dynamic method with ['loading' => 'defer']. + */ + public function addDeferJs($asset, $priority = 10, $pipeline = true, $group = 'head') + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use dynamic method with [\'loading\' => \'defer\']', E_USER_DEPRECATED); + + 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 new file mode 100644 index 0000000..15dc00e --- /dev/null +++ b/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php @@ -0,0 +1,341 @@ +collections[$asset]) || isset($this->assets_css[$asset]) || isset($this->assets_js[$asset]); + } + + /** + * Return the array of all the registered collections + * + * @return array + */ + public function getCollections() + { + return $this->collections; + } + + /** + * Set the array of collections explicitly + * + * @param array $collections + * @return $this + */ + public function setCollection($collections) + { + $this->collections = $collections; + + return $this; + } + + /** + * Return the array of all the registered CSS assets + * If a $key is provided, it will try to return only that asset + * else it will return null + * + * @param string|null $key the asset key + * @return array + */ + public function getCss($key = null) + { + if (null !== $key) { + $asset_key = md5($key); + + return $this->assets_css[$asset_key] ?? null; + } + + return $this->assets_css; + } + + /** + * Return the array of all the registered JS assets + * If a $key is provided, it will try to return only that asset + * else it will return null + * + * @param string|null $key the asset key + * @return array + */ + public function getJs($key = null) + { + if (null !== $key) { + $asset_key = md5($key); + + return $this->assets_js[$asset_key] ?? null; + } + + return $this->assets_js; + } + + /** + * Set the whole array of CSS assets + * + * @param array $css + * @return $this + */ + public function setCss($css) + { + $this->assets_css = $css; + + return $this; + } + + /** + * Set the whole array of JS assets + * + * @param array $js + * @return $this + */ + public function setJs($js) + { + $this->assets_js = $js; + + return $this; + } + + /** + * Removes an item from the CSS array if set + * + * @param string $key The asset key + * @return $this + */ + public function removeCss($key) + { + $asset_key = md5($key); + if (isset($this->assets_css[$asset_key])) { + unset($this->assets_css[$asset_key]); + } + + return $this; + } + + /** + * Removes an item from the JS array if set + * + * @param string $key The asset key + * @return $this + */ + public function removeJs($key) + { + $asset_key = md5($key); + if (isset($this->assets_js[$asset_key])) { + unset($this->assets_js[$asset_key]); + } + + return $this; + } + + /** + * Sets the state of CSS Pipeline + * + * @param bool $value + * @return $this + */ + public function setCssPipeline($value) + { + $this->css_pipeline = (bool)$value; + + return $this; + } + + /** + * Sets the state of JS Pipeline + * + * @param bool $value + * @return $this + */ + public function setJsPipeline($value) + { + $this->js_pipeline = (bool)$value; + + return $this; + } + + /** + * Reset all assets. + * + * @return $this + */ + public function reset() + { + $this->resetCss(); + $this->resetJs(); + $this->setCssPipeline(false); + $this->setJsPipeline(false); + $this->order = []; + + return $this; + } + + /** + * Reset JavaScript assets. + * + * @return $this + */ + public function resetJs() + { + $this->assets_js = []; + + return $this; + } + + /** + * Reset CSS assets. + * + * @return $this + */ + public function resetCss() + { + $this->assets_css = []; + + return $this; + } + + /** + * Explicitly set's a timestamp for assets + * + * @param string|int $value + */ + public function setTimestamp($value) + { + $this->timestamp = $value; + } + + /** + * Get the timestamp for assets + * + * @param bool $include_join + * @return string|null + */ + public function getTimestamp($include_join = true) + { + if ($this->timestamp) { + return $include_join ? '?' . $this->timestamp : $this->timestamp; + } + + return null; + } + + /** + * Add all assets matching $pattern within $directory. + * + * @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 = GRAV_ROOT; + + // Check if $directory is a stream. + if (strpos($directory, '://')) { + $directory = Grav::instance()['locator']->findResource($directory, null); + } + + // Get files + $files = $this->rglob($root_dir . DIRECTORY_SEPARATOR . $directory, $pattern, $root_dir . '/'); + + // No luck? Nothing to do + if (!$files) { + return $this; + } + + // Add CSS files + if ($pattern === self::CSS_REGEX) { + foreach ($files as $file) { + $this->addCss($file); + } + + return $this; + } + + // Add JavaScript files + if ($pattern === self::JS_REGEX) { + foreach ($files as $file) { + $this->addJs($file); + } + + return $this; + } + + // Unknown pattern. + foreach ($files as $asset) { + $this->add($asset); + } + + return $this; + } + + /** + * 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) + { + return $this->addDir($directory, self::JS_REGEX); + } + + /** + * 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) + { + return $this->addDir($directory, self::CSS_REGEX); + } + + /** + * Recursively get files matching $pattern within $directory. + * + * @param string $directory + * @param string $pattern (regex) + * @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); + $files = []; + + foreach ($iterator as $file) { + $files[] = substr($file->getPathname(), $offset); + } + + return $files; + } +} diff --git a/system/src/Grav/Common/Backup/Backups.php b/system/src/Grav/Common/Backup/Backups.php new file mode 100644 index 0000000..9483b15 --- /dev/null +++ b/system/src/Grav/Common/Backup/Backups.php @@ -0,0 +1,323 @@ +addListener('onSchedulerInitialized', [$this, 'onSchedulerInitialized']); + + $grav->fireEvent('onBackupsInitialized', new Event(['backups' => $this])); + } + + /** + * @return void + */ + public function setup() + { + if (null === static::$backup_dir) { + $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['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->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(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(); + $size = array_sum(array_column($backups, 'size')); + + return $size ?? 0; + } + + /** + * @param bool $force + * @return array + */ + public static function getAvailableBackups($force = false) + { + if ($force || null === static::$backups) { + static::$backups = []; + + $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 + */ + foreach ($backups_itr as $name => $file) { + if (preg_match(static::BACKUP_FILENAME_REGEXZ, $name, $matches)) { + $date = DateTime::createFromFormat(static::BACKUP_DATE_FORMAT, $matches[2]); + $timestamp = $date->getTimestamp(); + $backup = new stdClass(); + $backup->title = $inflector->titleize($matches[1]); + $backup->time = $date; + $backup->date = $date->format($long_date_format); + $backup->filename = $name; + $backup->path = $file->getPathname(); + $backup->size = $file->getSize(); + static::$backups[$timestamp] = $backup; + } + } + // Reverse Key Sort to get in reverse date order + krsort(static::$backups); + } + + return static::$backups; + } + + /** + * Backup + * + * @param int $id + * @param callable|null $status + * @return string|null + */ + public static function backup($id = 0, callable $status = null) + { + $grav = Grav::instance(); + + $profiles = static::getBackupProfiles(); + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + if (isset($profiles[$id])) { + $backup = (object) $profiles[$id]; + } else { + throw new RuntimeException('No backups defined...'); + } + + $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'); + $backup_root = $backup->root; + + if ($locator->isStream($backup_root)) { + $backup_root = $locator->findResource($backup_root); + } else { + $backup_root = rtrim(GRAV_ROOT . $backup_root, '/'); + } + + if (!file_exists($backup_root)) { + throw new RuntimeException("Backup location: {$backup_root} does not exist..."); + } + + $options = [ + 'exclude_files' => static::convertExclude($backup->exclude_files ?? ''), + 'exclude_paths' => static::convertExclude($backup->exclude_paths ?? ''), + ]; + + $archiver = Archiver::create('zip'); + $archiver->setArchive($destination)->setOptions($options)->compress($backup_root, $status)->addEmptyFolders($options['exclude_paths'], $status); + + $status && $status([ + 'type' => 'message', + 'message' => 'Done...', + ]); + + $status && $status([ + 'type' => 'progress', + 'complete' => true + ]); + + if ($max_execution_time !== false) { + ini_set('max_execution_time', $max_execution_time); + } + + // Log the backup + $grav['log']->notice('Backup Created: ' . $destination); + + // Fire Finished event + $grav->fireEvent('onBackupFinished', new Event(['backup' => $destination])); + + // Purge anything required + static::purge(); + + // Log + $log = JsonFile::instance($locator->findResource("log://backup.log", true, true)); + $log->content([ + 'time' => time(), + 'location' => $destination + ]); + $log->save(); + + return $destination; + } + + /** + * @return void + * @throws Exception + */ + public static function purge() + { + $purge_config = static::getPurgeConfig(); + $trigger = $purge_config['trigger']; + $backups = static::getAvailableBackups(true); + + switch ($trigger) { + case 'number': + $backups_count = count($backups); + if ($backups_count > $purge_config['max_backups_count']) { + $last = end($backups); + unlink($last->path); + static::purge(); + } + break; + + case 'time': + $last = end($backups); + $now = new DateTime(); + $interval = $now->diff($last->time); + if ($interval->days > $purge_config['max_backups_time']) { + unlink($last->path); + static::purge(); + } + break; + + default: + $used_space = static::getTotalBackupsSize(); + $max_space = $purge_config['max_backups_space'] * 1024 * 1024 * 1024; + if ($used_space > $max_space) { + $last = end($backups); + unlink($last->path); + static::purge(); + } + break; + } + } + + /** + * @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), '/')); + } +} diff --git a/system/src/Grav/Common/Backup/ZipBackup.php b/system/src/Grav/Common/Backup/ZipBackup.php deleted file mode 100644 index 517d7da..0000000 --- a/system/src/Grav/Common/Backup/ZipBackup.php +++ /dev/null @@ -1,144 +0,0 @@ -findResource('backup://', true); - - if (!$destination) { - throw new \RuntimeException('The backup folder is missing.'); - } - } - - $name = substr(strip_tags(Grav::instance()['config']->get('site.title', basename(GRAV_ROOT))), 0, 20); - - $inflector = new Inflector(); - - if (is_dir($destination)) { - $date = date('YmdHis', time()); - $filename = trim($inflector->hyphenize($name), '-') . '-' . $date . '.zip'; - $destination = rtrim($destination, DS) . DS . $filename; - } - - $messager && $messager([ - 'type' => 'message', - 'level' => 'info', - 'message' => 'Creating new Backup "' . $destination . '"' - ]); - $messager && $messager([ - 'type' => 'message', - 'level' => 'info', - 'message' => '' - ]); - - $zip = new \ZipArchive(); - $zip->open($destination, \ZipArchive::CREATE); - - $max_execution_time = ini_set('max_execution_time', 600); - - static::folderToZip(GRAV_ROOT, $zip, strlen(rtrim(GRAV_ROOT, DS) . DS), $messager); - - $messager && $messager([ - 'type' => 'progress', - 'percentage' => false, - 'complete' => true - ]); - - $messager && $messager([ - 'type' => 'message', - 'level' => 'info', - 'message' => '' - ]); - $messager && $messager([ - 'type' => 'message', - 'level' => 'info', - 'message' => 'Saving and compressing archive...' - ]); - - $zip->close(); - - if ($max_execution_time !== false) { - ini_set('max_execution_time', $max_execution_time); - } - - return $destination; - } - - /** - * @param $folder - * @param $zipFile - * @param $exclusiveLength - * @param $messager - */ - private static function folderToZip($folder, \ZipArchive $zipFile, $exclusiveLength, callable $messager = null) - { - $handle = opendir($folder); - while (false !== $f = readdir($handle)) { - if ($f !== '.' && $f !== '..') { - $filePath = "$folder/$f"; - // Remove prefix from file path before add to zip. - $localPath = substr($filePath, $exclusiveLength); - - if (in_array($f, static::$ignoreFolders)) { - continue; - } - if (in_array($localPath, static::$ignorePaths)) { - $zipFile->addEmptyDir($f); - continue; - } - - if (is_file($filePath)) { - $zipFile->addFile($filePath, $localPath); - - $messager && $messager([ - 'type' => 'progress', - 'percentage' => false, - 'complete' => false - ]); - } elseif (is_dir($filePath)) { - // Add sub-directory. - $zipFile->addEmptyDir($localPath); - static::folderToZip($filePath, $zipFile, $exclusiveLength, $messager); - } - } - } - closedir($handle); - } -} diff --git a/system/src/Grav/Common/Browser.php b/system/src/Grav/Common/Browser.php index bb0a3fb..e4a6513 100644 --- a/system/src/Grav/Common/Browser.php +++ b/system/src/Grav/Common/Browser.php @@ -1,18 +1,23 @@ useragent = parse_user_agent(); - } catch (\InvalidArgumentException $e) { + } catch (InvalidArgumentException $e) { $this->useragent = parse_user_agent("Mozilla/5.0 (compatible; Unknown;)"); } } @@ -107,13 +112,13 @@ 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() { $version = explode('.', $this->getLongVersion()); - return intval($version[0]); + return (int)$version[0]; } /** @@ -134,4 +139,15 @@ class Browser return true; } + + /** + * Determine if “Do Not Track” is set by browser + * @see https://www.w3.org/TR/tracking-dnt/ + * + * @return bool + */ + public function isTrackable(): bool + { + return !(isset($_SERVER['HTTP_DNT']) && $_SERVER['HTTP_DNT'] === '1'); + } } diff --git a/system/src/Grav/Common/Cache.php b/system/src/Grav/Common/Cache.php index 71eb29f..a961904 100644 --- a/system/src/Grav/Common/Cache.php +++ b/system/src/Grav/Common/Cache.php @@ -1,25 +1,35 @@ config = $grav['config']; $this->now = time(); - $this->cache_dir = $grav['locator']->findResource('cache://doctrine', true, true); + if (null === $this->enabled) { + $this->enabled = (bool)$this->config->get('system.cache.enabled'); + } /** @var Uri $uri */ $uri = $grav['uri']; $prefix = $this->config->get('system.cache.prefix'); - - if (is_null($this->enabled)) { - $this->enabled = (bool)$this->config->get('system.cache.enabled'); - } + $uniqueness = substr(md5($uri->rootUrl(true) . $this->config->key() . GRAV_VERSION), 2, 8); // Cache key allows us to invalidate all cache on configuration changes. - $this->key = ($prefix ? $prefix : 'g') . '-' . substr(md5($uri->rootUrl(true) . $this->config->key() . GRAV_VERSION), - 2, 8); - + $this->key = ($prefix ? $prefix : 'g') . '-' . $uniqueness; + $this->cache_dir = $grav['locator']->findResource('cache://doctrine/' . $uniqueness, true, true); $this->driver_setting = $this->config->get('system.cache.driver'); - $this->driver = $this->getCacheDriver(); - - // Set the cache namespace to our unique key $this->driver->setNamespace($this->key); + + /** @var EventDispatcher $dispatcher */ + $dispatcher = Grav::instance()['events']; + $dispatcher->addListener('onSchedulerInitialized', [$this, 'onSchedulerInitialized']); + } + + /** + * @return CacheInterface + */ + public function getSimpleCache() + { + if (null === $this->simpleCache) { + $cache = new \Grav\Framework\Cache\Adapter\DoctrineCache($this->driver, '', $this->getLifetime()); + + // Disable cache key validation. + $cache->setValidation(false); + + $this->simpleCache = $cache; + } + + return $this->simpleCache; + } + + /** + * Deletes the old out of date file-based caches + * + * @return int + */ + public function purgeOldCache() + { + $cache_dir = dirname($this->cache_dir); + $current = basename($this->cache_dir); + $count = 0; + + foreach (new DirectoryIterator($cache_dir) as $file) { + $dir = $file->getBasename(); + if ($dir === $current || $file->isDot() || $file->isFile()) { + continue; + } + + Folder::delete($file->getPathname()); + $count++; + } + + return $count; } /** * Public accessor to set the enabled state of the cache * - * @param $enabled + * @param bool|int $enabled + * @return void */ public function setEnabled($enabled) { - $this->enabled = (bool) $enabled; + $this->enabled = (bool)$enabled; } /** @@ -184,19 +238,15 @@ class Cache extends Getters // CLI compatibility requires a non-volatile cache driver if ($this->config->get('system.cache.cli_compatibility') && ( - $setting == 'auto' || $this->isVolatileDriver($setting))) { + $setting === 'auto' || $this->isVolatileDriver($setting))) { $setting = $driver_name; } - if (!$setting || $setting == 'auto') { + if (!$setting || $setting === 'auto') { if (extension_loaded('apcu')) { $driver_name = 'apcu'; - } elseif (extension_loaded('apc')) { - $driver_name = 'apc'; } elseif (extension_loaded('wincache')) { $driver_name = 'wincache'; - } elseif (extension_loaded('xcache')) { - $driver_name = 'xcache'; } } else { $driver_name = $setting; @@ -206,9 +256,6 @@ class Cache extends Getters switch ($driver_name) { case 'apc': - $driver = new DoctrineCache\ApcCache(); - break; - case 'apcu': $driver = new DoctrineCache\ApcuCache(); break; @@ -217,45 +264,65 @@ class Cache extends Getters $driver = new DoctrineCache\WinCacheCache(); break; - case 'xcache': - $driver = new DoctrineCache\XcacheCache(); - break; - case 'memcache': - $memcache = new \Memcache(); - $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); + 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) + ); + $driver = new DoctrineCache\MemcacheCache(); + $driver->setMemcache($memcache); + } else { + throw new LogicException('Memcache PHP extension has not been installed'); + } break; case 'memcached': - $memcached = new \Memcached(); - $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); + 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) + ); + $driver = new DoctrineCache\MemcachedCache(); + $driver->setMemcached($memcached); + } else { + throw new LogicException('Memcached PHP extension has not been installed'); + } break; case 'redis': - $redis = new \Redis(); - $socket = $this->config->get('system.cache.redis.socket', false); - $password = $this->config->get('system.cache.redis.password', false); + if (extension_loaded('redis')) { + $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); + if ($socket) { + $redis->connect($socket); + } else { + $redis->connect( + $this->config->get('system.cache.redis.server', 'localhost'), + $this->config->get('system.cache.redis.port', 6379) + ); + } + + // Authenticate with password if set + if ($password && !$redis->auth($password)) { + 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 { - $redis->connect($this->config->get('system.cache.redis.server', 'localhost'), - $this->config->get('system.cache.redis.port', 6379)); + throw new LogicException('Redis PHP extension has not been installed'); } - - // Authenticate with password if set - if ($password && !$redis->auth($password)) { - throw new \RedisException('Redis authentication failed'); - } - - $driver = new DoctrineCache\RedisCache(); - $driver->setRedis($redis); break; default: @@ -270,24 +337,23 @@ 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) { if ($this->enabled) { return $this->driver->fetch($id); - } else { - return false; } + + return false; } /** * 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) { @@ -310,6 +376,21 @@ class Cache extends Getters if ($this->enabled) { return $this->driver->delete($id); } + + return false; + } + + /** + * Deletes all cache + * + * @return bool + */ + public function deleteAll() + { + if ($this->enabled) { + return $this->driver->deleteAll(); + } + return false; } @@ -324,11 +405,14 @@ class Cache extends Getters if ($this->enabled) { return $this->driver->contains(($id)); } + return false; } /** * Getter method to get the cache key + * + * @return string */ public function getKey() { @@ -337,6 +421,9 @@ class Cache extends Getters /** * Setter method to set key (Advanced) + * + * @param string $key + * @return void */ public function setKey($key) { @@ -348,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') @@ -373,24 +459,33 @@ class Cache extends Getters case 'tmp-only': $remove_paths = self::$tmp_remove; break; + case 'invalidate': + $remove_paths = []; + break; default: if (Grav::instance()['config']->get('system.cache.clear_images_by_default')) { $remove_paths = self::$standard_remove; } else { $remove_paths = self::$standard_remove_no_images; } + } + // Delete entries in the doctrine cache if required + if (in_array($remove, ['all', 'standard'])) { + $cache = Grav::instance()['cache']; + $cache->driver->deleteAll(); } // Clearing cache event to add paths to clear 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 . '/*'); @@ -404,7 +499,7 @@ class Cache extends Getters $anything = true; } } elseif (is_dir($file)) { - if (Folder::delete($file)) { + if (Folder::delete($file, false)) { $anything = true; } } @@ -414,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(); } @@ -422,7 +517,7 @@ class Cache extends Getters $output[] = ''; - if (($remove == 'all' || $remove == 'standard') && file_exists($user_config)) { + if (($remove === 'all' || $remove === 'standard') && file_exists($user_config)) { touch($user_config); $output[] = 'Touched: ' . $user_config; @@ -437,14 +532,36 @@ 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'; + + if (file_exists($user_config)) { + touch($user_config); + } + + // Clear stat cache + @clearstatcache(); + + // Clear opcache + if (function_exists('opcache_reset')) { + @opcache_reset(); + } + } /** * Set the cache lifetime programmatically * * @param int $future timestamp + * @return void */ public function setLifetime($future) { @@ -452,7 +569,7 @@ class Cache extends Getters return; } - $interval = $future - $this->now; + $interval = (int)($future - $this->now); if ($interval > 0 && $interval < $this->getLifetime()) { $this->lifetime = $interval; } @@ -462,12 +579,12 @@ class Cache extends Getters /** * Retrieve the cache lifetime (in seconds) * - * @return mixed + * @return int */ public function getLifetime() { if ($this->lifetime === null) { - $this->lifetime = $this->config->get('system.cache.lifetime') ?: 604800; // 1 week default + $this->lifetime = (int)($this->config->get('system.cache.lifetime') ?: 604800); // 1 week default } return $this->lifetime; @@ -476,7 +593,7 @@ class Cache extends Getters /** * Returns the current driver name * - * @return mixed + * @return string */ public function getDriverName() { @@ -486,7 +603,7 @@ class Cache extends Getters /** * Returns the current driver setting * - * @return mixed + * @return string */ public function getDriverSetting() { @@ -496,15 +613,82 @@ class Cache extends Getters /** * is this driver a volatile driver in that it resides in PHP process memory * - * @param $setting + * @param string $setting * @return bool */ public function isVolatileDriver($setting) { if (in_array($setting, ['apc', 'apcu', 'xcache', 'wincache'])) { return true; + } + + return false; + } + + /** + * Static function to call as a scheduled Job to purge old Doctrine files + * + * @param bool $echo + * + * @return string|void + */ + public static function purgeJob($echo = false) + { + /** @var Cache $cache */ + $cache = Grav::instance()['cache']; + $deleted_folders = $cache->purgeOldCache(); + $msg = 'Purged ' . $deleted_folders . ' old cache folders...'; + + if ($echo) { + echo $msg; } else { - return false; + return $msg; } } + + /** + * Static function to call as a scheduled Job to clear Grav cache + * + * @param string $type + * @return void + */ + public static function clearJob($type) + { + $result = static::clearCache($type); + static::invalidateCache(); + + echo strip_tags(implode("\n", $result)); + } + + /** + * @param Event $event + * @return void + */ + public function onSchedulerInitialized(Event $event) + { + /** @var Scheduler $scheduler */ + $scheduler = $event['scheduler']; + $config = Grav::instance()['config']; + + // File Cache Purge + $at = $config->get('system.cache.purge_at'); + $name = 'cache-purge'; + $logs = 'logs/' . $name . '.out'; + + $job = $scheduler->addFunction('Grav\Common\Cache::purgeJob', [true], $name); + $job->at($at); + $job->output($logs); + $job->backlink('/config/system#caching'); + + // Cache Clear + $at = $config->get('system.cache.clear_at'); + $clear_type = $config->get('system.cache.clear_job_type'); + $name = 'cache-clear'; + $logs = 'logs/' . $name . '.out'; + + $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 2671925..f1f6e5d 100644 --- a/system/src/Grav/Common/Composer.php +++ b/system/src/Grav/Common/Composer.php @@ -1,17 +1,24 @@ 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) @@ -87,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 @@ -128,13 +125,16 @@ abstract class CompiledBase */ public function checksum() { - if (!isset($this->checksum)) { + if (null === $this->checksum) { $this->checksum = md5(json_encode($this->files) . $this->version); } return $this->checksum; } + /** + * @return string + */ protected function createFilename() { return "{$this->cacheFolder}/{$this->name()->name}.php"; @@ -144,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(); @@ -156,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); @@ -196,12 +200,9 @@ abstract class CompiledBase } $cache = include $filename; - if ( - !is_array($cache) - || !isset($cache['checksum']) - || !isset($cache['data']) - || !isset($cache['@class']) - || $cache['@class'] != get_class($this) + if (!is_array($cache) + || !isset($cache['checksum'], $cache['data'], $cache['@class']) + || $cache['@class'] !== get_class($this) ) { return false; } @@ -212,7 +213,7 @@ abstract class CompiledBase } $this->createObject($cache['data']); - $this->timestamp = isset($cache['timestamp']) ? $cache['timestamp'] : 0; + $this->timestamp = $cache['timestamp'] ?? 0; $this->finalizeObject(); @@ -223,7 +224,8 @@ abstract class CompiledBase * Save compiled file. * * @param string $filename - * @throws \RuntimeException + * @return void + * @throws RuntimeException * @internal */ protected function saveCompiledFile($filename) @@ -233,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. } @@ -257,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 a29ecde..7743054 100644 --- a/system/src/Grav/Common/Config/CompiledBlueprints.php +++ b/system/src/Grav/Common/Config/CompiledBlueprints.php @@ -1,27 +1,36 @@ version = 2; + } /** * Returns checksum from the configuration files. @@ -42,7 +51,7 @@ class CompiledBlueprints extends CompiledBase /** * Create configuration object. * - * @param array $data + * @param array $data */ protected function createObject(array $data = []) { @@ -61,6 +70,8 @@ class CompiledBlueprints extends CompiledBase /** * Finalize configuration object. + * + * @return void */ protected function finalizeObject() { @@ -71,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) { @@ -109,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 6f21123..22225bc 100644 --- a/system/src/Grav/Common/Config/CompiledConfig.php +++ b/system/src/Grav/Common/Config/CompiledConfig.php @@ -1,36 +1,41 @@ version = 1; + } /** * Set blueprints for the configuration. @@ -60,6 +65,7 @@ class CompiledConfig extends CompiledBase * Create configuration object. * * @param array $data + * @return void */ protected function createObject(array $data = []) { @@ -73,6 +79,8 @@ class CompiledConfig extends CompiledBase /** * Finalize configuration object. + * + * @return void */ protected function finalizeObject() { @@ -82,6 +90,8 @@ class CompiledConfig extends CompiledBase /** * Function gets called when cached configuration is saved. + * + * @return void */ public function modified() { @@ -93,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 610e347..6674389 100644 --- a/system/src/Grav/Common/Config/CompiledLanguages.php +++ b/system/src/Grav/Common/Config/CompiledLanguages.php @@ -1,8 +1,9 @@ version = 1; + } /** * Create configuration object. * * @param array $data + * @return void */ protected function createObject(array $data = []) { @@ -34,6 +43,8 @@ class CompiledLanguages extends CompiledBase /** * Finalize configuration object. + * + * @return void */ protected function finalizeObject() { @@ -44,6 +55,8 @@ class CompiledLanguages extends CompiledBase /** * Function gets called when cached configuration is saved. + * + * @return void */ public function modified() { @@ -55,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 a8e2bdf..7adb523 100644 --- a/system/src/Grav/Common/Config/Config.php +++ b/system/src/Grav/Common/Config/Config.php @@ -1,8 +1,9 @@ checksum(); + if (null === $this->key) { + $this->key = md5($this->checksum . $this->timestamp); + } + + return $this->key; } + /** + * @param string|null $checksum + * @return string|null + */ public function checksum($checksum = null) { if ($checksum !== null) { @@ -35,6 +59,10 @@ class Config extends Data return $this->checksum; } + /** + * @param bool|null $modified + * @return bool + */ public function modified($modified = null) { if ($modified !== null) { @@ -44,6 +72,10 @@ class Config extends Data return $this->modified; } + /** + * @param int|null $timestamp + * @return int + */ public function timestamp($timestamp = null) { if ($timestamp !== null) { @@ -53,6 +85,9 @@ class Config extends Data return $this->timestamp; } + /** + * @return $this + */ public function reload() { $grav = Grav::instance(); @@ -75,6 +110,9 @@ class Config extends Data return $this; } + /** + * @return void + */ public function debug() { /** @var Debugger $debugger */ @@ -86,6 +124,9 @@ class Config extends Data } } + /** + * @return void + */ public function init() { $setup = Grav::instance()['setup']->toArray(); @@ -98,14 +139,13 @@ class Config extends Data } } - // Override the media.upload_limit based on PHP values - $upload_limit = Utils::getUploadLimit(); - $this->items['system']['media']['upload_limit'] = $upload_limit > 0 ? $upload_limit : 1024*1024*1024; + // Legacy value - Override the media.upload_limit based on PHP values + $this->items['system']['media']['upload_limit'] = Utils::getUploadLimit(); } /** * @return mixed - * @deprecated + * @deprecated 1.5 Use Grav::instance()['languages'] instead. */ public function getLanguages() { diff --git a/system/src/Grav/Common/Config/ConfigFileFinder.php b/system/src/Grav/Common/Config/ConfigFileFinder.php index 83b13ec..e6b1afe 100644 --- a/system/src/Grav/Common/Config/ConfigFileFinder.php +++ b/system/src/Grav/Common/Config/ConfigFileFinder.php @@ -1,17 +1,25 @@ detectRecursive($folder, $pattern, $levels); } + return $list; } @@ -60,6 +69,7 @@ class ConfigFileFinder $list += $files[trim($path, '/')]; } + return $list; } @@ -77,6 +87,7 @@ class ConfigFileFinder foreach ($paths as $folder) { $list = array_merge_recursive($list, $this->detectAll($folder, $pattern, $levels)); } + return $list; } @@ -95,6 +106,7 @@ class ConfigFileFinder foreach ($folders as $folder) { $list += $this->detectInFolder($folder, $filename); } + return $list; } @@ -102,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) @@ -112,6 +124,7 @@ class ConfigFileFinder $path = trim(Folder::getRelativePath($folder), '/'); $list[$path] = $this->detectInFolder($folder, $filename); } + return $list; } @@ -165,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()]; } ], @@ -186,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 */ @@ -199,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; @@ -243,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 aa97cdb..4a863a1 100644 --- a/system/src/Grav/Common/Config/Languages.php +++ b/system/src/Grav/Common/Config/Languages.php @@ -1,8 +1,9 @@ checksum; } + /** + * @param bool|null $modified + * @return bool + */ public function modified($modified = null) { if ($modified !== null) { @@ -31,6 +53,10 @@ class Languages extends Data return $this->modified; } + /** + * @param int|null $timestamp + * @return int + */ public function timestamp($timestamp = null) { if ($timestamp !== null) { @@ -40,6 +66,9 @@ class Languages extends Data return $this->timestamp; } + /** + * @return void + */ public function reformat() { if (isset($this->items['plugins'])) { @@ -48,8 +77,31 @@ 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 fba7fd1..5693a92 100644 --- a/system/src/Grav/Common/Config/Setup.php +++ b/system/src/Grav/Common/Config/Setup.php @@ -1,44 +1,96 @@ 'unknown', + '127.0.0.1' => 'localhost', + '::1' => 'localhost' + ]; + + /** + * @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. ], - 'asset' => [ + 'system' => [ 'type' => 'ReadOnlyStream', + 'prefixes' => [ + '' => ['system'], + ] + ], + 'asset' => [ + 'type' => 'Stream', 'prefixes' => [ '' => ['assets'], ] @@ -46,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' => [ @@ -76,40 +128,11 @@ 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' => [ - 'type' => 'ReadOnlyStream', + 'type' => 'Stream', 'prefixes' => [ '' => ['user://images', 'system://images'] ] @@ -120,6 +143,13 @@ class Setup extends Data '' => ['user://pages'] ] ], + 'user-data' => [ + 'type' => 'Stream', + 'force' => true, + 'prefixes' => [ + '' => ['user://data'] + ] + ], 'account' => [ 'type' => 'ReadOnlyStream', 'prefixes' => [ @@ -133,13 +163,58 @@ class Setup extends Data */ public function __construct($container) { - $environment = null !== static::$environment ? static::$environment : ($container['uri']->environment() ?: 'localhost'); + // 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 (null === $environment) { + if (defined('GRAV_CLI')) { + $environment = 'cli'; + } else { + /** @var ServerRequestInterface $request */ + $request = $container['request']; + $host = $request->getUri()->getHost(); + + $environment = Utils::substrToString($host, ':'); + } + } + + // Resolve server aliases to the proper 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 "GRAVE_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'])) { @@ -150,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 ?: 'cli'); - $this->def('streams.schemes.environment.prefixes', ['' => $environment ? ["user://{$this->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; @@ -186,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. @@ -199,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) { @@ -212,8 +310,8 @@ class Setup extends Data $locator->addPath($scheme, '', $config['paths']); } - $override = isset($config['override']) ? $config['override'] : false; - $force = isset($config['force']) ? $config['force'] : false; + $override = $config['override'] ?? false; + $force = $config['force'] ?? false; if (isset($config['prefixes'])) { foreach ((array)$config['prefixes'] as $prefix => $paths) { @@ -232,7 +330,7 @@ class Setup extends Data { $schemes = []; foreach ((array) $this->get('streams.schemes') as $scheme => $config) { - $type = !empty($config['type']) ? $config['type'] : 'ReadOnlyStream'; + $type = $config['type'] ?? 'ReadOnlyStream'; if ($type[0] !== '\\') { $type = '\\RocketTheme\\Toolbox\\StreamWrapper\\' . $type; } @@ -245,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 = isset($this->items['streams']['schemes']) ? $this->items['streams']['schemes'] : null; + $streams = $this->items['streams']['schemes'] ?? null; if (!is_array($streams)) { - throw new \InvalidArgumentException('Configuration is missing streams.schemes!'); + 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' => []]); @@ -271,13 +396,17 @@ class Setup extends Data // Create security.yaml if it doesn't exist. $filename = $locator->findResource('config://security.yaml', true, true); - $file = YamlFile::instance($filename); - if (!$file->exists()) { - $file->save(['salt' => Utils::generateRandomString(14)]); - $file->free(); + $security_file = CompiledYamlFile::instance($filename); + $security_content = (array)$security_file->content(); + + if (!isset($security_content['salt'])) { + $security_content = array_merge($security_content, ['salt' => Utils::generateRandomString(14)]); + $security_file->content($security_content); + $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 bf33d41..29a2636 100644 --- a/system/src/Grav/Common/Data/Blueprint.php +++ b/system/src/Grav/Common/Data/Blueprint.php @@ -1,8 +1,9 @@ blueprintSchema) { + $this->blueprintSchema = clone $this->blueprintSchema; + } + } + + /** + * @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. * @@ -40,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. * @@ -51,7 +126,93 @@ class Blueprint extends BlueprintForm { $this->initInternals(); - return $this->blueprintSchema->getDefaults(); + if (null === $this->defaults) { + $this->defaults = $this->blueprintSchema->getDefaults(); + } + + return $this->defaults; + } + + /** + * Initialize blueprints with its dynamic fields. + * + * @return $this + */ + public function init() + { + foreach ($this->dynamic as $key => $data) { + // Locate field. + $path = explode('/', $key); + $current = &$this->items; + + foreach ($path as $field) { + if (is_object($current)) { + // Handle objects. + if (!isset($current->{$field})) { + $current->{$field} = []; + } + + $current = &$current->{$field}; + } else { + // Handle arrays and scalars. + if (!is_array($current)) { + $current = [$field => []]; + } elseif (!isset($current[$field])) { + $current[$field] = []; + } + + $current = &$current[$field]; + } + } + + // Set dynamic property. + 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]; + $callable($current, $property, $call); + } elseif (method_exists($this, $method)) { + $this->{$method}($current, $property, $call); + } + } + } + + 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; } /** @@ -59,7 +220,7 @@ class Blueprint extends BlueprintForm * * @param array $data1 * @param array $data2 - * @param string $name Optional + * @param string|null $name Optional * @param string $separator Optional * @return array */ @@ -70,6 +231,20 @@ class Blueprint extends BlueprintForm return $this->blueprintSchema->mergeData($data1, $data2, $name, $separator); } + /** + * Process data coming from a form. + * + * @param array $data + * @param array $toggles + * @return array + */ + public function processForm(array $data, array $toggles = []) + { + $this->initInternals(); + + return $this->blueprintSchema->processForm($data, $toggles); + } + /** * Return data fields that do not exist in blueprints. * @@ -88,28 +263,48 @@ 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); } /** * Filter data by using blueprints. * * @param array $data + * @param bool $missingValuesAsNull + * @param bool $keepEmptyValues * @return array */ - public function filter(array $data) + public function filter(array $data, bool $missingValuesAsNull = false, bool $keepEmptyValues = false) { $this->initInternals(); - return $this->blueprintSchema->filter($data); + return $this->blueprintSchema->filter($data, $missingValuesAsNull, $keepEmptyValues) ?? []; } + + /** + * Flatten data by using blueprints. + * + * @param array $data + * @param bool $includeAll + * @return array + */ + public function flattenData(array $data, bool $includeAll = false) + { + $this->initInternals(); + + return $this->blueprintSchema->flattenData($data, $includeAll); + } + + /** * Return blueprint data schema. * @@ -122,31 +317,46 @@ 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; + } + /** * Initialize validator. + * + * @return void */ protected function initInternals() { - if (!isset($this->blueprintSchema)) { + if (null === $this->blueprintSchema) { $types = Grav::instance()['plugins']->formFieldTypes; $this->blueprintSchema = new BlueprintSchema; + if ($types) { $this->blueprintSchema->setTypes($types); } + $this->blueprintSchema->embed('', $this->items); $this->blueprintSchema->init(); + $this->defaults = null; } } /** * @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; @@ -154,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) @@ -163,16 +373,26 @@ class Blueprint extends BlueprintForm $locator = Grav::instance()['locator']; if (is_string($path) && !$locator->isStream($path)) { + if (is_file($path)) { + return [$path]; + } + // Find path overrides. - $paths = isset($this->overrides[$path]) ? (array) $this->overrides[$path] : []; + 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] !== '/') { $context .= '/'; } + $path = $context . $path; if (!preg_match('/\.yaml$/', $path)) { @@ -200,6 +420,7 @@ class Blueprint extends BlueprintForm * @param array $field * @param string $property * @param array $call + * @return void */ protected function dynamicData(array &$field, $property, array &$call) { @@ -212,20 +433,22 @@ class Blueprint extends BlueprintForm $params = []; } - list($o, $f) = preg_split('/::/', $function, 2); + [$o, $f] = explode('::', $function, 2); + + $data = null; if (!$f) { if (function_exists($o)) { $data = call_user_func_array($o, $params); } } else { if (method_exists($o, $f)) { - $data = call_user_func_array(array($o, $f), $params); + $data = call_user_func_array([$o, $f], $params); } } // If function returns a value, - if (isset($data)) { - if (isset($field[$property]) && is_array($field[$property]) && is_array($data)) { + if (null !== $data) { + if (is_array($data) && isset($field[$property]) && is_array($field[$property])) { // Combine field and @data-field together. $field[$property] += $data; } else { @@ -239,16 +462,132 @@ 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 = isset($field[$property]) ? $field[$property] : null; + $default = $field[$property] ?? null; $config = Grav::instance()['config']->get($value, $default); + if (!empty($field['value_only'])) { + $config = array_combine($config, $config); + } - if (!is_null($config)) { - $field[$property] = $config; + if (null !== $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; + } + } + } + + /** + * @param array $field + * @param string $property + * @param array $call + * @return void + */ + protected function dynamicSecurity(array &$field, $property, array &$call) + { + if ($property || !empty($field['validate']['ignore'])) { + return; + } + + $grav = Grav::instance(); + $actions = (array)$call['params']; + + /** @var UserInterface|null $user */ + $user = $grav['user'] ?? null; + $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) + { + if ($property && $property !== 'ignore') { + return; + } + + $scopes = (array)$call['params']; + $matches = in_array($this->scope, $scopes, true); + if ($this->scope && $property !== 'ignore') { + $matches = !$matches; + } + + if ($matches) { + $this->addPropertyRecursive($field, 'validate', ['ignore' => true]); + return; + } + } + + /** + * @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])) { + $field[$property] = array_merge_recursive($field[$property], $value); + } else { + $field[$property] = $value; + } + + if (!empty($field['fields'])) { + foreach ($field['fields'] as $key => &$child) { + $this->addPropertyRecursive($child, $property, $value); + } } } } diff --git a/system/src/Grav/Common/Data/BlueprintSchema.php b/system/src/Grav/Common/Data/BlueprintSchema.php index 3205c38..a50bf23 100644 --- a/system/src/Grav/Common/Data/BlueprintSchema.php +++ b/system/src/Grav/Common/Data/BlueprintSchema.php @@ -1,22 +1,35 @@ true, 'xss_check' => true]; + + /** @var array */ protected $ignoreFormKeys = [ 'title' => true, 'help' => true, @@ -26,18 +39,37 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface 'fields' => true ]; + /** + * @return array + */ + public function getTypes() + { + return $this->types; + } + + /** + * @param string $name + * @return array + */ + public function getType($name) + { + return $this->types[$name] ?? []; + } + /** * 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(); } @@ -47,40 +79,125 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface } /** - * Filter data by using blueprints. - * - * @param array $data + * @param array $data + * @param array $toggles * @return array */ - public function filter(array $data) + public function processForm(array $data, array $toggles = []) { - return $this->filterArray($data, $this->nested); + return $this->processFormRecursive($data, $toggles, $this->nested) ?? []; + } + + /** + * Filter data by using blueprints. + * + * @param array $data Incoming data, for example from a form. + * @param bool $missingValuesAsNull Include missing values as nulls. + * @param bool $keepEmptyValues Include empty values. + * @return array + */ + public function filter(array $data, $missingValuesAsNull = false, $keepEmptyValues = false) + { + $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, bool $includeAll = false) + { + $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, '')); } /** * @param array $data * @param array $rules - * @returns array - * @throws \RuntimeException - * @internal + * @param string $prefix + * @return array */ - protected function validateArray(array $data, array $rules) + protected function flattenArray(array $data, array $rules, string $prefix) + { + $array = []; + + foreach ($data as $key => $field) { + $val = $rules[$key] ?? $rules['*'] ?? null; + $rule = is_string($val) ? $this->items[$val] : null; + + if ($rule || isset($val['*'])) { + // Item has been defined in blueprints. + $array[$prefix.$key] = $field; + } elseif (is_array($field) && is_array($val)) { + // Array has been defined in blueprints. + $array += $this->flattenArray($field, $val, $prefix . $key . '.'); + } else { + // Undefined/extra item. + $array[$prefix.$key] = $field; + } + } + + return $array; + } + + /** + * @param array $data + * @param array $rules + * @param bool $strict + * @param bool $xss + * @return array + * @throws RuntimeException + */ + protected function validateArray(array $data, array $rules, bool $strict, bool $xss = true) { $messages = $this->checkRequired($data, $rules); - foreach ($data as $key => $field) { - $val = isset($rules[$key]) ? $rules[$key] : (isset($rules['*']) ? $rules['*'] : null); + foreach ($data as $key => $child) { + $val = $rules[$key] ?? $rules['*'] ?? null; $rule = is_string($val) ? $this->items[$val] : null; + $checkXss = $xss; if ($rule) { // Item has been defined in blueprints. - $messages += Validation::validate($field, $rule); - } elseif (is_array($field) && is_array($val)) { + if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) { + // Skip validation in the ignored field. + continue; + } + + $messages += Validation::validate($child, $rule); + + } elseif (is_array($child) && is_array($val)) { // Array has been defined in blueprints. - $messages += $this->validateArray($field, $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]); } } @@ -90,32 +207,139 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface /** * @param array $data * @param array $rules - * @return array - * @internal + * @param string $parent + * @param bool $missingValuesAsNull + * @param bool $keepEmptyValues + * @return array|null */ - protected function filterArray(array $data, array $rules) + protected function filterArray(array $data, array $rules, string $parent, bool $missingValuesAsNull, bool $keepEmptyValues) { - $results = array(); - foreach ($data as $key => $field) { - $val = isset($rules[$key]) ? $rules[$key] : (isset($rules['*']) ? $rules['*'] : null); - $rule = is_string($val) ? $this->items[$val] : null; + $results = []; - if ($rule) { - // Item has been defined in blueprints. + foreach ($data as $key => $field) { + $val = $rules[$key] ?? $rules['*'] ?? null; + $rule = is_string($val) ? $this->items[$val] : $this->items[$parent . $key] ?? null; + + 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. - $field = $this->filterArray($field, $val); + $k = $isParent ? '*' : $key; + $field = $this->filterArray($field, $val, $parent . $k . '.', $missingValuesAsNull, $keepEmptyValues); + + if (null === $field) { + // Nested parent has no values. + unset($results[$key]); + continue; + } } elseif (isset($rules['validation']) && $rules['validation'] === 'strict') { - $field = null; + // Skip any extra data. + continue; } - if (isset($field) && (!is_array($field) || !empty($field))) { + if ($keepEmptyValues || (null !== $field && (!is_array($field) || !empty($field)))) { $results[$key] = $field; } } - return $results; + 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 + * @param array $nested + * @return array|null + */ + protected function processFormRecursive(?array $data, array $toggles, array $nested) + { + foreach ($nested as $key => $value) { + if ($key === '') { + continue; + } + if ($key === '*') { + // TODO: Add support to collections. + 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, $toggle, $value); + } else { + $field = $this->get($value); + // Do not add the field if: + if ( + // Not an input field + !$field + // Field has been disabled + || !empty($field['disabled']) + // Field validation is set to be ignored + || !empty($field['validate']['ignore']) + // Field is overridable and the toggle is turned off + || (!empty($field['overridable']) && empty($toggles[$key])) + ) { + continue; + } + if (!isset($data[$key])) { + $data[$key] = null; + } + } + } + + return $data; } /** @@ -131,10 +355,23 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface if (!is_string($field)) { continue; } + $field = $this->items[$field]; + + // Skip ignored field, it will not be required. + if (!empty($field['disabled']) || !empty($field['validate']['ignore'])) { + 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; } @@ -142,9 +379,9 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface continue; } - $value = isset($field['label']) ? $field['label'] : $field['name']; + $value = $field['label'] ?? $field['name']; $language = Grav::instance()['language']; - $message = sprintf($language->translate('FORM.MISSING_REQUIRED_FIELD', null, true) . ' %s', $language->translate($value)); + $message = sprintf($language->translate('GRAV.FORM.MISSING_REQUIRED_FIELD', null, true) . ' %s', $language->translate($value)); $messages[$field['name']][] = $message; } } @@ -156,12 +393,13 @@ 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) { $value = $call['params']; - $default = isset($field[$property]) ? $field[$property] : null; + $default = $field[$property] ?? null; $config = Grav::instance()['config']->get($value, $default); if (null !== $config) { diff --git a/system/src/Grav/Common/Data/Blueprints.php b/system/src/Grav/Common/Data/Blueprints.php index c7090c5..abe153f 100644 --- a/system/src/Grav/Common/Data/Blueprints.php +++ b/system/src/Grav/Common/Data/Blueprints.php @@ -1,20 +1,32 @@ instances[$type])) { - $this->instances[$type] = $this->loadFile($type); + $blueprint = $this->loadFile($type); + $this->instances[$type] = $blueprint; } return $this->instances[$type]; @@ -49,7 +62,7 @@ class Blueprints public function types() { if ($this->types === null) { - $this->types = array(); + $this->types = []; $grav = Grav::instance(); @@ -60,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; @@ -95,6 +107,15 @@ class Blueprints $blueprint->setContext($this->search); } - return $blueprint->load()->init(); + try { + $blueprint->load()->init(); + } catch (RuntimeException $e) { + $log = Grav::instance()['log']; + $log->error(sprintf('Blueprint %s cannot be loaded: %s', $name, $e->getMessage())); + + throw $e; + } + + return $blueprint; } } diff --git a/system/src/Grav/Common/Data/Data.php b/system/src/Grav/Common/Data/Data.php index a9ceca3..67fb0a8 100644 --- a/system/src/Grav/Common/Data/Data.php +++ b/system/src/Grav/Common/Data/Data.php @@ -1,45 +1,82 @@ items = $items; - $this->blueprints = $blueprints; + if (null !== $blueprints) { + $this->blueprints = $blueprints; + } + } + + /** + * @param bool $value + * @return $this + */ + public function setKeepEmptyValues(bool $value) + { + $this->keepEmptyValues = $value; + + return $this; + } + + /** + * @param bool $value + * @return $this + */ + public function setMissingValuesAsNull(bool $value) + { + $this->missingValuesAsNull = $value; + + return $this; } /** @@ -64,20 +101,22 @@ class Data implements DataInterface, \ArrayAccess, \Countable, ExportInterface * @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); + throw new RuntimeException('Value ' . $old); } + if (is_object($value)) { $value = (array) $value; } elseif (!is_array($value)) { - throw new \RuntimeException('Value ' . $value); + throw new RuntimeException('Value ' . $value); } + $value = $this->blueprints()->mergeData($old, $value, $name, $separator); } @@ -111,6 +150,7 @@ class Data implements DataInterface, \ArrayAccess, \Countable, ExportInterface if (is_object($value)) { $value = (array) $value; } + $old = $this->get($name, null, $separator); if ($old !== null) { $value = $this->blueprints()->mergeData($value, $old, $name, $separator); @@ -125,17 +165,17 @@ class Data implements DataInterface, \ArrayAccess, \Countable, ExportInterface * Get value from the configuration and join it with given data. * * @param string $name Dot separated path to the requested value. - * @param array $value Value to be joined. + * @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)) { $value = (array) $value; } elseif (!is_array($value)) { - throw new \RuntimeException('Value ' . $value); + throw new RuntimeException('Value ' . $value); } $old = $this->get($name, null, $separator); @@ -146,7 +186,7 @@ class Data implements DataInterface, \ArrayAccess, \Countable, ExportInterface } if (!is_array($old)) { - throw new \RuntimeException('Value ' . $old); + throw new RuntimeException('Value ' . $old); } // Return joined data. @@ -184,7 +224,7 @@ class Data implements DataInterface, \ArrayAccess, \Countable, ExportInterface * Validate by blueprints. * * @return $this - * @throws \Exception + * @throws Exception */ public function validate() { @@ -195,11 +235,14 @@ class Data implements DataInterface, \ArrayAccess, \Countable, ExportInterface /** * @return $this - * Filter all items by using blueprints. */ public function filter() { - $this->items = $this->blueprints()->filter($this->items); + $args = func_get_args(); + $missingValuesAsNull = (bool)(array_shift($args) ?? $this->missingValuesAsNull); + $keepEmptyValues = (bool)(array_shift($args) ?? $this->keepEmptyValues); + + $this->items = $this->blueprints()->filter($this->items, $missingValuesAsNull, $keepEmptyValues); return $this; } @@ -221,19 +264,22 @@ class Data implements DataInterface, \ArrayAccess, \Countable, ExportInterface */ public function blueprints() { - if (!$this->blueprints){ - $this->blueprints = new Blueprint; + 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() { @@ -274,14 +320,23 @@ class Data implements DataInterface, \ArrayAccess, \Countable, ExportInterface /** * 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) { if ($storage) { $this->storage = $storage; } + 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 aa61d26..b4452bb 100644 --- a/system/src/Grav/Common/Data/DataInterface.php +++ b/system/src/Grav/Common/Data/DataInterface.php @@ -1,15 +1,21 @@ translate($field['validate']['message']) - : $language->translate('FORM.INVALID_INPUT', null, true) . ' "' . $language->translate($name) . '"'; + : $language->translate('GRAV.FORM.INVALID_INPUT') . ' "' . $language->translate($name) . '"'; + // Validate type with fallback type text. + $method = 'type' . str_replace('-', '_', $type); + // If this is a YAML field validate/filter as such - if ($type != 'yaml' && isset($field['yaml']) && $field['yaml'] === true) { + if (isset($field['yaml']) && $field['yaml'] === true) { $method = 'typeYaml'; } - if (method_exists(__CLASS__, $method)) { - $success = self::$method($value, $validate, $field); - } else { - $success = true; - } + $messages = []; + $success = method_exists(__CLASS__, $method) ? self::$method($value, $validate, $field) : true; if (!$success) { $messages[$field['name']][] = $message; } // Check individual rules. foreach ($validate as $rule => $params) { - $method = 'validate' . ucfirst(strtr($rule, '-', '_')); + $method = 'validate' . ucfirst(str_replace('-', '_', $rule)); if (method_exists(__CLASS__, $method)) { $success = self::$method($value, $params); @@ -80,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. * @@ -89,29 +192,27 @@ class Validation */ public static function filter($value, array $field) { - $validate = isset($field['validate']) ? (array) $field['validate'] : []; + $validate = (array)($field['filter'] ?? $field['validate'] ?? null); // If value isn't required, we will return null if empty value is given. - if (empty($validate['required']) && ($value === null || $value === '')) { + if (($value === null || $value === '') && empty($validate['required'])) { return null; } if (!isset($field['type'])) { $field['type'] = 'text'; } + $type = $field['filter']['type'] ?? $field['validate']['type'] ?? $field['type']; - - // Validate type with fallback type text. - $type = (string) isset($field['validate']['type']) ? $field['validate']['type'] : $field['type']; - $method = 'filter' . ucfirst(strtr($type, '-', '_')); + $method = 'filter' . ucfirst(str_replace('-', '_', $type)); // If this is a YAML field validate/filter as such - if ($type !== 'yaml' && isset($field['yaml']) && $field['yaml'] === true) { + if (isset($field['yaml']) && $field['yaml'] === true) { $method = 'filterYaml'; } if (!method_exists(__CLASS__, $method)) { - $method = 'filterText'; + $method = isset($field['array']) && $field['array'] === true ? 'filterArray' : 'filterText'; } return self::$method($value, $validate, $field); @@ -133,16 +234,25 @@ class Validation $value = (string)$value; - if (isset($params['min']) && strlen($value) < $params['min']) { + if (!empty($params['trim'])) { + $value = trim($value); + } + + $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 = isset($params['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,29 +263,92 @@ class Validation return true; } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return string + */ protected static function filterText($value, array $params, array $field) { - return (string) $value; + if (!is_string($value) && !is_numeric($value)) { + return ''; + } + + $value = (string)$value; + + if (!empty($params['trim'])) { + $value = trim($value); + } + + return preg_replace("/\r\n|\r/um", "\n", $value); } + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return string|null + */ + protected static function filterCheckbox($value, array $params, array $field) + { + $value = (string)$value; + $field_value = (string)($field['value'] ?? '1'); + + 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); } + /** + * @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); } - protected static function filterLower($value, array $params) + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return array|array[]|false|string[] + */ + protected static function filterLines($value, array $params, array $field) { - return strtolower($value); + 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 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); } @@ -234,9 +407,16 @@ class Validation { // Set multiple: true so checkboxes can easily use min/max counts to control number of options required $field['multiple'] = true; + 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); @@ -252,16 +432,10 @@ class Validation */ public static function typeCheckbox($value, array $params, array $field) { - $value = (string) $value; + $value = (string)$value; + $field_value = (string)($field['value'] ?? '1'); - if (!isset($field['value'])) { - $field['value'] = 1; - } - if (isset($value) && $value != $field['value']) { - return false; - } - - return true; + return $value === $field_value; } /** @@ -287,6 +461,10 @@ class Validation */ public static function typeToggle($value, array $params, array $field) { + if (is_bool($value)) { + $value = (int)$value; + } + return self::typeArray((array) $value, $params, $field); } @@ -300,12 +478,18 @@ class Validation */ public static function typeFile($value, array $params, array $field) { - return self::typeArray((array) $value, $params, $field); + 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; + return (array)$value; } /** @@ -343,30 +527,38 @@ class Validation return false; } - $min = isset($params['min']) ? $params['min'] : 0; - if (isset($params['step']) && fmod($value - $min, $params['step']) == 0) { - return false; - } + $min = $params['min'] ?? 0; - return true; + 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 * @@ -380,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); @@ -410,8 +608,8 @@ class Validation { $values = !is_array($value) ? explode(',', preg_replace('/\s+/', '', $value)) : $value; - foreach ($values as $value) { - if (!(self::typeText($value, $params, $field) && filter_var($value, FILTER_VALIDATE_EMAIL))) { + foreach ($values as $val) { + if (!(self::typeText($val, $params, $field) && filter_var($val, FILTER_VALIDATE_EMAIL))) { return false; } } @@ -427,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); @@ -443,15 +640,17 @@ class Validation */ public static function typeDatetime($value, array $params, array $field) { - if ($value instanceof \DateTime) { + if ($value instanceof DateTime) { return true; - } elseif (!is_string($value)) { + } + if (!is_string($value)) { return false; - } elseif (!isset($params['format'])) { + } + 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()); } @@ -479,10 +678,10 @@ class Validation */ public static function typeDate($value, array $params, array $field) { - $params = array($params); if (!isset($params['format'])) { $params['format'] = 'Y-m-d'; } + return self::typeDatetime($value, $params, $field); } @@ -496,10 +695,10 @@ class Validation */ public static function typeTime($value, array $params, array $field) { - $params = array($params); if (!isset($params['format'])) { $params['format'] = 'H:i'; } + return self::typeDatetime($value, $params, $field); } @@ -513,10 +712,10 @@ class Validation */ public static function typeMonth($value, array $params, array $field) { - $params = array($params); if (!isset($params['format'])) { $params['format'] = 'Y-m'; } + return self::typeDatetime($value, $params, $field); } @@ -533,6 +732,7 @@ class Validation if (!isset($params['format']) && !preg_match('/^\d{4}-W\d{2}$/u', $value)) { return false; } + return self::typeDatetime($value, $params, $field); } @@ -559,65 +759,159 @@ class Validation return false; } - $min = isset($params['min']) ? $params['min'] : 0; - if (isset($params['step']) && (count($value) - $min) % $params['step'] == 0) { + $min = $params['min'] ?? 0; + if (isset($params['step']) && (count($value) - $min) % $params['step'] === 0) { return false; } } - $options = isset($field['options']) ? array_keys($field['options']) : array(); - $values = isset($field['use']) && $field['use'] == 'keys' ? array_keys($value) : $value; - if ($options && array_diff($values, $options)) { - return false; + // If creating new values is allowed, no further checks are needed. + if (!empty($field['selectize']['create'])) { + return true; } - return true; + $options = $field['options'] ?? []; + $use = $field['use'] ?? 'values'; + + if (empty($field['selectize']) || empty($field['multiple'])) { + $options = array_keys($options); + } + if ($use === 'keys') { + $value = array_keys($value); + } + + 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']) : array(); - $multi = isset($field['multiple']) ? $field['multiple'] : false; + $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; } if ($options) { - $useKey = isset($field['use']) && $field['use'] == 'keys'; - foreach ($values as $key => $value) { - $values[$key] = $useKey ? (bool) $value : $value; + $useKey = isset($field['use']) && $field['use'] === 'keys'; + foreach ($values as $key => $val) { + $values[$key] = $useKey ? (bool) $val : $val; } } if ($multi) { - foreach ($values as $key => $value) { - if (is_array($value)) { - $value = implode(',', $value); - $values[$key] = array_map('trim', explode(',', $value)); + foreach ($values as $key => $val) { + if (is_array($val)) { + $val = implode(',', $val); + $values[$key] = array_map('trim', explode(',', $val)); } else { - $values[$key] = trim($value); + $values[$key] = trim($val); } } } - if (isset($field['ignore_empty']) && Utils::isPositive($field['ignore_empty'])) { - foreach ($values as $key => $value) { - foreach ($value as $inner_key => $inner_value) { - if ($inner_value == '') { - unset($value[$inner_key]); + $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]); + } + } + 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] = $value; + 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)) { @@ -628,7 +922,7 @@ class Validation foreach ($value as $key => $item) { foreach ($field['fields'] as $subKey => $subField) { $subKey = trim($subKey, '.'); - $subValue = isset($item[$subKey]) ? $item[$subKey] : null; + $subValue = $item[$subKey] ?? null; self::validate($subValue, $subField); } } @@ -637,11 +931,22 @@ 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)) { @@ -649,7 +954,6 @@ class Validation } return (array) Yaml::parse($value); - } /** @@ -665,99 +969,221 @@ 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; } + /** + * Input value which can be ignored. + * + * @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 typeUnset($value, array $params, array $field) + { + return true; + } + + /** + * @param mixed $value + * @param array $params + * @param array $field + * @return null + */ + public static function filterUnset($value, array $params, array $field) + { + return null; + } // 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)) { return (bool) $params !== true || $value !== ''; - } else { - return (bool) $params !== true || !empty($value); } + + 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; } + /** + * @param mixed $value + * @param mixed $params + * @return bool + */ public static function validateBool($value, $params) { 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)); } + /** + * @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; + return is_numeric($value) && (int)$value == $value; } + /** + * @param mixed $value + * @param mixed $params + * @return int + */ protected static function filterInt($value, $params) { - return (int) $value; + 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 9272894..2d94ab8 100644 --- a/system/src/Grav/Common/Data/ValidationException.php +++ b/system/src/Grav/Common/Data/ValidationException.php @@ -1,24 +1,36 @@ messages = $messages; $language = Grav::instance()['language']; - $this->message = $language->translate('FORM.VALIDATION_FAIL', null, true) . ' ' . $this->message; + $this->message = $language->translate('GRAV.FORM.VALIDATION_FAIL', null, true) . ' ' . $this->message; foreach ($messages as $variable => &$list) { $list = array_unique($list); @@ -30,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 8fee09f..6a1e256 100644 --- a/system/src/Grav/Common/Debugger.php +++ b/system/src/Grav/Common/Debugger.php @@ -1,97 +1,384 @@ init() gets called. - $this->enabled = true; + static::$instance = $this; - $this->debugbar = new StandardDebugBar(); - $this->debugbar['time']->addMeasure('Loading', $this->debugbar['time']->getRequestStartTime(), microtime(true)); + $this->currentTime = microtime(true); + + if (!defined('GRAV_REQUEST_TIME')) { + define('GRAV_REQUEST_TIME', $this->currentTime); + } + + $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() { + if ($this->initialized) { + return $this; + } + $this->grav = Grav::instance(); $this->config = $this->grav['config']; // Enable/disable debugger based on configuration. - $this->enabled = $this->config->get('system.debugger.enabled'); + $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; + + $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); + 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)); + } - $this->debugbar->addCollector(new ConfigCollector((array)$this->config->get('system'), 'Config')); - $this->debugbar->addCollector(new ConfigCollector($plugins_config, 'Plugins')); - $this->addMessage('Grav v' . GRAV_VERSION); + $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 - * - * @return null + * @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) { if ($state !== null) { - $this->enabled = $state; + $this->enabled = (bool)$state; } return $this->enabled; @@ -104,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') { @@ -115,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); @@ -147,14 +447,15 @@ class Debugger /** * Adds a data collector * - * @param $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; } @@ -162,14 +463,17 @@ class Debugger /** * Returns a data collector * - * @param $collector - * - * @return \DebugBar\DataCollector\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; } /** @@ -179,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(); @@ -201,7 +506,8 @@ class Debugger */ public function sendDataInHeaders() { - if ($this->enabled()) { + if ($this->enabled && $this->debugbar) { + $this->addMeasures(); $this->addDeprecations(); $this->debugbar->sendDataInHeaders(); } @@ -212,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 $name + * @param string $name * @param string|null $description - * * @return $this */ public function startTimer($name, $description = null) { - if ($name[0] === '_' || $this->enabled()) { - $this->debugbar['time']->startMeasure($name, $description); - $this->timers[] = $name; - } + $this->timers[$name] = [$description, microtime(true)]; return $this; } @@ -248,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) && ($name[0] === '_' || $this->enabled())) { - $this->debugbar['time']->stopMeasure($name); + if (isset($this->timers[$name])) { + $endTime = microtime(true); + $this->timers[$name][] = $endTime; } return $this; @@ -263,16 +717,78 @@ class Debugger /** * Dump variables into the Messages tab of the Debug Bar * - * @param $message + * @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; @@ -281,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->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( @@ -309,73 +838,221 @@ class Debugger */ public function deprecatedErrorHandler($errno, $errstr, $errfile, $errline) { - if ($errno !== E_USER_DEPRECATED) { + 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; } - $backtrace = debug_backtrace(false); + // Figure out error scope from the error. + $scope = 'unknown'; + if (stripos($errstr, 'grav') !== false) { + $scope = 'grav'; + } elseif (strpos($errfile, '/twig/') !== false) { + $scope = 'twig'; + } elseif (stripos($errfile, '/yaml/') !== false) { + $scope = 'yaml'; + } elseif (strpos($errfile, '/vendor/') !== false) { + $scope = 'vendor'; + } + + // Clean up backtrace to make it more useful. + $backtrace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT); // Skip current call. array_shift($backtrace); + // Find yaml file where the error happened. + if ($scope === 'yaml') { + foreach ($backtrace as $current) { + if (isset($current['args'])) { + foreach ($current['args'] as $arg) { + if ($arg instanceof SplFileInfo) { + $arg = $arg->getPathname(); + } + if (is_string($arg) && preg_match('/.+\.(yaml|md)$/i', $arg)) { + $errfile = $arg; + $errline = 0; + + break 2; + } + } + } + } + } + + // Filter arguments. + $cut = 0; + $previous = null; + foreach ($backtrace as $i => &$current) { + if (isset($current['args'])) { + $args = []; + foreach ($current['args'] as $arg) { + if (is_string($arg)) { + $arg = "'" . $arg . "'"; + if (mb_strlen($arg) > 100) { + $arg = 'string'; + } + } elseif (is_bool($arg)) { + $arg = $arg ? 'true' : 'false'; + } elseif (is_scalar($arg)) { + $arg = $arg; + } elseif (is_object($arg)) { + $arg = get_class($arg) . ' $object'; + } elseif (is_array($arg)) { + $arg = '$array'; + } else { + $arg = '$object'; + } + + $args[] = $arg; + } + $current['args'] = $args; + } + + $object = $current['object'] ?? null; + unset($current['object']); + + $reflection = null; + if ($object instanceof TemplateWrapper) { + $reflection = new ReflectionObject($object); + $property = $reflection->getProperty('template'); + $property->setAccessible(true); + $object = $property->getValue($object); + } + + if ($object instanceof Template) { + $file = $current['file'] ?? null; + + if (preg_match('`(Template.php|TemplateWrapper.php)$`', $file)) { + $current = null; + continue; + } + + $debugInfo = $object->getDebugInfo(); + + $line = 1; + if (!$reflection) { + foreach ($debugInfo as $codeLine => $templateLine) { + if ($codeLine <= $current['line']) { + $line = $templateLine; + break; + } + } + } + + $src = $object->getSourceContext(); + //$code = preg_split('/\r\n|\r|\n/', $src->getCode()); + //$current['twig']['twig'] = trim($code[$line - 1]); + $current['twig']['file'] = $src->getPath(); + $current['twig']['line'] = $line; + + $prevFile = $previous['file'] ?? null; + if ($prevFile && $file === $prevFile) { + $prevLine = $previous['line']; + + $line = 1; + foreach ($debugInfo as $codeLine => $templateLine) { + if ($codeLine <= $prevLine) { + $line = $templateLine; + break; + } + } + + //$previous['twig']['twig'] = trim($code[$line - 1]); + $previous['twig']['file'] = $src->getPath(); + $previous['twig']['line'] = $line; + } + + $cut = $i; + } elseif ($object instanceof ProcessorInterface) { + $cut = $cut ?: $i; + break; + } + + $previous = &$backtrace[$i]; + } + unset($current); + + if ($cut) { + $backtrace = array_slice($backtrace, 0, $cut + 1); + } + $backtrace = array_values(array_filter($backtrace)); + // Skip vendor libraries and the method where error was triggered. - while ($current = array_shift($backtrace)) { - if (isset($current['file']) && strpos($current['file'], 'vendor') !== false) { + foreach ($backtrace as $i => $current) { + if (!isset($current['file'])) { + continue; + } + if (strpos($current['file'], '/vendor/') !== false) { + $cut = $i + 1; continue; } if (isset($current['function']) && ($current['function'] === 'user_error' || $current['function'] === 'trigger_error')) { - $current = array_shift($backtrace); + $cut = $i + 1; + continue; } break; } - // Add back last call. - array_unshift($backtrace, $current); - - // Filter arguments. - foreach ($backtrace as &$current) { - if (isset($current['args'])) { - $args = []; - foreach ($current['args'] as $arg) { - if (\is_string($arg)) { - $args[] = "'" . $arg . "'"; - } elseif (\is_bool($arg)) { - $args[] = $arg ? 'true' : 'false'; - } elseif (\is_scalar($arg)) { - $args[] = $arg; - } elseif (\is_object($arg)) { - $args[] = get_class($arg) . ' $object'; - } elseif (\is_array($arg)) { - $args[] = '$array'; - } else { - $args[] = '$object'; - } - } - $current['args'] = $args; - } + if ($cut) { + $backtrace = array_slice($backtrace, $cut); } - unset($current); + $backtrace = array_values(array_filter($backtrace)); - $this->deprecations[] = [ + $current = reset($backtrace); + + // If the issue happened inside twig file, change the file and line to match that file. + $file = $current['twig']['file'] ?? ''; + if ($file) { + $errfile = $file; + $errline = $current['twig']['line'] ?? 0; + } + + $deprecation = [ + 'scope' => $scope, 'message' => $errstr, 'file' => $errfile, 'line' => $errline, 'trace' => $backtrace, + 'count' => 1 ]; + $this->deprecations[] = $deprecation; + // Do not pass forward. 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) { @@ -394,50 +1071,70 @@ class Debugger } } + /** + * @param array $deprecated + * @return array + */ protected function getDepracatedMessage($deprecated) { - $scope = 'unknown'; - if (stripos($deprecated['message'], 'grav') !== false) { - $scope = 'grav'; - } elseif (!isset($deprecated['file'])) { - $scope = 'unknown'; - } elseif (stripos($deprecated['file'], 'twig') !== false) { - $scope = 'twig'; - } elseif (stripos($deprecated['file'], 'yaml') !== false) { - $scope = 'yaml'; - } elseif (stripos($deprecated['file'], 'vendor') !== false) { - $scope = 'vendor'; - } + $scope = $deprecated['scope']; $trace = []; - foreach ($deprecated['trace'] as $current) { - $class = isset($current['class']) ? $current['class'] : ''; - $type = isset($current['type']) ? $current['type'] : ''; - $function = $this->getFunction($current); - if (isset($current['file'])) { - $current['file'] = str_replace(GRAV_ROOT . '/', '', $current['file']); + if (isset($deprecated['trace'])) { + foreach ($deprecated['trace'] as $current) { + $class = $current['class'] ?? ''; + $type = $current['type'] ?? ''; + $function = $this->getFunction($current); + if (isset($current['file'])) { + $current['file'] = str_replace(GRAV_ROOT . '/', '', $current['file']); + } + + unset($current['class'], $current['type'], $current['function'], $current['args']); + + if (isset($current['twig'])) { + $trace[] = $current['twig']; + } else { + $trace[] = ['call' => $class . $type . $function] + $current; + } } - - unset($current['class'], $current['type'], $current['function'], $current['args']); - - $trace[] = ['call' => $class . $type . $function] + $current; } + $array = [ + 'message' => $deprecated['message'], + 'file' => $deprecated['file'], + 'line' => $deprecated['line'], + 'trace' => $trace + ]; + return [ - [ - 'message' => $deprecated['message'], - 'trace' => $trace - ], + array_filter($array), $scope ]; } + /** + * @param array $trace + * @return string + */ protected function getFunction($trace) { if (!isset($trace['function'])) { return ''; } - return $trace['function'] . '(' . implode(', ', $trace['args']) . ')'; + 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 25c0e0a..206b57e 100644 --- a/system/src/Grav/Common/Errors/BareHandler.php +++ b/system/src/Grav/Common/Errors/BareHandler.php @@ -1,8 +1,9 @@ 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 9e8535c..3dc99c6 100644 --- a/system/src/Grav/Common/Errors/Errors.php +++ b/system/src/Grav/Common/Errors/Errors.php @@ -1,27 +1,40 @@ get('system.errors'); - $jsonRequest = $_SERVER && isset($_SERVER['HTTP_ACCEPT']) && $_SERVER['HTTP_ACCEPT'] == 'application/json'; + $jsonRequest = $_SERVER && isset($_SERVER['HTTP_ACCEPT']) && $_SERVER['HTTP_ACCEPT'] === 'application/json'; // Setup Whoops-based error handler $system = new SystemFacade; - $whoops = new \Whoops\Run($system); + $whoops = new Run($system); $verbosity = 1; @@ -35,42 +48,33 @@ 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 (method_exists('Whoops\Util\Misc', 'isAjaxRequest')) { //Whoops 2.0 - if (Whoops\Util\Misc::isAjaxRequest() || $jsonRequest) { - $whoops->pushHandler(new Whoops\Handler\JsonResponseHandler); - } - } elseif (function_exists('Whoops\isAjaxRequest')) { //Whoops 2.0.0-alpha - if (Whoops\isAjaxRequest() || $jsonRequest) { - $whoops->pushHandler(new Whoops\Handler\JsonResponseHandler); - } - } else { //Whoops 1.x - $json_page = new Whoops\Handler\JsonResponseHandler; - $json_page->onlyForAjaxRequests(true); + 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; } - }, 'log'); + }); } $whoops->register(); diff --git a/system/src/Grav/Common/Errors/SimplePageHandler.php b/system/src/Grav/Common/Errors/SimplePageHandler.php index 829f596..0118929 100644 --- a/system/src/Grav/Common/Errors/SimplePageHandler.php +++ b/system/src/Grav/Common/Errors/SimplePageHandler.php @@ -1,54 +1,63 @@ searchPaths[] = __DIR__ . "/Resources"; + $this->searchPaths[] = __DIR__ . '/Resources'; } /** - * @return int|null + * @return int */ public function handle() { $inspector = $this->getInspector(); $helper = new TemplateHelper(); - $templateFile = $this->getResource("layout.html.php"); - $cssFile = $this->getResource("error.css"); + $templateFile = $this->getResource('layout.html.php'); + $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); } $vars = array( - "stylesheet" => file_get_contents($cssFile), - "code" => $code, - "message" => filter_var(rawurldecode($message), FILTER_SANITIZE_STRING), + 'stylesheet' => file_get_contents($cssFile), + 'code' => $code, + 'message' => filter_var(rawurldecode($message), FILTER_SANITIZE_STRING), ); $helper->setVariables($vars); @@ -58,10 +67,9 @@ class SimplePageHandler extends Handler } /** - * @param $resource - * + * @param string $resource * @return string - * @throws \RuntimeException + * @throws RuntimeException */ protected function getResource($resource) { @@ -74,7 +82,7 @@ class SimplePageHandler extends Handler // Search through available search paths, until we find the // resource we're after: foreach ($this->searchPaths as $path) { - $fullPath = $path . "/$resource"; + $fullPath = "{$path}/{$resource}"; if (is_file($fullPath)) { // Cache the result: @@ -84,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" ); } @@ -100,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 5b73a2b..8a7f1ce 100644 --- a/system/src/Grav/Common/Errors/SystemFacade.php +++ b/system/src/Grav/Common/Errors/SystemFacade.php @@ -1,20 +1,25 @@ 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); @@ -37,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 @@ -46,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. } @@ -75,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); @@ -85,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 c2a9a32..dae95ba 100644 --- a/system/src/Grav/Common/File/CompiledJsonFile.php +++ b/system/src/Grav/Common/File/CompiledJsonFile.php @@ -1,8 +1,9 @@ ['.DS_Store'], + 'exclude_paths' => [] + ]; + + /** @var string */ + protected $archive_file; + + /** + * @param string $compression + * @return ZipArchiver + */ + public static function create($compression) + { + if ($compression === 'zip') { + return new ZipArchiver(); + } + + 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 (Utils::functionExists('set_time_limit')) { + @set_time_limit(0); + } + + $this->options = $options + $this->options; + + return $this; + } + + /** + * @param string $folder + * @param callable|null $status + * @return $this + */ + abstract public function compress($folder, callable $status = null); + + /** + * @param string $destination + * @param callable|null $status + * @return $this + */ + abstract public function extract($destination, 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); + $filterItr = new RecursiveDirectoryFilterIterator($dirItr, $rootPath, $exclude_paths, $exclude_files); + $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 0d69100..6a3783b 100644 --- a/system/src/Grav/Common/Filesystem/Folder.php +++ b/system/src/Grav/Common/Filesystem/Folder.php @@ -1,16 +1,31 @@ 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) { @@ -48,34 +66,37 @@ abstract class Folder /** * Recursively find the last modified time under given path by file. * - * @param string $path + * @param string $path * @param string $extensions which files to search for specifically - * * @return int */ public static function lastModifiedFile($path, $extensions = 'md|yaml') { + if (!file_exists($path)) { + return 0; + } + $last_modified = 0; /** @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()); } } @@ -86,26 +107,29 @@ abstract class Folder /** * Recursively md5 hash all files in a path * - * @param $path + * @param string $path * @return string */ public static function hashAllFiles($path) { - $flags = \RecursiveDirectoryIterator::SKIP_DOTS; $files = []; - /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - if ($locator->isStream($path)) { - $directory = $locator->getRecursiveIterator($path, $flags); - } else { - $directory = new \RecursiveDirectoryIterator($path, $flags); - } + if (file_exists($path)) { + $flags = RecursiveDirectoryIterator::SKIP_DOTS; - $iterator = new \RecursiveIteratorIterator($directory, \RecursiveIteratorIterator::SELF_FIRST); + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($locator->isStream($path)) { + $directory = $locator->getRecursiveIterator($path, $flags); + } else { + $directory = new RecursiveDirectoryIterator($path, $flags); + } - foreach ($iterator as $file) { - $files[] = $file->getPathname() . '?'. $file->getMTime(); + $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); + + foreach ($iterator as $file) { + $files[] = $file->getPathname() . '?'. $file->getMTime(); + } } return md5(serialize($files)); @@ -114,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) @@ -141,6 +164,7 @@ abstract class Folder */ public static function getRelativePathDotDot($path, $base) { + // Normalize paths. $base = preg_replace('![\\\/]+!', '/', $base); $path = preg_replace('![\\\/]+!', '/', $path); @@ -148,8 +172,8 @@ abstract class Folder return ''; } - $baseParts = explode('/', isset($base[0]) && '/' === $base[0] ? substr($base, 1) : $base); - $pathParts = explode('/', isset($path[0]) && '/' === $path[0] ? substr($path, 1) : $path); + $baseParts = explode('/', ltrim($base, '/')); + $pathParts = explode('/', ltrim($path, '/')); array_pop($baseParts); $lastPart = array_pop($pathParts); @@ -164,7 +188,7 @@ abstract class Folder $path = str_repeat('../', count($baseParts)) . implode('/', $pathParts); return '' === $path - || '/' === $path[0] + || strpos($path, '/') === 0 || false !== ($colonPos = strpos($path, ':')) && ($colonPos < ($slashPos = strpos($path, '/')) || false === $slashPos) ? "./$path" : $path; } @@ -190,50 +214,53 @@ 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 []; } $compare = isset($params['compare']) ? 'get' . $params['compare'] : null; - $pattern = isset($params['pattern']) ? $params['pattern'] : null; - $filters = isset($params['filters']) ? $params['filters'] : null; - $recursive = isset($params['recursive']) ? $params['recursive'] : true; - $levels = isset($params['levels']) ? $params['levels'] : -1; + $pattern = $params['pattern'] ?? null; + $filters = $params['filters'] ?? null; + $recursive = $params['recursive'] ?? true; + $levels = $params['levels'] ?? -1; $key = isset($params['key']) ? 'get' . $params['key'] : null; - $value = isset($params['value']) ? 'get' . $params['value'] : ($recursive ? 'getSubPathname' : 'getFilename'); - $folders = isset($params['folders']) ? $params['folders'] : true; - $files = isset($params['files']) ? $params['files'] : true; + $value = 'get' . ($params['value'] ?? ($recursive ? 'SubPathname' : 'Filename')); + $folders = $params['folders'] ?? true; + $files = $params['files'] ?? true; /** @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 ($file->getFilename()[0] === '.') { + if (strpos($file->getFilename(), '.') === 0 && $file->isFile()) { continue; } if (!$folders && $file->isDir()) { @@ -255,7 +282,7 @@ abstract class Folder if (isset($filters['value'])) { $filter = $filters['value']; if (is_callable($filter)) { - $filePath = call_user_func($filter, $file); + $filePath = $filter($file); } else { $filePath = preg_replace($filter, '', $filePath); } @@ -277,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) { @@ -286,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. @@ -316,7 +344,7 @@ abstract class Folder if (!$success) { $error = error_get_last(); - throw new \RuntimeException($error['message']); + throw new RuntimeException($error['message'] ?? 'Unknown error'); } // Make sure that the change will be detected when caching. @@ -328,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 @@ -342,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. @@ -354,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); @@ -376,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) { @@ -388,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. @@ -403,7 +432,8 @@ abstract class Folder /** * @param string $folder - * @throws \RuntimeException + * @return void + * @throws RuntimeException */ public static function mkdir($folder) { @@ -412,30 +442,34 @@ abstract class Folder /** * @param string $folder - * @throws \RuntimeException + * @return void + * @throws RuntimeException */ public static function create($folder) { - if (is_dir($folder)) { + // Silence error for open_basedir; should fail in mkdir instead. + if (@is_dir($folder)) { return; } $success = @mkdir($folder, 0777, true); if (!$success) { - $error = error_get_last(); - throw new \RuntimeException($error['message']); + // 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)); + } } } /** * Recursive copy of one directory to another * - * @param $src - * @param $dest - * + * @param string $src + * @param string $dest * @return bool - * @throws \RuntimeException + * @throws RuntimeException */ public static function rcopy($src, $dest) { @@ -448,12 +482,11 @@ abstract class Folder // If the destination directory does not exist create it if (!is_dir($dest)) { - static::mkdir($dest); + static::create($dest); } // 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()); @@ -466,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 @@ -475,7 +524,7 @@ abstract class Folder protected static function doDelete($folder, $include_target = true) { // Special case for symbolic links. - if (is_link($folder)) { + if ($include_target && is_link($folder)) { return @unlink($folder); } diff --git a/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php b/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php new file mode 100644 index 0000000..0dac81c --- /dev/null +++ b/system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php @@ -0,0 +1,82 @@ +current(); + $filename = $file->getFilename(); + $relative_filename = str_replace($this::$root . '/', '', $file->getPathname()); + + if ($file->isDir()) { + if (in_array($relative_filename, $this::$ignore_folders, true)) { + return false; + } + if (!in_array($filename, $this::$ignore_files, true)) { + return true; + } + } elseif ($file->isFile() && !in_array($filename, $this::$ignore_files, true)) { + return true; + } + return false; + } + + /** + * @return RecursiveDirectoryFilterIterator|RecursiveFilterIterator + */ + public function getChildren() + { + /** @var RecursiveDirectoryFilterIterator $iterator */ + $iterator = $this->getInnerIterator(); + + return new self($iterator->getChildren(), $this::$root, $this::$ignore_folders, $this::$ignore_files); + } +} diff --git a/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php b/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php index 3972074..ded0259 100644 --- a/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php +++ b/system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php @@ -1,30 +1,43 @@ get('system.pages.ignore_folders'); + + if (empty($ignore_folders)) { + $ignore_folders = Grav::instance()['config']->get('system.pages.ignore_folders'); } + + $this::$ignore_folders = $ignore_folders; } /** @@ -34,12 +47,9 @@ class RecursiveFolderFilterIterator extends \RecursiveFilterIterator */ public function accept() { - /** @var $current \SplFileInfo */ + /** @var SplFileInfo $current */ $current = $this->current(); - if ($current->isDir() && !in_array($current->getFilename(), $this::$folder_ignores, true)) { - return true; - } - return false; + 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 new file mode 100644 index 0000000..450a581 --- /dev/null +++ b/system/src/Grav/Common/Filesystem/ZipArchiver.php @@ -0,0 +1,136 @@ +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); + } + + $zip->close(); + + return $this; + } + + 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...'); + } + + if (!file_exists($source)) { + 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...'); + } + + // Get real path for our folder + $rootPath = realpath($source); + + $files = $this->getArchiveFiles($rootPath); + + $status && $status([ + 'type' => 'count', + 'steps' => iterator_count($files), + ]); + + foreach ($files as $file) { + $filePath = $file->getPathname(); + $relativePath = ltrim(substr($filePath, strlen($rootPath)), '/'); + + if ($file->isDir()) { + $zip->addEmptyDir($relativePath); + } else { + $zip->addFile($filePath, $relativePath); + } + + $status && $status([ + 'type' => 'progress', + ]); + } + + $status && $status([ + 'type' => 'message', + 'message' => 'Compressing...' + ]); + + $zip->close(); + + 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...'); + } + + $zip = new ZipArchive(); + if (!$zip->open($this->archive_file)) { + throw new InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be opened...'); + } + + $status && $status([ + 'type' => 'message', + 'message' => 'Adding empty folders...' + ]); + + foreach ($folders as $folder) { + $zip->addEmptyDir($folder); + $status && $status([ + 'type' => 'progress', + ]); + } + + $zip->close(); + + return $this; + } +} 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 start date and end date are optional + * Dates must be passed in as text that strtotime() can process + * http://php.net/manual/en/function.strtotime.php + * + * @param string|null $startDate + * @param string|null $endDate + * @param string|null $field + * @return static + * @throws Exception + */ + public function dateRange($startDate = null, $endDate = null, $field = null) + { + $start = $startDate ? Utils::date2timestamp($startDate) : null; + $end = $endDate ? Utils::date2timestamp($endDate) : null; + + $entries = []; + foreach ($this as $key => $object) { + if (!$object) { + continue; + } + + $date = $field ? strtotime($object->getNestedProperty($field)) : $object->date(); + + if ((!$start || $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..e2938ae --- /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 start date and end date are optional + * Dates must be passed in as text that strtotime() can process + * http://php.net/manual/en/function.strtotime.php + * + * @param string|null $startDate + * @param string|null $endDate + * @param string|null $field + * @return static + * @throws Exception + */ + public function dateRange($startDate = null, $endDate = null, $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..62305cd --- /dev/null +++ b/system/src/Grav/Common/Flex/Types/Users/UserObject.php @@ -0,0 +1,909 @@ + '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(); + if (!$media instanceof MediaUploadInterface) { + return; + } + + $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, ['filesize' => 0] + $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->clearMediaCache(); + + $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 new file mode 100644 index 0000000..430f15e --- /dev/null +++ b/system/src/Grav/Common/Form/FormFlash.php @@ -0,0 +1,106 @@ +files as $field => $files) { + if (strpos($field, '/')) { + continue; + } + foreach ($files as $file) { + if (is_array($file)) { + $file['tmp_name'] = $this->getTmpDir() . '/' . $file['tmp_name']; + $fields[$field][$file['path'] ?? $file['name']] = $file; + } + } + } + + return $fields; + } + + /** + * @param string $field + * @param string $filename + * @param array $upload + * @return bool + * @deprecated 1.6 For backwards compatibility only, do not use + */ + public function uploadFile(string $field, string $filename, array $upload): bool + { + if (!$this->uniqueId) { + return false; + } + + $tmp_dir = $this->getTmpDir(); + Folder::create($tmp_dir); + + $tmp_file = $upload['file']['tmp_name']; + $basename = basename($tmp_file); + + if (!move_uploaded_file($tmp_file, $tmp_dir . '/' . $basename)) { + return false; + } + + $upload['file']['tmp_name'] = $basename; + $upload['file']['name'] = $filename; + + $this->addFileInternal($field, $filename, $upload['file']); + + return true; + } + + /** + * @param string $field + * @param string $filename + * @param array $upload + * @param array $crop + * @return bool + * @deprecated 1.6 For backwards compatibility only, do not use + */ + public function cropFile(string $field, string $filename, array $upload, array $crop): bool + { + if (!$this->uniqueId) { + return false; + } + + $tmp_dir = $this->getTmpDir(); + Folder::create($tmp_dir); + + $tmp_file = $upload['file']['tmp_name']; + $basename = basename($tmp_file); + + if (!move_uploaded_file($tmp_file, $tmp_dir . '/' . $basename)) { + return false; + } + + $upload['file']['tmp_name'] = $basename; + $upload['file']['name'] = $filename; + + $this->addFileInternal($field, $filename, $upload['file'], $crop); + + return true; + } +} diff --git a/system/src/Grav/Common/GPM/AbstractCollection.php b/system/src/Grav/Common/GPM/AbstractCollection.php index 3430a6a..b5b867e 100644 --- a/system/src/Grav/Common/GPM/AbstractCollection.php +++ b/system/src/Grav/Common/GPM/AbstractCollection.php @@ -1,8 +1,9 @@ items as $name => $package) { - $items[$name] = $package->toArray(); - } - - return json_encode($items); + 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 78d93bc..5d6ad9f 100644 --- a/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php +++ b/system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php @@ -1,8 +1,9 @@ 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 0244692..172ba93 100644 --- a/system/src/Grav/Common/GPM/Common/CachedCollection.php +++ b/system/src/Grav/Common/GPM/Common/CachedCollection.php @@ -1,8 +1,9 @@ $item) { + foreach (self::$cache[$method] as $name => $item) { $this->append([$name => $item]); } } diff --git a/system/src/Grav/Common/GPM/Common/Package.php b/system/src/Grav/Common/GPM/Common/Package.php index fe4618a..e06ebb6 100644 --- a/system/src/Grav/Common/GPM/Common/Package.php +++ b/system/src/Grav/Common/GPM/Common/Package.php @@ -1,8 +1,9 @@ data = $package; if ($type) { @@ -22,28 +33,63 @@ class Package { } } - public function getData() { + /** + * @return Data + */ + public function getData() + { return $this->data; } - public function __get($key) { + /** + * @param string $key + * @return mixed + */ + public function __get($key) + { return $this->data->get($key); } - public function __isset($key) { - return isset($this->data->$key); + /** + * @param string $key + * @param mixed $value + * @return void + */ + public function __set($key, $value) + { + $this->data->set($key, $value); } - public function __toString() { + /** + * @param string $key + * @return bool + */ + public function __isset($key) + { + return isset($this->data->{$key}); + } + + /** + * @return string + */ + public function __toString() + { return $this->toJson(); } - public function toJson() { + /** + * @return string + */ + public function toJson() + { return $this->data->toJson(); } - public function toArray() { + /** + * @return array + */ + public function toArray() + { return $this->data->toArray(); } - } diff --git a/system/src/Grav/Common/GPM/GPM.php b/system/src/Grav/Common/GPM/GPM.php index 696ba65..222aa0a 100644 --- a/system/src/Grav/Common/GPM/GPM.php +++ b/system/src/Grav/Common/GPM/GPM.php @@ -1,45 +1,45 @@ 'user/plugins/%name%', 'themes' => 'user/themes/%name%', @@ -48,16 +48,19 @@ class GPM extends Iterator /** * 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 + * + * @param bool $refresh Applies to Remote Packages only and forces a refetch of data + * @param callable|null $callback Either a function or callback in array notation */ public function __construct($refresh = false, $callback = null) { + parent::__construct(); + $this->cache = []; $this->installed = new Local\Packages(); try { $this->repository = new Remote\Packages($refresh, $callback); $this->grav = new Remote\GravCore($refresh, $callback); - } catch (\Exception $e) { + } catch (Exception $e) { } } @@ -89,12 +92,14 @@ class GPM extends Iterator $items[$type] = $to_install; $items['total'] += count($to_install); } + return $items; } /** * Returns the amount of locally installed packages - * @return integer Amount of installed packages + * + * @return int Amount of installed packages */ public function countInstalled() { @@ -107,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; } /** @@ -141,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 boolean True if the Plugin has been installed. False otherwise + * @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() @@ -176,39 +197,51 @@ class GPM extends Iterator } /** - * Checks if a Theme is installed + * Checks if a Theme is enabled + * * @param string $slug The slug of the Theme - * @return boolean True if the Theme has been installed. False otherwise + * @return bool True if the Theme has been set to the default theme. False if installed, but not enabled. Null otherwise. */ - public function isThemeInstalled($slug) + 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): bool { return isset($this->installed['themes'][$slug]); } /** * Returns the amount of updates available - * @return integer Amount of available updates + * + * @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) { @@ -219,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 @@ -242,13 +282,12 @@ class GPM extends Iterator continue; } - $local_version = $plugin->version ? $plugin->version : 'Unknown'; + $local_version = $plugin->version ?? 'Unknown'; $remote_version = $repository[$slug]->version; if (version_compare($local_version, $remote_version) < 0) { $repository[$slug]->available = $remote_version; $repository[$slug]->version = $local_version; - $repository[$slug]->name = $repository[$slug]->name; $repository[$slug]->type = $repository[$slug]->release_type; $items[$slug] = $repository[$slug]; } @@ -262,12 +301,15 @@ class GPM extends Iterator /** * Get the latest release of a package from the GPM * - * @param $package_name - * + * @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; @@ -284,8 +326,9 @@ class GPM extends Iterator /** * Check if a Plugin or Theme is updatable + * * @param string $slug The slug of the package - * @return boolean True if updatable. False otherwise or if not found + * @return bool True if updatable. False otherwise or if not found */ public function isUpdatable($slug) { @@ -294,8 +337,9 @@ class GPM extends Iterator /** * Checks if a Plugin is updatable + * * @param string $plugin The slug of the Plugin - * @return boolean True if the Plugin is updatable. False otherwise + * @return bool True if the Plugin is updatable. False otherwise */ public function isPluginUpdatable($plugin) { @@ -305,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 @@ -322,7 +372,7 @@ class GPM extends Iterator continue; } - $local_version = $plugin->version ? $plugin->version : 'Unknown'; + $local_version = $plugin->version ?? 'Unknown'; $remote_version = $repository[$slug]->version; if (version_compare($local_version, $remote_version) < 0) { @@ -340,8 +390,9 @@ class GPM extends Iterator /** * Checks if a Theme is Updatable + * * @param string $theme The slug of the Theme - * @return boolean True if the Theme is updatable. False otherwise + * @return bool True if the Theme is updatable. False otherwise */ public function isThemeUpdatable($theme) { @@ -351,12 +402,15 @@ class GPM extends Iterator /** * Get the release type of a package (stable / testing) * - * @param $package_name - * + * @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; @@ -374,9 +428,8 @@ class GPM extends Iterator /** * Returns true if the package latest release is stable * - * @param $package_name - * - * @return boolean + * @param string $package_name + * @return bool */ public function isStableRelease($package_name) { @@ -386,59 +439,67 @@ class GPM extends Iterator /** * Returns true if the package latest release is testing * - * @param $package_name - * - * @return boolean + * @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() @@ -448,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; } @@ -469,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; } } @@ -503,23 +556,27 @@ class GPM extends Iterator /** * Download the zip package via the URL * - * @param $package_file - * @param $tmp - * @return null|string + * @param string $package_file + * @param string $tmp + * @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, []); if ($output) { - Folder::mkdir($tmp); + Folder::create($tmp); file_put_contents($tmp . DS . $filename, $output); return $tmp . DS . $filename; } @@ -530,18 +587,18 @@ class GPM extends Iterator /** * Copy the local zip package to tmp * - * @param $package_file - * @param $tmp - * @return null|string + * @param string $package_file + * @param string $tmp + * @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::mkdir($tmp); - copy(realpath($package_file), $tmp . DS . $filename); + Folder::create($tmp); + copy($package_file, $tmp . DS . $filename); return $tmp . DS . $filename; } @@ -551,71 +608,80 @@ class GPM extends Iterator /** * Try to guess the package type from the source files * - * @param $source - * @return bool|string + * @param string $source + * @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'; - } else { - // must have a blueprint - if (!file_exists($source . 'blueprints.yaml')) { - return false; - } + } - // either theme or plugin - $name = basename($source); - if (Utils::contains($name, 'theme')) { - return 'theme'; - } elseif (Utils::contains($name, 'plugin')) { - return 'plugin'; - } - foreach (glob($source . "*.php") as $filename) { - $contents = file_get_contents($filename); - if (preg_match($theme_regex, $contents)) { - return 'theme'; - } elseif (preg_match($plugin_regex, $contents)) { - return 'plugin'; - } - } + // must have a blueprint + if (!file_exists($source . 'blueprints.yaml')) { + return false; + } - // Assume it's a theme + // either theme or plugin + $name = basename($source); + if (Utils::contains($name, 'theme')) { return 'theme'; } + if (Utils::contains($name, 'plugin')) { + return 'plugin'; + } + + $glob = glob($source . '*.php') ?: []; + foreach ($glob as $filename) { + $contents = file_get_contents($filename); + if (!$contents) { + continue; + } + if (preg_match($theme_regex, $contents)) { + return 'theme'; + } + if (preg_match($plugin_regex, $contents)) { + return 'plugin'; + } + } + + // Assume it's a theme + return 'theme'; } /** * Try to guess the package name from the source files * - * @param $source - * @return bool|string + * @param string $source + * @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; } /** * Find/Parse the blueprint file * - * @param $source - * @return array|bool + * @param string $source + * @return array|false */ public static function getBlueprints($source) { @@ -634,24 +700,26 @@ class GPM extends Iterator /** * Get the install path for a name and a particular type of package * - * @param $type - * @param $name + * @param string $type + * @param string $name * @return string */ public static function getInstallPath($type, $name) { $locator = Grav::instance()['locator']; - if ($type == 'theme') { + if ($type === 'theme') { $install_path = $locator->findResource('themes://', false) . DS . $name; } 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, ] @@ -691,8 +759,8 @@ class GPM extends Iterator $type = 'plugins'; } - $not_found = new \stdClass(); - $not_found->name = $inflector->camelize($search); + $not_found = new stdClass(); + $not_found->name = $inflector::camelize($search); $not_found->slug = $search; $not_found->package_type = $type; $not_found->install_path = str_replace('%name%', $search, $this->install_paths[$type]); @@ -708,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) @@ -717,37 +784,34 @@ 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; } /** * Get the required version of a dependency of a package * - * @param $package_slug - * @param $dependency_slug - * - * @return mixed + * @param string $package_slug + * @param string $dependency_slug + * @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]; @@ -764,35 +828,28 @@ class GPM extends Iterator * @param string $slug * @param string $version_with_operator * @param array $ignore_packages_list - * * @return bool - * @throws \Exception + * @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)) { - throw new \Exception("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 + ); } } } @@ -804,15 +861,15 @@ class GPM extends Iterator /** * Check the passed packages list can be updated * - * @param $packages_names_list - * - * @throws \Exception + * @param array $packages_names_list + * @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); } } @@ -825,44 +882,41 @@ 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)) { + $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 - ) { + if ($dependency_slug === 'php') { + $testVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator); + if (version_compare($testVersion, PHP_VERSION) === 1) { //Needs a Grav update first - throw new \Exception("One of the packages require PHP " . $dependencies['php'] . ". Please update PHP to resolve this"); - } else { - unset($dependencies[$dependency_slug]); - continue; + throw new RuntimeException("One of the packages require PHP {$dependencies['php']}. Please update PHP to resolve this"); } + + unset($dependencies[$dependency_slug]); + continue; } //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 - ) { + if ($dependency_slug === 'grav') { + $testVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator); + if (version_compare($testVersion, GRAV_VERSION) === 1) { //Needs a Grav update first - throw new \Exception("One of the packages require Grav " . $dependencies['grav'] . ". Please update Grav to the latest release."); - } else { - unset($dependencies[$dependency_slug]); - continue; + throw new RuntimeException("One of the packages require Grav {$dependencies['grav']}. Please update Grav to the latest release."); } + + unset($dependencies[$dependency_slug]); + continue; } if ($this->isPluginInstalled($dependency_slug)) { @@ -882,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 \Exception('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 + ); } } @@ -899,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 \Exception('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); @@ -920,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 + ); } } } @@ -940,100 +998,98 @@ 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; + return version_compare($firstVersion, $secondVersion) === -1; } /** * 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 \Exception('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 \Exception('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); } } } @@ -1046,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) { @@ -1071,22 +1125,24 @@ class GPM extends Iterator * $versionInformation == '' => returns null * * @param string $version - * - * @return null|string + * @return string|null */ public function calculateVersionNumberFromDependencyVersion($version) { - if ($version == '*') { + if ($version === '*') { return null; - } elseif ($version == '') { - return null; - } elseif ($this->versionFormatIsNextSignificantRelease($version)) { - return trim(substr($version, 1)); - } elseif ($this->versionFormatIsEqualOrHigher($version)) { - return trim(substr($version, 2)); - } else { - return $version; } + if ($version === '') { + return null; + } + if ($this->versionFormatIsNextSignificantRelease($version)) { + return trim(substr($version, 1)); + } + if ($this->versionFormatIsEqualOrHigher($version)) { + return trim(substr($version, 2)); + } + + return $version; } /** @@ -1094,13 +1150,12 @@ class GPM extends Iterator * * Example: returns true for $version: '~2.0' * - * @param $version - * + * @param string $version * @return bool */ - public function versionFormatIsNextSignificantRelease($version) + public function versionFormatIsNextSignificantRelease($version): bool { - return substr($version, 0, 1) == '~'; + return strpos($version, '~') === 0; } /** @@ -1108,13 +1163,12 @@ class GPM extends Iterator * * Example: returns true for $version: '>=2.0' * - * @param $version - * + * @param string $version * @return bool */ - public function versionFormatIsEqualOrHigher($version) + public function versionFormatIsEqualOrHigher($version): bool { - return substr($version, 0, 2) == '>='; + return strpos($version, '>=') === 0; } /** @@ -1127,21 +1181,20 @@ 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) + public function checkNextSignificantReleasesAreCompatible($version1, $version2): bool { $version1array = explode('.', $version1); $version2array = explode('.', $version2); if (count($version1array) > count($version2array)) { - list($version1array, $version2array) = [$version2array, $version1array]; + [$version1array, $version2array] = [$version2array, $version1array]; } $i = 0; while ($i < count($version1array) - 1) { - if ($version1array[$i] != $version2array[$i]) { + if ($version1array[$i] !== $version2array[$i]) { return false; } $i++; @@ -1149,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 f16cdcc..639240b 100644 --- a/system/src/Grav/Common/GPM/Installer.php +++ b/system/src/Grav/Common/GPM/Installer.php @@ -1,62 +1,61 @@ true, 'ignore_symlinks' => true, @@ -73,30 +72,33 @@ 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. */ - public static function install($zip, $destination, $options = [], $extracted = null) + public static function install($zip, $destination, $options = [], $extracted = null, $keepExtracted = false) { $destination = rtrim($destination, DS); $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; } - if (self::lastErrorCode() == self::IS_LINK && $options['ignore_symlinks'] || - self::lastErrorCode() == self::EXISTS && !$options['overwrite'] + if ((self::lastErrorCode() === self::IS_LINK && $options['ignore_symlinks']) || + (self::lastErrorCode() === self::EXISTS && !$options['overwrite']) ) { return false; } // Create a tmp location $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true); - $tmp = $tmp_dir . '/Grav-' . uniqid(); + $tmp = $tmp_dir . '/Grav-' . uniqid('', false); if (!$extracted) { $extracted = self::unZip($zip, $tmp); @@ -111,7 +113,6 @@ class Installer return false; } - $is_install = true; $installer = self::loadInstaller($extracted, $is_install); @@ -134,13 +135,16 @@ 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); } } else { - self::sophisticatedInstall($extracted, $install_path, $options['ignores']); + self::sophisticatedInstall($extracted, $install_path, $options['ignores'], $keepExtracted); } Folder::delete($tmp); @@ -159,23 +163,22 @@ class Installer self::$error = self::OK; return true; - } /** * Unzip a file to somewhere * - * @param $zip_file - * @param $destination - * @return bool|string + * @param string $zip_file + * @param string $destination + * @return string|false */ public static function unZip($zip_file, $destination) { - $zip = new \ZipArchive(); + $zip = new ZipArchive(); $archive = $zip->open($zip_file); if ($archive === true) { - Folder::mkdir($destination); + Folder::create($destination); $unzip = $zip->extractTo($destination); @@ -187,15 +190,19 @@ 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(); - $extracted_folder = $destination . '/' . $package_folder_name; - return $extracted_folder; + return $destination . '/' . $package_folder_name; } 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 $source_path - * @param $install_path - * + * @param string $source_path + * @param string $install_path * @return bool */ public static function moveInstall($source_path, $install_path) @@ -270,33 +273,32 @@ class Installer } /** - * @param $source_path - * @param $install_path - * + * @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"); - } else { - Folder::rcopy($source_path, $install_path); + throw new RuntimeException("Directory $source_path is missing"); } + Folder::rcopy($source_path, $install_path); + return true; } /** - * @param $source_path - * @param $install_path - * + * @param string $source_path + * @param string $install_path + * @param array $ignores + * @param bool $keep_source * @return bool */ - public static function sophisticatedInstall($source_path, $install_path, $ignores = []) + 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)) { + foreach (new DirectoryIterator($source_path) as $file) { + if ($file->isLink() || $file->isDot() || in_array($file->getFilename(), $ignores, true)) { continue; } @@ -304,10 +306,15 @@ class Installer if ($file->isDir()) { Folder::delete($path); - Folder::move($file->getPathname(), $path); + if ($keep_source) { + Folder::copy($file->getPathname(), $path); + } else { + Folder::move($file->getPathname(), $path); + } if ($file->getFilename() === 'bin') { - foreach (glob($path . DS . '*') as $bin_file) { + $glob = glob($path . DS . '*') ?: []; + foreach ($glob as $bin_file) { @chmod($bin_file, 0755); } } @@ -325,8 +332,7 @@ class Installer * * @param string $path The slug of the package(s) * @param array $options Options to use for uninstalling - * - * @return boolean True if everything went fine, False otherwise. + * @return bool True if everything went fine, False otherwise. */ public static function uninstall($path, $options = []) { @@ -367,8 +373,7 @@ class Installer * * @param string $destination The directory to run validations at * @param array $exclude An array of constants to exclude from the validation - * - * @return boolean True if validation passed. False otherwise + * @return bool True if validation passed. False otherwise */ public static function isValidDestination($destination, $exclude = []) { @@ -385,27 +390,25 @@ class Installer self::$error = self::NOT_DIRECTORY; } - if (count($exclude) && in_array(self::$error, $exclude)) { + if (count($exclude) && in_array(self::$error, $exclude, true)) { return true; } - return !(self::$error); + return !self::$error; } /** * Validates if the given path is a Grav Instance * * @param string $target The local path to the Grav Instance - * - * @return boolean True if is a Grav Instance. False otherwise + * @return bool True if is a Grav Instance. False otherwise */ public static function isGravInstance($target) { 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') @@ -418,6 +421,7 @@ class Installer /** * Returns the last message added by the installer + * * @return string The message */ public static function getMessage() @@ -427,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() @@ -467,42 +472,46 @@ 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: - $msg .= "File already exists."; + switch (self::$error_zip) { + case ZipArchive::ER_EXISTS: + $msg .= 'File already exists.'; break; - case \ZipArchive::ER_INCONS: - $msg .= "Zip archive inconsistent."; + case ZipArchive::ER_INCONS: + $msg .= 'Zip archive inconsistent.'; break; - case \ZipArchive::ER_MEMORY: - $msg .= "Malloc failure."; + case ZipArchive::ER_MEMORY: + $msg .= 'Memory allocation failure.'; break; - case \ZipArchive::ER_NOENT: - $msg .= "No such file."; + case ZipArchive::ER_NOENT: + $msg .= 'No such file.'; break; - case \ZipArchive::ER_NOZIP: - $msg .= "Not a zip archive."; + 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: - $msg .= "Read error."; + case ZipArchive::ER_READ: + $msg .= 'Read error.'; break; - case \ZipArchive::ER_SEEK: - $msg .= "Seek error."; + case ZipArchive::ER_SEEK: + $msg .= 'Seek error.'; break; } } break; + case self::INVALID_SOURCE: + $msg = 'Invalid source file'; + break; + default: $msg = 'Unknown Error'; break; @@ -513,7 +522,8 @@ class Installer /** * Returns the last error code of the occurred error - * @return integer The code of the last error + * + * @return int|string The code of the last error */ public static function lastErrorCode() { @@ -524,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 6ca596a..825343d 100644 --- a/system/src/Grav/Common/GPM/Licenses.php +++ b/system/src/Grav/Common/GPM/Licenses.php @@ -1,8 +1,9 @@ content(); + $data = (array)$licenses->content(); $slug = strtolower($slug); if ($license && !self::validate($license)) { @@ -66,34 +62,29 @@ class Licenses /** * Returns the license for a Premium package * - * @param $slug - * - * @return string + * @param string|null $slug + * @return string[]|string */ public static function get($slug = null) { $licenses = self::getLicenseFile(); - $data = $licenses->content(); + $data = (array)$licenses->content(); $licenses->free(); + + if (null === $slug) { + return $data['licenses'] ?? []; + } + $slug = strtolower($slug); - if (!$slug) { - return isset($data['licenses']) ? $data['licenses'] : []; - } - - if (!isset($data['licenses']) || !isset($data['licenses'][$slug])) { - return ''; - } - - return $data['licenses'][$slug]; + return $data['licenses'][$slug] ?? ''; } /** * Validates the License format * - * @param $license - * + * @param string|null $license * @return bool */ public static function validate($license = null) @@ -102,19 +93,18 @@ class Licenses return false; } - return preg_match('#' . self::$regex. '#', $license); + return (bool)preg_match('#' . self::$regex. '#', $license); } /** - * Get's the License File object + * 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'; + $path = Grav::instance()['locator']->findResource('user-data://') . '/licenses.yaml'; if (!file_exists($path)) { touch($path); } diff --git a/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php b/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php index b976208..d1f3e46 100644 --- a/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php +++ b/system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php @@ -1,8 +1,9 @@ $data) { $data->set('slug', $name); $this->items[$name] = new Package($data, $this->type); diff --git a/system/src/Grav/Common/GPM/Local/Package.php b/system/src/Grav/Common/GPM/Local/Package.php index d0fae3a..ffe2c63 100644 --- a/system/src/Grav/Common/GPM/Local/Package.php +++ b/system/src/Grav/Common/GPM/Local/Package.php @@ -1,8 +1,9 @@ blueprints()->toArray()); @@ -22,18 +34,18 @@ class Package extends BasePackage $this->settings = $package->toArray(); - $html_description = \Parsedown::instance()->line($this->description); - $this->data->set('slug', $package->slug); + $html_description = Parsedown::instance()->line($this->__get('description')); + $this->data->set('slug', $package->__get('slug')); $this->data->set('description_html', $html_description); $this->data->set('description_plain', strip_tags($html_description)); - $this->data->set('symlink', is_link(USER_DIR . $package_type . DS . $this->slug)); + $this->data->set('symlink', is_link(USER_DIR . $package_type . DS . $this->__get('slug'))); } /** - * @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 3120d21..0290296 100644 --- a/system/src/Grav/Common/GPM/Local/Packages.php +++ b/system/src/Grav/Common/GPM/Local/Packages.php @@ -1,8 +1,9 @@ all()); } } diff --git a/system/src/Grav/Common/GPM/Local/Themes.php b/system/src/Grav/Common/GPM/Local/Themes.php index 9b557af..73253cc 100644 --- a/system/src/Grav/Common/GPM/Local/Themes.php +++ b/system/src/Grav/Common/GPM/Local/Themes.php @@ -1,8 +1,9 @@ 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 ffdd90c..9e68d66 100644 --- a/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php +++ b/system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php @@ -1,8 +1,9 @@ get('system.gpm.releases', 'stable'); @@ -53,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); @@ -62,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 ab5287f..f93209e 100644 --- a/system/src/Grav/Common/GPM/Remote/GravCore.php +++ b/system/src/Grav/Common/GPM/Remote/GravCore.php @@ -1,8 +1,9 @@ fetch($refresh, $callback); $this->data = json_decode($this->raw, true); - $this->version = isset($this->data['version']) ? $this->data['version'] : '-'; - $this->date = isset($this->data['date']) ? $this->data['date'] : '-'; - $this->min_php = isset($this->data['min_php']) ? $this->data['min_php'] : null; + $this->version = $this->data['version'] ?? '-'; + $this->date = $this->data['date'] ?? '-'; + $this->min_php = $this->data['min_php'] ?? null; if (isset($this->data['assets'])) { foreach ((array)$this->data['assets'] as $slug => $data) { @@ -60,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) @@ -72,7 +82,7 @@ class GravCore extends AbstractPackageCollection $diffLog = []; foreach ((array)$this->data['changelog'] as $version => $changelog) { - preg_match("/[\w-\.]+/", $version, $cleanVersion); + preg_match("/[\w\-\.]+/", $version, $cleanVersion); if (!$cleanVersion || version_compare($diff, $cleanVersion[0], '>=')) { continue; @@ -117,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 (is_null($this->min_php)) { - $this->min_php = phpversion(); + if (null === $this->min_php) { + $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 830f63d..c37d6a0 100644 --- a/system/src/Grav/Common/GPM/Remote/Package.php +++ b/system/src/Grav/Common/GPM/Remote/Package.php @@ -1,8 +1,9 @@ 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 d8afd90..f55c12d 100644 --- a/system/src/Grav/Common/GPM/Remote/Packages.php +++ b/system/src/Grav/Common/GPM/Remote/Packages.php @@ -1,8 +1,9 @@ [ - 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'])) { - $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 (!Utils::isFunctionDisabled('set_time_limit') && !ini_get('safe_mode') && function_exists('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']) && isset($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 boolean - */ - public static function isCurlAvailable() - { - return function_exists('curl_version'); - } - - /** - * Checks if the remote fopen request is enabled in PHP - * - * @return boolean - */ - public static function isFopenAvailable() - { - return preg_match('/1|yes|on|true/i', ini_get('allow_url_fopen')); - } /** * Is this a remote file or not * - * @param $file + * @param string $file * @return bool */ public static function isRemote($file) @@ -213,220 +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_array(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]; - - 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; - if (isset($http_response_header)) { - $code = explode(' ', $http_response_header[0])[1]; - } - - 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]; - - $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 $ch - * @param $options - * @param $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 = isset($options['curl'][CURLOPT_MAXREDIRS]) ? $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 = curl_getinfo($rch, CURLINFO_HTTP_CODE); - if ($code == 301 || $code == 302 || $code == 303) { - preg_match('/Location:(.*?)\n/', $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 1c995bc..dfa6eb1 100644 --- a/system/src/Grav/Common/GPM/Upgrader.php +++ b/system/src/Grav/Common/GPM/Upgrader.php @@ -1,14 +1,16 @@ minPHPVersion(), '<')) { + if (version_compare(PHP_VERSION, $this->minPHPVersion(), '<')) { return false; } @@ -108,32 +105,32 @@ class Upgrader /** * Get minimum PHP version from remote * - * @return null + * @return string */ public function minPHPVersion() { - if (is_null($this->min_php)) { + 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() { - return version_compare($this->getLocalVersion(), $this->getRemoteVersion(), "<"); + return version_compare($this->getLocalVersion(), $this->getRemoteVersion(), '<'); } /** * 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 6f58fba..8f3a73a 100644 --- a/system/src/Grav/Common/Getters.php +++ b/system/src/Grav/Common/Getters.php @@ -1,26 +1,31 @@ gettersVariable; return isset($this->{$var}[$offset]); - } else { - return isset($this->{$offset}); } + + return isset($this->{$offset}); } /** - * @param mixed $offset - * + * @param int|string $offset * @return mixed */ public function offsetGet($offset) @@ -88,14 +89,14 @@ abstract class Getters implements \ArrayAccess, \Countable if ($this->gettersVariable) { $var = $this->gettersVariable; - return isset($this->{$var}[$offset]) ? $this->{$var}[$offset] : null; - } else { - return isset($this->{$offset}) ? $this->{$offset} : null; + return $this->{$var}[$offset] ?? null; } + + return $this->{$offset} ?? null; } /** - * @param mixed $offset + * @param int|string $offset * @param mixed $value */ public function offsetSet($offset, $value) @@ -109,7 +110,7 @@ abstract class Getters implements \ArrayAccess, \Countable } /** - * @param mixed $offset + * @param int|string $offset */ public function offsetUnset($offset) { @@ -128,10 +129,10 @@ abstract class Getters implements \ArrayAccess, \Countable { if ($this->gettersVariable) { $var = $this->gettersVariable; - count($this->{$var}); - } else { - count($this->toArray()); + return count($this->{$var}); } + + return count($this->toArray()); } /** @@ -145,16 +146,16 @@ abstract class Getters implements \ArrayAccess, \Countable $var = $this->gettersVariable; return $this->{$var}; - } else { - $properties = (array)$this; - $list = []; - foreach ($properties as $property => $value) { - if ($property[0] != "\0") { - $list[$property] = $value; - } - } - - return $list; } + + $properties = (array)$this; + $list = []; + foreach ($properties as $property => $value) { + if ($property[0] !== "\0") { + $list[$property] = $value; + } + } + + return $list; } } diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php index 84260d0..c9097ee 100644 --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ -1,31 +1,79 @@ 'Grav\Common\Uri', - 'events' => 'RocketTheme\Toolbox\Event\EventDispatcher', - 'cache' => 'Grav\Common\Cache', - 'Grav\Common\Service\SessionServiceProvider', - 'plugins' => 'Grav\Common\Plugins', - 'themes' => 'Grav\Common\Themes', - 'twig' => 'Grav\Common\Twig\Twig', - 'taxonomy' => 'Grav\Common\Taxonomy', - 'language' => 'Grav\Common\Language\Language', - 'pages' => 'Grav\Common\Page\Pages', - 'Grav\Common\Service\TaskServiceProvider', - 'Grav\Common\Service\AssetsServiceProvider', - 'Grav\Common\Service\PageServiceProvider', - 'Grav\Common\Service\OutputServiceProvider', - 'browser' => 'Grav\Common\Browser', - 'exif' => 'Grav\Common\Helpers\Exif', - 'Grav\Common\Service\StreamsServiceProvider', - 'Grav\Common\Service\ConfigServiceProvider', - 'inflector' => 'Grav\Common\Inflector', - 'siteSetupProcessor' => 'Grav\Common\Processors\SiteSetupProcessor', - 'configurationProcessor' => 'Grav\Common\Processors\ConfigurationProcessor', - 'errorsProcessor' => 'Grav\Common\Processors\ErrorsProcessor', - 'debuggerInitProcessor' => 'Grav\Common\Processors\DebuggerInitProcessor', - 'initializeProcessor' => 'Grav\Common\Processors\InitializeProcessor', - 'pluginsProcessor' => 'Grav\Common\Processors\PluginsProcessor', - 'themesProcessor' => 'Grav\Common\Processors\ThemesProcessor', - 'tasksProcessor' => 'Grav\Common\Processors\TasksProcessor', - 'assetsProcessor' => 'Grav\Common\Processors\AssetsProcessor', - 'twigProcessor' => 'Grav\Common\Processors\TwigProcessor', - 'pagesProcessor' => 'Grav\Common\Processors\PagesProcessor', - 'debuggerAssetsProcessor' => 'Grav\Common\Processors\DebuggerAssetsProcessor', - 'renderProcessor' => 'Grav\Common\Processors\RenderProcessor', + 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 processors that are processed in $this->process() + * @var array All middleware processors that are processed in $this->process() */ - protected $processors = [ - 'siteSetupProcessor', - 'configurationProcessor', - 'errorsProcessor', - 'debuggerInitProcessor', + protected $middleware = [ 'initializeProcessor', 'pluginsProcessor', 'themesProcessor', + 'requestProcessor', 'tasksProcessor', + 'backupsProcessor', + 'schedulerProcessor', 'assetsProcessor', 'twigProcessor', 'pagesProcessor', @@ -88,12 +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; } } @@ -102,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; @@ -119,28 +162,349 @@ class Grav extends Container return self::$instance; } + /** + * Get Grav version. + * + * @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 + */ + public function setup(string $environment = null) + { + if (isset($this->initialized['setup'])) { + return $this; + } + + $this->initialized['setup'] = true; + + // Force environment if passed to the method. + if ($environment) { + Setup::$environment = $environment; + } + + // Initialize setup and streams. + $this['setup']; + $this['streams']; + + return $this; + } + + /** + * Initialize CLI environment. + * + * Call after `$grav->setup($environment)` + * + * - Load configuration + * - Initialize logger + * - Disable debugger + * - Set timezone, locale + * - Load plugins (call PluginsLoadedEvent) + * - Set Pages and Users type to be used in the site + * + * This method WILL NOT initialize assets, twig or pages. + * + * @return $this + */ + public function initializeCli() + { + InitializeProcessor::initializeCli($this); + + return $this; + } + /** * Process a request + * + * @return void */ public function process() { - // process all processors (e.g. config, initialize, assets, ..., render) - foreach ($this->processors as $processor) { - $processor = $this[$processor]; - $this->measureTime($processor->id, $processor->title, function () use ($processor) { - $processor->process(); - }); + if (isset($this->initialized['process'])) { + return; } + // Initialize Grav if needed. + $this->setup(); + + $this->initialized['process'] = true; + + $container = new Container( + [ + 'initializeProcessor' => function () { + return new InitializeProcessor($this); + }, + 'backupsProcessor' => function () { + return new BackupsProcessor($this); + }, + 'pluginsProcessor' => function () { + return new PluginsProcessor($this); + }, + 'themesProcessor' => function () { + return new ThemesProcessor($this); + }, + 'schedulerProcessor' => function () { + return new SchedulerProcessor($this); + }, + 'requestProcessor' => function () { + return new RequestProcessor($this); + }, + 'tasksProcessor' => function () { + return new TasksProcessor($this); + }, + 'assetsProcessor' => function () { + return new AssetsProcessor($this); + }, + 'twigProcessor' => function () { + return new TwigProcessor($this); + }, + 'pagesProcessor' => function () { + return new PagesProcessor($this); + }, + 'debuggerAssetsProcessor' => function () { + return new DebuggerAssetsProcessor($this); + }, + 'renderProcessor' => function () { + return new RenderProcessor($this); + }, + ] + ); + + $default = static function () { + return new Response(404, ['Expires' => 0, 'Cache-Control' => 'no-store, max-age=0'], 'Not Found'); + }; + + $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 . '"'); + + $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; + + $this['debugger']->render(); + + // 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']); + } + } + + /** + * 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']; - $debugger->render(); + $response = $debugger->logRequest($request, $response); - register_shutdown_function([$this, 'shutdown']); + $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|null $code Redirection code (30x) + * @return void + */ + 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']; + + // Clean route for redirect + $route = preg_replace("#^\/[\\\/]+\/#", '/', $route); + + 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 ($uri::isExternal($route)) { + $url = $route; + } else { + $url = rtrim($uri->rootUrl(), '/') . '/'; + + if ($this['config']->get('system.pages.redirect_trailing_slash', true)) { + $url .= trim($route, '/'); // Remove trailing slash + } else { + $url .= ltrim($route, '/'); // Support trailing slash default routes + } + } + + return new Response($code, ['Location' => $url]); + } + + /** + * Redirect browser to another location taking language into account (preferred) + * + * @param string $route Internal route. + * @param int $code Redirection code (30x) + * @return void + */ + public function redirectLangSafe($route, $code = null) + { + if (!$this['uri']->isExternal($route)) { + $this->redirect($this['pages']->route($route), $code); + } else { + $this->redirect($route, $code); + } + } + + /** + * Set response header. + * + * @param ResponseInterface|null $response + * @return void + */ + public function header(ResponseInterface $response = null) + { + if (null === $response) { + /** @var PageInterface $page */ + $page = $this['page']; + $response = new Response($page->httpResponseCode(), $page->httpHeaders(), ''); + } + + 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() { @@ -154,134 +518,54 @@ class Grav extends Container } /** - * Redirect browser to another location. - * - * @param string $route Internal route. - * @param int $code Redirection code (30x) + * @param object $event + * @return object */ - public function redirect($route, $code = null) + public function dispatchEvent($event) { - /** @var Uri $uri */ - $uri = $this['uri']; + /** @var EventDispatcherInterface $events */ + $events = $this['events']; + $eventName = get_class($event); - //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]; - } + $timestamp = microtime(true); + $event = $events->dispatch($event); - if ($code === null) { - $code = $this['config']->get('system.pages.redirect_default_code', 302); - } + /** @var Debugger $debugger */ + $debugger = $this['debugger']; + $debugger->addEvent($eventName, $event, $events, $timestamp); - if (isset($this['session'])) { - $this['session']->close(); - } - - if ($uri->isExternal($route)) { - $url = $route; - } else { - $url = rtrim($uri->rootUrl(), '/') . '/'; - - if ($this['config']->get('system.pages.redirect_trailing_slash', true)) { - $url .= trim($route, '/'); // Remove trailing slash - } else { - $url .= ltrim($route, '/'); // Support trailing slash default routes - } - } - - header("Location: {$url}", true, $code); - exit(); - } - - /** - * Redirect browser to another location taking language into account (preferred) - * - * @param string $route Internal route. - * @param int $code Redirection code (30x) - */ - public function redirectLangSafe($route, $code = null) - { - if (!$this['uri']->isExternal($route)) { - $this->redirect($this['pages']->route($route), $code); - } else { - $this->redirect($route, $code); - } - } - - /** - * Set response header. - */ - public function header() - { - /** @var Page $page */ - $page = $this['page']; - - $format = $page->templateFormat(); - - header('Content-type: ' . Utils::getMimeByExtension($format, 'text/html')); - - $cache_control = $page->cacheControl(); - - // Calculate Expires Headers if set to > 0 - $expires = $page->expires(); - - if ($expires > 0) { - $expires_date = gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT'; - if (!$cache_control) { - header('Cache-Control: max-age=' . $expires); - } - header('Expires: ' . $expires_date); - } - - // Set cache-control header - if ($cache_control) { - header('Cache-Control: ' . strtolower($cache_control)); - } - - // Set the last modified time - if ($page->lastModified()) { - $last_modified_date = gmdate('D, d M Y H:i:s', $page->modified()) . ' GMT'; - header('Last-Modified: ' . $last_modified_date); - } - - // Calculate a Hash based on the raw file - if ($page->eTag()) { - header('ETag: "' . md5($page->raw() . $page->modified()).'"'); - } - - // Set HTTP response code - if (isset($this['page']->header()->http_response_code)) { - http_response_code($this['page']->header()->http_response_code); - } - - // Vary: Accept-Encoding - if ($this['config']->get('system.pages.vary_accept_encoding', false)) { - header('Vary: Accept-Encoding'); - } + 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() { @@ -295,35 +579,36 @@ 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; - 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"); + header('Connection: close'); ob_end_flush(); @ob_flush(); @@ -337,43 +622,58 @@ class Grav extends Container /** * Magic Catch All Function - * Used to call closures like measureTime on the instance. + * + * Used to call closures. + * * Source: http://stackoverflow.com/questions/419804/closures-as-class-members + * + * @param string $method + * @param array $args + * @return mixed|null */ public function __call($method, $args) { - $closure = $this->$method; - call_user_func_array($closure, $args); + $closure = $this->{$method} ?? null; + + return is_callable($closure) ? $closure(...$args) : null; + } + + /** + * Measure how long it takes to do an action. + * + * @param string $timerId + * @param string $timerTitle + * @param callable $callback + * @return mixed Returns value returned by the callable. + */ + public function measureTime(string $timerId, string $timerTitle, callable $callback) + { + $debugger = $this['debugger']; + $debugger->startTimer($timerId, $timerTitle); + $result = $callback(); + $debugger->stopTimer($timerId); + + return $result; } /** * Initialize and return a Grav instance * * @param array $values - * * @return static */ protected static function load(array $values) { $container = new static($values); - $container['grav'] = $container; - $container['debugger'] = new Debugger(); - $debugger = $container['debugger']; + $container['grav'] = function (Container $container) { + user_error('Calling $grav[\'grav\'] or {{ grav.grav }} is deprecated since Grav 1.6, just use $grav or {{ grav }}', E_USER_DEPRECATED); - // closure that measures time by wrapping a function into startTimer and stopTimer - // The debugger can be passed to the closure. Should be more performant - // then to get it from the container all time. - $container->measureTime = function ($timerId, $timerTitle, $callback) use ($debugger) { - $debugger->startTimer($timerId, $timerTitle); - $callback(); - $debugger->stopTimer($timerId); + return $container; }; - $container->measureTime('_services', 'Services', function () use ($container) { - $container->registerServices($container); - }); + $container->registerServices(); return $container; } @@ -390,44 +690,20 @@ class Grav extends Container { foreach (self::$diMap as $serviceKey => $serviceClass) { if (is_int($serviceKey)) { - $this->registerServiceProvider($serviceClass); + $this->register(new $serviceClass); } else { - $this->registerService($serviceKey, $serviceClass); + $this[$serviceKey] = function ($c) use ($serviceClass) { + return new $serviceClass($c); + }; } } } - /** - * Register a service provider with the container. - * - * @param string $serviceClass - * - * @return void - */ - protected function registerServiceProvider($serviceClass) - { - $this->register(new $serviceClass); - } - - /** - * Register a service with the container. - * - * @param string $serviceKey - * @param string $serviceClass - * - * @return void - */ - protected function registerService($serviceKey, $serviceClass) - { - $this[$serviceKey] = function ($c) use ($serviceClass) { - return new $serviceClass($c); - }; - } - /** * This attempts to find media, other files, and download them * - * @param $path + * @param string $path + * @return PageInterface|false */ public function fallbackUrl($path) { @@ -444,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)) { @@ -453,8 +729,9 @@ class Grav extends Container $path_parts = pathinfo($path); - /** @var Page $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(); @@ -466,7 +743,7 @@ 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)) { + if (in_array($action, ImageMedium::$magic_actions, true)) { call_user_func_array([&$medium, $action], explode(',', $params)); } } @@ -486,7 +763,7 @@ class Grav extends Container if ($extension) { $download = true; - if (in_array(ltrim($extension, '.'), $config->get('system.media.unsupported_inline_types', []))) { + 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 a17a170..4638905 100644 --- a/system/src/Grav/Common/GravTrait.php +++ b/system/src/Grav/Common/GravTrait.php @@ -1,31 +1,34 @@ ', '?' 0xFF,0x00,0x01,0x02,0x03,0x04,0x05,0x06, // '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G' @@ -22,27 +34,32 @@ class Base32 { 0x07,0x08,0x09,0x0A,0x0B,0x0C,0x0D,0x0E, // 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o' 0x0F,0x10,0x11,0x12,0x13,0x14,0x15,0x16, // 'p', 'q', 'r', 's', 't', 'u', 'v', 'w' 0x17,0x18,0x19,0xFF,0xFF,0xFF,0xFF,0xFF // 'x', 'y', 'z', '{', '|', '}', '~', 'DEL' - ); + ]; /** * Encode in Base32 * - * @param $bytes + * @param string $bytes * @return string */ - public static function encode( $bytes ) { - $i = 0; $index = 0; $digit = 0; + public static function encode($bytes) + { + $i = 0; + $index = 0; $base32 = ''; - $bytes_len = strlen($bytes); - while( $i < $bytes_len ) { - $currByte = ord($bytes{$i}); + $bytesLen = strlen($bytes); + + while ($i < $bytesLen) { + $currByte = ord($bytes[$i]); + /* Is the current digit going to span a byte boundary? */ - if( $index > 3 ) { - if( ($i + 1) < $bytes_len ) { - $nextByte = ord($bytes{$i+1}); + if ($index > 3) { + if (($i + 1) < $bytesLen) { + $nextByte = ord($bytes[$i+1]); } else { $nextByte = 0; } + $digit = $currByte & (0xFF >> $index); $index = ($index + 5) % 8; $digit <<= $index; @@ -51,9 +68,12 @@ class Base32 { } else { $digit = ($currByte >> (8 - ($index + 5))) & 0x1F; $index = ($index + 5) % 8; - if( $index === 0 ) $i++; + if ($index === 0) { + $i++; + } } - $base32 .= self::$base32Chars{$digit}; + + $base32 .= self::$base32Chars[$digit]; } return $base32; } @@ -61,30 +81,42 @@ class Base32 { /** * Decode in Base32 * - * @param $base32 + * @param string $base32 * @return string */ - public static function decode( $base32 ) { - $bytes = array(); - $base32_len = strlen($base32); - for( $i=$base32_len*5/8-1; $i>=0; --$i ) { + public static function decode($base32) + { + $bytes = []; + $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 < $base32_len; $i++ ) { - $lookup = ord($base32{$i}) - ord('0'); + + for ($i = 0, $index = 0, $offset = 0; $i < $base32Len; $i++) { + $lookup = ord($base32[$i]) - ord('0'); + /* Skip chars outside the lookup table */ - if( $lookup < 0 || $lookup >= count(self::$base32Lookup) ) { + if ($lookup < 0 || $lookup >= $base32LookupLen) { continue; } + $digit = self::$base32Lookup[$lookup]; + /* If this digit is not in the table, ignore it */ - if( $digit == 0xFF ) continue; - if( $index <= 3 ) { + if ($digit === 0xFF) { + continue; + } + + if ($index <= 3) { $index = ($index + 5) % 8; - if( $index == 0) { + if ($index === 0) { $bytes[$offset] |= $digit; $offset++; - if( $offset >= count($bytes) ) break; + if ($offset >= count($bytes)) { + break; + } } else { $bytes[$offset] |= $digit << (8 - $index); } @@ -92,12 +124,18 @@ class Base32 { $index = ($index + 5) % 8; $bytes[$offset] |= ($digit >> $index); $offset++; - if ($offset >= count($bytes) ) break; + if ($offset >= count($bytes)) { + break; + } $bytes[$offset] |= $digit << (8 - $index); } } + $bites = ''; - foreach( $bytes as $byte ) $bites .= chr($byte); + foreach ($bytes as $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 53faaf4..cd285cf 100644 --- a/system/src/Grav/Common/Helpers/Excerpts.php +++ b/system/src/Grav/Common/Helpers/Excerpts.php @@ -1,31 +1,36 @@ ` - * @param Page $page The current page object - * @return string Returns final HTML string + * @param string $html HTML tag e.g. `` + * @param PageInterface|null $page Page, defaults to the current page object + * @return string Returns final HTML string */ - public static function processImageHtml($html, Page $page) + public static function processImageHtml($html, PageInterface $page = null) { $excerpt = static::getExcerptFromHtml($html, 'img'); @@ -35,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); @@ -46,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 * @@ -55,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; @@ -79,7 +117,7 @@ class Excerpts /** * Rebuild HTML tag from an excerpt array * - * @param $excerpt + * @param array $excerpt * @return string */ public static function getHtmlFromExcerpt($excerpt) @@ -98,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 .= ' />'; @@ -110,253 +148,44 @@ class Excerpts /** * Process a Link excerpt * - * @param $excerpt - * @param Page $page + * @param array $excerpt + * @param PageInterface|null $page Page, defaults to the current page object * @param string $type * @return mixed */ - public static function processLinkExcerpt($excerpt, Page $page, $type = 'link') + public static function processLinkExcerpt($excerpt, PageInterface $page = null, $type = 'link') { - $url = htmlspecialchars_decode(rawurldecode($excerpt['element']['attributes']['href'])); + $excerpts = new ExcerptsObject($page); - $url_parts = static::parseUrl($url); - - // If there is a query, then parse it and build action calls. - if (isset($url_parts['query'])) { - $actions = array_reduce(explode('&', $url_parts['query']), function ($carry, $item) { - $parts = explode('=', $item, 2); - $value = isset($parts[1]) ? rawurldecode($parts[1]) : true; - $carry[$parts[0]] = $value; - - return $carry; - }, []); - - // Valid attributes supported. - $valid_attributes = ['rel', 'target', 'id', 'class', 'classes']; - - // Unless told to not process, go through actions. - if (array_key_exists('noprocess', $actions)) { - unset($actions['noprocess']); - } else { - // Loop through actions for the image and call them. - foreach ($actions as $attrib => $value) { - $key = $attrib; - - if (in_array($attrib, $valid_attributes, true)) { - // support both class and classes. - if ($attrib === 'classes') { - $attrib = 'class'; - } - $excerpt['element']['attributes'][$attrib] = str_replace(',', ' ', $value); - unset($actions[$key]); - } - } - } - - $url_parts['query'] = http_build_query($actions, null, '&', PHP_QUERY_RFC3986); - } - - // If no query elements left, unset query. - if (empty($url_parts['query'])) { - unset ($url_parts['query']); - } - - // Set path to / if not set. - if (empty($url_parts['path'])) { - $url_parts['path'] = ''; - } - - // 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'])) { - $url_parts['path'] = Grav::instance()['base_url_relative'] . '/' . static::resolveStream("{$url_parts['scheme']}://{$url_parts['path']}"); - unset($url_parts['stream'], $url_parts['scheme']); - } - - $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts); - return $excerpt; - } - - // Handle paths and such. - $url_parts = Uri::convertUrl($page, $url_parts, $type); - - // Build the URL from the component parts and set it on the element. - $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts); - - return $excerpt; + return $excerpts->processLinkExcerpt($excerpt, $type); } /** * Process an image excerpt * * @param array $excerpt - * @param Page $page - * @return mixed + * @param PageInterface|null $page Page, defaults to the current page object + * @return array */ - public static function processImageExcerpt(array $excerpt, Page $page) + public static function processImageExcerpt(array $excerpt, PageInterface $page = null) { - $url = htmlspecialchars_decode(urldecode($excerpt['element']['attributes']['src'])); - $url_parts = static::parseUrl($url); + $excerpts = new ExcerptsObject($page); - $media = null; - $filename = null; - - if (!empty($url_parts['stream'])) { - $filename = $url_parts['scheme'] . '://' . (isset($url_parts['path']) ? $url_parts['path'] : ''); - - $media = $page->media(); - - } else { - // File is also local if scheme is http(s) and host matches. - $local_file = isset($url_parts['path']) - && (empty($url_parts['scheme']) || in_array($url_parts['scheme'], ['http', 'https'], true)) - && (empty($url_parts['host']) || $url_parts['host'] === Grav::instance()['uri']->host()); - - if ($local_file) { - $filename = basename($url_parts['path']); - $folder = dirname($url_parts['path']); - - // Get the local path to page media if possible. - if ($folder === $page->url(false, false, false)) { - // Get the media objects for this page. - $media = $page->media(); - } else { - // see if this is an external page to this one - $base_url = rtrim(Grav::instance()['base_url_relative'] . Grav::instance()['pages']->base(), '/'); - $page_route = '/' . ltrim(str_replace($base_url, '', $folder), '/'); - - /** @var Page $ext_page */ - $ext_page = Grav::instance()['pages']->dispatch($page_route, true); - if ($ext_page) { - $media = $ext_page->media(); - } else { - Grav::instance()->fireEvent('onMediaLocate', new Event(['route' => $page_route, 'media' => &$media])); - } - } - } - } - - // If there is a media file that matches the path referenced.. - if ($media && $filename && isset($media[$filename])) { - // Get the medium object. - /** @var Medium $medium */ - $medium = $media[$filename]; - - // Process operations - $medium = static::processMediaActions($medium, $url_parts); - $element_excerpt = $excerpt['element']['attributes']; - - $alt = isset($element_excerpt['alt']) ? $element_excerpt['alt'] : ''; - $title = isset($element_excerpt['title']) ? $element_excerpt['title'] : ''; - $class = isset($element_excerpt['class']) ? $element_excerpt['class'] : ''; - $id = isset($element_excerpt['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); - } - - return $excerpt; + return $excerpts->processImageExcerpt($excerpt); } /** * Process media actions * - * @param $medium - * @param $url - * @return mixed + * @param Medium $medium + * @param string|array $url + * @param PageInterface|null $page Page, defaults to the current page object + * @return Medium|Link */ - public static function processMediaActions($medium, $url) + public static function processMediaActions($medium, $url, PageInterface $page = null) { - if (!is_array($url)) { - $url_parts = parse_url($url); - } else { - $url_parts = $url; - } + $excerpts = new ExcerptsObject($page); - $actions = []; - - // if there is a query, then parse it and build action calls - if (isset($url_parts['query'])) { - $actions = array_reduce(explode('&', $url_parts['query']), function ($carry, $item) { - $parts = explode('=', $item, 2); - $value = isset($parts[1]) ? $parts[1] : null; - $carry[] = ['method' => $parts[0], 'params' => $value]; - - return $carry; - }, []); - } - - if (Grav::instance()['config']->get('system.images.auto_fix_orientation')) { - $actions[] = ['method' => 'fixOrientation', 'params' => '']; - } - $defaults = Grav::instance()['config']->get('system.images.defaults'); - if (is_array($defaults) && count($defaults)) { - foreach ($defaults as $method => $params) { - $actions[] = [ - 'method' => $method, - 'params' => $params, - ]; - } - } - - // loop through actions for the image and call them - foreach ($actions as $action) { - $matches = []; - - if (preg_match('/\[(.*)\]/', $action['params'], $matches)) { - $args = [explode(',', $matches[1])]; - } else { - $args = explode(',', $action['params']); - } - - $medium = call_user_func_array([$medium, $action['method']], $args); - } - - if (isset($url_parts['fragment'])) { - $medium->urlHash($url_parts['fragment']); - } - - return $medium; - } - - /** - * Variation of parse_url() which works also with local streams. - * - * @param string $url - * @return array|bool - */ - protected static function parseUrl($url) - { - $url_parts = Utils::multibyteParseUrl($url); - - if (isset($url_parts['scheme'])) { - /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - - // Special handling for the streams. - if ($locator->schemeExists($url_parts['scheme'])) { - if (isset($url_parts['host'])) { - // Merge host and path into a path. - $url_parts['path'] = $url_parts['host'] . (isset($url_parts['path']) ? '/' . $url_parts['path'] : ''); - unset($url_parts['host']); - } - - $url_parts['stream'] = true; - } - } - - return $url_parts; - } - - protected static function resolveStream($url) - { - /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - - return $locator->isStream($url) ? ($locator->findResource($url, false) ?: $locator->findResource($url, false, true)) : $url; + return $excerpts->processMediaActions($medium, $url); } } diff --git a/system/src/Grav/Common/Helpers/Exif.php b/system/src/Grav/Common/Helpers/Exif.php index ee0c4ca..36e391f 100644 --- a/system/src/Grav/Common/Helpers/Exif.php +++ b/system/src/Grav/Common/Helpers/Exif.php @@ -1,18 +1,26 @@ 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 new file mode 100644 index 0000000..a03fde8 --- /dev/null +++ b/system/src/Grav/Common/Helpers/LogViewer.php @@ -0,0 +1,165 @@ +.*)\] (?P\w+).(?P\w+): (?P.*[^ ]+) (?P[^ ]+) (?P[^ ]+)/'; + + /** + * Get the objects of a tailed file + * + * @param string $filepath + * @param int $lines + * @param bool $desc + * @return array + */ + public function objectTail($filepath, $lines = 1, $desc = true) + { + $data = $this->tail($filepath, $lines); + $tailed_log = $data ? explode(PHP_EOL, $data) : []; + $line_objects = []; + + foreach ($tailed_log as $line) { + $line_objects[] = $this->parse($line); + } + + return $desc ? $line_objects : array_reverse($line_objects); + } + + /** + * Optimized way to get just the last few entries of a log file + * + * @param string $filepath + * @param int $lines + * @return string|false + */ + public function tail($filepath, $lines = 1) + { + + $f = $filepath ? @fopen($filepath, 'rb') : false; + if ($f === false) { + return false; + } + + $buffer = ($lines < 2 ? 64 : ($lines < 10 ? 512 : 4096)); + + fseek($f, -1, SEEK_END); + if (fread($f, 1) != "\n") { + $lines -= 1; + } + + // Start reading + $output = ''; + $chunk = ''; + // While we would like more + while (ftell($f) > 0 && $lines >= 0) { + // Figure out how far back we should jump + $seek = min(ftell($f), $buffer); + // Do the jump (backwards, relative to where we are) + fseek($f, -$seek, SEEK_CUR); + // Read a chunk and prepend it to our output + $output = ($chunk = fread($f, $seek)) . $output; + // Jump back to where we started reading + fseek($f, -mb_strlen($chunk, '8bit'), SEEK_CUR); + // Decrease our line counter + $lines -= substr_count($chunk, "\n"); + } + // While we have too many lines + // (Because of buffer size we might have read too many) + while ($lines++ < 0) { + // Find first newline and remove all text before that + $output = substr($output, strpos($output, "\n") + 1); + } + // Close file and return + fclose($f); + + return trim($output); + } + + /** + * Helper class to get level color + * + * @param string $level + * @return string + */ + public static function levelColor($level) + { + $colors = [ + 'DEBUG' => 'green', + 'INFO' => 'cyan', + 'NOTICE' => 'yellow', + 'WARNING' => 'yellow', + 'ERROR' => 'red', + 'CRITICAL' => 'red', + 'ALERT' => 'red', + 'EMERGENCY' => 'magenta' + ]; + return $colors[$level] ?? 'white'; + } + + /** + * Parse a monolog row into array bits + * + * @param string $line + * @return array + */ + public function parse($line) + { + if (!is_string($line) || strlen($line) === 0) { + return array(); + } + + preg_match($this->pattern, $line, $data); + if (!isset($data['date'])) { + return array(); + } + + preg_match('/(.*)- Trace:(.*)/', $data['message'], $matches); + if (is_array($matches) && isset($matches[1])) { + $data['message'] = trim($matches[1]); + $data['trace'] = trim($matches[2]); + } + + return array( + 'date' => DateTime::createFromFormat('Y-m-d H:i:s', $data['date']), + 'logger' => $data['logger'], + 'level' => $data['level'], + 'message' => $data['message'], + 'trace' => isset($data['trace']) ? $this->parseTrace($data['trace']) : null, + 'context' => json_decode($data['context'], true), + 'extra' => json_decode($data['extra'], true) + ); + } + + /** + * Parse text of trace into an array of lines + * + * @param string $trace + * @param int $rows + * @return array + */ + 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 474aae1..0a701b6 100644 --- a/system/src/Grav/Common/Helpers/Truncator.php +++ b/system/src/Grav/Common/Helpers/Truncator.php @@ -1,8 +1,9 @@ getElementsByTagName("body")->item(0); + $doc = self::htmlToDomDocument($html); + $container = $doc->getElementsByTagName('div')->item(0); + $container = $container->parentNode->removeChild($container); // Iterate over words. - $words = new DOMWordsIterator($body); + $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]; @@ -65,7 +65,7 @@ class Truncator { $words[$offset][1] + strlen($words[$offset][0]) ); - self::removeProceedingNodes($curNode, $body); + self::removeProceedingNodes($curNode, $container); if (!empty($ellipsis)) { self::insertEllipsis($curNode, $ellipsis); @@ -75,46 +75,43 @@ class Truncator { break; } - } // Return original HTML if not truncated. if ($truncated) { - return self::innerHTML($body); - } else { - return $html; + $html = self::getCleanedHtml($doc, $container); } + + return $html; } /** * Safely truncates HTML by a given number of letters. + * * @param string $html Input HTML. - * @param integer $limit Limit to how many letters we preserve. + * @param int $limit Limit to how many letters we preserve. * @param string $ellipsis String to use as ellipsis (if any). * @return string Safe truncated HTML. */ - public static function truncateLetters($html, $limit = 0, $ellipsis = "") + public static function truncateLetters($html, $limit = 0, $ellipsis = '') { if ($limit <= 0) { return $html; } - $dom = self::htmlToDomDocument($html); - - // Grab the body of our DOM. - $body = $dom->getElementsByTagName('body')->item(0); + $doc = self::htmlToDomDocument($html); + $container = $doc->getElementsByTagName('div')->item(0); + $container = $container->parentNode->removeChild($container); // Iterate over letters. - $letters = new DOMLettersIterator($body); + $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], $body); + self::removeProceedingNodes($currentText[0], $container); if (!empty($ellipsis)) { self::insertEllipsis($currentText[0], $ellipsis); @@ -128,21 +125,22 @@ class Truncator { // Return original HTML if not truncated. if ($truncated) { - return self::innerHTML($body); - } else { - return $html; + $html = self::getCleanedHtml($doc, $container); } + + return $html; } /** * 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) { if (!$html) { - $html = '

'; + $html = ''; } // Transform multibyte entities which otherwise display incorrectly. @@ -154,19 +152,21 @@ class Truncator { // Instantiate new DOMDocument object, and then load in UTF-8 HTML. $dom = new DOMDocument(); $dom->encoding = 'UTF-8'; - $dom->loadHTML($html); + $dom->loadHTML("
$html
"); return $dom; } /** * 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) { @@ -187,8 +187,29 @@ class Truncator { } } + /** + * Clean extra code + * + * @param DOMDocument $doc + * @param DOMNode $container + * @return string + */ + private static function getCleanedHTML(DOMDocument $doc, DOMNode $container) + { + while ($doc->firstChild) { + $doc->removeChild($doc->firstChild); + } + + while ($container->firstChild) { + $doc->appendChild($container->firstChild); + } + + 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 @@ -201,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; @@ -214,21 +236,109 @@ class Truncator { } /** - * Returns the innerHTML of a particular DOMElement - * - * @param $element + * @param string $text + * @param int $length + * @param string $ending + * @param bool $exact + * @param bool $considerHtml * @return string */ - private static function innerHTML($element) { - $innerHTML = ''; - $children = $element->childNodes; - foreach ($children as $child) - { - $tmp_dom = new DOMDocument(); - $tmp_dom->appendChild($tmp_dom->importNode($child, true)); - $innerHTML.=trim($tmp_dom->saveHTML()); - } - return $innerHTML; - } + public function truncate( + $text, + $length = 100, + $ending = '...', + $exact = false, + $considerHtml = true + ) { + if ($considerHtml) { + // if the plain text is shorter than the maximum length, return the whole text + 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); + $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])) { + // if it's an "empty element" with or without xhtml-conform closing slash + 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 + } 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 + } 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])); + } + // add html-tag to $truncate'd text + $truncate .= $line_matchings[1]; + } + // calculate the length of the plain text part of the line; handle entities as one character + $content_length = strlen(preg_replace('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|[0-9a-f]{1,6};/i', ' ', $line_matchings[2])); + if ($total_length+$content_length> $length) { + // the number of characters which are left + $left = $length - $total_length; + $entities_length = 0; + // search for html entities + if (preg_match_all('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|[0-9a-f]{1,6};/i', $line_matchings[2], $entities, PREG_OFFSET_CAPTURE)) { + // calculate the real length of all entities in the legal range + foreach ($entities[0] as $entity) { + if ($entity[1]+1-$entities_length <= $left) { + $left--; + $entities_length += strlen($entity[0]); + } else { + // no more characters left + break; + } + } + } + $truncate .= substr($line_matchings[2], 0, $left+$entities_length); + // maximum lenght is reached, so get off the loop + break; + } else { + $truncate .= $line_matchings[2]; + $total_length += $content_length; + } + // if the maximum length is reached, get off the loop + if ($total_length>= $length) { + break; + } + } + } else { + if (strlen($text) <= $length) { + return $text; + } + + $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 (false !== $spacepos) { + // ...and cut the text in this position + $truncate = substr($truncate, 0, $spacepos); + } + } + // add the defined ending to the text + $truncate .= $ending; + 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 new file mode 100644 index 0000000..45b5144 --- /dev/null +++ b/system/src/Grav/Common/Helpers/YamlLinter.php @@ -0,0 +1,121 @@ +get('system.pages.theme'); + $theme_path = 'themes://' . $current_theme . '/blueprints'; + + $locator->addPath('blueprints', '', [$theme_path]); + return static::recurseFolder('blueprints://'); + } + + /** + * @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 | RecursiveDirectoryIterator::FOLLOW_SYMLINKS; + if ($locator->isStream($path)) { + $directory = $locator->getRecursiveIterator($path, $flags); + } else { + $directory = new RecursiveDirectoryIterator($path, $flags); + } + $recursive = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); + $iterator = new RegexIterator($recursive, '/^.+\.'.$extensions.'$/ui'); + + /** @var RecursiveDirectoryIterator $file */ + foreach ($iterator as $filepath => $file) { + try { + Yaml::parse(static::extractYaml($filepath)); + } catch (Exception $e) { + $lint_errors[str_replace(GRAV_ROOT, '', $filepath)] = $e->getMessage(); + } + } + + 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 61c2338..50c218f 100644 --- a/system/src/Grav/Common/Inflector.php +++ b/system/src/Grav/Common/Inflector.php @@ -1,36 +1,55 @@ plural)) { + if (!static::$initialized) { + static::$initialized = true; + /** @var Language $language */ $language = Grav::instance()['language']; - $this->plural = $language->translate('INFLECTOR_PLURALS', null, true) ?: []; - $this->singular = $language->translate('INFLECTOR_SINGULAR', null, true) ?: []; - $this->uncountable = $language->translate('INFLECTOR_UNCOUNTABLE', null, true) ?: []; - $this->irregular = $language->translate('INFLECTOR_IRREGULAR', null, true) ?: []; - $this->ordinals = $language->translate('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); + } } } @@ -39,39 +58,43 @@ class Inflector * * @param string $word English noun to pluralize * @param int $count The count - * - * @return string Plural noun + * @return string|false Plural noun */ - public function pluralize($word, $count = 2) + public static function pluralize($word, $count = 2) { - $this->init(); + static::init(); - if ($count == 1) { + if ((int)$count === 1) { return $word; } $lowercased_word = strtolower($word); - foreach ($this->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 ($this->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 ($this->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; - } /** @@ -82,30 +105,37 @@ class Inflector * * @return string Singular noun. */ - public function singularize($word, $count = 1) + public static function singularize($word, $count = 1) { - $this->init(); + static::init(); - if ($count != 1) { + if ((int)$count !== 1) { return $word; } $lowercased_word = strtolower($word); - foreach ($this->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 ($this->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 ($this->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); + } } } @@ -129,11 +159,11 @@ class Inflector * * @return string Text formatted as title */ - public function titleize($word, $uppercase = '') + public static function titleize($word, $uppercase = '') { - $uppercase = $uppercase == 'first' ? 'ucfirst' : 'ucwords'; + $uppercase = $uppercase === 'first' ? 'ucfirst' : 'ucwords'; - return $uppercase($this->humanize($this->underscorize($word))); + return $uppercase(static::humanize(static::underscorize($word))); } /** @@ -145,11 +175,10 @@ 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 function camelize($word) + public static function camelize($word) { return str_replace(' ', '', ucwords(preg_replace('/[^A-Z^a-z^0-9]+/', ' ', $word))); } @@ -162,11 +191,10 @@ 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 function underscorize($word) + public static function underscorize($word) { $regex1 = preg_replace('/([A-Z]+)([A-Z][a-z])/', '\1_\2', $word); $regex2 = preg_replace('/([a-zd])([A-Z])/', '\1_\2', $regex1); @@ -183,17 +211,18 @@ 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 function hyphenize($word) + public static function hyphenize($word) { $regex1 = preg_replace('/([A-Z]+)([A-Z][a-z])/', '\1-\2', $word); $regex2 = preg_replace('/([a-z])([A-Z])/', '\1-\2', $regex1); $regex3 = preg_replace('/([0-9])([A-Z])/', '\1-\2', $regex2); $regex4 = preg_replace('/[^A-Z^a-z^0-9]+/', '-', $regex3); + $regex4 = trim($regex4, '-'); + return strtolower($regex4); } @@ -213,9 +242,9 @@ class Inflector * * @return string Human-readable word */ - public function humanize($word, $uppercase = '') + public static function humanize($word, $uppercase = '') { - $uppercase = $uppercase == 'all' ? 'ucwords' : 'ucfirst'; + $uppercase = $uppercase === 'all' ? 'ucwords' : 'ucfirst'; return $uppercase(str_replace('_', ' ', preg_replace('/_id$/', '', $word))); } @@ -229,13 +258,12 @@ class Inflector * * @see camelize * - * @param string $word Word to lowerCamelCase - * + * @param string $word Word to lowerCamelCase * @return string Returns a lowerCamelCasedWord */ - public function variablize($word) + public static function variablize($word) { - $word = $this->camelize($word); + $word = static::camelize($word); return strtolower($word[0]) . substr($word, 1); } @@ -248,13 +276,12 @@ 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 function tableize($class_name) + public static function tableize($class_name) { - return $this->pluralize($this->underscorize($class_name)); + return static::pluralize(static::underscorize($class_name)); } /** @@ -265,13 +292,12 @@ 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 function classify($table_name) + public static function classify($table_name) { - return $this->camelize($this->singularize($table_name)); + return static::camelize(static::singularize($table_name)); } /** @@ -279,31 +305,30 @@ class Inflector * * This method converts 13 to 13th, 2 to 2nd ... * - * @param integer $number Number to get its ordinal value - * + * @param int $number Number to get its ordinal value * @return string Ordinal representation of given string. */ - public function ordinalize($number) + public static function ordinalize($number) { - $this->init(); + if (!is_array(static::$ordinals)) { + return (string)$number; + } - if (in_array(($number % 100), range(11, 13))) { - return $number . $this->ordinals['default']; - } else { - switch (($number % 10)) { - case 1: - return $number . $this->ordinals['first']; - break; - case 2: - return $number . $this->ordinals['second']; - break; - case 3: - return $number . $this->ordinals['third']; - break; - default: - return $number . $this->ordinals['default']; - break; - } + static::init(); + + if (in_array($number % 100, range(11, 13), true)) { + return $number . static::$ordinals['default']; + } + + switch ($number % 10) { + case 1: + return $number . static::$ordinals['first']; + case 2: + return $number . static::$ordinals['second']; + case 3: + return $number . static::$ordinals['third']; + default: + return $number . static::$ordinals['default']; } } @@ -311,21 +336,20 @@ class Inflector * Converts a number of days to a number of months * * @param int $days - * * @return int */ - public function monthize($days) + 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); // handle years if ($diff->y > 0) { - $diff->m = $diff->m + 12 * $diff->y; + $diff->m += 12 * $diff->y; } return $diff->m; diff --git a/system/src/Grav/Common/Iterator.php b/system/src/Grav/Common/Iterator.php index 99fc0c4..cc7cf92 100644 --- a/system/src/Grav/Common/Iterator.php +++ b/system/src/Grav/Common/Iterator.php @@ -1,8 +1,9 @@ items[$key])) ? $this->items[$key] : null; + return $this->items[$key] ?? null; } /** @@ -44,7 +50,7 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable { foreach ($this as $key => $value) { if (is_object($value)) { - $this->$key = clone $this->$key; + $this->{$key} = clone $this->{$key}; } } } @@ -62,7 +68,8 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable /** * Remove item from the list. * - * @param $key + * @param string $key + * @return void */ public function remove($key) { @@ -83,14 +90,13 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable * Return nth item. * * @param int $key - * * @return mixed|bool */ public function nth($key) { $items = array_keys($this->items); - return (isset($items[$key])) ? $this->offsetGet($items[$key]) : false; + return isset($items[$key]) ? $this->offsetGet($items[$key]) : false; } /** @@ -132,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) { @@ -169,8 +175,7 @@ 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) @@ -184,13 +189,13 @@ 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) { - if ($num > count($this->items)) { - $num = count($this->items); + $count = count($this->items); + if ($num > $count) { + $num = $count; } $this->items = array_intersect_key($this->items, array_flip((array)array_rand($this->items, $num))); @@ -202,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) @@ -226,9 +230,8 @@ class Iterator implements \ArrayAccess, \Iterator, \Countable, \Serializable public function filter(callable $callback = null) { foreach ($this->items as $key => $value) { - if ( - ($callback && !call_user_func($callback, $value, $key)) || - (!$callback && !(bool)$value) + if ((!$callback && !(bool)$value) || + ($callback && !$callback($value, $key)) ) { unset($this->items[$key]); } @@ -242,11 +245,8 @@ 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) diff --git a/system/src/Grav/Common/Language/Language.php b/system/src/Grav/Common/Language/Language.php index 076d103..6709754 100644 --- a/system/src/Grav/Common/Language/Language.php +++ b/system/src/Grav/Common/Language/Language.php @@ -1,54 +1,98 @@ 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() { - $this->default = reset($this->languages); + $default = $this->config->get('system.languages.default_lang'); + if (null !== $default) { + $default = (string)$default; + } + + // 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); } } @@ -62,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 * @@ -75,40 +129,49 @@ class Language /** * Sets the current supported languages manually * - * @param $langs + * @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(static function ($value) use ($delimiter) { + return preg_quote($value, $delimiter); + }, $languagesArray); + sort($languagesArray); + return implode('|', array_reverse($languagesArray)); } /** * Gets language, active if set, else default * - * @return mixed + * @return string|false */ public function getLanguage() { - return $this->active ? $this->active : $this->default; + return $this->active ?: $this->default; } /** * Gets current default language * - * @return mixed + * @return string|false */ public function getDefault() { @@ -118,12 +181,12 @@ class Language /** * Sets default language manually * - * @param $lang - * - * @return bool + * @param string $lang + * @return string|bool */ public function setDefault($lang) { + $lang = (string)$lang; if ($this->validate($lang)) { $this->default = $lang; @@ -136,7 +199,7 @@ class Language /** * Gets current active language * - * @return mixed + * @return string|false */ public function getActive() { @@ -146,13 +209,17 @@ class Language /** * Sets active language manually * - * @param $lang - * - * @return 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; @@ -164,9 +231,8 @@ class Language /** * Sets the active language based on the first part of the URL * - * @param $uri - * - * @return mixed + * @param string $uri + * @return string */ public function setActiveFromUri($uri) { @@ -177,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. @@ -189,28 +255,21 @@ class Language } } else { // 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; + if (isset($this->grav['session']) && $this->grav['session']->isStarted() && + $this->config->get('system.languages.session_store_active', true)) { + $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')) { - $preferred = $this->getBrowserLanguages(); - foreach ($preferred as $lang) { - if ($this->validate($lang)) { - $this->active = $lang; - break; - } - } + 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); - // Repeat if not found, try base language only - fixes Safari sending the language code always - // with a locale (e.g. it-it or fr-fr). - foreach ($preferred as $lang) { - $lang = substr($lang, 0, 2); - if ($this->validate($lang)) { - $this->active = $lang; - break; - } + if ($best_language instanceof AcceptLanguage) { + $this->setActive($best_language->getType()); + } else { + $this->setActive($this->getDefault()); } } } @@ -220,13 +279,17 @@ class Language } /** - * Get's a URL prefix based on configuration + * Get a URL prefix based on configuration * - * @param null $lang + * @param string|null $lang * @return string */ public function getLanguageURLPrefix($lang = null) { + if (!$this->enabled()) { + return ''; + } + // if active lang is not passed in, use current active if (!$lang) { $lang = $this->getLanguage(); @@ -238,21 +301,21 @@ class Language /** * Test to see if language is default and language should be included in the URL * - * @param null $lang + * @param string|null $lang * @return bool */ 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(); } - if ($this->default == $lang && $this->config->get('system.languages.include_default_lang') === false) { - return false; - } else { - return true; - } + return !($this->default === $lang && $this->config->get('system.languages.include_default_lang') === false); } /** @@ -265,45 +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 (empty($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); - unset($valid_lang_extensions[$key]); - array_unshift($valid_lang_extensions, $active_extension); - } - - $this->page_extensions = array_merge($valid_lang_extensions, (array)$file_ext); - } 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: * @@ -312,63 +393,101 @@ class Language * $this->grav['language']->resetFallbackPageExtensions(); * $this->grav['pages']->init(); * ``` + * + * @return void */ - public function resetFallbackPageExtensions() { - $this->page_extensions = null; + public function resetFallbackPageExtensions() + { + $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); - 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 $lang - * + * @param string $lang * @return bool */ public function validate($lang) { - if (in_array($lang, $this->languages)) { - return true; - } - - return false; + return in_array($lang, $this->languages, true); } /** * Translate a key and possibly arguments into a string using current lang and fallbacks * - * @param mixed $args The first argument is the lookup key value + * @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) { @@ -379,77 +498,68 @@ class Language $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); - } else { - return $translation; } + + return $translation; } } + } elseif ($array_support) { + return [$lookup]; } if ($html_out) { return '' . $lookup . ''; - } else { - return $lookup; } + + return $lookup; } /** * Translate Array * - * @param $key - * @param $index - * @param null $languages + * @param string $key + * @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]; } } if ($html_out) { return '' . $key . '[' . $index . ']'; - } else { - return $key . '[' . $index . ']'; } + + return $key . '[' . $index . ']'; } /** @@ -458,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); @@ -475,11 +588,13 @@ class Language * Get the browser accepted languages * * @param array $accept_langs - * * @return array + * @deprecated 1.6 No longer used - using content negotiation. */ public function getBrowserLanguages($accept_langs = []) { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, no longer used', E_USER_DEPRECATED); + if (empty($this->http_accept_language)) { if (empty($accept_langs) && isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { $accept_langs = $_SERVER['HTTP_ACCEPT_LANGUAGE']; @@ -487,6 +602,8 @@ class Language return $accept_langs; } + $langs = []; + foreach (explode(',', $accept_langs) as $k => $pref) { // split $pref again by ';q=' // and decorate the language entries by inverted position @@ -504,4 +621,42 @@ class Language return $this->http_accept_language; } + /** + * Accessible wrapper to LanguageCodes + * + * @param string $code + * @param string $type + * @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 063d41d..9282070 100644 --- a/system/src/Grav/Common/Language/LanguageCodes.php +++ b/system/src/Grav/Common/Language/LanguageCodes.php @@ -1,15 +1,21 @@ [ 'name' => 'Afrikaans', 'nativeName' => 'Afrikaans' ], 'ak' => [ 'name' => 'Akan', 'nativeName' => 'Akan' ], // unverified native name @@ -86,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' ], @@ -149,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])) { @@ -167,6 +181,10 @@ class LanguageCodes return $code; } + /** + * @param string $code + * @return string + */ public static function getOrientation($code) { if (isset(static::$codes[$code])) { @@ -177,14 +195,19 @@ class LanguageCodes return 'ltr'; } + /** + * @param string $code + * @return bool + */ public static function isRtl($code) { - if (static::getOrientation($code) === 'rtl') { - return true; - } - return false; + return static::getOrientation($code) === 'rtl'; } + /** + * @param array $keys + * @return array + */ public static function getNames(array $keys) { $results = []; @@ -196,7 +219,12 @@ class LanguageCodes return $results; } - protected static function get($code, $type) + /** + * @param string $code + * @param string $type + * @return string|false + */ + public static function get($code, $type) { if (isset(static::$codes[$code][$type])) { return static::$codes[$code][$type]; @@ -204,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 b066ad3..bdc5bdc 100644 --- a/system/src/Grav/Common/Markdown/Parsedown.php +++ b/system/src/Grav/Common/Markdown/Parsedown.php @@ -1,13 +1,21 @@ init($page, $defaults); - } + if (!$excerpts || $excerpts instanceof PageInterface || null !== $defaults) { + // Deprecated in Grav 1.6.10 + if ($defaults) { + $defaults = ['markdown' => $defaults]; + } + $excerpts = new Excerpts($excerpts, $defaults); + user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use new ' . __CLASS__ . '(new ' . Excerpts::class . '($page, [\'markdown\' => $defaults])) instead.', E_USER_DEPRECATED); + } + $this->init($excerpts, $defaults); + } } diff --git a/system/src/Grav/Common/Markdown/ParsedownExtra.php b/system/src/Grav/Common/Markdown/ParsedownExtra.php index 481c52f..b8e760e 100644 --- a/system/src/Grav/Common/Markdown/ParsedownExtra.php +++ b/system/src/Grav/Common/Markdown/ParsedownExtra.php @@ -1,13 +1,22 @@ $defaults]; + } + $excerpts = new Excerpts($excerpts, $defaults); + user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use new ' . __CLASS__ . '(new ' . Excerpts::class . '($page, [\'markdown\' => $defaults])) instead.', E_USER_DEPRECATED); + } + parent::__construct(); - $this->init($page, $defaults); + $this->init($excerpts, $defaults); } } diff --git a/system/src/Grav/Common/Markdown/ParsedownGravTrait.php b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php index 59be69b..7c2b0d6 100644 --- a/system/src/Grav/Common/Markdown/ParsedownGravTrait.php +++ b/system/src/Grav/Common/Markdown/ParsedownGravTrait.php @@ -1,64 +1,96 @@ page = $page; - $this->BlockTypes['{'] [] = 'TwigTag'; - $this->special_chars = ['>' => 'gt', '<' => 'lt', '"' => 'quot']; - - if ($defaults === null) { - $defaults = Grav::instance()['config']->get('system.pages.markdown'); + if (!$excerpts || $excerpts instanceof PageInterface) { + // Deprecated in Grav 1.6.10 + if ($defaults) { + $defaults = ['markdown' => $defaults]; + } + $this->excerpts = new Excerpts($excerpts, $defaults); + user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use ->init(new ' . Excerpts::class . '($page, [\'markdown\' => $defaults])) instead.', E_USER_DEPRECATED); + } else { + $this->excerpts = $excerpts; } - $this->setBreaksEnabled($defaults['auto_line_breaks']); - $this->setUrlsLinked($defaults['auto_url_links']); - $this->setMarkupEscaped($defaults['escape_markup']); - $this->setSpecialChars($defaults['special_chars']); + $this->BlockTypes['{'][] = 'TwigTag'; + $this->special_chars = ['>' => 'gt', '<' => 'lt', '"' => 'quot']; - $grav->fireEvent('onMarkdownInitialized', new Event(['markdown' => $this])); + $defaults = $this->excerpts->getConfig(); + if (isset($defaults['markdown']['auto_line_breaks'])) { + $this->setBreaksEnabled($defaults['markdown']['auto_line_breaks']); + } + if (isset($defaults['markdown']['auto_url_links'])) { + $this->setUrlsLinked($defaults['markdown']['auto_url_links']); + } + if (isset($defaults['markdown']['escape_markup'])) { + $this->setMarkupEscaped($defaults['markdown']['escape_markup']); + } + if (isset($defaults['markdown']['special_chars'])) { + $this->setSpecialChars($defaults['markdown']['special_chars']); + } + + $this->excerpts->fireInitializedEvent($this); + } + + /** + * @return Excerpts + */ + public function getExcerpts() + { + return $this->excerpts; } /** * Be able to define a new Block type or override an existing one * - * @param $type - * @param $tag + * @param string $type + * @param string $tag * @param bool $continuable * @param bool $completable - * @param $index + * @param int|null $index + * @return void */ public function addBlockType($type, $tag, $continuable = false, $completable = false, $index = null) { @@ -87,9 +119,10 @@ trait ParsedownGravTrait /** * Be able to define a new Inline type or override an existing one * - * @param $type - * @param $tag - * @param $index + * @param string $type + * @param string $tag + * @param int|null $index + * @return void */ public function addInlineType($type, $tag, $index = null) { @@ -107,13 +140,13 @@ trait ParsedownGravTrait /** * Overrides the default behavior to allow for plugin-provided blocks to be continuable * - * @param $Type - * + * @param string $Type * @return bool */ protected function isBlockContinuable($Type) { - $continuable = \in_array($Type, $this->continuable_blocks) || method_exists($this, 'block' . $Type . 'Continue'); + $continuable = in_array($Type, $this->continuable_blocks, true) + || method_exists($this, 'block' . $Type . 'Continue'); return $continuable; } @@ -121,13 +154,13 @@ trait ParsedownGravTrait /** * Overrides the default behavior to allow for plugin-provided blocks to be completable * - * @param $Type - * + * @param string $Type * @return bool */ protected function isBlockCompletable($Type) { - $completable = \in_array($Type, $this->completable_blocks) || method_exists($this, 'block' . $Type . 'Complete'); + $completable = in_array($Type, $this->completable_blocks, true) + || method_exists($this, 'block' . $Type . 'Complete'); return $completable; } @@ -137,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) @@ -148,8 +180,7 @@ trait ParsedownGravTrait /** * Setter for special chars * - * @param $special_chars - * + * @param array $special_chars * @return $this */ public function setSpecialChars($special_chars) @@ -174,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'])) { @@ -193,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)) { @@ -209,19 +248,19 @@ trait ParsedownGravTrait // if this is an image process it if (isset($excerpt['element']['attributes']['src'])) { - $excerpt = Excerpts::processImageExcerpt($excerpt, $this->page); + $excerpt = $this->excerpts->processImageExcerpt($excerpt); } return $excerpt; } + /** + * @param array $excerpt + * @return array + */ protected function inlineLink($excerpt) { - if (isset($excerpt['type'])) { - $type = $excerpt['type']; - } else { - $type = 'link'; - } + $type = $excerpt['type'] ?? 'link'; // do some trickery to get around Parsedown requirement for valid URL if its Twig in there if (preg_match($this->twig_link_regex, $excerpt['text'], $matches)) { @@ -237,19 +276,25 @@ trait ParsedownGravTrait // if this is a link if (isset($excerpt['element']['attributes']['href'])) { - $excerpt = Excerpts::processLinkExcerpt($excerpt, $this->page, $type); + $excerpt = $this->excerpts->processLinkExcerpt($excerpt, $type); } return $excerpt; } - // For extending this class via plugins + /** + * 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 b8ff918..5faba82 100644 --- a/system/src/Grav/Common/Media/Traits/MediaTrait.php +++ b/system/src/Grav/Common/Media/Traits/MediaTrait.php @@ -1,15 +1,32 @@ 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() { - $cache = $this->getMediaCache(); - - if ($this->media === null) { - // Use cached media if possible. + $media = $this->media; + if (null === $media) { + $cache = $this->getMediaCache(); $cacheKey = md5('media' . $this->getCacheKey()); - if (!$media = $cache->fetch($cacheKey)) { - $media = new Media($this->getMediaFolder(), $this->getMediaOrder()); - $cache->save($cacheKey, $media); + + // Use cached media if possible. + $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) { $cache = $this->getMediaCache(); $cacheKey = md5('media' . $this->getCacheKey()); - $cache->save($cacheKey, $media); + $cache->set($cacheKey, $media); $this->media = $media; return $this; } + /** + * @return void + */ + protected function freeMedia() + { + $this->media = null; + } + /** * Clear media cache. + * + * @return void */ protected function clearMediaCache() { $cache = $this->getMediaCache(); $cacheKey = md5('media' . $this->getCacheKey()); $cache->delete($cacheKey); + + $this->freeMedia(); } /** - * @return Cache + * @return CacheInterface */ protected function getMediaCache() { - return Grav::instance()['cache']; + /** @var Cache $cache */ + $cache = Grav::instance()['cache']; + + return $cache->getSimpleCache(); } /** * @return string */ - abstract protected function getCacheKey(); + abstract protected function getCacheKey(): string; } 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 5113e81..139a58b 100644 --- a/system/src/Grav/Common/Page/Collection.php +++ b/system/src/Grav/Common/Page/Collection.php @@ -1,27 +1,38 @@ 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) { $this->items[$page->path()] = ['slug' => $page->slug()]; @@ -66,8 +89,8 @@ class Collection extends Iterator /** * Add a page with path and slug * - * @param $path - * @param $slug + * @param string $path + * @param string $slug * @return $this */ public function add($path, $slug) @@ -92,51 +115,40 @@ 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); } + return $this; } /** * 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. * - * @return Page + * @return PageInterface */ public function current() { @@ -160,20 +172,19 @@ 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) { - return !empty($this->items[$offset]) ? $this->pages->get($offset) : null; + return $this->pages->get($offset) ?: null; } /** * Split collection into array of smaller collections. * - * @param $size - * @return array|Collection[] + * @param int $size + * @return Collection[] */ public function batch($size) { @@ -190,20 +201,19 @@ class Collection extends Iterator /** * Remove item from the list. * - * @param Page|string|null $key - * + * @param PageInterface|string|null $key * @return $this - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public function remove($key = null) { - if ($key instanceof Page) { + if ($key instanceof PageInterface) { $key = $key->path(); - } elseif (is_null($key)) { - $key = key($this->items); + } elseif (null === $key) { + $key = (string)key($this->items); } if (!is_string($key)) { - throw new \InvalidArgumentException('Invalid argument $key.'); + throw new InvalidArgumentException('Invalid argument $key.'); } parent::remove($key); @@ -216,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) @@ -232,32 +241,22 @@ class Collection extends Iterator * Check to see if this item is the first in the collection. * * @param string $path - * - * @return boolean True if item is first. + * @return bool True if item is first. */ - public function isFirst($path) + public function isFirst($path): bool { - if ($this->items && $path == array_keys($this->items)[0]) { - return true; - } else { - return false; - } + return $this->items && $path === array_keys($this->items)[0]; } /** * Check to see if this item is the last in the collection. * * @param string $path - * - * @return boolean True if item is last. + * @return bool True if item is last. */ - public function isLast($path) + public function isLast($path): bool { - if ($this->items && $path == array_keys($this->items)[count($this->items) - 1]) { - return true; - } else { - return false; - } + return $this->items && $path === array_keys($this->items)[count($this->items) - 1]; } /** @@ -265,7 +264,7 @@ class Collection extends Iterator * * @param string $path * - * @return Page The previous item. + * @return PageInterface The previous item. */ public function prevSibling($path) { @@ -277,7 +276,7 @@ class Collection extends Iterator * * @param string $path * - * @return Page The next item. + * @return PageInterface The next item. */ public function nextSibling($path) { @@ -288,9 +287,8 @@ class Collection extends Iterator * Returns the adjacent sibling based on a direction. * * @param string $path - * @param integer $direction either -1 or +1 - * - * @return Page The sibling item. + * @param int $direction either -1 or +1 + * @return PageInterface|Collection The sibling item. */ public function adjacentSibling($path, $direction = 1) { @@ -304,52 +302,54 @@ class Collection extends Iterator } return $this; - } /** * Returns the item in the current position. * * @param string $path the path the item - * - * @return Integer 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)); + $pos = array_search($path, array_keys($this->items), true); + + return $pos !== false ? $pos : null; } /** * 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 + * an arbitrary datetime page field where start date and end date are optional + * Dates must be passed in as text that strtotime() can process * http://php.net/manual/en/function.strtotime.php * - * @param $startDate - * @param bool $endDate - * @param $field - * + * @param string|null $startDate + * @param string|null $endDate + * @param string|null $field * @return $this - * @throws \Exception + * @throws Exception */ - public function dateRange($startDate, $endDate = false, $field = false) + public function dateRange($startDate = null, $endDate = null, $field = null) { - $start = Utils::date2timestamp($startDate); - $end = $endDate ? Utils::date2timestamp($endDate) : false; + $start = $startDate ? Utils::date2timestamp($startDate) : null; + $end = $endDate ? Utils::date2timestamp($endDate) : null; $date_range = []; foreach ($this->items as $path => $slug) { $page = $this->pages->get($path); - if ($page !== null) { - $date = $field ? strtotime($page->value($field)) : $page->date(); + if (!$page) { + continue; + } - if ($date >= $start && (!$end || $date <= $end)) { - $date_range[$path] = $slug; - } + $date = $field ? strtotime($page->value($field)) : $page->date(); + + if ((!$start || $date >= $start) && (!$end || $date <= $end)) { + $date_range[$path] = $slug; } } $this->items = $date_range; + return $this; } @@ -394,17 +394,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; } } @@ -414,17 +414,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; } } @@ -433,6 +433,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 * @@ -518,8 +584,7 @@ class Collection extends Iterator /** * Creates new collection with only pages of the specified type * - * @param $type - * + * @param string $type * @return Collection The collection */ public function ofType($type) @@ -528,7 +593,7 @@ class Collection extends Iterator foreach ($this->items as $path => $slug) { $page = $this->pages->get($path); - if ($page !== null && $page->template() == $type) { + if ($page !== null && $page->template() === $type) { $items[$path] = $slug; } } @@ -541,8 +606,7 @@ class Collection extends Iterator /** * Creates new collection with only pages of one of the specified types * - * @param $types - * + * @param string[] $types * @return Collection The collection */ public function ofOneOfTheseTypes($types) @@ -551,7 +615,7 @@ class Collection extends Iterator foreach ($this->items as $path => $slug) { $page = $this->pages->get($path); - if ($page !== null && in_array($page->template(), $types)) { + if ($page !== null && in_array($page->template(), $types, true)) { $items[$path] = $slug; } } @@ -564,8 +628,7 @@ class Collection extends Iterator /** * Creates new collection with only pages of one of the specified access levels * - * @param $accessLevels - * + * @param array $accessLevels * @return Collection The collection */ public function ofOneOfTheseAccessLevels($accessLevels) @@ -583,12 +646,12 @@ class Collection extends Iterator foreach ($page->header()->access as $index => $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; } } @@ -598,11 +661,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; } } - } } @@ -615,7 +677,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 7df862b..71c7b16 100644 --- a/system/src/Grav/Common/Page/Header.php +++ b/system/src/Grav/Common/Page/Header.php @@ -1,17 +1,37 @@ 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..5002911 --- /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 new file mode 100644 index 0000000..5156ede --- /dev/null +++ b/system/src/Grav/Common/Page/Interfaces/PageContentInterface.php @@ -0,0 +1,257 @@ +true) for example + * + * @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); + + /** + * 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|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|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|null $var New identifier + * @return string The identifier + */ + public function id($var = null); + + /** + * Gets and sets the modified 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 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|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|null $var New string representation of a date + * @return int Unix timestamp representation of the date + */ + public function date($var = null); + + /** + * 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|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|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 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. + * + * @return bool True if its a page with a .md file associated + */ + public function isPage(); + + /** + * Returns whether or not this Page object is a directory or a page. + * + * @return bool True if its a directory + */ + public function isDir(); + + /** + * Returns whether the page exists in the filesystem. + * + * @return bool + */ + public function exists(); +} 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 14d6cdc..e5ea20c 100644 --- a/system/src/Grav/Common/Page/Interfaces/PageInterface.php +++ b/system/src/Grav/Common/Page/Interfaces/PageInterface.php @@ -1,9 +1,25 @@ save() in order to perform the move. + * + * @param PageInterface $parent New parent page. + * @return $this + */ + public function move(PageInterface $parent); + + /** + * 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 $parent New parent page. + * @return $this + */ + public function copy(PageInterface $parent); + + /** + * Get blueprints for the page. + * + * @return Blueprint + */ + public function blueprints(); + + /** + * Get the blueprint name for this page. Use the blueprint form field if set + * + * @return string + */ + public function blueprintName(); + + /** + * Validate page header. + * + * @throws Exception + */ + public function validate(); + + /** + * Filter page header from illegal contents. + */ + public function filter(); + + /** + * Get unknown header variables. + * + * @return array + */ + public function extra(); + + /** + * Convert page to an array. + * + * @return array + */ + public function toArray(); + + /** + * Convert page to YAML encoded string. + * + * @return string + */ + public function toYaml(); + + /** + * Convert page to JSON encoded string. + * + * @return string + */ + public function toJson(); + + /** + * Returns normalized list of name => form pairs. + * + * @return array + */ + public function forms(); + + /** + * @param array $new + */ + public function addForms(array $new); + + /** + * 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); + + /** + * Returns child page type. + * + * @return string + */ + public function childType(); + + /** + * 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); + + /** + * 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); + + /** + * Gets and sets the extension field. + * + * @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|null $var The new expires value. + * @return int The expires value + */ + public function expires($var = null); + + /** + * 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); + + /** + * @param bool|null $var + * @return bool + */ + public function ssl($var = null); + + /** + * Returns the state of the debugger override etting for this page + * + * @return bool + */ + public function debugger(); + + /** + * 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); + + /** + * 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; + + /** + * 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); + + /** + * Gets the relative path to the .md file + * + * @return string The relative file path + */ + public function filePathClean(); + + /** + * 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" + * @deprecated 1.6 + */ + public function orderDir($var = null); + + /** + * 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" + * @deprecated 1.6 + */ + public function orderBy($var = null); + + /** + * Gets the manual order set in the header. + * + * @param string|null $var supported options include "default", "title", "date", and "folder" + * @return array + * @deprecated 1.6 + */ + public function orderManual($var = null); + + /** + * 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 + * @deprecated 1.6 + */ + public function maxCount($var = null); + + /** + * 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); + + /** + * 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); + + /** + * Returns children of this page. + * + * @return PageCollectionInterface|Collection + */ + public function children(); + + /** + * 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(); + + /** + * 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(); + + /** + * Gets the previous sibling based on current position. + * + * @return PageInterface the previous Page item + */ + public function prevSibling(); + + /** + * Gets the next sibling based on current position. + * + * @return PageInterface the next Page item + */ + public function nextSibling(); + + /** + * 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); + + /** + * Helper method to return an ancestor page. + * + * @param bool|null $lookup Name of the parent folder + * @return PageInterface page you were looking for if it exists + */ + public function ancestor($lookup = null); + + /** + * 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 + */ + public function inherited($field); + + /** + * 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); + + /** + * Helper method to return a page. + * + * @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); + + /** + * Get a collection of pages in the current context. + * + * @param string|array $params + * @param bool $pagination + * @return Collection + * @throws InvalidArgumentException + */ + public function collection($params = 'content', $pagination = true); + + /** + * @param string|array $value + * @param bool $only_published + * @return PageCollectionInterface|Collection + */ + public function evaluate($value, $only_published = true); + + /** + * Returns whether or not the current folder exists + * + * @return bool + */ + public function folderExists(); + + /** + * Gets the Page Unmodified (original) version of the page. + * + * @return PageInterface The original version of the page. + */ + public function getOriginal(); + + /** + * Gets the action. + * + * @return string The Action string. + */ + public function getAction(); +} diff --git a/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php b/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php new file mode 100644 index 0000000..2900266 --- /dev/null +++ b/system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php @@ -0,0 +1,180 @@ +page = $page ?? Grav::instance()['page'] ?? null; + + // Add defaults to the configuration. + if (null === $config || !isset($config['markdown'], $config['images'])) { + $c = Grav::instance()['config']; + $config = $config ?? []; + $config += [ + 'markdown' => $c->get('system.pages.markdown', []), + 'images' => $c->get('system.images', []) + ]; + } + + $this->config = $config; + } + + /** + * @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(); + + $grav->fireEvent('onMarkdownInitialized', new Event(['markdown' => $markdown, 'page' => $this->page])); + } + + /** + * Process a Link excerpt + * + * @param array $excerpt + * @param string $type + * @return array + */ + 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. + if (isset($url_parts['query'])) { + $actions = array_reduce( + explode('&', $url_parts['query']), + static function ($carry, $item) { + $parts = explode('=', $item, 2); + $value = isset($parts[1]) ? rawurldecode($parts[1]) : true; + $carry[$parts[0]] = $value; + + return $carry; + }, + [] + ); + + // Valid attributes supported. + $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']); + } + + // 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)) { + // support both class and classes. + if ($attrib === 'classes') { + $attrib = 'class'; + } + $excerpt['element']['attributes'][$attrib] = str_replace(',', ' ', $value); + unset($actions[$key]); + } + } + } + + $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']); + } + + // Set path to / if not set. + if (empty($url_parts['path'])) { + $url_parts['path'] = ''; + } + + // If scheme isn't http(s).. + if (!empty($url_parts['scheme']) && !in_array($url_parts['scheme'], ['http', 'https'])) { + // Handle custom streams. + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + if ($type === 'link' && $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']); + } + + $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts); + + return $excerpt; + } + + // Handle paths and such. + $url_parts = Uri::convertUrl($this->page, $url_parts, $type); + + // Build the URL from the component parts and set it on the element. + $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts); + + return $excerpt; + } + + /** + * Process an image excerpt + * + * @param array $excerpt + * @return array + */ + public function processImageExcerpt(array $excerpt): array + { + $url = htmlspecialchars_decode(urldecode($excerpt['element']['attributes']['src'])); + $url_parts = $this->parseUrl($url); + + $media = null; + $filename = null; + + if (!empty($url_parts['stream'])) { + $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']) + && (empty($url_parts['scheme']) || in_array($url_parts['scheme'], ['http', 'https'], true)) + && (empty($url_parts['host']) || $url_parts['host'] === $grav['uri']->host()); + + if ($local_file) { + $filename = basename($url_parts['path']); + $folder = dirname($url_parts['path']); + + // Get the local path to page media if possible. + if ($this->page && $folder === $this->page->url(false, false, false)) { + // Get the media objects for this page. + $media = $this->page->getMedia(); + } else { + // see if this is an external page to this one + $base_url = rtrim($grav['base_url_relative'] . $pages->base(), '/'); + $page_route = '/' . ltrim(str_replace($base_url, '', $folder), '/'); + + $ext_page = $pages->find($page_route, true); + if ($ext_page) { + $media = $ext_page->getMedia(); + } else { + $grav->fireEvent('onMediaLocate', new Event(['route' => $page_route, 'media' => &$media])); + } + } + } + } + + // If there is a media file that matches the path referenced.. + if ($media && $filename && isset($media[$filename])) { + // Get the medium object. + /** @var Medium $medium */ + $medium = $media[$filename]; + + // Process operations + $medium = $this->processMediaActions($medium, $url_parts); + $element_excerpt = $excerpt['element']['attributes']; + + $alt = $element_excerpt['alt'] ?? ''; + $title = $element_excerpt['title'] ?? ''; + $class = $element_excerpt['class'] ?? ''; + $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); + } + + return $excerpt; + } + + /** + * Process media actions + * + * @param Medium $medium + * @param string|array $url + * @return Medium|Link + */ + public function processMediaActions($medium, $url) + { + $url_parts = is_string($url) ? $this->parseUrl($url) : $url; + $actions = []; + + + // if there is a query, then parse it and build action calls + if (isset($url_parts['query'])) { + $actions = array_reduce( + explode('&', $url_parts['query']), + static function ($carry, $item) { + $parts = explode('=', $item, 2); + $value = $parts[1] ?? null; + $carry[] = ['method' => $parts[0], 'params' => $value]; + + return $carry; + }, + [] + ); + } + + $defaults = $this->config['images']['defaults'] ?? []; + if (count($defaults)) { + foreach ($defaults as $method => $params) { + if (array_search($method, array_column($actions, 'method')) === false) { + $actions[] = [ + 'method' => $method, + 'params' => $params, + ]; + } + } + } + + // loop through actions for the image and call them + foreach ($actions as $action) { + $matches = []; + + if (preg_match('/\[(.*)\]/', $action['params'], $matches)) { + $args = [explode(',', $matches[1])]; + } else { + $args = explode(',', $action['params']); + } + + $medium = call_user_func_array([$medium, $action['method']], $args); + } + + if (isset($url_parts['fragment'])) { + $medium->urlHash($url_parts['fragment']); + } + + return $medium; + } + + /** + * Variation of parse_url() which works also with local streams. + * + * @param string $url + * @return array + */ + protected function parseUrl(string $url) + { + $url_parts = Utils::multibyteParseUrl($url); + + if (isset($url_parts['scheme'])) { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + // Special handling for the streams. + if ($locator->schemeExists($url_parts['scheme'])) { + if (isset($url_parts['host'])) { + // Merge host and path into a path. + $url_parts['path'] = $url_parts['host'] . (isset($url_parts['path']) ? '/' . $url_parts['path'] : ''); + unset($url_parts['host']); + } + + $url_parts['stream'] = true; + } + } + + return $url_parts; + } +} diff --git a/system/src/Grav/Common/Page/Media.php b/system/src/Grav/Common/Page/Media.php index 9cc1573..9201e66 100644 --- a/system/src/Grav/Common/Page/Media.php +++ b/system/src/Grav/Common/Page/Media.php @@ -1,39 +1,51 @@ path = $path; + $this->setPath($path); $this->media_order = $media_order; $this->__wakeup(); - $this->init(); + if ($load) { + $this->init(); + } } /** @@ -41,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) @@ -58,9 +69,8 @@ class Media extends AbstractMedia } /** - * @param mixed $offset - * - * @return mixed + * @param string $offset + * @return MediaObjectInterface|null */ public function offsetGet($offset) { @@ -69,56 +79,64 @@ class Media extends AbstractMedia /** * Initialize class. + * + * @return void */ protected function init() { - $config = Grav::instance()['config']; + /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; + $config = Grav::instance()['config']; $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->path)) { + if (!$path || !is_dir($path)) { return; } - $iterator = new \FilesystemIterator($this->path, \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' || $info->getFilename()[0] === '.') { + 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)) { + 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; @@ -134,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)) { @@ -148,8 +168,7 @@ class Media extends AbstractMedia if (file_exists($meta_path)) { $types['meta']['file'] = $meta_path; - } elseif ($file_path && $medium->get('mime') === 'image/jpeg' && empty($types['meta']) && $config->get('system.media.auto_metadata_exif') && $exif_reader) { - + } 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) { @@ -207,12 +226,11 @@ class Media extends AbstractMedia } /** - * Enable accessing the media path - * - * @return mixed + * @return string|null + * @deprecated 1.6 Use $this->getPath() instead. */ - public function path() + public function path(): ?string { - return $this->path; + return $this->getPath(); } } diff --git a/system/src/Grav/Common/Page/Medium/AbstractMedia.php b/system/src/Grav/Common/Page/Medium/AbstractMedia.php index 58f9886..db81ddd 100644 --- a/system/src/Grav/Common/Page/Medium/AbstractMedia.php +++ b/system/src/Grav/Common/Page/Medium/AbstractMedia.php @@ -1,35 +1,83 @@ path; + } + + /** + * @param string|null $path + * @return void + */ + public function setPath(?string $path): void + { + $this->path = $path; + } + /** * Get medium by filename. * * @param string $filename - * @return Medium|null + * @return MediaObjectInterface|null */ public function get($filename) { @@ -48,83 +96,93 @@ abstract class AbstractMedia extends Getters implements MediaCollectionInterface } /** - * @param mixed $offset + * Set file modification timestamps (query params) for all the media files. * - * @return mixed + * @param string|int|null $timestamp + * @return $this */ - public function offsetGet($offset) + public function setTimestamps($timestamp = null) { - $object = parent::offsetGet($offset); + foreach ($this->items as $instance) { + $instance->setTimestamp($timestamp); + } - // It would be nice if previous image modification would not affect the later ones. - //$object = $object ? clone($object) : null; - - return $object; + return $this; } /** * Get a list of all media. * - * @return array|MediaObjectInterface[] + * @return MediaObjectInterface[] */ public function all() { - $this->instances = $this->orderMedia($this->instances); + $this->items = $this->orderMedia($this->items); - return $this->instances; + return $this->items; } /** * Get a list of all image media. * - * @return array|MediaObjectInterface[] + * @return MediaObjectInterface[] */ public function images() { $this->images = $this->orderMedia($this->images); + return $this->images; } /** * Get a list of all video media. * - * @return array|MediaObjectInterface[] + * @return MediaObjectInterface[] */ public function videos() { $this->videos = $this->orderMedia($this->videos); + return $this->videos; } /** * Get a list of all audio media. * - * @return array|MediaObjectInterface[] + * @return MediaObjectInterface[] */ public function audios() { $this->audios = $this->orderMedia($this->audios); + return $this->audios; } /** * Get a list of all file media. * - * @return array|MediaObjectInterface[] + * @return MediaObjectInterface[] */ public function files() { $this->files = $this->orderMedia($this->files); + return $this->files; } /** * @param string $name - * @param MediaObjectInterface $file + * @param MediaObjectInterface|null $file + * @return void */ - protected function add($name, $file) + public function add($name, $file) { - $this->instances[$name] = $file; + if (null === $file) { + return; + } + + $this->offsetSet($name, $file); + switch ($file->type) { case 'image': $this->images[$name] = $file; @@ -140,19 +198,67 @@ abstract class AbstractMedia extends Getters implements MediaCollectionInterface } } + /** + * @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 * - * @param $media + * @param array $media * @return array */ protected function orderMedia($media) { if (null === $this->media_order) { - $page = Grav::instance()['pages']->get($this->path); - - 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)); + } } } @@ -165,6 +271,11 @@ abstract class AbstractMedia extends Getters implements MediaCollectionInterface return $media; } + protected function fileExists(string $filename, string $destination): bool + { + return file_exists("{$destination}/{$filename}"); + } + /** * Get filename, extension and meta part. * @@ -205,6 +316,28 @@ abstract class AbstractMedia extends Getters implements MediaCollectionInterface } } - 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 aae3597..f34f0a9 100644 --- a/system/src/Grav/Common/Page/Medium/AudioMedium.php +++ b/system/src/Grav/Common/Page/Medium/AudioMedium.php @@ -1,142 +1,24 @@ 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 $preload - * @return $this - */ - public function preload($preload) - { - $validPreloadAttrs = array('auto','metadata','none'); - - if (in_array($preload, $validPreloadAttrs)) - { - $this->attributes['preload'] = $preload; - } - return $this; - } - - /** - * Allows to set the controlsList behaviour - * Separate multiple values with a hyphen - * - * @param $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. @@ -147,7 +29,8 @@ 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 74adc08..4097c8f 100644 --- a/system/src/Grav/Common/Page/Medium/GlobalMedia.php +++ b/system/src/Grav/Common/Page/Medium/GlobalMedia.php @@ -1,21 +1,49 @@ 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) { @@ -57,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 85f2459..bd853bf 100644 --- a/system/src/Grav/Common/Page/Medium/ImageFile.php +++ b/system/src/Grav/Common/Page/Medium/ImageFile.php @@ -1,28 +1,51 @@ getAdapter()->deinit(); + $adapter = $this->adapter; + if ($adapter) { + $adapter->deinit(); + } } /** * Clear previously applied operations + * + * @return void */ public function clearOperations() { @@ -32,13 +55,13 @@ class ImageFile extends Image /** * This is the same as the Gregwar Image class except this one fires a Grav Event on creation of new cached file * - * @param string $type the image type - * @param int $quality the quality (for JPEG) - * @param bool $actual - * + * @param string $type the image type + * @param int $quality the quality (for JPEG) + * @param bool $actual + * @param array $extras * @return string */ - public function cacheFile($type = 'jpg', $quality = 80, $actual = false) + public function cacheFile($type = 'jpg', $quality = 80, $actual = false, $extras = []) { if ($type === 'guess') { $type = $this->guessType(); @@ -49,21 +72,19 @@ class ImageFile extends Image } // Computes the hash - $this->hash = $this->getHash($type, $quality); + $this->hash = $this->getHash($type, $quality, $extras); - // Generates the cache file - $cacheFile = ''; + /** @var Config $config */ + $config = Grav::instance()['config']; - if (!$this->prettyName || $this->prettyPrefix) { - $cacheFile .= $this->hash; - } + // Seo friendly image names + $seofriendly = $config->get('system.images.seofriendly', false); - if ($this->prettyPrefix) { - $cacheFile .= '-'; - } - - if ($this->prettyName) { - $cacheFile .= $this->prettyName; + if ($seofriendly) { + $mini_hash = substr($this->hash, 0, 4) . substr($this->hash, -4); + $cacheFile = "{$this->prettyName}-{$mini_hash}"; + } else { + $cacheFile = "{$this->hash}-{$this->prettyName}"; } $cacheFile .= '.' . $type; @@ -90,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) { @@ -98,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,4 +129,83 @@ class ImageFile extends Image return $this->getFilename($file); } + + /** + * Gets the hash. + * + * @param string $type + * @param int $quality + * @param array $extras + * @return string + */ + public function getHash($type = 'guess', $quality = 80, $extras = []) + { + if (null === $this->hash) { + $this->generateHash($type, $quality, $extras); + } + + return $this->hash; + } + + /** + * Generates the hash. + * + * @param string $type + * @param int $quality + * @param array $extras + */ + public function generateHash($type = 'guess', $quality = 80, $extras = []) + { + $inputInfos = $this->source->getInfos(); + + $data = [ + $inputInfos, + $this->serializeOperations(), + $type, + $quality, + $extras + ]; + + $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 72f23b4..60d4997 100644 --- a/system/src/Grav/Common/Page/Medium/ImageMedium.php +++ b/system/src/Grav/Common/Page/Medium/ImageMedium.php @@ -1,101 +1,71 @@ [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']; - if (filesize($this->get('filepath')) === 0) { + $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($this->get('filepath')); - $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', $path); - $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(); @@ -104,22 +74,71 @@ 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. * - * @param $filepath + * @param string $filepath * @return $this */ public function addMetaFile($filepath) @@ -132,14 +151,6 @@ class ImageMedium extends Medium return $this; } - /** - * Clear out the alternatives - */ - public function clearAlternatives() - { - $this->alternatives = []; - } - /** * Return PATH to image. * @@ -165,19 +176,21 @@ class ImageMedium extends Medium */ public function url($reset = true) { - /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - $image_path = $locator->findResource('cache://images', true); - $image_dir = $locator->findResource('cache://images', false); - $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)) { + $image_dir = $locator->findResource('cache://images', false); $output = '/' . $image_dir . preg_replace('|^' . preg_quote($image_path, '|') . '|', '', $output); } @@ -185,24 +198,9 @@ class ImageMedium extends Medium $this->reset(); } - return trim(Grav::instance()['base_url'] . '/' . ltrim($output . $this->querystring() . $this->urlHash(), '/'), '\\'); + 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. * @@ -228,110 +226,11 @@ class ImageMedium extends Medium return implode(', ', $srcset); } - /** - * Allows the ability to override the Inmage's Pretty name stored in cache - * - * @param $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=2500] - * @param int [$step=200] - * @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 * * @param array $attributes - * @param boolean $reset + * @param bool $reset * @return array */ public function sourceParsedownElement(array $attributes, $reset = true) @@ -344,39 +243,32 @@ 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->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]; } /** * Turn the current Medium into a Link * - * @param boolean $reset + * @param bool $reset * @param array $attributes - * @return Link + * @return MediaLinkInterface */ public function link($reset = true, array $attributes = []) { @@ -394,8 +286,8 @@ class ImageMedium extends Medium * * @param int $width * @param int $height - * @param boolean $reset - * @return Link + * @param bool $reset + * @return MediaLinkInterface */ public function lightbox($width = null, $height = null, $reset = true) { @@ -404,104 +296,44 @@ class ImageMedium extends Medium } if ($width && $height) { - $this->cropResize($width, $height); + $this->__call('cropResize', [$width, $height]); } return parent::lightbox($width, $height, $reset); } - /** - * Sets or gets the quality of the image - * - * @param int $quality 0-100 quality - * @return Medium - */ - public function quality($quality = null) + public function autoSizes($enabled = 'true') { - if ($quality) { - if (!$this->image) { - $this->image(); - } + $enabled = $enabled === 'true' ?: false; + $this->auto_sizes = $enabled; - $this->quality = $quality; - return $this; - } - - return $this->quality; + return $this; } - /** - * Sets image output format. - * - * @param string $format - * @return $this - */ - public function format($format) + public function aspectRatio($enabled = 'true') { - if (!$this->image) { - $this->image(); - } + $enabled = $enabled === 'true' ?: false; + $this->aspect_ratio = $enabled; + + return $this; + } + + public function retinaScale($scale = 1) + { + $this->retina_scale = intval($scale); - $this->format = $format; return $this; } /** - * Set or get sizes parameter for srcset media action + * Handle this commonly used variant * - * @param string $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 mixed $value A value or 'auto' or empty to use the width of the image * @return $this */ - public function width($value = 'auto') + public function cropZoom() { - if (!$value || $value === 'auto') - $this->attributes['width'] = $this->get('width'); - else - $this->attributes['width'] = $value; - return $this; - } + $this->__call('zoomCrop', func_get_args()); - /** - * 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') - { - if (!$value || $value === 'auto') - $this->attributes['height'] = $this->get('height'); - else - $this->attributes['height'] = $value; return $this; } @@ -514,11 +346,7 @@ class ImageMedium extends Medium */ public function __call($method, $args) { - if ($method === 'cropZoom') { - $method = 'zoomCrop'; - } - - if (!\in_array($method, self::$magic_actions, true)) { + if (!in_array($method, static::$magic_actions, true)) { return parent::__call($method, $args); } @@ -528,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); - } - - /** - * 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 15aac7b..d576875 100644 --- a/system/src/Grav/Common/Page/Medium/Link.php +++ b/system/src/Grav/Common/Page/Medium/Link.php @@ -1,43 +1,63 @@ attributes = $attributes; - $this->source = $medium->reset()->thumbnail('auto')->display('thumbnail'); - $this->source->linked = true; + + $source = $medium->reset()->thumbnail('auto')->display('thumbnail'); + if (!$source instanceof MediaObjectInterface) { + 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 boolean $reset + * @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) @@ -47,7 +67,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 ]; } @@ -61,10 +81,21 @@ 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.'); + } - // 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; + $object = call_user_func_array($callable, $args); + if (!$object instanceof MediaLinkInterface) { + // Don't start nesting links, if user has multiple link calls in his + // actions, we will drop the previous links. + return $this; + } + + $this->source = $object; + + return $object; } } diff --git a/system/src/Grav/Common/Page/Medium/Medium.php b/system/src/Grav/Common/Page/Medium/Medium.php index 0a4b9bf..87009ac 100644 --- a/system/src/Grav/Common/Page/Medium/Medium.php +++ b/system/src/Grav/Common/Page/Medium/Medium.php @@ -1,8 +1,9 @@ get('system.media.enable_media_timestamp', true)) { - $this->querystring('&' . Grav::instance()['cache']->getKey()); + $this->timestamp = Grav::instance()['cache']->getKey(); } $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; - } - - /** - * Returns an array containing just the metadata - * - * @return array - */ - public function metadata() - { - return $this->metadata; - } - /** * Add meta file for the medium. * - * @param $filepath + * @param string $filepath */ public function addMetaFile($filepath) { @@ -134,21 +74,15 @@ class Medium extends Data implements RenderableInterface, MediaObjectInterface } /** - * Add alternative Medium to this Medium. - * - * @param $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, + ]; } /** @@ -162,435 +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) { - if ($reset) { - $this->reset(); - } - - return str_replace(GRAV_ROOT, '', $this->get('filepath')); - } - - /** - * 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'] . '/' . ltrim($output . $this->querystring() . $this->urlHash(), '/'), '\\'); - } - - /** - * Get/set querystring for the file's url - * - * @param string $querystring - * @param boolean $withQuestionmark - * @return string - */ - public function querystring($querystring = null, $withQuestionmark = true) - { - if (!is_null($querystring)) { - $this->set('querystring', ltrim($querystring, '?&')); - - foreach ($this->alternatives as $alt) { - $alt->querystring($querystring, $withQuestionmark); - } - } - - $querystring = $this->get('querystring', ''); - - if ($withQuestionmark && !empty($querystring)) { - return '?' . $querystring; - } else { - return $querystring; - } - } - - /** - * Get/set hash for the file's url - * - * @param string $hash - * @param boolean $withHash - * @return string - */ - public function urlHash($hash = null, $withHash = true) - { - if ($hash) { - $this->set('urlHash', ltrim($hash, '#')); - } - - $hash = $this->get('urlHash', ''); - - if ($withHash && !empty($hash)) { - return '#' . $hash; - } else { - return $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 boolean $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; - } - - if ($reset) { - $this->reset(); - } - - $this->display('source'); - - return $element; - } - - /** - * Parsedown element for source display mode - * - * @param array $attributes - * @param boolean $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 boolean $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)) { - return $this; - } - - if ($this->thumbnailType !== $type) { - $this->_thumbnail = null; - } - - $this->thumbnailType = $type; - - return $this; - } - - - /** - * Turn the current Medium into a Link - * - * @param boolean $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 boolean $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(',', (array)$classes); - } - - return $this; + return $this->items; } - - /** - * Add an id to the element from Markdown or Twig - * Example: ![Example](myimg.png?id=primary-img) - * - * @param $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 b5b197e..7dee4ea 100644 --- a/system/src/Grav/Common/Page/Medium/MediumFactory.php +++ b/system/src/Grav/Common/Page/Medium/MediumFactory.php @@ -1,8 +1,9 @@ get("media.types." . strtolower($ext)); - if (!$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. - $params += $config->get('media.types.defaults'); + $params += (array)$config->get('media.types.defaults'); $params += [ 'type' => 'file', 'thumb' => 'media/thumb.png', @@ -66,6 +82,66 @@ class MediumFactory return static::fromArray($params); } + /** + * Create Medium from an uploaded file + * + * @param UploadedFileInterface $uploadedFile + * @param array $params + * @return Medium|null + */ + public static function fromUploadedFile(UploadedFileInterface $uploadedFile, array $params = []) + { + // 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'] ?? ''; + $basename = $parts['filename']; + $file = $uploadedFile->getTmpFile(); + $path = $file ? dirname($file) : ''; + + $config = Grav::instance()['config']; + + $media_params = $ext ? $config->get('media.types.' . strtolower($ext)) : null; + if (!is_array($media_params)) { + return null; + } + + $params += $media_params; + + // Add default settings for undefined variables. + $params += (array)$config->get('media.types.defaults'); + $params += [ + 'type' => 'file', + 'thumb' => 'media/thumb.png', + 'mime' => 'application/octet-stream', + 'filepath' => $file, + 'filename' => $filename, + 'basename' => $basename, + 'extension' => $ext, + 'path' => $path, + 'modified' => $file ? filemtime($file) : 0, + 'thumbnails' => [] + ]; + + $locator = Grav::instance()['locator']; + + $file = $locator->findResource("image://{$params['thumb']}"); + if ($file) { + $params['thumbnails']['default'] = $file; + } + + return static::fromArray($params); + } + /** * Create Medium from array of parameters * @@ -75,42 +151,36 @@ class MediumFactory */ public static function fromArray(array $items = [], Blueprint $blueprint = null) { - $type = isset($items['type']) ? $items['type'] : null; + $type = $items['type'] ?? null; switch ($type) { case 'image': return new ImageMedium($items, $blueprint); - break; case 'thumbnail': return new ThumbnailImageMedium($items, $blueprint); - break; case 'animated': case 'vector': return new StaticImageMedium($items, $blueprint); - break; case 'video': return new VideoMedium($items, $blueprint); - break; case 'audio': return new AudioMedium($items, $blueprint); - break; default: return new Medium($items, $blueprint); - break; } } /** * 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 aaf6fde..44ab952 100644 --- a/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php +++ b/system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php @@ -1,29 +1,33 @@ parsedownElement($title, $alt, $class, $id, $reset); if (!$this->parsedown) { - $this->parsedown = new Parsedown(null, null); + $this->parsedown = new Parsedown(new Excerpts()); } return $this->parsedown->elementToHtml($element); diff --git a/system/src/Grav/Common/Page/Medium/RenderableInterface.php b/system/src/Grav/Common/Page/Medium/RenderableInterface.php index 35b0378..ac72447 100644 --- a/system/src/Grav/Common/Page/Medium/RenderableInterface.php +++ b/system/src/Grav/Common/Page/Medium/RenderableInterface.php @@ -1,35 +1,41 @@ url($reset); + if (empty($attributes['src'])) { + $attributes['src'] = $this->url($reset); + } - return [ 'name' => 'img', 'attributes' => $attributes ]; + 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 4e7d619..d95b2d6 100644 --- a/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php +++ b/system/src/Grav/Common/Page/Medium/StaticResizeTrait.php @@ -1,27 +1,24 @@ 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 6f88a2c..a56a20d 100644 --- a/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php +++ b/system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php @@ -1,130 +1,21 @@ 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 boolean $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 boolean $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 boolean $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 23271bf..bfcf550 100644 --- a/system/src/Grav/Common/Page/Medium/VideoMedium.php +++ b/system/src/Grav/Common/Page/Medium/VideoMedium.php @@ -1,132 +1,24 @@ 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 $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'] = true; - } else { - unset($this->attributes['autoplay']); - } - - 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. @@ -137,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 a9a5f0b..a035613 100644 --- a/system/src/Grav/Common/Page/Page.php +++ b/system/src/Grav/Common/Page/Page.php @@ -1,8 +1,9 @@ initialized = true; + + // some extension logic + if (empty($extension)) { + $this->extension('.' . $file->getExtension()); + } else { + $this->extension($extension); + } + + // extract page language from page extension + $language = trim(basename($this->extension(), 'md'), '.') ?: null; + $this->language($language); + $this->hide_home_route = $config->get('system.home.hide_in_urls', false); $this->home_route = $this->adjustRouteCase($config->get('system.home.alias')); - $this->filePath($file->getPathName()); + $this->filePath($file->getPathname()); $this->modified($file->getMTime()); $this->id($this->modified() . md5($this->filePath())); $this->routable(true); @@ -142,20 +215,12 @@ class Page implements PageInterface $this->published(); $this->urlExtension(); - // some extension logic - if (empty($extension)) { - $this->extension('.' . $file->getExtension()); - } else { - $this->extension($extension); - } - - // extract page language from page extension - $language = trim(basename($this->extension(), 'md'), '.') ?: null; - $this->language($language); - return $this; } + /** + * @return void + */ protected function processFrontmatter() { // Quick check for twig output tags in frontmatter if enabled @@ -177,23 +242,39 @@ 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); - $route = isset($aPage->header()->routes['default']) ? $aPage->header()->routes['default'] : $aPage->rawRoute(); + // 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(); } @@ -202,7 +283,7 @@ class Page implements PageInterface continue; } - $translatedLanguages[$language] = $route; + $translatedLanguages[$languageCode] = $route; } } @@ -213,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) @@ -276,7 +345,6 @@ class Page implements PageInterface */ public function frontmatter($var = null) { - if ($var) { $this->frontmatter = (string)$var; @@ -299,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) @@ -331,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 @@ -340,9 +409,9 @@ class Page implements PageInterface $this->processFrontmatter(); } } - } catch (ParseException $e) { + } catch (Exception $e) { $file->raw(Grav::instance()['language']->translate([ - 'FRONTMATTER_ERROR_PAGE', + 'GRAV.FRONTMATTER_ERROR_PAGE', $this->slug(), $file->filename(), $e->getMessage(), @@ -354,16 +423,14 @@ class Page implements PageInterface } $var = true; } - - } if ($var) { if (isset($this->header->slug)) { - $this->slug(($this->header->slug)); + $this->slug($this->header->slug); } if (isset($this->header->routes)) { - $this->routes = (array)($this->header->routes); + $this->routes = (array)$this->header->routes; } if (isset($this->header->title)) { $this->title = trim($this->header->title); @@ -408,12 +475,10 @@ class Page implements PageInterface $this->markdown_extra = (bool)$this->header->markdown_extra; } if (isset($this->header->taxonomy)) { - foreach ((array)$this->header->taxonomy as $taxonomy => $taxitems) { - $this->taxonomy[$taxonomy] = (array)$taxitems; - } + $this->taxonomy($this->header->taxonomy); } if (isset($this->header->max_count)) { - $this->max_count = intval($this->header->max_count); + $this->max_count = (int)$this->header->max_count; } if (isset($this->header->process)) { foreach ((array)$this->header->process as $process => $status) { @@ -430,7 +495,7 @@ class Page implements PageInterface $this->unpublishDate($this->header->unpublish_date); } if (isset($this->header->expires)) { - $this->expires = intval($this->header->expires); + $this->expires = (int)$this->header->expires; } if (isset($this->header->cache_control)) { $this->cache_control = $this->header->cache_control; @@ -450,6 +515,9 @@ class Page implements PageInterface if (isset($this->header->debugger)) { $this->debugger = (bool)$this->header->debugger; } + if (isset($this->header->append_url_extension)) { + $this->url_extension = $this->header->append_url_extension; + } } return $this->header; @@ -458,8 +526,7 @@ class Page implements PageInterface /** * Get page language * - * @param $var - * + * @param string|null $var * @return mixed */ public function language($var = null) @@ -474,21 +541,75 @@ class Page implements PageInterface /** * Modify a header value directly * - * @param $key - * @param $value + * @param string $key + * @param mixed $value */ public function modifyHeader($key, $value) { $this->header->{$key} = $value; } + /** + * @return int + */ + public function httpResponseCode() + { + return (int)($this->header()->http_response_code ?? 200); + } + + /** + * @return array + */ + public function httpHeaders() + { + $headers = []; + + $grav = Grav::instance(); + $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; + } + + // Ask Grav to calculate ETag from the final content. + if ($this->eTag()) { + $headers['ETag'] = '1'; + } + + // Set Vary: Accept-Encoding header + if ($grav['config']->get('system.pages.vary_accept_encoding', false)) { + $headers['Vary'] = 'Accept-Encoding'; + } + + return $headers; + } + /** * Get the summary. * - * @param int $size Max summary size. - * - * @param boolean $textOnly Only count text size. - * + * @param int|null $size Max summary size. + * @param bool $textOnly Only count text size. * @return string */ public function summary($size = null, $textOnly = false) @@ -508,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'); } @@ -520,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); } @@ -548,12 +668,12 @@ class Page implements PageInterface return $content; } - return mb_strimwidth($content, 0, $size, '...', 'utf-8'); + return mb_strimwidth($content, 0, $size, '…', 'UTF-8'); } - $summary = Utils::truncateHTML($content, $size); + $summary = Utils::truncateHtml($content, $size); - return html_entity_decode($summary); + return html_entity_decode($summary, ENT_COMPAT | ENT_HTML401, 'UTF-8'); } /** @@ -569,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) @@ -599,7 +718,7 @@ class Page implements PageInterface // Load cached content /** @var Cache $cache */ $cache = Grav::instance()['cache']; - $cache_id = md5('page' . $this->id()); + $cache_id = md5('page' . $this->getCacheKey()); $content_obj = $cache->fetch($cache_id); if (is_array($content_obj)) { @@ -613,14 +732,20 @@ class Page implements PageInterface $process_markdown = $this->shouldProcess('markdown'); $process_twig = $this->shouldProcess('twig') || $this->modularTwig(); - $cache_enable = isset($this->header->cache_enable) ? $this->header->cache_enable : $config->get('system.cache.enabled', - true); - $twig_first = isset($this->header->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 = isset($this->header->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) { @@ -643,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; @@ -659,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 @@ -711,8 +834,8 @@ class Page implements PageInterface /** * Add an entry to the page's contentMeta array * - * @param $name - * @param $value + * @param string $name + * @param mixed $value */ public function addContentMeta($name, $value) { @@ -722,18 +845,14 @@ class Page implements PageInterface /** * Return the whole contentMeta array as it currently stands * - * @param null $name + * @param string|null $name * - * @return mixed + * @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; @@ -742,9 +861,9 @@ class Page implements PageInterface /** * Sets the whole content meta array in one shot * - * @param $content_meta + * @param array $content_meta * - * @return mixed + * @return array */ public function setContentMeta($content_meta) { @@ -753,51 +872,93 @@ 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']; - $defaults = (array)$config->get('system.pages.markdown'); + $markdownDefaults = (array)$config->get('system.pages.markdown'); if (isset($this->header()->markdown)) { - $defaults = array_merge($defaults, $this->header()->markdown); + $markdownDefaults = array_merge($markdownDefaults, $this->header()->markdown); } // pages.markdown_extra is deprecated, but still check it... - if (!isset($defaults['extra']) && (isset($this->markdown_extra) || $config->get('system.pages.markdown_extra') !== null)) { + if (!isset($markdownDefaults['extra']) && (isset($this->markdown_extra) || $config->get('system.pages.markdown_extra') !== null)) { user_error('Configuration option \'system.pages.markdown_extra\' is deprecated since Grav 1.5, use \'system.pages.markdown.extra\' instead', E_USER_DEPRECATED); - $defaults['extra'] = $this->markdown_extra ?: $config->get('system.pages.markdown_extra'); + $markdownDefaults['extra'] = $this->markdown_extra ?: $config->get('system.pages.markdown_extra'); } + $extra = $markdownDefaults['extra'] ?? false; + $defaults = [ + 'markdown' => $markdownDefaults, + 'images' => $config->get('system.images', []) + ]; + + $excerpts = new Excerpts($this, $defaults); + // Initialize the preferred variant of Parsedown - if ($defaults['extra']) { - $parsedown = new ParsedownExtra($this, $defaults); + if ($extra) { + $parsedown = new ParsedownExtra($excerpts); } else { - $parsedown = new Parsedown($this, $defaults); + $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->id()); + $cache_id = md5('page' . $this->getCacheKey()); $cache->save($cache_id, ['content' => $this->content, 'content_meta' => $this->content_meta]); } @@ -814,13 +975,12 @@ class Page implements PageInterface /** * Needed by the onPageContentProcessed event to set the raw page content * - * @param $content + * @param string $content + * @return void */ public function setRawContent($content) { - $content = $content === null ? '': $content; - - $this->content = $content; + $this->content = $content ?? ''; } /** @@ -828,7 +988,6 @@ class Page implements PageInterface * * @param string $name Variable name. * @param mixed $default - * * @return mixed */ public function value($name, $default = null) @@ -837,7 +996,9 @@ class Page implements PageInterface return $this->raw_content; } if ($name === 'route') { - return $this->parent()->rawRoute(); + $parent = $this->parent(); + + return $parent ? $parent->rawRoute() : ''; } if ($name === 'order') { $order = $this->order(); @@ -854,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(); @@ -906,9 +1070,8 @@ class Page implements PageInterface /** * Gets and Sets the Page raw content * - * @param null $var - * - * @return null + * @param string|null $var + * @return string */ public function rawMarkdown($var = null) { @@ -919,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. * @@ -936,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) { @@ -956,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; } @@ -964,11 +1144,10 @@ class Page implements PageInterface * * You need to call $this->save() in order to perform the move. * - * @param Page $parent New parent page. - * + * @param PageInterface $parent New parent page. * @return $this */ - public function move(Page $parent) + public function move(PageInterface $parent) { if (!$this->_original) { $clone = clone $this; @@ -978,10 +1157,10 @@ class Page implements PageInterface $this->_action = 'move'; if ($this->route() === $parent->route()) { - throw new Exception('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 Exception('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); @@ -1008,11 +1187,10 @@ class Page implements PageInterface * Returns a new Page object for the copy. * You need to call $this->save() in order to perform the move. * - * @param Page $parent New parent page. - * + * @param PageInterface $parent New parent page. * @return $this */ - public function copy($parent) + public function copy(PageInterface $parent) { $this->move($parent); $this->_action = 'copy'; @@ -1064,6 +1242,7 @@ class Page implements PageInterface /** * Validate page header. * + * @return void * @throws Exception */ public function validate() @@ -1074,6 +1253,8 @@ class Page implements PageInterface /** * Filter page header from illegal contents. + * + * @return void */ public function filter() { @@ -1132,7 +1313,7 @@ class Page implements PageInterface /** * @return string */ - protected function getCacheKey() + public function getCacheKey(): string { return $this->id(); } @@ -1140,8 +1321,7 @@ class Page implements PageInterface /** * 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) @@ -1150,7 +1330,10 @@ class Page implements PageInterface $this->setMedia($var); } - return $this->getMedia(); + /** @var Media $media */ + $media = $this->getMedia(); + + return $media; } /** @@ -1178,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) @@ -1188,7 +1370,7 @@ class Page implements PageInterface $this->name = $var; } - return empty($this->name) ? 'default.md' : $this->name; + return $this->name ?: 'default.md'; } /** @@ -1205,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) @@ -1215,28 +1396,27 @@ 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; + if (null !== $var) { + $this->template_format = is_string($var) ? $var : null; } - if (empty($this->template_format)) { - $this->template_format = Grav::instance()['uri']->extension('html'); + if (!isset($this->template_format)) { + $this->template_format = ltrim($this->header->append_url_extension ?? Utils::getPageFormat(), '.'); } return $this->template_format; @@ -1245,9 +1425,8 @@ class Page implements PageInterface /** * Gets and sets the extension field. * - * @param null $var - * - * @return null|string + * @param string|null $var + * @return string */ public function extension($var = null) { @@ -1274,9 +1453,8 @@ class Page implements PageInterface } // if not set in the page get the value from system config - if (empty($this->url_extension)) { - $this->url_extension = trim(isset($this->header->append_url_extension) ? $this->header->append_url_extension : Grav::instance()['config']->get('system.pages.append_url_extension', - false)); + if (null === $this->url_extension) { + $this->url_extension = Grav::instance()['config']->get('system.pages.append_url_extension', ''); } return $this->url_extension; @@ -1285,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) @@ -1295,15 +1472,15 @@ class Page implements PageInterface $this->expires = $var; } - return !isset($this->expires) ? Grav::instance()['config']->get('system.pages.expires') : $this->expires; + return $this->expires ?? 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 null $var - * @return null + * @param string|null $var + * @return string|null */ public function cacheControl($var = null) { @@ -1311,14 +1488,13 @@ class Page implements PageInterface $this->cache_control = $var; } - return !isset($this->cache_control) ? Grav::instance()['config']->get('system.pages.cache_control') : $this->cache_control; + return $this->cache_control ?? Grav::instance()['config']->get('system.pages.cache_control'); } /** * 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) @@ -1337,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) @@ -1356,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) @@ -1382,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) @@ -1403,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) @@ -1419,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) @@ -1437,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) @@ -1450,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) { @@ -1463,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) @@ -1477,25 +1650,20 @@ 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() { - if (isset($this->debugger) && $this->debugger === false) { - return false; - } - - return true; + return !(isset($this->debugger) && $this->debugger === false); } /** * 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) @@ -1506,18 +1674,23 @@ 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)) { + if (isset($this->header->metadata) && is_array($this->header->metadata)) { // Merge any site.metadata settings in with page metadata $metadata = array_merge($metadata, $this->header->metadata); } @@ -1534,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)) { + 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; @@ -1571,12 +1744,19 @@ class Page implements PageInterface return $this->metadata; } + /** + * Reset the metadata and pull from header again + */ + public function resetMetadata() + { + $this->metadata = null; + } + /** * 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) @@ -1589,21 +1769,19 @@ class Page implements PageInterface $this->slug = $this->adjustRouteCase(preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder)) ?: null; } - return $this->slug; } /** * 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) { if ($var !== null) { - $order = !empty($var) ? sprintf('%02d.', (int)$var) : ''; + $order = $var ? sprintf('%02d.', (int)$var) : ''; $this->folder($order . preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder)); return $order; @@ -1611,14 +1789,13 @@ class Page implements PageInterface preg_match(PAGE_ORDER_PREFIX_REGEX, $this->folder, $order); - return isset($order[0]) ? $order[0] : false; + return $order[0] ?? false; } /** * Gets the URL for a page - alias of url(). * * @param bool $include_host - * * @return string the permalink */ public function link($include_host = false) @@ -1639,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) @@ -1654,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) @@ -1672,7 +1847,7 @@ class Page implements PageInterface /** @var Config $config */ $config = $grav['config']; - // get base route (multisite base and language) + // get base route (multi-site base and language) $route = $include_base ? $pages->baseRoute() : ''; // add full route if configured to do so @@ -1692,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); } @@ -1704,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) { @@ -1745,15 +1914,13 @@ class Page implements PageInterface */ public function unsetRouteSlug() { - unset($this->route); - unset($this->slug); + unset($this->route, $this->slug); } /** * Gets and Sets the page raw route * - * @param null $var - * + * @param string|null $var * @return null|string */ public function rawRoute($var = null) @@ -1763,7 +1930,8 @@ class Page implements PageInterface } if (empty($this->raw_route)) { - $baseRoute = $this->parent ? (string)$this->parent()->rawRoute() : null; + $parent = $this->parent(); + $baseRoute = $parent ? (string)$parent->rawRoute() : null; $slug = $this->adjustRouteCase(preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder)); @@ -1776,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) @@ -1797,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) @@ -1817,12 +1983,15 @@ 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) { + if (null === $this->id) { + // We need to set unique id to avoid potential cache conflicts between pages. + $var = time() . md5($this->filePath()); + } if ($var !== null) { // store unique per language $active_lang = Grav::instance()['language']->getLanguage() ?: ''; @@ -1836,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) @@ -1852,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) { @@ -1862,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 boolean $var show etag header - * - * @return boolean 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; @@ -1881,15 +2047,14 @@ 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 boolean $var show last_modified header - * - * @return boolean show last_modified header + * @param bool|null $var show last_modified header + * @return bool show last_modified header */ public function lastModified($var = null) { @@ -1906,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) @@ -1918,10 +2082,10 @@ class Page implements PageInterface // Folder of the page. $this->folder = basename(dirname($var)); // Path to the page. - $this->path = dirname(dirname($var)); + $this->path = dirname($var, 2); } - return $this->path . '/' . $this->folder . '/' . ($this->name ?: ''); + return rtrim($this->path . '/' . $this->folder . '/' . ($this->name() ?: ''), '/'); } /** @@ -1931,27 +2095,24 @@ class Page implements PageInterface */ public function filePathClean() { - $path = str_replace(ROOT_DIR, '', $this->filePath()); - - return $path; + return str_replace(GRAV_ROOT . DS, '', $this->filePath()); } /** * Returns the clean path to the page file + * + * @return string */ public function relativePagePath() { - $path = str_replace('/' . $this->name(), '', $this->filePathClean()); - - return $path; + return str_replace('/' . $this->name(), '', $this->filePathClean()); } /** * 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) @@ -1969,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) @@ -1985,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) @@ -2006,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) @@ -2022,15 +2180,18 @@ 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 */ public function orderDir($var = null) { + //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + if ($var !== null) { $this->order_dir = $var; } + if (empty($this->order_dir)) { $this->order_dir = 'asc'; } @@ -2046,12 +2207,14 @@ 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 */ public function orderBy($var = null) { + //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + if ($var !== null) { $this->order_by = $var; } @@ -2062,12 +2225,14 @@ 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 */ public function orderManual($var = null) { + //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + if ($var !== null) { $this->order_manual = $var; } @@ -2079,12 +2244,14 @@ 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 */ public function maxCount($var = null) { + //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED); + if ($var !== null) { $this->max_count = (int)$var; } @@ -2100,13 +2267,20 @@ 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) { + $value = (array) $value; + }); + // make sure all values are strings + array_walk_recursive($var, function (&$value) { + $value = (string) $value; + }); $this->taxonomy = $var; } @@ -2116,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); } @@ -2129,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) @@ -2146,29 +2321,27 @@ 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) { - return isset($this->process[$process]) ? (bool)$this->process[$process] : false; + return (bool)($this->process[$process] ?? false); } /** * Gets and Sets the parent object for this page * - * @param Page $var the parent page object - * - * @return Page|null the parent page object if it exists. + * @param PageInterface|null $var the parent page object + * @return PageInterface|null the parent page object if it exists. */ - public function parent(Page $var = null) + public function parent(PageInterface $var = null) { if ($var) { $this->parent = $var->path(); @@ -2183,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 Page|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(); @@ -2210,7 +2379,7 @@ class Page implements PageInterface /** * Returns children of this page. * - * @return \Grav\Common\Page\Collection + * @return PageCollectionInterface|Collection */ public function children() { @@ -2224,11 +2393,12 @@ class Page implements PageInterface /** * Check to see if this item is the first in an array of sub-pages. * - * @return boolean True if item is first. + * @return bool True if item is first. */ public function isFirst() { - $collection = $this->parent()->collection('content', false); + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; if ($collection instanceof Collection) { return $collection->isFirst($this->path()); } @@ -2239,11 +2409,12 @@ class Page implements PageInterface /** * Check to see if this item is the last in an array of sub-pages. * - * @return boolean True if item is last + * @return bool True if item is last */ public function isLast() { - $collection = $this->parent()->collection('content', false); + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; if ($collection instanceof Collection) { return $collection->isLast($this->path()); } @@ -2254,7 +2425,7 @@ class Page implements PageInterface /** * Gets the previous sibling based on current position. * - * @return Page the previous Page item + * @return PageInterface the previous Page item */ public function prevSibling() { @@ -2264,7 +2435,7 @@ class Page implements PageInterface /** * Gets the next sibling based on current position. * - * @return Page the next Page item + * @return PageInterface the next Page item */ public function nextSibling() { @@ -2274,13 +2445,13 @@ class Page implements PageInterface /** * Returns the adjacent sibling based on a direction. * - * @param integer $direction either -1 or +1 - * - * @return Page|bool the sibling page + * @param int $direction either -1 or +1 + * @return PageInterface|false the sibling page */ public function adjacentSibling($direction = 1) { - $collection = $this->parent()->collection('content', false); + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; if ($collection instanceof Collection) { return $collection->adjacentSibling($this->path(), $direction); } @@ -2291,18 +2462,17 @@ class Page implements PageInterface /** * Returns the item in the current position. * - * @param string $path the path the item - * - * @return Integer the index of the current page. + * @return int|null The index of the current page. */ public function currentPosition() { - $collection = $this->parent()->collection('content', false); + $parent = $this->parent(); + $collection = $parent ? $parent->collection('content', false) : null; if ($collection instanceof Collection) { return $collection->currentPosition($this->path()); } - return true; + return 1; } /** @@ -2315,14 +2485,7 @@ class Page implements PageInterface $uri_path = rtrim(urldecode(Grav::instance()['uri']->path()), '/') ?: '/'; $routes = Grav::instance()['pages']->routes(); - if (isset($routes[$uri_path])) { - if ($routes[$uri_path] === $this->path()) { - return true; - } - - } - - return false; + return isset($routes[$uri_path]) && $routes[$uri_path] === $this->path(); } /** @@ -2333,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 Page $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(); } } @@ -2362,9 +2527,8 @@ class Page implements PageInterface public function home() { $home = Grav::instance()['config']->get('system.home.alias'); - $is_home = ($this->route() === $home || $this->rawRoute() === $home); - return $is_home; + return $this->route() === $home || $this->rawRoute() === $home; } /** @@ -2374,20 +2538,14 @@ class Page implements PageInterface */ public function root() { - if (!$this->parent && !$this->name && !$this->visible) { - return true; - } - - return false; + return !$this->parent && !$this->name && !$this->visible; } /** * Helper method to return an ancestor page. * - * @param string $url The url of the page - * @param bool $lookup Name of the parent folder - * - * @return \Grav\Common\Page\Page page you were looking for if it exists + * @param bool|null $lookup Name of the parent folder + * @return PageInterface page you were looking for if it exists */ public function ancestor($lookup = null) { @@ -2402,12 +2560,11 @@ class Page implements PageInterface * page object is returned. * * @param string $field Name of the parent folder - * - * @return Page + * @return PageInterface */ public function inherited($field) { - list($inherited, $currentParams) = $this->getInheritedParams($field); + [$inherited, $currentParams] = $this->getInheritedParams($field); $this->modifyHeader($field, $currentParams); @@ -2424,7 +2581,7 @@ class Page implements PageInterface */ public function inheritedField($field) { - list($inherited, $currentParams) = $this->getInheritedParams($field); + [$inherited, $currentParams] = $this->getInheritedParams($field); return $currentParams; } @@ -2433,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) @@ -2442,7 +2598,7 @@ class Page implements PageInterface /** @var Pages $pages */ $inherited = $pages->inherited($this->route, $field); - $inheritedParams = (array)$inherited->value('header.' . $field); + $inheritedParams = $inherited ? (array)$inherited->value('header.' . $field) : []; $currentParams = (array)$this->value('header.' . $field); if ($inheritedParams && is_array($inheritedParams)) { $currentParams = array_replace_recursive($inheritedParams, $currentParams); @@ -2457,7 +2613,7 @@ class Page implements PageInterface * @param string $url the url of the page * @param bool $all * - * @return \Grav\Common\Page\Page page you were looking for if it exists + * @return PageInterface page you were looking for if it exists */ public function find($url, $all = false) { @@ -2471,334 +2627,54 @@ class Page implements PageInterface * Get a collection of pages in the current context. * * @param string|array $params - * @param boolean $pagination + * @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 = isset($params['url_taxonomy_filters']) ? $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]) - ) { - $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) { - if (isset($params['filter'][$type]) && isset($params['filter']['non-'.$type])) { - if ($params['filter'][$type] && $params['filter']['non-'.$type]) { - unset ($params['filter'][$type]); - unset ($params['filter']['non-'.$type]); - } - - } - } - - 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 = isset($params['dateRange']['start']) ? $params['dateRange']['start'] : 0; - $end = isset($params['dateRange']['end']) ? $params['dateRange']['end'] : false; - $field = isset($params['dateRange']['field']) ? $params['dateRange']['field'] : false; - $collection->dateRange($start, $end, $field); - } - - if (isset($params['order'])) { - $by = isset($params['order']['by']) ? $params['order']['by'] : 'default'; - $dir = isset($params['order']['dir']) ? $params['order']['dir'] : 'asc'; - $custom = isset($params['order']['custom']) ? $params['order']['custom'] : null; - $sort_flags = isset($params['order']['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()['grav']; - - // 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 = isset($params['limit']) ? $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 - * @internal + * @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)->toArray(); - } else { - $result = $result + $this->evaluate([$key => $val])->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)->nonModular(); - 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); } /** @@ -2825,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. * @@ -2851,7 +2735,6 @@ class Page implements PageInterface * Cleans the path. * * @param string $path the path - * * @return string the path */ protected function cleanPath($path) @@ -2867,7 +2750,7 @@ class Page implements PageInterface /** * Reorders all siblings according to a defined order * - * @param $new_order + * @param array|null $new_order */ protected function doReorder($new_order) { @@ -2880,28 +2763,32 @@ class Page implements PageInterface $this->_original->path($this->path()); - $siblings = $this->parent()->children(); - $siblings->order('slug', 'asc', $new_order); + $parent = $this->parent(); + $siblings = $parent ? $parent->children() : null; - $counter = 0; + if ($siblings) { + $siblings->order('slug', 'asc', $new_order); - // Reorder all moved pages. - foreach ($siblings as $slug => $page) { - $order = (int)trim($page->order(), '.'); - $counter++; + $counter = 0; - if ($order) { - if ($page->path() === $this->path() && $this->folderExists()) { - // Handle current page; we do want to change ordering number, but nothing else. - $this->order($counter); - $this->save(false); - } else { - // Handle all the other pages. - $page = $pages->get($page->path()); - if ($page && $page->folderExists() && !$page->_action) { - $page = $page->move($this->parent()); - $page->order($counter); - $page->save(false); + // Reorder all moved pages. + foreach ($siblings as $slug => $page) { + $order = (int)trim($page->order(), '.'); + $counter++; + + if ($order) { + if ($page->path() === $this->path() && $this->folderExists()) { + // Handle current page; we do want to change ordering number, but nothing else. + $this->order($counter); + $this->save(false); + } else { + // Handle all the other pages. + $page = $pages->get($page->path()); + if ($page && $page->folderExists() && !$page->_action) { + $page = $page->move($this->parent()); + $page->order($counter); + $page->save(false); + } } } } @@ -2912,7 +2799,7 @@ class Page implements PageInterface * Moves or copies the page in filesystem. * * @internal - * + * @return void * @throws Exception */ protected function doRelocation() @@ -2935,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 @@ -2959,36 +2848,34 @@ class Page implements PageInterface } } + /** + * @param string $route + * @return string + */ protected function adjustRouteCase($route) { $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls'); - if ($case_insensitive) { - return mb_strtolower($route); - } else { - return $route; - } + return $case_insensitive ? mb_strtolower($route) : $route; } /** * Gets the Page Unmodified (original) version of the page. * - * @return Page - * The original version of the page. + * @return PageInterface The original version of the page. */ 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 0c527d1..97f6e18 100644 --- a/system/src/Grav/Common/Page/Pages.php +++ b/system/src/Grav/Common/Page/Pages.php @@ -1,125 +1,154 @@ */ + 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 Types - */ - static protected $types; - - /** - * @var string - */ - static protected $home_route; - + /** @var string */ + protected $check_method; + /** @var string */ protected $pages_cache_id; + /** @var bool */ + protected $initialized = false; + /** @var string */ + protected $active_lang; + /** @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 = []; } @@ -130,13 +159,12 @@ 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) { - $key = $lang ?: 'default'; + $key = $lang ?: $this->active_lang ?: 'default'; if (!isset($this->baseRoute[$key])) { /** @var Language $language */ @@ -156,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) @@ -173,14 +200,19 @@ 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) { - $type = $absolute === null ? 'base_url' : ($absolute ? 'base_url_absolute' : 'base_url_relative'); + if ($absolute === null) { + $type = 'base_url'; + } elseif ($absolute) { + $type = 'base_url_absolute'; + } else { + $type = 'base_url_relative'; + } return $this->grav[$type] . $this->baseRoute($lang); } @@ -189,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) @@ -204,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) @@ -219,27 +249,78 @@ class Pages } /** - * Class initialization. Must be called before using this class. + * @param string $method + * @return void */ - public function init() + public function setCheckMethod($method): void + { + $this->check_method = strtolower($method); + } + + /** + * @return void + */ + public function register(): void { $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'); + $type = $config->get('system.pages.type'); + if ($type === 'flex') { + $this->initFlexPages(); + } + } + + /** + * Reset pages (used in search indexing etc). + * + * @return void + */ + public function reset() + { + $this->initialized = false; + + $this->init(); + } + + /** + * Class initialization. Must be called before using this class. + */ + public function init(): void + { + if ($this->initialized) { + return; + } + + $config = $this->grav['config']; + $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 = []; + if (!$this->check_method) { + $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) @@ -254,11 +335,19 @@ class Pages /** * Returns a list of all pages. * - * @return array|Page[] + * @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; } /** @@ -274,33 +363,340 @@ class Pages /** * Adds a page and assigns a route to it. * - * @param Page $page Page to be added. - * @param string $route Optional route (uses route from the object if not set). + * @param PageInterface $page Page to be added. + * @param string|null $route Optional route (uses route from the object if not set). */ - public function addPage(Page $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'] ?? null; + $end = $params['dateRange']['end'] ?? null; + $field = $params['dateRange']['field'] ?? null; + $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 Page $page - * @param string $order_by - * @param string $order_dir - * + * @param PageInterface $page + * @param string|null $order_by + * @param string|null $order_dir * @return array */ - public function sort(Page $page, $order_by = null, $order_dir = null, $sort_flags = null) + public function sort(PageInterface $page, $order_by = null, $order_dir = null, $sort_flags = null) { if ($order_by === null) { $order_by = $page->orderBy(); @@ -310,7 +706,11 @@ class Pages } $path = $page->path(); - $children = isset($this->children[$path]) ? $this->children[$path] : []; + if (null === $path) { + return []; + } + + $children = $this->children[$path] ?? []; if (!$children) { return $children; @@ -331,10 +731,10 @@ class Pages /** * @param Collection $collection - * @param $orderBy + * @param string $orderBy * @param string $orderDir - * @param null $orderManual - * + * @param array|null $orderManual + * @param int|null $sort_flags * @return array * @internal */ @@ -357,32 +757,75 @@ class Pages } return $sort; - } /** * Get a page instance. * * @param string $path The filesystem full path of the page - * - * @return Page - * @throws \Exception + * @return PageInterface|null + * @throws RuntimeException */ public function get($path) { - return isset($this->instances[(string)$path]) ? $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) { - $children = isset($this->children[(string)$path]) ? $this->children[(string)$path] : []; + $children = $this->children[(string)$path] ?? []; return new Collection($children, [], $this); } @@ -391,20 +834,21 @@ 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 - * - * @return Page|null + * @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; } - if ($page && !$page->parent()->root()) { - return $this->ancestor($page->parent()->route(), $path); + + $parent = $page ? $page->parent() : null; + if ($parent && !$parent->root()) { + return $this->ancestor($parent->route(), $path); } } @@ -415,21 +859,20 @@ 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 - * - * @return Page|null + * @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->find($route, true); - $page = $this->dispatch($route, true); - - if ($page && $page->parent()->value('header.' . $field) !== null) { - return $page->parent(); + $parent = $page ? $page->parent() : null; + if ($parent && $parent->value('header.' . $field) !== null) { + return $parent; } - if ($page && !$page->parent()->root()) { - return $this->inherited($page->parent()->route(), $field); + if ($parent && !$parent->root()) { + return $this->inherited($parent->route(), $field); } } @@ -437,97 +880,146 @@ class Pages } /** - * alias method to return find a page. + * Find a page based on route. * - * @param string $route The relative URL of the page - * @param bool $all - * - * @return Page|null + * @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 - * @return Page|null - * @throws \Exception + * @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 */ 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()); } } } @@ -538,20 +1030,27 @@ class Pages /** * Get root page. * - * @return 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) @@ -562,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; @@ -577,15 +1076,14 @@ class Pages /** * Get all pages * - * @param \Grav\Common\Page\Page $current - * - * @return \Grav\Common\Page\Collection + * @param PageInterface|null $current + * @return Collection */ - public function all(Page $current = null) + public function all(PageInterface $current = null) { $all = new Collection(); - /** @var Page $current */ + /** @var PageInterface $current */ $current = $current ?: $this->root(); if (!$current->root()) { @@ -615,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) @@ -638,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 Page $current + * @param PageInterface|null $current * @param int $level * @param bool $rawRoutes - * * @param bool $showAll * @param bool $showFullpath * @param bool $showSlug @@ -658,11 +1153,11 @@ class Pages * @param bool $limitLevels * @return array */ - public function getList(Page $current = null, $level = 0, $rawRoutes = false, $showAll = true, $showFullpath = false, $showSlug = false, $showModular = false, $limitLevels = false) + public function getList(PageInterface $current = null, $level = 0, $rawRoutes = false, $showAll = true, $showFullpath = false, $showSlug = false, $showModular = false, $limitLevels = false) { if (!$current) { if ($level) { - throw new \RuntimeException('Internal error'); + throw new RuntimeException('Internal error'); } $current = $this->root(); @@ -678,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)); } } @@ -709,24 +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')) { @@ -735,19 +1240,32 @@ 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 ($types as $type => $paths) { + foreach ($paths as $k => $path) { + if (strpos($path, 'blueprints://') === 0) { + unset($paths[$k]); + } + } + if ($paths) { + $locator->addPath('blueprints', "pages/$type.yaml", $paths); + } + } + + self::$types = $types; } return self::$types; @@ -780,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 Page $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 []; @@ -810,20 +1332,19 @@ class Pages { $accessLevels = []; foreach ($this->all() as $page) { - if (isset($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)) { foreach ($accessLevel as $innerIndex => $innerAccessLevel) { - array_push($accessLevels, $innerIndex); + $accessLevels[] = $innerIndex; } } else { - array_push($accessLevels, $index); + $accessLevels[] = $index; } } } else { - - array_push($accessLevels, $page->header()->access); + $accessLevels[] = $page->header()->access; } } } @@ -843,8 +1364,6 @@ class Pages return self::getParents($rawRoutes); } - - /** * Gets the home route * @@ -878,7 +1397,6 @@ class Pages } catch (ErrorException $e) { $home = $home_aliases[$default]; } - } } @@ -890,41 +1408,251 @@ 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 (strtolower($config->get('system.cache.check.method', 'file'))) { + switch ($this->check_method) { case 'none': case 'off': $hash = 0; @@ -941,31 +1669,35 @@ class Pages $this->pages_cache_id = md5($pages_dir . $hash . $language->getActive() . $config->checksum()); - list($this->instances, $this->routes, $this->children, $taxonomy_map, $this->sort) = $cache->fetch($this->pages_cache_id); - if (!$this->instances) { - $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..'); + /** @var Cache $cache */ + $cache = $this->grav['cache']; + $cached = $cache->fetch($this->pages_cache_id); + if ($cached && $this->getVersion() === $cached[0]) { + [, $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort] = $cached; - // recurse pages and cache result - $this->resetPages($pages_dir); - - } else { - // If pages was found in cache, set the taxonomy - $this->grav['debugger']->addMessage('Page cache hit.'); + /** @var Taxonomy $taxonomy */ + $taxonomy = $this->grav['taxonomy']; $taxonomy->taxonomy($taxonomy_map); + + 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); } /** * Accessible method to manually reset the pages cache * - * @param $pages_dir + * @param string $pages_dir */ - public function resetPages($pages_dir) + public function resetPages($pages_dir): void { + $this->sort = []; $this->recurse($pages_dir); $this->buildRoutes(); @@ -977,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]); } } @@ -985,13 +1717,12 @@ class Pages * Recursive function to load & build page relationships. * * @param string $directory - * @param Page|null $parent - * - * @return Page - * @throws \RuntimeException + * @param PageInterface|null $parent + * @return PageInterface + * @throws RuntimeException * @internal */ - protected function recurse($directory, Page $parent = null) + protected function recurse($directory, PageInterface $parent = null) { $directory = rtrim($directory, DS); $page = new Page; @@ -1002,13 +1733,10 @@ class Pages /** @var Language $language */ $language = $this->grav['language']; - // stuff to do at root page - if ($parent === null) { - - // Fire event for memory and time consuming plugins... - if ($config->get('system.pages.events.page')) { - $this->grav->fireEvent('onBuildPagesInitialized'); - } + // Stuff to do at root page + // Fire event for memory and time consuming plugins... + if ($parent === null && $this->fire_events) { + $this->grav->fireEvent('onBuildPagesInitialized'); } $page->path($directory); @@ -1020,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. @@ -1040,30 +1769,29 @@ class Pages $folders = []; $page_found = null; - $page_extension = ''; + $page_extension = '.md'; $last_modified = 0; - $iterator = new \FilesystemIterator($directory); - /** @var \FilesystemIterator $file */ + $iterator = new FilesystemIterator($directory); foreach ($iterator as $file) { $filename = $file->getFilename(); // Ignore all hidden files if set. - if ($this->ignore_hidden && $filename && $filename[0] === '.') { + if ($this->ignore_hidden && $filename && strpos($filename, '.') === 0) { continue; } // 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; } @@ -1090,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(); @@ -1112,20 +1840,26 @@ 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])); } } - // Set routability to false if no page found if (!$content_exists) { + // Set routable to false if no page found $page->routable(false); + + // Hide empty folders if option set + if ($config->get('system.pages.hide_empty_folders')) { + $page->visible(false); + } } // Override the modified time if modular @@ -1141,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); @@ -1152,53 +1886,68 @@ class Pages /** * @internal */ - protected function buildRoutes() + protected function buildRoutes(): void { - /** @var $taxonomy Taxonomy */ + /** @var Taxonomy $taxonomy */ $taxonomy = $this->grav['taxonomy']; // Get the home route $home = self::resetHomeRoute(); - // Build routes and taxonomy map. - /** @var $page 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. - if ($home && isset($this->routes['/' . $home])) { - $this->routes['/'] = $this->routes['/' . $home]; - $this->get($this->routes['/' . $home])->route('/'); + $homeRoute = "/{$home}"; + if ($home && isset($this->routes[$homeRoute])) { + $home = $this->get($this->routes[$homeRoute]); + if ($home) { + $this->routes['/'] = $this->routes[$homeRoute]; + $home->route('/'); + } } } @@ -1206,30 +1955,28 @@ class Pages * @param string $path * @param array $pages * @param string $order_by - * @param array $manual - * @param int $sort_flags - * - * @throws \RuntimeException + * @param array|null $manual + * @param int|null $sort_flags + * @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_default = null; $header_query = null; + $header_default = null; // do this header query work only once if (strpos($order_by, 'header.') === 0) { - $header_query = explode('|', str_replace('header.', '', $order_by)); - if (isset($header_query[1])) { - $header_default = $header_query[1]; - } + $query = explode('|', str_replace('header.', '', $order_by), 2); + $header_query = array_shift($query) ?? ''; + $header_default = array_shift($query); } foreach ($pages as $key => $info) { - $child = isset($this->instances[$key]) ? $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) { @@ -1261,21 +2008,25 @@ class Pages case 'folder': $list[$key] = $child->folder(); break; - case (is_string($header_query[0])): - $child_header = new Header((array)$child->header()); - $header_value = $child_header->get($header_query[0]); - 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; 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; + } + $sort_flags = $sort_flags ?: SORT_REGULAR; + break; + } $list[$key] = $key; $sort_flags = $sort_flags ?: SORT_REGULAR; } @@ -1291,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))) { @@ -1324,7 +2079,7 @@ class Pages foreach ($list as $key => $dummy) { $info = $pages[$key]; - $order = array_search($info['slug'], $manual); + $order = array_search($info['slug'], $manual, true); if ($order === false) { $order = $i++; } @@ -1334,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) { @@ -1347,7 +2102,6 @@ class Pages * Shuffles an associative array * * @param array $list - * * @return array */ protected function arrayShuffle($list) @@ -1363,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 436d4f8..c718368 100644 --- a/system/src/Grav/Common/Page/Types.php +++ b/system/src/Grav/Common/Page/Types.php @@ -1,39 +1,54 @@ items[$type])) { $this->items[$type] = []; - } elseif (!$blueprint) { + } elseif (null === $blueprint) { return; } - if (!$blueprint && $this->systemBlueprints) { - $blueprint = isset($this->systemBlueprints[$type]) ? $this->systemBlueprints[$type] : $this->systemBlueprints['default']; + if (null === $blueprint) { + $blueprint = $this->systemBlueprints[$type] ?? $this->systemBlueprints['default'] ?? null; } if ($blueprint) { @@ -41,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'); + throw new InvalidArgumentException('First parameter must be URI'); } foreach ($this->findBlueprints($uri) as $type => $blueprint) { @@ -61,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'); + throw new InvalidArgumentException('First parameter must be URI'); } $options = [ @@ -89,6 +117,9 @@ class Types implements \ArrayAccess, \Iterator, \Countable } } + /** + * @return array + */ public function pageSelect() { $list = []; @@ -96,12 +127,16 @@ class Types implements \ArrayAccess, \Iterator, \Countable if (strpos($name, '/')) { continue; } - $list[$name] = ucfirst(strtr($name, '_', ' ')); + $list[$name] = ucfirst(str_replace('_', ' ', $name)); } ksort($list); + return $list; } + /** + * @return array + */ public function modularSelect() { $list = []; @@ -109,12 +144,17 @@ class Types implements \ArrayAccess, \Iterator, \Countable if (strpos($name, 'modular/') !== 0) { continue; } - $list[$name] = trim(ucfirst(strtr(basename($name), '_', ' '))); + $list[$name] = ucfirst(trim(str_replace('_', ' ', basename($name)))); } ksort($list); + return $list; } + /** + * @param string $uri + * @return array + */ private function findBlueprints($uri) { $options = [ @@ -133,8 +173,6 @@ class Types implements \ArrayAccess, \Iterator, \Countable $options['value'] = 'Url'; } - $list = Folder::all($uri, $options); - - return $list; + return Folder::all($uri, $options); } } diff --git a/system/src/Grav/Common/Plugin.php b/system/src/Grav/Common/Plugin.php index ec5b346..65b11db 100644 --- a/system/src/Grav/Common/Plugin.php +++ b/system/src/Grav/Common/Plugin.php @@ -1,44 +1,46 @@ name = $name; $this->grav = $grav; + if ($config) { $this->setConfig($config); } @@ -94,11 +97,11 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess */ public function config() { - return $this->config["plugins.{$this->name}"]; + return null !== $this->config ? $this->config["plugins.{$this->name}"] : []; } /** - * Determine if this is running under the admin + * Determine if plugin is running under the admin * * @return bool */ @@ -107,29 +110,43 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess return Utils::isAdminPlugin(); } + /** + * Determine if plugin is running under the CLI + * + * @return bool + */ + public function isCli() + { + return defined('GRAV_CLI'); + } + /** * Determine if this route is in Admin and active for the plugin * - * @param $plugin_route + * @param string $plugin_route * @return bool */ 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) { @@ -140,17 +157,30 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess if (is_string($params)) { $dispatcher->addListener($eventName, [$this, $params]); } elseif (is_string($params[0])) { - $dispatcher->addListener($eventName, [$this, $params[0]], isset($params[1]) ? $params[1] : 0); + $dispatcher->addListener($eventName, [$this, $params[0]], $this->getPriority($params, $eventName)); } else { foreach ($params as $listener) { - $dispatcher->addListener($eventName, [$this, $listener[0]], isset($listener[1]) ? $listener[1] : 0); + $dispatcher->addListener($eventName, [$this, $listener[0]], $this->getPriority($listener, $eventName)); } } } } + /** + * @param array $params + * @param string $eventName + * @return int + */ + private function getPriority($params, $eventName) + { + $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) { @@ -173,39 +203,41 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess /** * Whether or not an offset exists. * - * @param mixed $offset An offset to check for. + * @param string $offset An offset to check for. * @return bool Returns TRUE on success or FALSE on failure. */ public function offsetExists($offset) { - $this->loadBlueprint(); - if ($offset === 'title') { $offset = 'name'; } - return isset($this->blueprint[$offset]); + + $blueprint = $this->getBlueprint(); + + return isset($blueprint[$offset]); } /** * Returns the value at specified offset. * - * @param mixed $offset The offset to retrieve. + * @param string $offset The offset to retrieve. * @return mixed Can return all value types. */ public function offsetGet($offset) { - $this->loadBlueprint(); - if ($offset === 'title') { $offset = 'name'; } - return isset($this->blueprint[$offset]) ? $this->blueprint[$offset] : null; + + $blueprint = $this->getBlueprint(); + + return $blueprint[$offset] ?? null; } /** * Assigns a value to the specified offset. * - * @param mixed $offset The offset to assign the value to. + * @param string $offset The offset to assign the value to. * @param mixed $value The value to set. * @throws LogicException */ @@ -217,7 +249,7 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess /** * Unsets an offset. * - * @param mixed $offset The offset to unset. + * @param string $offset The offset to unset. * @throws LogicException */ public function offsetUnset($offset) @@ -225,6 +257,19 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess 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; + } + /** * This function will search a string for markdown links in a specific format. The link value can be * optionally compared against via the $internal_regex and operated on by the callback $function @@ -235,39 +280,45 @@ 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. * - * @param Page $page The page to merge the configurations with the + * 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(Page $page, $deep = false, $params = [], $type = 'plugins') + 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)) { + 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; + $config = $page_header->{$class_name}; if (is_bool($config)) { // Overwrite enabled option with boolean value in page header $config = ['enabled' => $config]; @@ -277,8 +328,8 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess // Create new config object and set it on the page object so it's cached for next time $page->modifyHeader($class_name_merged, new Data($header)); - } else if (isset($page_header->$class_name_merged)) { - $merged = $page_header->$class_name_merged; + } elseif (isset($page_header->{$class_name_merged})) { + $merged = $page_header->{$class_name_merged}; $header = $merged->toArray(); } if (empty($header)) { @@ -294,12 +345,12 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess /** * Merge arrays based on deepness * - * @param bool $deep - * @param $array1 - * @param $array2 - * @return array|mixed + * @param string|bool $deep + * @param array $array1 + * @param array $array2 + * @return array */ - private function mergeArrays($deep = false, $array1, $array2) + private function mergeArrays($deep, $array1, $array2) { if ($deep === 'merge') { return Utils::arrayMergeRecursiveUnique($array1, $array2); @@ -314,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. - * - * @return true + * @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; } @@ -338,25 +392,32 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess /** * Simpler getter for the plugin blueprint * - * @return mixed + * @return Blueprint */ 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 2a9d523..cc6f7ea 100644 --- a/system/src/Grav/Common/Plugins.php +++ b/system/src/Grav/Common/Plugins.php @@ -1,24 +1,42 @@ 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); + } } } @@ -51,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) { @@ -89,11 +119,15 @@ class Plugins extends Iterator /** * Registers all plugins. * - * @return array|Plugin[] array of Plugin objects - * @throws \RuntimeException + * @return Plugin[] array of Plugin objects + * @throws RuntimeException */ public function init() { + if ($this->plugins_initialized) { + return $this->items; + } + $grav = Grav::instance(); /** @var Config $config */ @@ -105,18 +139,31 @@ 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; } /** * Add a plugin * - * @param $plugin + * @param Plugin $plugin + * @return void */ public function add($plugin) { @@ -125,19 +172,73 @@ 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() { - $plugins = Grav::instance()['plugins']; + $grav = Grav::instance(); + + /** @var Plugins $plugins */ + $plugins = $grav['plugins']; $list = []; foreach ($plugins as $instance) { $name = $instance->name; - $result = self::get($name); + + try { + $result = self::get($name); + } catch (Exception $e) { + $exception = new RuntimeException(sprintf('Plugin %s: %s', $name, $e->getMessage()), $e->getCode(), $e); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage("Plugin {$name} cannot be loaded, please check Exceptions tab", 'error'); + $debugger->addException($exception); + + continue; + } if ($result) { $list[$name] = $result; @@ -151,7 +252,6 @@ class Plugins extends Iterator * Get a plugin by name * * @param string $name - * * @return Data|null */ public static function get($name) @@ -167,7 +267,7 @@ class Plugins extends Iterator return null; } - $obj = new Data($file->content(), $blueprint); + $obj = new Data((array)$file->content(), $blueprint); // Override with user configuration. $obj->merge(Grav::instance()['config']->get('plugins.' . $name) ?: []); @@ -179,29 +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); - $filePath = $locator->findResource('plugins://' . $name . DS . $name . PLUGIN_EXT); - if (!is_file($filePath)) { + if (is_file($file)) { + // Local variables available in the file: $grav, $name, $file + $class = include_once $file; + + 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 (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; } - require_once $filePath; - - $pluginClassName = 'Grav\\Plugin\\' . ucfirst($name) . 'Plugin'; - if (!class_exists($pluginClassName)) { - $pluginClassName = 'Grav\\Plugin\\' . $grav['inflector']->camelize($name) . 'Plugin'; - if (!class_exists($pluginClassName)) { - throw new \RuntimeException(sprintf("Plugin '%s' class not found! Try reinstalling this plugin.", $name)); - } - } - return new $pluginClassName($name, $grav); + return $class; } - } diff --git a/system/src/Grav/Common/Processors/AssetsProcessor.php b/system/src/Grav/Common/Processors/AssetsProcessor.php index 6ca952d..66bb5e3 100644 --- a/system/src/Grav/Common/Processors/AssetsProcessor.php +++ b/system/src/Grav/Common/Processors/AssetsProcessor.php @@ -1,21 +1,41 @@ startTimer(); $this->container['assets']->init(); $this->container->fireEvent('onAssetsInitialized'); + $this->stopTimer(); + + return $handler->handle($request); } } diff --git a/system/src/Grav/Common/Processors/BackupsProcessor.php b/system/src/Grav/Common/Processors/BackupsProcessor.php new file mode 100644 index 0000000..6e960b4 --- /dev/null +++ b/system/src/Grav/Common/Processors/BackupsProcessor.php @@ -0,0 +1,41 @@ +startTimer(); + $backups = $this->container['backups']; + $backups->init(); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/ConfigurationProcessor.php b/system/src/Grav/Common/Processors/ConfigurationProcessor.php deleted file mode 100644 index 0347853..0000000 --- a/system/src/Grav/Common/Processors/ConfigurationProcessor.php +++ /dev/null @@ -1,21 +0,0 @@ -container['config']->init(); - $this->container['plugins']->setup(); - } -} diff --git a/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php b/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php index 643e8d1..de7cafc 100644 --- a/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php +++ b/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php @@ -1,20 +1,40 @@ startTimer(); $this->container['debugger']->addAssets(); + $this->stopTimer(); + + return $handler->handle($request); } } diff --git a/system/src/Grav/Common/Processors/DebuggerInitProcessor.php b/system/src/Grav/Common/Processors/DebuggerInitProcessor.php deleted file mode 100644 index e3b4e1d..0000000 --- a/system/src/Grav/Common/Processors/DebuggerInitProcessor.php +++ /dev/null @@ -1,20 +0,0 @@ -container['debugger']->init(); - } -} diff --git a/system/src/Grav/Common/Processors/ErrorsProcessor.php b/system/src/Grav/Common/Processors/ErrorsProcessor.php deleted file mode 100644 index 7c7685d..0000000 --- a/system/src/Grav/Common/Processors/ErrorsProcessor.php +++ /dev/null @@ -1,20 +0,0 @@ -container['errors']->resetHandlers(); - } -} diff --git a/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php b/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php new file mode 100644 index 0000000..32908ba --- /dev/null +++ b/system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php @@ -0,0 +1,82 @@ +offsetGet('request'); + } + + /** + * @return Route + */ + public function getRoute(): Route + { + return $this->getRequest()->getAttribute('route'); + } + + /** + * @return RequestHandler + */ + public function getHandler(): RequestHandler + { + return $this->offsetGet('handler'); + } + + /** + * @return ResponseInterface|null + */ + public function getResponse(): ?ResponseInterface + { + return $this->offsetGet('response'); + } + + /** + * @param ResponseInterface $response + * @return $this + */ + public function setResponse(ResponseInterface $response): self + { + $this->offsetSet('response', $response); + $this->stopPropagation(); + + return $this; + } + + /** + * @param string $name + * @param MiddlewareInterface $middleware + * @return RequestHandlerEvent + */ + public function addMiddleware(string $name, MiddlewareInterface $middleware): self + { + /** @var RequestHandler $handler */ + $handler = $this['handler']; + $handler->addMiddleware($name, $middleware); + + return $this; + } +} diff --git a/system/src/Grav/Common/Processors/InitializeProcessor.php b/system/src/Grav/Common/Processors/InitializeProcessor.php index 6bf8cdd..cc8e4f2 100644 --- a/system/src/Grav/Common/Processors/InitializeProcessor.php +++ b/system/src/Grav/Common/Processors/InitializeProcessor.php @@ -1,27 +1,342 @@ processCli(); + } + } + + /** + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $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(); @@ -30,26 +345,116 @@ class InitializeProcessor extends ProcessorBase implements ProcessorInterface ob_start(); } + $this->stopTimer('_init_ob'); + } + + /** + * @param Config $config + */ + protected function initializeLocale(Config $config): void + { + $this->startTimer('_init_locale', 'Initialize Locale'); + // Initialize the timezone. - if ($config->get('system.timezone')) { - date_default_timezone_set($this->container['config']->get('system.timezone')); + $timezone = $config->get('system.timezone'); + if ($timezone) { + date_default_timezone_set($timezone); } - // 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)) { - $this->container['session']->init(); + $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 = $this->container['uri']; + $uri = $grav['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, '/')) { - $this->container->redirectLangSafe(rtrim($path, '/')); + $this->stopTimer('_init_uri'); + } + + protected function handleRedirectRequest(RequestInterface $request, int $code = null): ?ResponseInterface + { + if (!in_array($request->getMethod(), ['GET', 'HEAD'])) { + return null; } - $this->container->setLocale(); + // 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)) { + $this->startTimer('_init_session', 'Start Session'); + + /** @var Session $session */ + $session = $this->container['session']; + + try { + $session->init(); + } catch (SessionException $e) { + $session->init(); + $message = 'Session corruption detected, restarting session...'; + $this->addMessage($message); + $this->container['messages']->add($message, 'error'); + } + + $this->stopTimer('_init_session'); + } } } diff --git a/system/src/Grav/Common/Processors/PagesProcessor.php b/system/src/Grav/Common/Processors/PagesProcessor.php index b2828f7..33b483f 100644 --- a/system/src/Grav/Common/Processors/PagesProcessor.php +++ b/system/src/Grav/Common/Processors/PagesProcessor.php @@ -1,23 +1,41 @@ startTimer(); + // Dump Cache state $this->container['debugger']->addMessage($this->container['cache']->getCacheStatus()); @@ -25,20 +43,41 @@ class PagesProcessor extends ProcessorBase implements ProcessorInterface $this->container->fireEvent('onPagesInitialized', new Event(['pages' => $this->container['pages']])); $this->container->fireEvent('onPageInitialized', new Event(['page' => $this->container['page']])); - /** @var Page $page */ + /** @var PageInterface $page */ $page = $this->container['page']; if (!$page->routable()) { // If no page found, fire event - $event = $this->container->fireEvent('onPageNotFound', new Event(['page' => $page])); + $event = new Event(['page' => $page]); + $event->page = null; + $event = $this->container->fireEvent('onPageNotFound', $event); if (isset($event->page)) { - unset ($this->container['page']); - $this->container['page'] = $event->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]"); + } else { + $this->addMessage("Routed to page {$page->rawRoute()} (type: {$page->template()})"); + + $task = $this->container['task']; + $action = $this->container['action']; + if ($task) { + $event = new Event(['task' => $task, 'page' => $page]); + $this->container->fireEvent('onPageTask', $event); + $this->container->fireEvent('onPageTask.' . $task, $event); + } elseif ($action) { + $event = new Event(['action' => $action, 'page' => $page]); + $this->container->fireEvent('onPageAction', $event); + $this->container->fireEvent('onPageAction.' . $action, $event); } } + $this->stopTimer(); + + return $handler->handle($request); } } diff --git a/system/src/Grav/Common/Processors/PluginsProcessor.php b/system/src/Grav/Common/Processors/PluginsProcessor.php index ee56393..485578e 100644 --- a/system/src/Grav/Common/Processors/PluginsProcessor.php +++ b/system/src/Grav/Common/Processors/PluginsProcessor.php @@ -1,21 +1,41 @@ container['plugins']->init(); - $this->container->fireEvent('onPluginsInitialized'); + $this->startTimer(); + $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 056c86d..a3506f5 100644 --- a/system/src/Grav/Common/Processors/ProcessorBase.php +++ b/system/src/Grav/Common/Processors/ProcessorBase.php @@ -1,25 +1,70 @@ container = $container; } + /** + * @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); + } + + /** + * @param string|null $id + */ + protected function stopTimer($id = null): void + { + /** @var Debugger $debugger */ + $debugger = $this->container['debugger']; + $debugger->stopTimer($id ?? $this->id); + } + + /** + * @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']; + $debugger->addMessage($message, $label, $isString); + } } diff --git a/system/src/Grav/Common/Processors/ProcessorInterface.php b/system/src/Grav/Common/Processors/ProcessorInterface.php index 0e4b169..8a6edbe 100644 --- a/system/src/Grav/Common/Processors/ProcessorInterface.php +++ b/system/src/Grav/Common/Processors/ProcessorInterface.php @@ -1,14 +1,20 @@ startTimer(); + $container = $this->container; $output = $container['output']; - if ($output instanceof \Psr\Http\Message\ResponseInterface) { - // Support for custom output providers like Slim Framework. - } else { - // Use internal Grav output. - $container->output = $output; - $container->fireEvent('onOutputGenerated'); - - // Set the header type - $container->header(); - - echo $container->output; - - // remove any output - $container->output = ''; - - $this->container->fireEvent('onOutputRendered'); + if ($output instanceof ResponseInterface) { + return $output; } + + /** @var PageInterface $page */ + $page = $this->container['page']; + + // Use internal Grav output. + $container->output = $output; + + ob_start(); + + $event = new Event(['page' => $page, 'output' => &$container->output]); + $container->fireEvent('onOutputGenerated', $event); + + echo $container->output; + + $html = ob_get_clean(); + + // remove any output + $container->output = ''; + + $event = new Event(['page' => $page, 'output' => $html]); + $this->container->fireEvent('onOutputRendered', $event); + + $this->stopTimer(); + + return new Response($page->httpResponseCode(), $page->httpHeaders(), $html); } } diff --git a/system/src/Grav/Common/Processors/RequestProcessor.php b/system/src/Grav/Common/Processors/RequestProcessor.php new file mode 100644 index 0000000..971fb67 --- /dev/null +++ b/system/src/Grav/Common/Processors/RequestProcessor.php @@ -0,0 +1,65 @@ +startTimer(); + + $header = $request->getHeaderLine('Content-Type'); + $type = trim(strstr($header, ';', true) ?: $header); + if ($type === 'application/json') { + $request = $request->withParsedBody(json_decode($request->getBody()->getContents(), true)); + } + + $uri = $request->getUri(); + $ext = mb_strtolower(pathinfo($uri->getPath(), PATHINFO_EXTENSION)); + + $request = $request + ->withAttribute('grav', $this->container) + ->withAttribute('time', $_SERVER['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME) + ->withAttribute('route', Uri::getCurrentRoute()->withExtension($ext)) + ->withAttribute('referrer', $this->container['uri']->referrer()); + + $event = new RequestHandlerEvent(['request' => $request, 'handler' => $handler]); + /** @var RequestHandlerEvent $event */ + $event = $this->container->fireEvent('onRequestHandlerInit', $event); + $response = $event->getResponse(); + $this->stopTimer(); + + if ($response) { + return $response; + } + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/SchedulerProcessor.php b/system/src/Grav/Common/Processors/SchedulerProcessor.php new file mode 100644 index 0000000..69cc163 --- /dev/null +++ b/system/src/Grav/Common/Processors/SchedulerProcessor.php @@ -0,0 +1,42 @@ +startTimer(); + $scheduler = $this->container['scheduler']; + $this->container->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler])); + $this->stopTimer(); + + return $handler->handle($request); + } +} diff --git a/system/src/Grav/Common/Processors/SiteSetupProcessor.php b/system/src/Grav/Common/Processors/SiteSetupProcessor.php deleted file mode 100644 index 1f3f8af..0000000 --- a/system/src/Grav/Common/Processors/SiteSetupProcessor.php +++ /dev/null @@ -1,21 +0,0 @@ -container['setup']->init(); - $this->container['streams']; - } -} diff --git a/system/src/Grav/Common/Processors/TasksProcessor.php b/system/src/Grav/Common/Processors/TasksProcessor.php index d1e7a2f..07e0934 100644 --- a/system/src/Grav/Common/Processors/TasksProcessor.php +++ b/system/src/Grav/Common/Processors/TasksProcessor.php @@ -1,23 +1,71 @@ startTimer(); + $task = $this->container['task']; - if ($task) { - $this->container->fireEvent('onTask.' . $task); + $action = $this->container['action']; + if ($task || $action) { + $attributes = $request->getAttribute('controller'); + + $controllerClass = $attributes['class'] ?? null; + if ($controllerClass) { + /** @var RequestHandlerInterface $controller */ + $controller = new $controllerClass($attributes['path'] ?? '', $attributes['params'] ?? []); + try { + $response = $controller->handle($request); + + if ($response->getStatusCode() === 418) { + $response = $handler->handle($request); + } + + $this->stopTimer(); + + return $response; + } catch (NotFoundException $e) { + // Task not found: Let it pass through. + } + } + + if ($task) { + $this->container->fireEvent('onTask.' . $task); + } elseif ($action) { + $this->container->fireEvent('onAction.' . $action); + } } + $this->stopTimer(); + + return $handler->handle($request); } } diff --git a/system/src/Grav/Common/Processors/ThemesProcessor.php b/system/src/Grav/Common/Processors/ThemesProcessor.php index c9ea013..951dc79 100644 --- a/system/src/Grav/Common/Processors/ThemesProcessor.php +++ b/system/src/Grav/Common/Processors/ThemesProcessor.php @@ -1,20 +1,40 @@ startTimer(); $this->container['themes']->init(); + $this->stopTimer(); + + return $handler->handle($request); } } diff --git a/system/src/Grav/Common/Processors/TwigProcessor.php b/system/src/Grav/Common/Processors/TwigProcessor.php index 392824f..6604b5c 100644 --- a/system/src/Grav/Common/Processors/TwigProcessor.php +++ b/system/src/Grav/Common/Processors/TwigProcessor.php @@ -1,21 +1,40 @@ startTimer(); $this->container['twig']->init(); - } + $this->stopTimer(); + return $handler->handle($request); + } } diff --git a/system/src/Grav/Common/Scheduler/Cron.php b/system/src/Grav/Common/Scheduler/Cron.php new file mode 100644 index 0000000..5127a99 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/Cron.php @@ -0,0 +1,577 @@ + modified for Grav integration + * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved. + * @license MIT License; see LICENSE file for details. + */ + +namespace Grav\Common\Scheduler; + +/* + * Usage examples : + * ---------------- + * + * $cron = new Cron('10-30/5 12 * * *'); + * + * var_dump($cron->getMinutes()); + * // array(5) { + * // [0]=> int(10) + * // [1]=> int(15) + * // [2]=> int(20) + * // [3]=> int(25) + * // [4]=> int(30) + * // } + * + * var_dump($cron->getText('fr')); + * // string(32) "Chaque jour à 12:10,15,20,25,30" + * + * var_dump($cron->getText('en')); + * // string(30) "Every day at 12:10,15,20,25,30" + * + * var_dump($cron->getType()); + * // string(3) "day" + * + * var_dump($cron->getCronHours()); + * // string(2) "12" + * + * var_dump($cron->matchExact(new \DateTime('2012-07-01 13:25:10'))); + * // bool(false) + * + * var_dump($cron->matchExact(new \DateTime('2012-07-01 12:15:20'))); + * // bool(true) + * + * 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 = ''; + public const TYPE_MINUTE = 'minute'; + public const TYPE_HOUR = 'hour'; + public const TYPE_DAY = 'day'; + public const TYPE_WEEK = 'week'; + public const TYPE_MONTH = 'month'; + public const TYPE_YEAR = 'year'; + /** + * + * @var array + */ + protected $texts = [ + 'fr' => [ + 'empty' => '-tout-', + 'name_minute' => 'minute', + 'name_hour' => 'heure', + 'name_day' => 'jour', + 'name_week' => 'semaine', + 'name_month' => 'mois', + 'name_year' => 'année', + 'text_period' => 'Chaque %s', + 'text_mins' => 'à %s minutes', + 'text_time' => 'à %02s:%02s', + 'text_dow' => 'le %s', + 'text_month' => 'de %s', + 'text_dom' => 'le %s', + 'weekdays' => ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'], + 'months' => ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'], + ], + 'en' => [ + 'empty' => '-all-', + 'name_minute' => 'minute', + 'name_hour' => 'hour', + 'name_day' => 'day', + 'name_week' => 'week', + 'name_month' => 'month', + 'name_year' => 'year', + 'text_period' => 'Every %s', + 'text_mins' => 'at %s minutes past the hour', + 'text_time' => 'at %02s:%02s', + 'text_dow' => 'on %s', + 'text_month' => 'of %s', + 'text_dom' => 'on the %s', + 'weekdays' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + 'months' => ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'], + ], + ]; + + /** + * min hour dom month dow + * @var string + */ + protected $cron = ''; + /** + * + * @var array + */ + protected $minutes = []; + /** + * + * @var array + */ + protected $hours = []; + /** + * + * @var array + */ + protected $months = []; + /** + * 0-7 : sunday, monday, ... saturday, sunday + * @var array + */ + protected $dow = []; + /** + * + * @var array + */ + protected $dom = []; + + /** + * @param string|null $cron + */ + public function __construct($cron = null) + { + if (null !== $cron) { + $this->setCron($cron); + } + } + + /** + * @return string + */ + public function getCron() + { + return implode(' ', [ + $this->getCronMinutes(), + $this->getCronHours(), + $this->getCronDaysOfMonth(), + $this->getCronMonths(), + $this->getCronDaysOfWeek(), + ]); + } + + /** + * @param string $lang 'fr' or 'en' + * @return string + */ + public function getText($lang) + { + // check lang + if (!isset($this->texts[$lang])) { + return $this->getCron(); + } + + $texts = $this->texts[$lang]; + // check type + + $type = $this->getType(); + if ($type === self::TYPE_UNDEFINED) { + return $this->getCron(); + } + + // init + $elements = []; + $elements[] = sprintf($texts['text_period'], $texts['name_' . $type]); + + // hour + if ($type === self::TYPE_HOUR) { + $elements[] = sprintf($texts['text_mins'], $this->getCronMinutes()); + } + + // week + if ($type === self::TYPE_WEEK) { + $dow = $this->getCronDaysOfWeek(); + foreach ($texts['weekdays'] as $i => $wd) { + $dow = str_replace((string) ($i + 1), $wd, $dow); + } + $elements[] = sprintf($texts['text_dow'], $dow); + } + + // month + year + if (in_array($type, [self::TYPE_MONTH, self::TYPE_YEAR], true)) { + $elements[] = sprintf($texts['text_dom'], $this->getCronDaysOfMonth()); + } + + // year + if ($type === self::TYPE_YEAR) { + $months = $this->getCronMonths(); + for ($i = count($texts['months']) - 1; $i >= 0; $i--) { + $months = str_replace((string) ($i + 1), $texts['months'][$i], $months); + } + $elements[] = sprintf($texts['text_month'], $months); + } + + // day + week + month + year + 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()); + } + + return str_replace('*', $texts['empty'], implode(' ', $elements)); + } + + /** + * @return string + */ + public function getType() + { + $mask = preg_replace('/[^\* ]/', '-', $this->getCron()); + $mask = preg_replace('/-+/', '-', $mask); + $mask = preg_replace('/[^-\*]/', '', $mask); + + if ($mask === '*****') { + return self::TYPE_MINUTE; + } + + if ($mask === '-****') { + return self::TYPE_HOUR; + } + + if (substr($mask, -3) === '***') { + return self::TYPE_DAY; + } + + if (substr($mask, -3) === '-**') { + return self::TYPE_MONTH; + } + + if (substr($mask, -3) === '**-') { + return self::TYPE_WEEK; + } + + if (substr($mask, -2) === '-*') { + return self::TYPE_YEAR; + } + + return self::TYPE_UNDEFINED; + } + + /** + * @param string $cron + * @return $this + */ + public function setCron($cron) + { + // sanitize + $cron = trim($cron); + $cron = preg_replace('/\s+/', ' ', $cron); + // explode + $elements = explode(' ', $cron); + if (count($elements) !== 5) { + throw new RuntimeException('Bad number of elements'); + } + + $this->cron = $cron; + $this->setMinutes($elements[0]); + $this->setHours($elements[1]); + $this->setDaysOfMonth($elements[2]); + $this->setMonths($elements[3]); + $this->setDaysOfWeek($elements[4]); + + return $this; + } + + /** + * @return string + */ + public function getCronMinutes() + { + return $this->arrayToCron($this->minutes); + } + + /** + * @return string + */ + public function getCronHours() + { + return $this->arrayToCron($this->hours); + } + + /** + * @return string + */ + public function getCronDaysOfMonth() + { + return $this->arrayToCron($this->dom); + } + + /** + * @return string + */ + public function getCronMonths() + { + return $this->arrayToCron($this->months); + } + + /** + * @return string + */ + public function getCronDaysOfWeek() + { + return $this->arrayToCron($this->dow); + } + + /** + * @return array + */ + public function getMinutes() + { + return $this->minutes; + } + + /** + * @return array + */ + public function getHours() + { + return $this->hours; + } + + /** + * @return array + */ + public function getDaysOfMonth() + { + return $this->dom; + } + + /** + * @return array + */ + public function getMonths() + { + return $this->months; + } + + /** + * @return array + */ + public function getDaysOfWeek() + { + return $this->dow; + } + + /** + * @param string|string[] $minutes + * @return $this + */ + public function setMinutes($minutes) + { + $this->minutes = $this->cronToArray($minutes, 0, 59); + + return $this; + } + + /** + * @param string|string[] $hours + * @return $this + */ + public function setHours($hours) + { + $this->hours = $this->cronToArray($hours, 0, 23); + + return $this; + } + + /** + * @param string|string[] $months + * @return $this + */ + public function setMonths($months) + { + $this->months = $this->cronToArray($months, 1, 12); + + return $this; + } + + /** + * @param string|string[] $dow + * @return $this + */ + public function setDaysOfWeek($dow) + { + $this->dow = $this->cronToArray($dow, 0, 7); + + return $this; + } + + /** + * @param string|string[] $dom + * @return $this + */ + public function setDaysOfMonth($dom) + { + $this->dom = $this->cronToArray($dom, 1, 31); + + return $this; + } + + /** + * @param mixed $date + * @param int $min + * @param int $hour + * @param int $day + * @param int $month + * @param int $weekday + * @return DateTime + */ + protected function parseDate($date, &$min, &$hour, &$day, &$month, &$weekday) + { + if (is_numeric($date) && (int)$date == $date) { + $date = new DateTime('@' . $date); + } elseif (is_string($date)) { + $date = new DateTime('@' . strtotime($date)); + } + 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'); + } + + return new DateTime($date->format('Y-m-d H:i:sP')); + } + + /** + * @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)) + ); + } + + /** + * @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 !'); + } + if ($minuteAfter < 0) { + throw new RuntimeException('MinuteAfter parameter cannot be negative !'); + } + + $date = $this->parseDate($date, $min, $hour, $day, $month, $weekday); + $interval = new DateInterval('PT1M'); // 1 min + if ($minuteBefore !== 0) { + $date->sub(new DateInterval('PT' . abs($minuteBefore) . 'M')); + } + $n = $minuteAfter - $minuteBefore + 1; + for ($i = 0; $i < $n; $i++) { + if ($this->matchExact($date)) { + return true; + } + $date->add($interval); + } + + return false; + } + + /** + * @param array $array + * @return string + */ + protected function arrayToCron($array) + { + $n = count($array); + if (!is_array($array) || $n === 0) { + return '*'; + } + + $cron = [$array[0]]; + $s = $c = $array[0]; + for ($i = 1; $i < $n; $i++) { + if ($array[$i] == $c + 1) { + $c = $array[$i]; + $cron[count($cron) - 1] = $s . '-' . $c; + } else { + $s = $c = $array[$i]; + $cron[] = $c; + } + } + + return implode(',', $cron); + } + + /** + * + * @param array|string $string + * @param int $min + * @param int $max + * @return array + */ + protected function cronToArray($string, $min, $max) + { + $array = []; + if (is_array($string)) { + foreach ($string as $val) { + if (is_numeric($val) && (int)$val == $val && $val >= $min && $val <= $max) { + $array[] = (int)$val; + } + } + } elseif ($string !== '*') { + while ($string !== '') { + // test "*/n" expression + if (preg_match('/^\*\/([0-9]+),?/', $string, $m)) { + for ($i = max(0, $min); $i <= min(59, $max); $i += $m[1]) { + $array[] = (int)$i; + } + $string = substr($string, strlen($m[0])); + continue; + } + // test "a-b/n" expression + if (preg_match('/^([0-9]+)-([0-9]+)\/([0-9]+),?/', $string, $m)) { + for ($i = max($m[1], $min); $i <= min($m[2], $max); $i += $m[3]) { + $array[] = (int)$i; + } + $string = substr($string, strlen($m[0])); + continue; + } + // test "a-b" expression + if (preg_match('/^([0-9]+)-([0-9]+),?/', $string, $m)) { + for ($i = max($m[1], $min); $i <= min($m[2], $max); $i++) { + $array[] = (int)$i; + } + $string = substr($string, strlen($m[0])); + continue; + } + // test "c" expression + if (preg_match('/^([0-9]+),?/', $string, $m)) { + if ($m[1] >= $min && $m[1] <= $max) { + $array[] = (int)$m[1]; + } + $string = substr($string, strlen($m[0])); + continue; + } + + // something goes wrong in the expression + return []; + } + } + sort($array, SORT_NUMERIC); + + return $array; + } +} diff --git a/system/src/Grav/Common/Scheduler/IntervalTrait.php b/system/src/Grav/Common/Scheduler/IntervalTrait.php new file mode 100644 index 0000000..cc5c165 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/IntervalTrait.php @@ -0,0 +1,404 @@ +at = $expression; + $this->executionTime = CronExpression::factory($expression); + + return $this; + } + + /** + * Set the execution time to every minute. + * + * @return self + */ + public function everyMinute() + { + return $this->at('* * * * *'); + } + + /** + * Set the execution time to every hour. + * + * @param int|string $minute + * @return self + */ + public function hourly($minute = 0) + { + $c = $this->validateCronSequence($minute); + + return $this->at("{$c['minute']} * * * *"); + } + + /** + * Set the execution time to once a day. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function daily($hour = 0, $minute = 0) + { + if (is_string($hour)) { + $parts = explode(':', $hour); + $hour = $parts[0]; + $minute = $parts[1] ?? '0'; + } + $c = $this->validateCronSequence($minute, $hour); + + return $this->at("{$c['minute']} {$c['hour']} * * *"); + } + + /** + * Set the execution time to once a week. + * + * @param int|string $weekday + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function weekly($weekday = 0, $hour = 0, $minute = 0) + { + if (is_string($hour)) { + $parts = explode(':', $hour); + $hour = $parts[0]; + $minute = $parts[1] ?? '0'; + } + $c = $this->validateCronSequence($minute, $hour, null, null, $weekday); + + return $this->at("{$c['minute']} {$c['hour']} * * {$c['weekday']}"); + } + + /** + * Set the execution time to once a month. + * + * @param int|string $month + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function monthly($month = '*', $day = 1, $hour = 0, $minute = 0) + { + if (is_string($hour)) { + $parts = explode(':', $hour); + $hour = $parts[0]; + $minute = $parts[1] ?? '0'; + } + $c = $this->validateCronSequence($minute, $hour, $day, $month); + + return $this->at("{$c['minute']} {$c['hour']} {$c['day']} {$c['month']} *"); + } + + /** + * Set the execution time to every Sunday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function sunday($hour = 0, $minute = 0) + { + return $this->weekly(0, $hour, $minute); + } + + /** + * Set the execution time to every Monday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function monday($hour = 0, $minute = 0) + { + return $this->weekly(1, $hour, $minute); + } + + /** + * Set the execution time to every Tuesday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function tuesday($hour = 0, $minute = 0) + { + return $this->weekly(2, $hour, $minute); + } + + /** + * Set the execution time to every Wednesday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function wednesday($hour = 0, $minute = 0) + { + return $this->weekly(3, $hour, $minute); + } + + /** + * Set the execution time to every Thursday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function thursday($hour = 0, $minute = 0) + { + return $this->weekly(4, $hour, $minute); + } + + /** + * Set the execution time to every Friday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function friday($hour = 0, $minute = 0) + { + return $this->weekly(5, $hour, $minute); + } + + /** + * Set the execution time to every Saturday. + * + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function saturday($hour = 0, $minute = 0) + { + return $this->weekly(6, $hour, $minute); + } + + /** + * Set the execution time to every January. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function january($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(1, $day, $hour, $minute); + } + + /** + * Set the execution time to every February. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function february($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(2, $day, $hour, $minute); + } + + /** + * Set the execution time to every March. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function march($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(3, $day, $hour, $minute); + } + + /** + * Set the execution time to every April. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function april($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(4, $day, $hour, $minute); + } + + /** + * Set the execution time to every May. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function may($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(5, $day, $hour, $minute); + } + + /** + * Set the execution time to every June. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function june($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(6, $day, $hour, $minute); + } + + /** + * Set the execution time to every July. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function july($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(7, $day, $hour, $minute); + } + + /** + * Set the execution time to every August. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function august($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(8, $day, $hour, $minute); + } + + /** + * Set the execution time to every September. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function september($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(9, $day, $hour, $minute); + } + + /** + * Set the execution time to every October. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function october($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(10, $day, $hour, $minute); + } + + /** + * Set the execution time to every November. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function november($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(11, $day, $hour, $minute); + } + + /** + * Set the execution time to every December. + * + * @param int|string $day + * @param int|string $hour + * @param int|string $minute + * @return self + */ + public function december($day = 1, $hour = 0, $minute = 0) + { + return $this->monthly(12, $day, $hour, $minute); + } + + /** + * Validate sequence of cron expression. + * + * @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) + { + return [ + 'minute' => $this->validateCronRange($minute, 0, 59), + 'hour' => $this->validateCronRange($hour, 0, 23), + 'day' => $this->validateCronRange($day, 1, 31), + 'month' => $this->validateCronRange($month, 1, 12), + 'weekday' => $this->validateCronRange($weekday, 0, 6), + ]; + } + + /** + * Validate sequence of cron expression. + * + * @param int|string|null $value + * @param int $min + * @param int $max + * @return mixed + */ + private function validateCronRange($value, $min, $max) + { + if ($value === null || $value === '*') { + return '*'; + } + + if (! is_numeric($value) || + ! ($value >= $min && $value <= $max) + ) { + throw new InvalidArgumentException( + "Invalid value: it should be '*' or between {$min} and {$max}." + ); + } + + return $value; + } +} diff --git a/system/src/Grav/Common/Scheduler/Job.php b/system/src/Grav/Common/Scheduler/Job.php new file mode 100644 index 0000000..f21c26d --- /dev/null +++ b/system/src/Grav/Common/Scheduler/Job.php @@ -0,0 +1,564 @@ +id = Grav::instance()['inflector']->hyphenize($id); + } else { + if (is_string($command)) { + $this->id = md5($command); + } else { + /* @var object $command */ + $this->id = spl_object_hash($command); + } + } + $this->creationTime = new DateTime('now'); + // initialize the directory path for lock files + $this->tempDir = sys_get_temp_dir(); + $this->command = $command; + $this->args = $args; + // Set enabled state + $status = Grav::instance()['config']->get('scheduler.status'); + $this->enabled = !(isset($status[$id]) && $status[$id] === 'disabled'); + } + + /** + * Get the command + * + * @return Closure|string + */ + public function getCommand() + { + return $this->command; + } + + /** + * Get the cron 'at' syntax for this job + * + * @return string + */ + public function getAt() + { + return $this->at; + } + + /** + * Get the status of this job + * + * @return bool + */ + public function getEnabled() + { + return $this->enabled; + } + + /** + * Get optional arguments + * + * @return string|null + */ + public function getArguments() + { + if (is_string($this->args)) { + return $this->args; + } + + return null; + } + + /** + * @return CronExpression + */ + public function getCronExpression() + { + return CronExpression::factory($this->at); + } + + /** + * Get the status of the last run for this job + * + * @return bool + */ + public function isSuccessful() + { + return $this->successful; + } + + /** + * Get the Job id. + * + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * Check if the Job is due to run. + * It accepts as input a DateTime used to check if + * the job is due. Defaults to job creation time. + * It also default the execution time if not previously defined. + * + * @param DateTime|null $date + * @return bool + */ + public function isDue(DateTime $date = null) + { + // The execution time is being defaulted if not defined + if (!$this->executionTime) { + $this->at('* * * * *'); + } + + $date = $date ?? $this->creationTime; + + return $this->executionTime->isDue($date); + } + + /** + * Check if the Job is overlapping. + * + * @return bool + */ + public function isOverlapping() + { + return $this->lockFile && + file_exists($this->lockFile) && + call_user_func($this->whenOverlapping, filemtime($this->lockFile)) === false; + } + + /** + * Force the Job to run in foreground. + * + * @return $this + */ + public function inForeground() + { + $this->runInBackground = false; + + return $this; + } + + /** + * Sets/Gets an option backlink + * + * @param string|null $link + * @return string|null + */ + public function backlink($link = null) + { + if ($link) { + $this->backlink = $link; + } + return $this->backlink; + } + + + /** + * Check if the Job can run in background. + * + * @return bool + */ + public function runInBackground() + { + return !(is_callable($this->command) || $this->runInBackground === false); + } + + /** + * This will prevent the Job from overlapping. + * It prevents another instance of the same Job of + * being executed if the previous is still running. + * The job id is used as a filename for the lock file. + * + * @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) + { + if ($tempDir === null || !is_dir($tempDir)) { + $tempDir = $this->tempDir; + } + $this->lockFile = implode('/', [ + trim($tempDir), + trim($this->id) . '.lock', + ]); + if ($whenOverlapping) { + $this->whenOverlapping = $whenOverlapping; + } else { + $this->whenOverlapping = function () { + return false; + }; + } + + return $this; + } + + /** + * Configure the job. + * + * @param array $config + * @return self + */ + public function configure(array $config = []) + { + // Check if config has defined a tempDir + if (isset($config['tempDir']) && is_dir($config['tempDir'])) { + $this->tempDir = $config['tempDir']; + } + + return $this; + } + + /** + * Truth test to define if the job should run if due. + * + * @param callable $fn + * @return self + */ + public function when(callable $fn) + { + $this->truthTest = $fn(); + + return $this; + } + + /** + * Run the job. + * + * @return bool + */ + public function run() + { + // If the truthTest failed, don't run + if ($this->truthTest !== true) { + return false; + } + + // If overlapping, don't run + if ($this->isOverlapping()) { + return false; + } + + // Write lock file if necessary + $this->createLockFile(); + + // Call before if required + if (is_callable($this->before)) { + call_user_func($this->before); + } + + // If command is callable... + if (is_callable($this->command)) { + $this->output = $this->exec(); + } else { + $args = is_string($this->args) ? explode(' ', $this->args) : $this->args; + $command = array_merge([$this->command], $args); + $process = new Process($command); + + $this->process = $process; + + if ($this->runInBackground()) { + $process->start(); + } else { + $process->run(); + $this->finalize(); + } + } + + return true; + } + + /** + * Finish up processing the job + * + * @return void + */ + public function finalize() + { + $process = $this->process; + + if ($process) { + $process->wait(); + + if ($process->isSuccessful()) { + $this->successful = true; + $this->output = $process->getOutput(); + } else { + $this->successful = false; + $this->output = $process->getErrorOutput(); + } + + $this->postRun(); + + unset($this->process); + } + } + + /** + * Things to run after job has run + * + * @return void + */ + private function postRun() + { + if (count($this->outputTo) > 0) { + foreach ($this->outputTo as $file) { + $output_mode = $this->outputMode === 'append' ? FILE_APPEND | LOCK_EX : LOCK_EX; + file_put_contents($file, $this->output, $output_mode); + } + } + + // Send output to email + $this->emailOutput(); + + // Call any callback defined + if (is_callable($this->after)) { + call_user_func($this->after, $this->output, $this->returnCode); + } + + $this->removeLockFile(); + } + + /** + * Create the job lock file. + * + * @param mixed $content + * @return void + */ + private function createLockFile($content = null) + { + if ($this->lockFile) { + if ($content === null || !is_string($content)) { + $content = $this->getId(); + } + file_put_contents($this->lockFile, $content); + } + } + + /** + * Remove the job lock file. + * + * @return void + */ + private function removeLockFile() + { + if ($this->lockFile && file_exists($this->lockFile)) { + unlink($this->lockFile); + } + } + + /** + * Execute a callable job. + * + * @return string + * @throws RuntimeException + */ + private function exec() + { + $return_data = ''; + ob_start(); + try { + $return_data = call_user_func_array($this->command, $this->args); + $this->successful = true; + } 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; + } + + /** + * Set the file/s where to write the output of the job. + * + * @param string|array $filename + * @param bool $append + * @return self + */ + public function output($filename, $append = false) + { + $this->outputTo = is_array($filename) ? $filename : [$filename]; + $this->outputMode = $append === false ? 'overwrite' : 'append'; + + return $this; + } + + /** + * Get the job output. + * + * @return mixed + */ + public function getOutput() + { + return $this->output; + } + + /** + * Set the emails where the output should be sent to. + * The Job should be set to write output to a file + * for this to work. + * + * @param string|array $email + * @return self + */ + public function email($email) + { + if (!is_string($email) && !is_array($email)) { + throw new InvalidArgumentException('The email can be only string or array'); + } + + $this->emailTo = is_array($email) ? $email : [$email]; + // Force the job to run in foreground + $this->inForeground(); + + return $this; + } + + /** + * Email the output of the job, if any. + * + * @return bool + */ + private function emailOutput() + { + if (!count($this->outputTo) || !count($this->emailTo)) { + return false; + } + + if (is_callable('Grav\Plugin\Email\Utils::sendEmail')) { + $subject ='Grav Scheduled Job [' . $this->getId() . ']'; + $content = "

Output from Job ID: {$this->getId()}

\n

Command: {$this->getCommand()}


\n".$this->getOutput()."\n
"; + $to = $this->emailTo; + + \Grav\Plugin\Email\Utils::sendEmail($subject, $content, $to); + } + + return true; + } + + /** + * Set function to be called before job execution + * Job object is injected as a parameter to callable function. + * + * @param callable $fn + * @return self + */ + public function before(callable $fn) + { + $this->before = $fn; + + return $this; + } + + /** + * Set a function to be called after job execution. + * By default this will force the job to run in foreground + * because the output is injected as a parameter of this + * function, but it could be avoided by passing true as a + * second parameter. The job will run in background if it + * meets all the other criteria. + * + * @param callable $fn + * @param bool $runInBackground + * @return self + */ + public function then(callable $fn, $runInBackground = false) + { + $this->after = $fn; + // Force the job to run in foreground + if ($runInBackground === false) { + $this->inForeground(); + } + + return $this; + } +} diff --git a/system/src/Grav/Common/Scheduler/Scheduler.php b/system/src/Grav/Common/Scheduler/Scheduler.php new file mode 100644 index 0000000..73a0712 --- /dev/null +++ b/system/src/Grav/Common/Scheduler/Scheduler.php @@ -0,0 +1,437 @@ +get('scheduler.defaults', []); + $this->config = $config; + + $this->status_path = Grav::instance()['locator']->findResource('user-data://scheduler', true, true); + if (!file_exists($this->status_path)) { + Folder::create($this->status_path); + } + } + + /** + * Load saved jobs from config/scheduler.yaml file + * + * @return $this + */ + public function loadSavedJobs() + { + $this->saved_jobs = []; + $saved_jobs = (array) Grav::instance()['config']->get('scheduler.custom_jobs', []); + + foreach ($saved_jobs as $id => $j) { + $args = $j['args'] ?? []; + $id = Grav::instance()['inflector']->hyphenize($id); + $job = $this->addCommand($j['command'], $args, $id); + + if (isset($j['at'])) { + $job->at($j['at']); + } + + if (isset($j['output'])) { + $mode = isset($j['output_mode']) && $j['output_mode'] === 'append'; + $job->output($j['output'], $mode); + } + + if (isset($j['email'])) { + $job->email($j['email']); + } + + // store in saved_jobs + $this->saved_jobs[] = $job; + } + + return $this; + } + + /** + * Get the queued jobs as background/foreground + * + * @param bool $all + * @return array + */ + public function getQueuedJobs($all = false) + { + $background = []; + $foreground = []; + foreach ($this->jobs as $job) { + if ($all || $job->getEnabled()) { + if ($job->runInBackground()) { + $background[] = $job; + } else { + $foreground[] = $job; + } + } + } + return [$background, $foreground]; + } + + /** + * Get all jobs if they are disabled or not as one array + * + * @return Job[] + */ + public function getAllJobs() + { + [$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|null $id Optional custom identifier + * @return Job + */ + public function addFunction(callable $fn, $args = [], $id = null) + { + $job = new Job($fn, $args, $id); + $this->queueJob($job->configure($this->config)); + + return $job; + } + + /** + * Queue a raw shell command. + * + * @param string $command The command to execute + * @param array $args Optional arguments to pass to the command + * @param string|null $id Optional custom identifier + * @return Job + */ + public function addCommand($command, $args = [], $id = null) + { + $job = new Job($command, $args, $id); + $this->queueJob($job->configure($this->config)); + + return $job; + } + + /** + * Run the scheduler. + * + * @param DateTime|null $runTime Optional, run at specific moment + * @param bool $force force run even if not due + */ + public function run(DateTime $runTime = null, $force = false) + { + $this->loadSavedJobs(); + + [$background, $foreground] = $this->getQueuedJobs(false); + $alljobs = array_merge($background, $foreground); + + if (null === $runTime) { + $runTime = new DateTime('now'); + } + + // Star processing jobs + foreach ($alljobs as $job) { + if ($job->isDue($runTime) || $force) { + $job->run(); + $this->jobs_run[] = $job; + } + } + + // Finish handling any background jobs + foreach ($background as $job) { + $job->finalize(); + } + + // Store states + $this->saveJobStates(); + } + + /** + * Reset all collected data of last run. + * + * Call before run() if you call run() multiple times. + * + * @return $this + */ + public function resetRun() + { + // Reset collected data of last run + $this->executed_jobs = []; + $this->failed_jobs = []; + $this->output_schedule = []; + + return $this; + } + + /** + * Get the scheduler verbose output. + * + * @param string $type Allowed: text, html, array + * @return string|array The return depends on the requested $type + */ + public function getVerboseOutput($type = 'text') + { + switch ($type) { + case 'text': + return implode("\n", $this->output_schedule); + case 'html': + return implode('
', $this->output_schedule); + case 'array': + return $this->output_schedule; + default: + throw new InvalidArgumentException('Invalid output type'); + } + } + + /** + * Remove all queued Jobs. + * + * @return $this + */ + public function clearJobs() + { + $this->jobs = []; + + return $this; + } + + /** + * Helper to get the full Cron command + * + * @return string + */ + public function getCronCommand() + { + $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->run(); + + if ($process->isSuccessful()) { + $output = $process->getOutput(); + $command = str_replace('/', '\/', $this->getSchedulerCommand('.*')); + $full_command = '/^(?!#).* .* .* .* .* ' . $command . '/m'; + + return preg_match($full_command, $output) ? 1 : 0; + } + + $error = $process->getErrorOutput(); + + return Utils::startsWith($error, 'crontab: no crontab') ? 0 : 2; + } + + /** + * Get the Job states file + * + * @return YamlFile + */ + public function getJobStates() + { + return YamlFile::instance($this->status_path . '/status.yaml'); + } + + /** + * Save job states to statys file + * + * @return void + */ + private function saveJobStates() + { + $now = time(); + $new_states = []; + + foreach ($this->jobs_run as $job) { + if ($job->isSuccessful()) { + $new_states[$job->getId()] = ['state' => 'success', 'last-run' => $now]; + $this->pushExecutedJob($job); + } else { + $new_states[$job->getId()] = ['state' => 'failure', 'last-run' => $now, 'error' => $job->getOutput()]; + $this->pushFailedJob($job); + } + } + + $saved_states = $this->getJobStates(); + $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. + * + * @param Job $job + * @return void + */ + private function queueJob(Job $job) + { + $this->jobs[] = $job; + + // Store jobs + } + + /** + * Add an entry to the scheduler verbose output array. + * + * @param string $string + * @return void + */ + private function addSchedulerVerboseOutput($string) + { + $now = '[' . (new DateTime('now'))->format('c') . '] '; + $this->output_schedule[] = $now . $string; + // Print to stdoutput in light gray + // echo "\033[37m{$string}\033[0m\n"; + } + + /** + * Push a succesfully executed job. + * + * @param Job $job + * @return Job + */ + private function pushExecutedJob(Job $job) + { + $this->executed_jobs[] = $job; + $command = $job->getCommand(); + $args = $job->getArguments(); + // If callable, log the string Closure + if (is_callable($command)) { + $command = is_string($command) ? $command : 'Closure'; + } + $this->addSchedulerVerboseOutput("Success: {$command} {$args}"); + + return $job; + } + + /** + * Push a failed job. + * + * @param Job $job + * @return Job + */ + private function pushFailedJob(Job $job) + { + $this->failed_jobs[] = $job; + $command = $job->getCommand(); + // If callable, log the string Closure + if (is_callable($command)) { + $command = is_string($command) ? $command : 'Closure'; + } + $output = trim($job->getOutput()); + $this->addSchedulerVerboseOutput("Error: {$command}{$output}"); + + return $job; + } +} diff --git a/system/src/Grav/Common/Security.php b/system/src/Grav/Common/Security.php index 8fa00e1..fe33d79 100644 --- a/system/src/Grav/Common/Security.php +++ b/system/src/Grav/Common/Security.php @@ -1,17 +1,77 @@ get('security.sanitize_svg')) { + $sanitizer = new Sanitizer(); + $sanitized = $sanitizer->sanitize($svg); + if (is_string($sanitized)) { + $svg = $sanitized; + } + } - public static function detectXssFromPages($pages, callable $status = null) + 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(); @@ -20,14 +80,13 @@ class Security $list = []; -// // This needs Symfony 4.1 to work -// $status && $status([ -// 'type' => 'count', -// 'steps' => count($routes), -// ]); + // This needs Symfony 4.1 to work + $status && $status([ + 'type' => 'count', + 'steps' => count($routes), + ]); foreach ($routes as $path) { - $status && $status([ 'type' => 'progress', ]); @@ -43,10 +102,13 @@ class Security $results = Security::detectXssFromArray($data); if (!empty($results)) { - $list[$page->filePathClean()] = $results; + if ($route) { + $list[$page->route()] = $results; + } else { + $list[$page->filePathClean()] = $results; + } } - - } catch (\Exception $e) { + } catch (Exception $e) { continue; } } @@ -55,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]; } } @@ -81,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 boolean|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 @@ -103,33 +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 = $config->get('security.xss_dangerous_tags'); - $dangerous_tags = array_map('preg_quote', array_map("trim", $dangerous_tags)); - - $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' => '#((java|live|vb)script|mocha|feed|data):.*?#iUu', + 'invalid_protocols' => '#(' . implode('|', array_map('preg_quote', $invalid_protocols, ['#'])) . '):\S.*?#iUu', // Match -moz-bindings 'moz_binding' => '#-moz-binding[a-z\x00-\x20]*:#u', @@ -138,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 new file mode 100644 index 0000000..8b158c9 --- /dev/null +++ b/system/src/Grav/Common/Service/AccountsServiceProvider.php @@ -0,0 +1,157 @@ +addTypes($config->get('permissions.types', [])); + + $array = $config->get('permissions.actions'); + if (is_array($array)) { + $actions = PermissionsReader::fromArray($array, $permissions->getTypes()); + $permissions->addActions($actions); + } + + $event = new PermissionsRegisterEvent($permissions); + $container->dispatchEvent($event); + + return $permissions; + }; + + $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']; + }); + } + + /** + * @param Container $container + * @return string + */ + protected function initialize(Container $container): string + { + $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) + { + /** @var Flex $flex */ + $flex = $container['flex']; + $directory = $flex->getDirectory('user-accounts'); + + return $directory ? $directory->getIndex() : null; + } +} diff --git a/system/src/Grav/Common/Service/AssetsServiceProvider.php b/system/src/Grav/Common/Service/AssetsServiceProvider.php index 9618013..1e4e647 100644 --- a/system/src/Grav/Common/Service/AssetsServiceProvider.php +++ b/system/src/Grav/Common/Service/AssetsServiceProvider.php @@ -1,8 +1,9 @@ setup(); + + return $backups; + }; + } +} diff --git a/system/src/Grav/Common/Service/ConfigServiceProvider.php b/system/src/Grav/Common/Service/ConfigServiceProvider.php index 379c042..a423f6e 100644 --- a/system/src/Grav/Common/Service/ConfigServiceProvider.php +++ b/system/src/Grav/Common/Service/ConfigServiceProvider.php @@ -1,30 +1,44 @@ init(); + + return $setup; }; $container['blueprints'] = function ($c) { @@ -45,13 +59,16 @@ class ConfigServiceProvider implements ServiceProviderInterface $container['languages'] = function ($c) { return static::languages($c); }; + + $container['language'] = function ($c) { + return new Language($c); + }; } - public static function setup(Container $container) - { - return new Setup($container); - } - + /** + * @param Container $container + * @return mixed + */ public static function blueprints(Container $container) { /** Setup $setup */ @@ -67,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); @@ -92,15 +111,24 @@ 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); - $config = new CompiledConfig($cache, $files, GRAV_ROOT); - $config->setBlueprints(function() use ($container) { + $compiled = new CompiledConfig($cache, $files, GRAV_ROOT); + $compiled->setBlueprints(function () use ($container) { return $container['blueprints']; }); - return $config->name("master-{$setup->environment}")->load(); + $config = $compiled->name("master-{$setup->environment}")->load(); + $config->environment = $setup->environment; + + return $config; } + /** + * @param Container $container + * @return mixed + */ public static function languages(Container $container) { /** @var Setup $setup */ @@ -133,8 +161,8 @@ class ConfigServiceProvider implements ServiceProviderInterface /** * Find specific paths in plugins * - * @param $plugins - * @param $folder_path + * @param array $plugins + * @param string $folder_path * @return array */ private static function pluginFolderPaths($plugins, $folder_path) @@ -142,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; @@ -161,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 139de54..3227365 100644 --- a/system/src/Grav/Common/Service/ErrorServiceProvider.php +++ b/system/src/Grav/Common/Service/ErrorServiceProvider.php @@ -1,8 +1,9 @@ $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 new file mode 100644 index 0000000..861a69e --- /dev/null +++ b/system/src/Grav/Common/Service/InflectorServiceProvider.php @@ -0,0 +1,32 @@ +findResource('log://grav.log', true, true); - $log->pushHandler(new StreamHandler($log_file, Logger::DEBUG)); return $log; diff --git a/system/src/Grav/Common/Service/OutputServiceProvider.php b/system/src/Grav/Common/Service/OutputServiceProvider.php index 89322ee..9a49185 100644 --- a/system/src/Grav/Common/Service/OutputServiceProvider.php +++ b/system/src/Grav/Common/Service/OutputServiceProvider.php @@ -1,27 +1,36 @@ processSite($page->templateFormat()); diff --git a/system/src/Grav/Common/Service/PageServiceProvider.php b/system/src/Grav/Common/Service/PageServiceProvider.php deleted file mode 100644 index ccfa580..0000000 --- a/system/src/Grav/Common/Service/PageServiceProvider.php +++ /dev/null @@ -1,99 +0,0 @@ -path() ?: '/'; // Don't trim to support trailing slash default routes - $page = $pages->dispatch($path); - - // Redirection tests - if ($page) { - // some debugger override logic - if ($page->debugger() === false) { - $c['debugger']->enabled(false); - } - - if ($config->get('system.force_ssl')) { - if (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') { - $url = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; - $c->redirect($url); - } - } - - $url = $pages->route($page->route()); - - 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(); - } - - /** @var Language $language */ - $language = $c['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); - } - } - - // if page is not found, try some fallback stuff - if (!$page || !$page->routable()) { - - // Try fallback URL stuff... - $page = $c->fallbackUrl($path); - - if (!$page) { - $path = $c['locator']->findResource('system://pages/notfound.md'); - $page = new Page(); - $page->init(new \SplFileInfo($path)); - $page->routable(false); - } - } - - return $page; - }; - } -} diff --git a/system/src/Grav/Common/Service/PagesServiceProvider.php b/system/src/Grav/Common/Service/PagesServiceProvider.php new file mode 100644 index 0000000..55f6a45 --- /dev/null +++ b/system/src/Grav/Common/Service/PagesServiceProvider.php @@ -0,0 +1,139 @@ +findResource('system://pages/notfound.md'); + $page = new Page(); + $page->init(new SplFileInfo($path)); + $page->routable(false); + + return $page; + }; + + return; + } + + $container['page'] = static function (Grav $grav) { + /** @var Pages $pages */ + $pages = $grav['pages']; + + /** @var Config $config */ + $config = $grav['config']; + + /** @var Uri $uri */ + $uri = $grav['uri']; + + $path = $uri->path() ?: '/'; // Don't trim to support trailing slash default routes + $page = $pages->dispatch($path); + + // Redirection tests + if ($page) { + // some debugger override logic + if ($page->debugger() === false) { + $grav['debugger']->enabled(false); + } + + if ($config->get('system.force_ssl')) { + $scheme = $uri->scheme(true); + if ($scheme !== 'https') { + $url = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; + $grav->redirect($url); + } + } + + $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->query()) { + $url .= '?' . $uri->query(); + } + if ($uri->fragment()) { + $url .= '#' . $uri->fragment(); + } + + /** @var Language $language */ + $language = $grav['language']; + + $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 = $grav->fallbackUrl($path); + + if (!$page) { + $path = $grav['locator']->findResource('system://pages/notfound.md'); + $page = new Page(); + $page->init(new SplFileInfo($path)); + $page->routable(false); + } + } + + return $page; + }; + } +} diff --git a/system/src/Grav/Common/Service/RequestServiceProvider.php b/system/src/Grav/Common/Service/RequestServiceProvider.php new file mode 100644 index 0000000..17b3149 --- /dev/null +++ b/system/src/Grav/Common/Service/RequestServiceProvider.php @@ -0,0 +1,103 @@ + $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 () { + return clone Uri::getCurrentRoute(); + }); + } +} diff --git a/system/src/Grav/Common/Service/SchedulerServiceProvider.php b/system/src/Grav/Common/Service/SchedulerServiceProvider.php new file mode 100644 index 0000000..a952725 --- /dev/null +++ b/system/src/Grav/Common/Service/SchedulerServiceProvider.php @@ -0,0 +1,32 @@ +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')) { - $base = '/' . trim($config->get('plugins.admin.route'), '/'); + $admin_base = '/' . trim($config->get('plugins.admin.route'), '/'); // Uri::route() is not processed yet, let's quickly get what we need. $current_route = str_replace(Uri::filterPath($uri->rootUrl(false)), '', parse_url($uri->url(true), PHP_URL_PATH)); + // Test to see if path starts with a supported language + admin base + $lang = Utils::pathPrefixedByLangCode($current_route); + $lang_admin_base = '/' . $lang . $admin_base; + // Check no language, simple language prefix (en) and region specific language prefix (en-US). - $pos = strpos($current_route, $base); - if ($pos === 0 || $pos === 3 || $pos === 6) { + if (Utils::startsWith($current_route, $admin_base) || Utils::startsWith($current_route, $lang_admin_base)) { $cookie_lifetime = $config->get('plugins.admin.session.timeout', 1800); $enabled = $is_admin = true; } @@ -67,8 +84,11 @@ class SessionServiceProvider implements ServiceProviderInterface $cookie_lifetime = 9999999999; } - $inflector = new Inflector(); - $session_name = $inflector->hyphenize($config->get('system.session.name', 'grav_site')) . '-' . substr(md5(GRAV_ROOT), 0, 7); + $session_prefix = $c['inflector']->hyphenize($config->get('system.session.name', 'grav-site')); + $session_uniqueness = $config->get('system.session.uniqueness', 'path') === 'path' ? substr(md5(GRAV_ROOT), 0, 7) : md5($config->get('security.salt')); + + $session_name = $session_prefix . '-' . $session_uniqueness; + if ($is_admin && $config->get('system.session.split', true)) { $session_name .= '-admin'; } @@ -80,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); @@ -96,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 a2ebcc6..edde09a 100644 --- a/system/src/Grav/Common/Service/StreamsServiceProvider.php +++ b/system/src/Grav/Common/Service/StreamsServiceProvider.php @@ -1,8 +1,9 @@ initializeLocator($locator); return $locator; }; - $container['streams'] = function($c) { + $container['streams'] = function (Container $container) { /** @var Setup $setup */ - $setup = $c['setup']; + $setup = $container['setup']; /** @var UniformResourceLocator $locator */ - $locator = $c['locator']; + $locator = $container['locator']; // Set locator to both streams. Stream::setLocator($locator); diff --git a/system/src/Grav/Common/Service/TaskServiceProvider.php b/system/src/Grav/Common/Service/TaskServiceProvider.php index 40b9696..9afab59 100644 --- a/system/src/Grav/Common/Service/TaskServiceProvider.php +++ b/system/src/Grav/Common/Service/TaskServiceProvider.php @@ -1,8 +1,9 @@ param('task'); + $container['task'] = function (Grav $c) { + $task = $_POST['task'] ?? $c['uri']->param('task'); + if (null !== $task) { + $task = filter_var($task, FILTER_SANITIZE_STRING); + } + + return $task ?: null; + }; + + $container['action'] = function (Grav $c) { + $action = $_POST['action'] ?? $c['uri']->param('action'); + if (null !== $action) { + $action = filter_var($action, FILTER_SANITIZE_STRING); + } + + return $action ?: null; }; } } diff --git a/system/src/Grav/Common/Session.php b/system/src/Grav/Common/Session.php index d617f46..8856e7a 100644 --- a/system/src/Grav/Common/Session.php +++ b/system/src/Grav/Common/Session.php @@ -1,13 +1,23 @@ getInstance() method instead. */ public static function instance() { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use getInstance() method instead', E_USER_DEPRECATED); + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getInstance() method instead', E_USER_DEPRECATED); return static::getInstance(); } @@ -28,10 +38,12 @@ class Session extends \Grav\Framework\Session\Session * Initialize session. * * Code in this function has been moved into SessionServiceProvider class. + * + * @return void */ public function init() { - if ($this->autoStart) { + if ($this->autoStart && !$this->isStarted()) { $this->start(); $this->autoStart = false; @@ -53,11 +65,11 @@ class Session extends \Grav\Framework\Session\Session * Returns attributes. * * @return array Attributes - * @deprecated 1.5 Use getAll() method instead + * @deprecated 1.5 Use ->getAll() method instead. */ public function all() { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use getAll() method instead', E_USER_DEPRECATED); + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getAll() method instead', E_USER_DEPRECATED); return $this->getAll(); } @@ -65,12 +77,12 @@ class Session extends \Grav\Framework\Session\Session /** * Checks if the session was started. * - * @return Boolean - * @deprecated 1.5 Use isStarted() method instead + * @return bool + * @deprecated 1.5 Use ->isStarted() method instead. */ public function started() { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use isStarted() method instead', E_USER_DEPRECATED); + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->isStarted() method instead', E_USER_DEPRECATED); return $this->isStarted(); } @@ -84,7 +96,7 @@ class Session extends \Grav\Framework\Session\Session */ public function setFlashObject($name, $object) { - $this->{$name} = serialize($object); + $this->__set($name, serialize($object)); return $this; } @@ -97,9 +109,34 @@ class Session extends \Grav\Framework\Session\Session */ public function getFlashObject($name) { - $object = unserialize($this->{$name}); + $serialized = $this->__get($name); - $this->{$name} = null; + $object = is_string($serialized) ? unserialize($serialized, ['allowed_classes' => true]) : $serialized; + + $this->__unset($name); + + if ($name === 'files-upload') { + $grav = Grav::instance(); + + // Make sure that Forms 3.0+ has been installed. + if (null === $object && isset($grav['forms'])) { + user_error( + __CLASS__ . '::' . __FUNCTION__ . '(\'files-upload\') is deprecated since Grav 1.6, use $form->getFlash()->getLegacyFiles() instead', + E_USER_DEPRECATED + ); + + /** @var Uri $uri */ + $uri = $grav['uri']; + /** @var Forms|null $form */ + $form = $grav['forms']->getActiveForm(); // @phpstan-ignore-line + + $sessionField = base64_encode($uri->url); + + /** @var FormFlash|null $flash */ + $flash = $form ? $form->getFlash() : null; // @phpstan-ignore-line + $object = $flash && method_exists($flash, 'getLegacyFiles') ? [$sessionField => $flash->getLegacyFiles()] : null; + } + } return $object; } @@ -128,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 dda2844..de3c452 100644 --- a/system/src/Grav/Common/Taxonomy.php +++ b/system/src/Grav/Common/Taxonomy.php @@ -1,8 +1,9 @@ published()) { + return; + } + if (!$page_taxonomy) { $page_taxonomy = $page->taxonomy(); } - if (!$page->published() || empty($page_taxonomy)) { + 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()]; } } @@ -81,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') @@ -99,7 +134,7 @@ class Taxonomy } } - if (strtolower($operator) == 'or') { + if (strtolower($operator) === 'or') { foreach ($matches as $match) { $results = array_merge($results, $match); } @@ -116,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) @@ -133,17 +167,10 @@ class Taxonomy * Gets item keys per taxonomy * * @param string $taxonomy taxonomy name - * * @return array keys of this taxonomy */ - public function getTaxonomyItemKeys($taxonomy) { - if (isset($this->taxonomy_map[$taxonomy])) { - - $results = array_keys($this->taxonomy_map[$taxonomy]); - - return $results; - } - - return []; + public function getTaxonomyItemKeys($taxonomy) + { + return isset($this->taxonomy_map[$taxonomy]) ? array_keys($this->taxonomy_map[$taxonomy]) : []; } } diff --git a/system/src/Grav/Common/Theme.php b/system/src/Grav/Common/Theme.php index 537df11..a5006a2 100644 --- a/system/src/Grav/Common/Theme.php +++ b/system/src/Grav/Common/Theme.php @@ -1,17 +1,22 @@ 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(Page $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 bf86d8d..bf155ed 100644 --- a/system/src/Grav/Common/Themes.php +++ b/system/src/Grav/Common/Themes.php @@ -1,29 +1,41 @@ initTheme(); } + /** + * @return void + */ public function initTheme() { if ($this->inited === false) { @@ -59,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'); @@ -92,20 +129,32 @@ class Themes extends Iterator $iterator = $locator->getIterator('themes://'); - /** @var \DirectoryIterator $directory */ + /** @var DirectoryIterator $directory */ foreach ($iterator as $directory) { if (!$directory->isDir() || $directory->isDot()) { continue; } $theme = $directory->getFilename(); - $result = self::get($theme); + + try { + $result = $this->get($theme); + } catch (Exception $e) { + $exception = new RuntimeException(sprintf('Theme %s: %s', $theme, $e->getMessage()), $e->getCode(), $e); + + /** @var Debugger $debugger */ + $debugger = $this->grav['debugger']; + $debugger->addMessage("Theme {$theme} cannot be loaded, please check Exceptions tab", 'error'); + $debugger->addException($exception); + + continue; + } if ($result) { $list[$theme] = $result; } } - ksort($list); + ksort($list, SORT_NATURAL | SORT_FLAG_CASE); return $list; } @@ -114,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://'); @@ -143,7 +191,7 @@ class Themes extends Iterator $blueprint->set('thumbnail', $this->grav['base_url'] . '/' . $path); } - $obj = new Data($file->content(), $blueprint); + $obj = new Data((array)$file->content(), $blueprint); // Override with user configuration. $obj->merge($this->config->get('themes.' . $name) ?: []); @@ -181,28 +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)) { - $themeClassName = $themeClass; - $class = new $themeClassName($grav, $config, $name); + 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)); @@ -217,7 +265,8 @@ class Themes extends Iterator /** * Configure and prepare streams for current template. * - * @throws \InvalidArgumentException + * @return void + * @throws InvalidArgumentException */ public function configure() { @@ -249,7 +298,7 @@ class Themes extends Iterator } } - if (in_array($scheme, $registered)) { + if (in_array($scheme, $registered, true)) { stream_wrapper_unregister($scheme); } $type = !empty($config['type']) ? $config['type'] : 'ReadOnlyStream'; @@ -258,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."); } } @@ -271,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) { @@ -280,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) { @@ -289,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; @@ -315,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) @@ -345,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..9918af8 --- /dev/null +++ b/system/src/Grav/Common/Twig/Extension/FilesystemExtension.php @@ -0,0 +1,387 @@ +locator = Grav::instance()['locator']; + } + + /** + * @return TwigFilter[] + */ + public function getFilters() + { + return [ + new TwigFilter('file_exists', [$this, 'file_exists']), + new TwigFilter('fileatime', [$this, 'fileatime']), + new TwigFilter('filectime', [$this, 'filectime']), + new TwigFilter('filemtime', [$this, 'filemtime']), + new TwigFilter('filesize', [$this, 'filesize']), + new TwigFilter('filetype', [$this, 'filetype']), + new TwigFilter('is_dir', [$this, 'is_dir']), + new TwigFilter('is_file', [$this, 'is_file']), + new TwigFilter('is_link', [$this, 'is_link']), + new TwigFilter('is_readable', [$this, 'is_readable']), + new TwigFilter('is_writable', [$this, 'is_writable']), + new TwigFilter('is_writeable', [$this, 'is_writable']), + new TwigFilter('lstat', [$this, 'lstat']), + new TwigFilter('getimagesize', [$this, 'getimagesize']), + new TwigFilter('exif_read_data', [$this, 'exif_read_data']), + new TwigFilter('read_exif_data', [$this, 'exif_read_data']), + new TwigFilter('exif_imagetype', [$this, 'exif_imagetype']), + new TwigFilter('hash_file', [$this, 'hash_file']), + new TwigFilter('hash_hmac_file', [$this, 'hash_hmac_file']), + new TwigFilter('md5_file', [$this, 'md5_file']), + new TwigFilter('sha1_file', [$this, 'sha1_file']), + new TwigFilter('get_meta_tags', [$this, 'get_meta_tags']), + new TwigFilter('pathinfo', [$this, 'pathinfo']), + ]; + } + + /** + * 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']), + new TwigFunction('pathinfo', [$this, 'pathinfo']), + ]; + } + + /** + * @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 $path + * @param int $flags + * @return string|string[] + */ + public function pathinfo($path, $flags = PATHINFO_ALL) + { + return pathinfo($path); + } + + /** + * @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..7774d2a --- /dev/null +++ b/system/src/Grav/Common/Twig/Extension/GravExtension.php @@ -0,0 +1,1612 @@ +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']), + + // PHP methods + new TwigFilter('count', 'count'), + new TwigFilter('array_diff', 'array_diff'), + ]; + } + + /** + * 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']), + + // PHP methods + new TwigFunction('is_numeric', 'is_numeric'), + new TwigFunction('is_iterable', 'is_iterable'), + new TwigFunction('is_countable', 'is_countable'), + new TwigFunction('is_null', 'is_null'), + new TwigFunction('is_string', 'is_string'), + new TwigFunction('is_array', 'is_array'), + new TwigFunction('is_object', 'is_object'), + new TwigFunction('count', 'count'), + new TwigFunction('array_diff', 'array_diff'), + ]; + } + + /** + * @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 0aa12ea..81cecae 100644 --- a/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php +++ b/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php @@ -1,25 +1,42 @@ $body], [], $lineno, $tag); } + /** * Compiles the node to PHP. * - * @param \Twig_Compiler A Twig_Compiler instance + * @param Compiler $compiler A Twig Compiler instance + * @return void */ - public function compile(\Twig_Compiler $compiler) + public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) @@ -30,6 +47,6 @@ class TwigNodeMarkdown extends \Twig_Node implements \Twig_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($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 new file mode 100644 index 0000000..798c6ba --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeRender.php @@ -0,0 +1,83 @@ + $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 + * @return void + * @throws LogicException + */ + public function compile(Compiler $compiler): void + { + $compiler->addDebugInfo($this); + $compiler->write('$object = ')->subcompile($this->getNode('object'))->raw(';' . PHP_EOL); + + if ($this->hasNode('layout')) { + $layout = $this->getNode('layout'); + $compiler->write('$layout = ')->subcompile($layout)->raw(';' . PHP_EOL); + } else { + $compiler->write('$layout = null;' . PHP_EOL); + } + + if ($this->hasNode('context')) { + $context = $this->getNode('context'); + $compiler->write('$attributes = ')->subcompile($context)->raw(';' . PHP_EOL); + } else { + $compiler->write('$attributes = null;' . PHP_EOL); + } + + $compiler + ->write('$html = $object->render($layout, $attributes ?? []);' . PHP_EOL) + ->write('$block = $context[\'block\'] ?? null;' . PHP_EOL) + ->write('if ($block instanceof \Grav\Framework\ContentBlock\ContentBlock && $html instanceof \Grav\Framework\ContentBlock\ContentBlock) {' . PHP_EOL) + ->indent() + ->write('$block->addBlock($html);' . PHP_EOL) + ->write('echo $html->getToken();' . PHP_EOL) + ->outdent() + ->write('} else {' . PHP_EOL) + ->indent() + ->write('echo (string)$html;' . PHP_EOL) + ->outdent() + ->write('}' . PHP_EOL) + ; + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeScript.php b/system/src/Grav/Common/Twig/Node/TwigNodeScript.php index 23f16a3..46a0870 100644 --- a/system/src/Grav/Common/Twig/Node/TwigNodeScript.php +++ b/system/src/Grav/Common/Twig/Node/TwigNodeScript.php @@ -1,55 +1,66 @@ $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 \Twig_Compiler $compiler A Twig_Compiler instance - * @throws \LogicException + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException */ - public function compile(\Twig_Compiler $compiler) + public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); - if ($this->getNode('attributes') !== null) { + $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];\n"); + + if ($this->hasNode('attributes')) { $compiler ->write('$attributes = ') ->subcompile($this->getNode('attributes')) ->raw(";\n") - ->write("if (\$attributes !== null && !is_array(\$attributes)) {\n") + ->write("if (!is_array(\$attributes)) {\n") ->indent() ->write("throw new UnexpectedValueException('{% {$this->tagName} with x %}: x is not an array');\n") ->outdent() @@ -58,45 +69,36 @@ class TwigNodeScript extends \Twig_Node implements \Twig_NodeCaptureInterface $compiler->write('$attributes = [];' . "\n"); } - if ($this->getNode('group') !== null) { - $compiler - ->write('$group = ') - ->subcompile($this->getNode('group')) - ->raw(";\n") - ->write("if (\$group !== null && !is_string(\$group)) {\n") - ->indent() - ->write("throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');\n") - ->outdent() - ->write("}\n"); - } else { - $compiler->write('$group = null;' . "\n"); - } - - if ($this->getNode('priority') !== null) { + if ($this->hasNode('group')) { $compiler - ->write('$priority = (int)(') - ->subcompile($this->getNode('priority')) - ->raw(");\n"); - } else { - $compiler->write('$priority = null;' . "\n"); + ->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"); } - $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];\n"); - - if ($this->getNode('file') !== null) { + if ($this->hasNode('priority')) { $compiler - ->write('$file = ') + ->write("\$attributes['priority'] = (int)(") + ->subcompile($this->getNode('priority')) + ->raw(");\n"); + } + + if ($this->hasNode('file')) { + $compiler + ->write('$assets->addJs(') ->subcompile($this->getNode('file')) - ->write(";\n") - ->write("\$pipeline = !empty(\$attributes['pipeline']);\n") - ->write("\$loading = !empty(\$attributes['defer']) ? 'defer' : (!empty(\$attributes['async']) ? 'async' : null);\n") - ->write("\$assets->addJs(\$file, \$priority, \$pipeline, \$loading, \$group);\n"); + ->raw(", \$attributes);\n"); } else { $compiler ->write("ob_start();\n") ->subcompile($this->getNode('body')) - ->write("\$content = ob_get_clean();") - ->write("\$assets->addInlineJs(\$content, \$priority, \$group, \$attributes);\n"); + ->write('$content = ob_get_clean();' . "\n") + ->write("\$assets->addInlineJs(\$content, \$attributes);\n"); } } } diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php b/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php index ae30e43..05355f9 100644 --- a/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php +++ b/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php @@ -1,52 +1,65 @@ $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 \Twig_Compiler $compiler A Twig_Compiler instance - * @throws \LogicException + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException */ - public function compile(\Twig_Compiler $compiler) + public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); - if ($this->getNode('attributes') !== null) { + $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];\n"); + + if ($this->hasNode('attributes')) { $compiler ->write('$attributes = ') ->subcompile($this->getNode('attributes')) ->raw(";\n") - ->write("if (\$attributes !== null && !is_array(\$attributes)) {\n") + ->write("if (!is_array(\$attributes)) {\n") ->indent() ->write("throw new UnexpectedValueException('{% {$this->tagName} with x %}: x is not an array');\n") ->outdent() @@ -55,44 +68,36 @@ class TwigNodeStyle extends \Twig_Node implements \Twig_NodeCaptureInterface $compiler->write('$attributes = [];' . "\n"); } - if ($this->getNode('group') !== null) { + if ($this->hasNode('group')) { $compiler - ->write('$group = ') + ->write("\$attributes['group'] = ") ->subcompile($this->getNode('group')) ->raw(";\n") - ->write("if (\$group !== null && !is_string(\$group)) {\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"); - } else { - $compiler->write('$group = null;' . "\n"); } - if ($this->getNode('priority') !== null) { + if ($this->hasNode('priority')) { $compiler - ->write('$priority = (int)(') + ->write("\$attributes['priority'] = (int)(") ->subcompile($this->getNode('priority')) ->raw(");\n"); - } else { - $compiler->write('$priority = null;' . "\n"); } - $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];\n"); - - if ($this->getNode('file') !== null) { + if ($this->hasNode('file')) { $compiler - ->write('$file = ') + ->write('$assets->addCss(') ->subcompile($this->getNode('file')) - ->write(";\n") - ->write("\$pipeline = !empty(\$attributes['pipeline']);\n") - ->write("\$assets->addCss(\$file, \$priority, \$pipeline, \$group);\n"); + ->raw(", \$attributes);\n"); } else { $compiler ->write("ob_start();\n") ->subcompile($this->getNode('body')) - ->write("\$content = ob_get_clean();") - ->write("\$assets->addInlineCss(\$content, \$priority, \$group);\n"); + ->write('$content = ob_get_clean();' . "\n") + ->write("\$assets->addInlineCss(\$content, \$attributes);\n"); } } } diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php b/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php index a03faa5..43ae56f 100644 --- a/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php +++ b/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php @@ -1,32 +1,46 @@ $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 \Twig_Compiler A Twig_Compiler instance + * @param Compiler $compiler A Twig Compiler instance + * @return void */ - public function compile(\Twig_Compiler $compiler) + public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) @@ -35,6 +49,7 @@ class TwigNodeSwitch extends \Twig_Node ->raw(") {\n") ->indent(); + /** @var Node $case */ foreach ($this->getNode('cases') as $case) { if (!$case->hasNode('body')) { continue; @@ -56,7 +71,7 @@ class TwigNodeSwitch extends \Twig_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 new file mode 100644 index 0000000..1a32a91 --- /dev/null +++ b/system/src/Grav/Common/Twig/Node/TwigNodeThrow.php @@ -0,0 +1,52 @@ + $message], ['code' => $code], $lineno, $tag); + } + + /** + * Compiles the node to PHP. + * + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException + */ + public function compile(Compiler $compiler): void + { + $compiler->addDebugInfo($this); + + $compiler + ->write('throw new \RuntimeException(') + ->subcompile($this->getNode('message')) + ->write(', ') + ->write($this->getAttribute('code') ?: 500) + ->write(");\n"); + } +} diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php b/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php index ecfcdab..da95a1d 100644 --- a/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php +++ b/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php @@ -1,53 +1,64 @@ $try, 'catch' => $catch), array(), $lineno, $tag); + $nodes = ['try' => $try, 'catch' => $catch]; + $nodes = array_filter($nodes); + + parent::__construct($nodes, [], $lineno, $tag); } /** * Compiles the node to PHP. * - * @param \Twig_Compiler $compiler A Twig_Compiler instance - * @throws \LogicException + * @param Compiler $compiler A Twig Compiler instance + * @return void + * @throws LogicException */ - public function compile(\Twig_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 a1d4135..9dab4ae 100644 --- a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php @@ -1,14 +1,18 @@ getLine(); - $this->parser->getStream()->expect(\Twig_Token::BLOCK_END_TYPE); - $body = $this->parser->subparse(array($this, 'decideMarkdownEnd'), true); - $this->parser->getStream()->expect(\Twig_Token::BLOCK_END_TYPE); + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); + $body = $this->parser->subparse([$this, 'decideMarkdownEnd'], true); + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); return new TwigNodeMarkdown($body, $lineno, $this->getTag()); } /** * Decide if current token marks end of Markdown block. * - * @param \Twig_Token $token + * @param Token $token * @return bool */ - public function decideMarkdownEnd(\Twig_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 new file mode 100644 index 0000000..ab2bdde --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php @@ -0,0 +1,74 @@ +getLine(); + + [$object, $layout, $context] = $this->parseArguments($token); + + return new TwigNodeRender($object, $layout, $context, $lineno, $this->getTag()); + } + + /** + * @param Token $token + * @return array + */ + protected function parseArguments(Token $token): array + { + $stream = $this->parser->getStream(); + + $object = $this->parser->getExpressionParser()->parseExpression(); + + $layout = null; + if ($stream->nextIf(Token::NAME_TYPE, 'layout')) { + $stream->expect(Token::PUNCTUATION_TYPE, ':'); + $layout = $this->parser->getExpressionParser()->parseExpression(); + } + + $context = null; + if ($stream->nextIf(Token::NAME_TYPE, 'with')) { + $context = $this->parser->getExpressionParser()->parseExpression(); + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return [$object, $layout, $context]; + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + 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 fd87b1b..b860631 100644 --- a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php @@ -1,89 +1,109 @@ 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) { $content = $this->parser->subparse([$this, 'decideBlockEnd'], true); - $stream->expect(\Twig_Token::BLOCK_END_TYPE); + $stream->expect(Token::BLOCK_END_TYPE); } return new TwigNodeScript($content, $file, $group, $priority, $attributes, $lineno, $this->getTag()); } /** - * @param \Twig_Token $token + * @param Token $token * @return array */ - protected function parseArguments(\Twig_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(\Twig_Token::NAME_TYPE) && !$stream->test(\Twig_Token::OPERATOR_TYPE) && !$stream->test(\Twig_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(\Twig_Token::OPERATOR_TYPE, 'in')) { + if ($stream->nextIf(Token::NAME_TYPE, 'at') || $stream->nextIf(Token::OPERATOR_TYPE, 'in')) { $group = $this->parser->getExpressionParser()->parseExpression(); } $priority = null; - if ($stream->nextIf(\Twig_Token::NAME_TYPE, 'priority')) { - $stream->expect(\Twig_Token::PUNCTUATION_TYPE, ':'); + if ($stream->nextIf(Token::NAME_TYPE, 'priority')) { + $stream->expect(Token::PUNCTUATION_TYPE, ':'); $priority = $this->parser->getExpressionParser()->parseExpression(); } $attributes = null; - if ($stream->nextIf(\Twig_Token::NAME_TYPE, 'with')) { + if ($stream->nextIf(Token::NAME_TYPE, 'with')) { $attributes = $this->parser->getExpressionParser()->parseExpression(); } - $stream->expect(\Twig_Token::BLOCK_END_TYPE); + $stream->expect(Token::BLOCK_END_TYPE); return [$file, $group, $priority, $attributes]; } /** - * @param \Twig_Token $token + * @param Token $token * @return bool */ - public function decideBlockEnd(\Twig_Token $token) + public function decideBlockEnd(Token $token): bool { return $token->test('endscript'); } @@ -93,7 +113,7 @@ class TwigTokenParserScript extends \Twig_TokenParser * * @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 0c09ed4..c8d9544 100644 --- a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php @@ -1,14 +1,18 @@ getLine(); $stream = $this->parser->getStream(); - list ($file, $group, $priority, $attributes) = $this->parseArguments($token); + [$file, $group, $priority, $attributes] = $this->parseArguments($token); $content = null; if (!$file) { $content = $this->parser->subparse([$this, 'decideBlockEnd'], true); - $stream->expect(\Twig_Token::BLOCK_END_TYPE); + $stream->expect(Token::BLOCK_END_TYPE); } return new TwigNodeStyle($content, $file, $group, $priority, $attributes, $lineno, $this->getTag()); } /** - * @param \Twig_Token $token + * @param Token $token * @return array */ - protected function parseArguments(\Twig_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(\Twig_Token::NAME_TYPE) && !$stream->test(\Twig_Token::OPERATOR_TYPE) && !$stream->test(\Twig_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(\Twig_Token::OPERATOR_TYPE, 'in')) { + if ($stream->nextIf(Token::NAME_TYPE, 'at') || $stream->nextIf(Token::OPERATOR_TYPE, 'in')) { $group = $this->parser->getExpressionParser()->parseExpression(); } $priority = null; - if ($stream->nextIf(\Twig_Token::NAME_TYPE, 'priority')) { - $stream->expect(\Twig_Token::PUNCTUATION_TYPE, ':'); + if ($stream->nextIf(Token::NAME_TYPE, 'priority')) { + $stream->expect(Token::PUNCTUATION_TYPE, ':'); $priority = $this->parser->getExpressionParser()->parseExpression(); } $attributes = null; - if ($stream->nextIf(\Twig_Token::NAME_TYPE, 'with')) { + if ($stream->nextIf(Token::NAME_TYPE, 'with')) { $attributes = $this->parser->getExpressionParser()->parseExpression(); } - $stream->expect(\Twig_Token::BLOCK_END_TYPE); + $stream->expect(Token::BLOCK_END_TYPE); return [$file, $group, $priority, $attributes]; } /** - * @param \Twig_Token $token + * @param Token $token * @return bool */ - public function decideBlockEnd(\Twig_Token $token) + public function decideBlockEnd(Token $token): bool { return $token->test('endstyle'); } @@ -92,7 +112,7 @@ class TwigTokenParserStyle extends \Twig_TokenParser * * @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 0768b30..4540bbf 100644 --- a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php @@ -1,8 +1,9 @@ getLine(); $stream = $this->parser->getStream(); $name = $this->parser->getExpressionParser()->parseExpression(); - $stream->expect(\Twig_Token::BLOCK_END_TYPE); + $stream->expect(Token::BLOCK_END_TYPE); // There can be some whitespace between the {% switch %} and first {% case %} tag. - while ($stream->getCurrent()->getType() === \Twig_Token::TEXT_TYPE && trim($stream->getCurrent()->getValue()) === '') { + while ($stream->getCurrent()->getType() === Token::TEXT_TYPE && trim($stream->getCurrent()->getValue()) === '') { $stream->next(); } - $stream->expect(\Twig_Token::BLOCK_START_TYPE); + $stream->expect(Token::BLOCK_START_TYPE); $expressionParser = $this->parser->getExpressionParser(); @@ -59,24 +66,24 @@ class TwigTokenParserSwitch extends \Twig_TokenParser while (true) { $values[] = $expressionParser->parsePrimaryExpression(); // Multiple allowed values? - if ($stream->test(\Twig_Token::OPERATOR_TYPE, 'or')) { + if ($stream->test(Token::OPERATOR_TYPE, 'or')) { $stream->next(); } else { break; } } - $stream->expect(\Twig_Token::BLOCK_END_TYPE); - $body = $this->parser->subparse(array($this, 'decideIfFork')); - $cases[] = new \Twig_Node([ - 'values' => new \Twig_Node($values), + $stream->expect(Token::BLOCK_END_TYPE); + $body = $this->parser->subparse([$this, 'decideIfFork']); + $cases[] = new Node([ + 'values' => new Node($values), 'body' => $body ]); break; case 'default': - $stream->expect(\Twig_Token::BLOCK_END_TYPE); - $default = $this->parser->subparse(array($this, 'decideIfEnd')); + $stream->expect(Token::BLOCK_END_TYPE); + $default = $this->parser->subparse([$this, 'decideIfEnd']); break; case 'endswitch': @@ -84,41 +91,41 @@ class TwigTokenParserSwitch extends \Twig_TokenParser break; default: - throw new \Twig_Error_Syntax(sprintf('Unexpected end of template. Twig was looking for the following tags "case", "default", or "endswitch" to close the "switch" block started at line %d)', $lineno), -1); + throw new SyntaxError(sprintf('Unexpected end of template. Twig was looking for the following tags "case", "default", or "endswitch" to close the "switch" block started at line %d)', $lineno), -1); } } - $stream->expect(\Twig_Token::BLOCK_END_TYPE); + $stream->expect(Token::BLOCK_END_TYPE); - return new TwigNodeSwitch($name, new \Twig_Node($cases), $default, $lineno, $this->getTag()); + return new TwigNodeSwitch($name, new Node($cases), $default, $lineno, $this->getTag()); } /** * Decide if current token marks switch logic. * - * @param \Twig_Token $token + * @param Token $token * @return bool */ - public function decideIfFork(\Twig_Token $token) + public function decideIfFork(Token $token): bool { - return $token->test(array('case', 'default', 'endswitch')); + return $token->test(['case', 'default', 'endswitch']); } /** * Decide if current token marks end of swtich block. * - * @param \Twig_Token $token + * @param Token $token * @return bool */ - public function decideIfEnd(\Twig_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 new file mode 100644 index 0000000..bd4adab --- /dev/null +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php @@ -0,0 +1,55 @@ + + * {% throw 404 'Not Found' %} + *
+ */ +class TwigTokenParserThrow extends AbstractTokenParser +{ + /** + * Parses a token and returns a node. + * + * @param Token $token + * @return TwigNodeThrow + * @throws SyntaxError + */ + public function parse(Token $token) + { + $lineno = $token->getLine(); + $stream = $this->parser->getStream(); + + $code = $stream->expect(Token::NUMBER_TYPE)->getValue(); + $message = $this->parser->getExpressionParser()->parseExpression(); + $stream->expect(Token::BLOCK_END_TYPE); + + return new TwigNodeThrow((int)$code, $message, $lineno, $this->getTag()); + } + + /** + * Gets the tag name associated with this token parser. + * + * @return string The tag name + */ + 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 b205861..46af176 100644 --- a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php +++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php @@ -1,14 +1,19 @@ */ -class TwigTokenParserTryCatch extends \Twig_TokenParser +class TwigTokenParserTryCatch extends AbstractTokenParser { /** * Parses a token and returns a node. * - * @param \Twig_Token $token A Twig_Token instance - * - * @return \Twig_Node A Twig_Node instance + * @param Token $token + * @return TwigNodeTryCatch + * @throws SyntaxError */ - public function parse(\Twig_Token $token) + public function parse(Token $token) { $lineno = $token->getLine(); $stream = $this->parser->getStream(); - $stream->expect(\Twig_Token::BLOCK_END_TYPE); + $stream->expect(Token::BLOCK_END_TYPE); $try = $this->parser->subparse([$this, 'decideCatch']); $stream->next(); - $stream->expect(\Twig_Token::BLOCK_END_TYPE); + $stream->expect(Token::BLOCK_END_TYPE); $catch = $this->parser->subparse([$this, 'decideEnd']); $stream->next(); - $stream->expect(\Twig_Token::BLOCK_END_TYPE); + $stream->expect(Token::BLOCK_END_TYPE); return new TwigNodeTryCatch($try, $catch, $lineno, $this->getTag()); } - public function decideCatch(\Twig_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(\Twig_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']); } /** @@ -61,7 +74,7 @@ class TwigTokenParserTryCatch extends \Twig_TokenParser * * @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 7d7ef50..2796c0d 100644 --- a/system/src/Grav/Common/Twig/Twig.php +++ b/system/src/Grav/Common/Twig/Twig.php @@ -1,62 +1,70 @@ twig)) { + if (null === $this->twig) { /** @var Config $config */ $config = $this->grav['config']; /** @var UniformResourceLocator $locator */ $locator = $this->grav['locator']; - /** @var Language $language */ $language = $this->grav['language']; @@ -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; } @@ -99,9 +108,10 @@ class Twig $this->grav->fireEvent('onTwigTemplatePaths'); // Add Grav core templates location - $this->twig_paths = array_merge($this->twig_paths, $locator->findResources('system://templates')); + $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 => $_) { @@ -113,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; } @@ -127,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)) { @@ -149,40 +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')) { - $this->twig->getExtension('Twig_Extension_Core')->setDateFormat($config->get('system.pages.dateformat.long')); + /** @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'); @@ -199,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), @@ -211,10 +255,12 @@ class Twig 'language_codes' => new LanguageCodes, ]; } + + return $this; } /** - * @return \Twig_Environment + * @return Environment */ public function twig() { @@ -222,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. * @@ -245,15 +300,14 @@ class Twig * 1) Handles modular pages by rendering a specific page based on its modular twig template * 2) Renders individual page items for twig processing before the site rendering * - * @param Page $item The page item to render - * @param string $content Optional content override + * @param PageInterface $item The page item to render + * @param string|null $content Optional content override * * @return string The rendered output - * @throws \Twig_Error_Loader */ - public function processPage(Page $item, $content = null) + public function processPage(PageInterface $item, $content = null) { - $content = $content !== null ? $content : $item->content(); + $content = $content ?? $item->content(); // override the twig header vars for local resolution $this->grav->fireEvent('onTwigPageVariables', new Event(['page' => $item])); @@ -262,16 +316,14 @@ class Twig $twig_vars['page'] = $item; $twig_vars['media'] = $item->media(); $twig_vars['header'] = $item->header(); + $local_twig = clone $this->twig; - $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); } @@ -282,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; @@ -306,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; - } @@ -335,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; @@ -346,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(); @@ -367,7 +420,6 @@ class Twig $twig_vars['header'] = $page->header(); $twig_vars['media'] = $page->media(); $twig_vars['content'] = $content; - $ext = '.' . ($format ? $format : 'html') . TWIG_EXT; // determine if params are set, if so disable twig cache $params = $this->grav['uri']->params(null, true); @@ -376,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 - * @param $template_path - * @param null $namespace + * 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__') { @@ -409,9 +453,10 @@ class Twig } /** - * Wraps the Twig_Loader_Filesystem prependPath method (should be used only in `onTwigLoader()` event - * @param $template_path - * @param null $namespace + * 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__') { @@ -423,23 +468,57 @@ class Twig * the one being passed in * * @param string $template the template name - * * @return string the template name */ public function template($template) { - if (isset($this->template)) { - return $this->template; - } else { - return $template; + 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 boolean $state - * @deprecated 1.5 + * @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 66ca8bf..bebbdf1 100644 --- a/system/src/Grav/Common/Twig/TwigEnvironment.php +++ b/system/src/Grav/Common/Twig/TwigEnvironment.php @@ -1,14 +1,21 @@ grav = Grav::instance(); - $this->debugger = isset($this->grav['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'], ['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']), - - // Translations - new \Twig_SimpleFilter('t', [$this, 'translate']), - 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']), - ]; - } - - /** - * 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('xss', [$this, 'xssFunc']), - - // Translations - new \Twig_simpleFunction('t', [$this, 'translate']), - new \Twig_simpleFunction('tl', [$this, 'translateLanguage']), - new \Twig_simpleFunction('ta', [$this, 'translateArray']), - ]; - } - - /** - * @return array - */ - public function getTokenParsers() - { - return [ - 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 = mt_rand( 0, 1); - if ( $j === 0 ) { - $email .= '&#' . ord( $str[$i] ) . ';'; - } elseif ( $j === 1 ) { - $email .= $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)) { - if (isset($items[$remainder])) { - return $items[$remainder]; - } - - return $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 mixed - */ - public function inflectorFilter($action, $data, $count = null) - { - $action = $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)) { - if ($count) { - return $inflector->$action($data, $count); - } - - return $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 $str - * @return string - */ - public function base32EncodeFilter($str) - { - return Base32::encode($str); - } - - /** - * Return Base32 decoded string - * - * @param $str - * @return bool|string - */ - public function base32DecodeFilter($str) - { - return Base32::decode($str); - } - - /** - * Return Base64 encoded string - * - * @param $str - * @return string - */ - public function base64EncodeFilter($str) - { - return base64_encode($str); - } - - /** - * Return Base64 decoded string - * - * @param $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 $value - * @param $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 boolean - */ - public function containsFilter($haystack, $needle) - { - return (strpos($haystack, $needle) !== false); - } - - /** - * displays a facebook style 'time ago' formatted date/time - * - * @param $date - * @param $long_strings - * - * @return boolean - */ - public function nicetimeFunc($date, $long_strings = true) - { - if (empty($date)) { - return $this->grav['language']->translate('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 == $date) { - $unix_date = $date; - } else { - $unix_date = strtotime($date); - } - - // check validity of date - if (empty($unix_date)) { - return $this->grav['language']->translate('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('NICETIME.AGO', null, true); - - } else if ($now == $unix_date) { - $difference = $now - $unix_date; - $tense = $this->grav['language']->translate('NICETIME.JUST_NOW', null, false); - - } else { - $difference = $unix_date - $now; - $tense = $this->grav['language']->translate('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($periods[$j], null, true); - - if ($now == $unix_date) { - return "{$tense}"; - } - - return "$difference $periods[$j] {$tense}"; - } - - /** - * Allow quick check of a string for XSS Vulnerabilities - * - * @param $string - * @return bool|string|array - */ - public function xssFunc($data) - { - if (is_array($data)) { - $results = Security::detectXssFromArray($data); - } else { - return Security::detectXss($data); - } - - $results_parts = array_map(function($value, $key) { - return $key.': \''.$value . '\''; - }, array_values($results), array_keys($results)); - - return implode(', ', $results_parts); - } - - /** - * @param $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 - * - * @param bool $block Block or Line processing - * @return mixed|string - */ - public function markdownFunction($string, $block = true) - { - $page = $this->grav['page']; - $defaults = $this->config->get('system.pages.markdown'); - - // Initialize the preferred variant of Parsedown - if ($defaults['extra']) { - $parsedown = new ParsedownExtra($page, $defaults); - } else { - $parsedown = new Parsedown($page, $defaults); - } - - if ($block) { - $string = $parsedown->text($string); - } else { - $string = $parsedown->line($string); - } - - - return $string; - } - - /** - * @param $haystack - * @param $needle - * - * @return bool - */ - public function startsWithFilter($haystack, $needle) - { - return Utils::startsWith($haystack, $needle); - } - - /** - * @param $haystack - * @param $needle - * - * @return bool - */ - public function endsWithFilter($haystack, $needle) - { - return Utils::endsWith($haystack, $needle); - } - - /** - * @param $value - * @param null $default - * - * @return null - */ - public function definedDefaultFilter($value, $default = null) - { - return null !== $value ? $value : $default; - } - - /** - * @param $value - * @param null $chars - * - * @return string - */ - public function rtrimFilter($value, $chars = null) - { - return rtrim($value, $chars); - } - - /** - * @param $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 mixed - */ - public function translate() - { - return $this->grav['language']->translate(func_get_args()); - } - - /** - * Translate Strings - * - * @param $args - * @param array|null $languages - * @param bool $array_support - * @param bool $html_out - * @return mixed - */ - public function translateLanguage($args, array $languages = null, $array_support = false, $html_out = false) - { - return $this->grav['language']->translate($args, $languages, $array_support, $html_out); - } - - /** - * @param $key - * @param $index - * @param null $lang - * - * @return mixed - */ - public function translateArray($key, $index, $lang = null) - { - return $this->grav['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 $context - * @param $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 $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 $input - * @param $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 $array1 - * @param $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 $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 User $user */ - $user = $this->grav['user']; - - if (!$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), $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 mixed 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 - * @param $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 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's the Exif data for a file - * - * @param $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 (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 $filepath - * @return bool|string - */ - public function readFileFunc($filepath) - { - /** @var UniformResourceLocator $locator */ - $locator = $this->grav['locator']; - - if ($locator->isStream($filepath)) { - $filepath = $locator->findResource($filepath); - } - - if (file_exists($filepath)) { - return file_get_contents($filepath); - } - - return false; - } - - /** - * Process a folder as Media and return a media object - * - * @param $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 (file_exists($media_dir)) { - return new Media($media_dir); - } - - return null; - } - - /** - * Dump a variable to the browser - * - * @param $var - */ - public function vardumpFunc($var) - { - var_dump($var); - } - - /** - * Returns a nicer more readable filesize based on bytes - * - * @param $bytes - * @return string - */ - public function niceFilesizeFunc($bytes) - { - if ($bytes >= 1073741824) - { - $bytes = number_format($bytes / 1073741824, 2) . ' GB'; - } - elseif ($bytes >= 1048576) - { - $bytes = number_format($bytes / 1048576, 2) . ' MB'; - } - elseif ($bytes >= 1024) - { - $bytes = number_format($bytes / 1024, 1) . ' KB'; - } - elseif ($bytes > 1) - { - $bytes = $bytes . ' bytes'; - } - elseif ($bytes == 1) - { - $bytes = $bytes . ' byte'; - } - else - { - $bytes = '0 bytes'; - } - - return $bytes; - } - - - /** - * Returns a nicer more readable number - * - * @param int|float $n - * @return bool|string - */ - public function niceNumberFunc($n) - { - // first strip any formatting; - $n = 0 + str_replace(',', '', $n); - - // is this a number? - if (!is_numeric($n)) { - return false; - } - - // 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 $var - * @param bool $default - * @return string - */ - public function themeVarFunc($var, $default = null) - { - $header = $this->grav['page']->header(); - $header_classes = isset($header->$var) ? $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 $classes - * @return string - */ - public function bodyClassFunc($classes) - { - - $header = $this->grav['page']->header(); - $body_classes = isset($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 $var - * @param 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 = array($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 $data - * @param $inline integer number of levels of inline syntax - * @return mixed - */ - public function yamlEncodeFilter($data, $inline = 10) - { - return Yaml::dump($data, $inline); - } - - /** - * Decode/Parse data from YAML format - * - * @param $data - * @return mixed - */ - public function yamlDecodeFilter($data) - { - return Yaml::parse($data); - } } diff --git a/system/src/Grav/Common/Twig/WriteCacheFileTrait.php b/system/src/Grav/Common/Twig/WriteCacheFileTrait.php index c413ca6..edab8e5 100644 --- a/system/src/Grav/Common/Twig/WriteCacheFileTrait.php +++ b/system/src/Grav/Common/Twig/WriteCacheFileTrait.php @@ -1,25 +1,34 @@ 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; } - $this->uri = Utils::replaceFirstOccurrence($orig_root_path, $this->root_path, $this->uri); + $this->uri = Utils::replaceFirstOccurrence($orig_root_path, $this->root_path, $this->uri); } else { $this->root = $this->base . $this->root_path; } $this->url = $this->base . $this->uri; - $uri = str_replace(static::filterPath($this->root), '', $this->url); + $uri = Utils::replaceFirstOccurrence(static::filterPath($this->root), '', $this->url); // remove the setup.php based base if set: $setup_base = $grav['pages']->base(); 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')); @@ -164,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'])) { @@ -173,7 +214,7 @@ class Uri } // Get the path. If there's no path, make sure pathinfo() still returns dirname variable - $path = isset($bits['path']) ? $bits['path'] : '/'; + $path = $bits['path'] ?? '/'; // remove the extension if there is one set $parts = pathinfo($path); @@ -186,17 +227,15 @@ class Uri $this->extension = $parts['extension']; } - $valid_page_types = implode('|', $config->get('system.pages.types')); - // Strip the file extension for valid page types - if (preg_match('/\.(' . $valid_page_types . ')$/', $parts['basename'])) { - $path = rtrim(str_replace(DIRECTORY_SEPARATOR, DS, $parts['dirname']), DS) . '/' . $parts['filename']; + if ($this->isValidExtension($this->extension)) { + $path = Utils::replaceLastOccurrence(".{$this->extension}", '', $path); } // set the new url $this->url = $this->root . $path; $this->path = static::cleanPath($path); - $this->content_path = trim(str_replace($this->base, '', $this->path), '/'); + $this->content_path = trim(Utils::replaceFirstOccurrence($this->base, '', $this->path), '/'); if ($this->content_path !== '') { $this->paths = explode('/', $this->content_path); } @@ -206,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) @@ -229,9 +268,8 @@ class Uri /** * Return route to the current URI. By default route doesn't include base path. * - * @param bool $absolute True to include full path. - * @param bool $domain True to include domain. Works only if first parameter is also true. - * + * @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) @@ -242,15 +280,15 @@ class Uri /** * Return full query string or a single query attribute. * - * @param string $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 + * @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 */ public function query($id = null, $raw = false) { if ($id !== null) { - return isset($this->queries[$id]) ? $this->queries[$id] : null; + return $this->queries[$id] ?? null; } if ($raw) { @@ -267,9 +305,8 @@ class Uri /** * Return all or a single query parameter as a URI compatible string. * - * @param string $id Optional parameter name. - * @param boolean $array return the array format or not - * + * @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) @@ -300,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])); + 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,8 +367,7 @@ class Uri /** * Return URL. * - * @param bool $include_host Include hostname. - * + * @param bool $include_host Include hostname. * @return string */ public function url($include_host = false) @@ -341,7 +376,7 @@ class Uri return $this->url; } - $url = str_replace($this->base, '', rtrim($this->url, '/')); + $url = Utils::replaceFirstOccurrence($this->base, '', rtrim($this->url, '/')); return $url ?: '/'; } @@ -349,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() { @@ -360,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) { @@ -372,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; @@ -386,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) @@ -460,7 +497,7 @@ class Uri /** * Gets the environment name * - * @return String + * @return string */ public function environment() { @@ -471,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() { @@ -482,7 +519,7 @@ class Uri * Return the full uri * * @param bool $include_root - * @return mixed + * @return string */ public function uri($include_root = true) { @@ -490,13 +527,13 @@ class Uri return $this->uri; } - return str_replace($this->root_path, '', $this->uri); + return Utils::replaceFirstOccurrence($this->root_path, '', $this->uri); } /** * Return the base of the URI * - * @return String The base of the URI + * @return string The base of the URI */ public function base() { @@ -507,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,9 +559,8 @@ class Uri /** * Return root URL to the site. * - * @param bool $include_host Include hostname. - * - * @return mixed + * @param bool $include_host Include hostname. + * @return string */ public function rootUrl($include_host = false) { @@ -532,7 +568,7 @@ class Uri return $this->root; } - return str_replace($this->base, '', $this->root); + return Utils::replaceFirstOccurrence($this->base, '', $this->root); } /** @@ -542,20 +578,21 @@ class Uri */ public function currentPage() { - return isset($this->params['page']) ? $this->params['page'] : 1; + $page = (int)($this->params['page'] ?? 1); + + return max(1, $page); } /** * 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) { - $referrer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : null; + $referrer = $_SERVER['HTTP_REFERER'] ?? null; // Check that referrer came from our site. $root = $this->rootUrl(true); @@ -578,20 +615,43 @@ class Uri return substr($referrer, strlen($root)); } + /** + * @return string + */ public function __toString() { return static::buildUrl($this->toArray()); } - public function 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) { + $root_path = $this->root_path ?? ''; + $extension = isset($this->extension) && $this->isValidExtension($this->extension) ? '.' . $this->extension : ''; + $path = $root_path . $this->path . $extension; + } else { + $path = $this->path; + } + return [ 'scheme' => $this->scheme, 'host' => $this->host, 'port' => $this->port, 'user' => $this->user, 'pass' => $this->password, - 'path' => $this->path, + 'path' => $path, 'params' => $this->params, 'query' => $this->query, 'fragment' => $this->fragment @@ -617,15 +677,15 @@ class Uri { if (getenv('HTTP_CLIENT_IP')) { $ip = getenv('HTTP_CLIENT_IP'); - } elseif (getenv('HTTP_X_FORWARDED_FOR')) { + } elseif (getenv('HTTP_X_FORWARDED_FOR') && Grav::instance()['config']->get('system.http_x_forwarded.ip')) { $ip = getenv('HTTP_X_FORWARDED_FOR'); - } elseif (getenv('HTTP_X_FORWARDED')) { + } elseif (getenv('HTTP_X_FORWARDED') && Grav::instance()['config']->get('system.http_x_forwarded.ip')) { $ip = getenv('HTTP_X_FORWARDED'); } elseif (getenv('HTTP_FORWARDED_FOR')) { $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'; @@ -651,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; @@ -667,31 +729,29 @@ 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) { - return Utils::startsWith($url, 'http'); + return (0 === strpos($url, 'http://') || 0 === strpos($url, 'https://') || 0 === strpos($url, '//')); } /** * The opposite of built-in PHP method parse_url() * * @param array $parsed_url - * * @return string */ public static function buildUrl($parsed_url) { $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . ':' : ''; $authority = isset($parsed_url['host']) ? '//' : ''; - $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; + $host = $parsed_url['host'] ?? ''; $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; - $user = isset($parsed_url['user']) ? $parsed_url['user'] : ''; + $user = $parsed_url['user'] ?? ''; $pass = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : ''; $pass = ($user || $pass) ? "{$pass}@" : ''; - $path = isset($parsed_url['path']) ? $parsed_url['path'] : ''; + $path = $parsed_url['path'] ?? ''; $path = !empty($parsed_url['params']) ? rtrim($path, '/') . static::buildParams($parsed_url['params']) : $path; $query = !empty($parsed_url['query']) ? '?' . $parsed_url['query'] : ''; $fragment = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : ''; @@ -723,14 +783,14 @@ class Uri /** * Converts links from absolute '/' or relative (../..) to a Grav friendly format * - * @param Page $page the current page to use as reference + * @param PageInterface $page the current page to use as reference * @param string|array $url the URL as it was written in the markdown * @param string $type the type of URL, image | link * @param bool $absolute if null, will use system default, if true will use absolute links internally * @param bool $route_only only return the route, not full URL path - * @return string the more friendly formatted url + * @return string|array the more friendly formatted url */ - public static function convertUrl(Page $page, $url, $type = 'link', $absolute = false, $route_only = false) + public static function convertUrl(PageInterface $page, $url, $type = 'link', $absolute = false, $route_only = false) { $grav = Grav::instance(); @@ -759,19 +819,18 @@ 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); $normalized_path = Utils::normalizePath($pages_dir . $url_path); } else { $page_route = ($page->home() && !empty($url_path)) ? $page->rawRoute() : $page->route(); - $normalized_url = $base_url . Utils::normalizePath($page_route . '/' . $url_path); + $normalized_url = $base_url . Utils::normalizePath(rtrim($page_route, '/') . '/' . $url_path); $normalized_path = Utils::normalizePath($page->path() . '/' . $url_path); } // special check to see if path checking is required. - $just_path = str_replace($normalized_url, '', $normalized_path); + $just_path = Utils::replaceFirstOccurrence($normalized_url, '', $normalized_path); if ($normalized_url === '/' || $just_path === $page->path()) { $url_path = $normalized_url; } else { @@ -806,7 +865,7 @@ class Uri // get page instances and try to find one that fits $instances = $grav['pages']->instances(); if (isset($instances[$page_path])) { - /** @var Page $target */ + /** @var PageInterface $target */ $target = $instances[$page_path]; $url_bits['path'] = $base_url . rtrim($target->route(), '/') . $filename; @@ -822,7 +881,6 @@ class Uri // handle absolute URLs 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); @@ -840,7 +898,7 @@ class Uri } // strip base from this path - $target_path = str_replace($uri->rootUrl(), '', $target_path); + $target_path = Utils::replaceFirstOccurrence($uri->rootUrl(), '', $target_path); // set to / if root if (empty($target_path)) { @@ -865,7 +923,7 @@ class Uri // Handle route only if ($route_only) { - $url_path = str_replace(static::filterPath($base_url), '', $url_path); + $url_path = Utils::replaceFirstOccurrence(static::filterPath($base_url), '', $url_path); } // transform back to string/array as needed @@ -878,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 ); @@ -894,7 +963,7 @@ class Uri return false; } - foreach($parts as $name => $value) { + foreach ($parts as $name => $value) { $parts[$name] = rawurldecode($value); } @@ -902,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; @@ -912,6 +981,11 @@ class Uri return $parts; } + /** + * @param string $uri + * @param string $delimiter + * @return array + */ public static function extractParams($uri, $delimiter) { $params = []; @@ -935,14 +1009,14 @@ class Uri /** * Converts links from absolute '/' or relative (../..) to a Grav friendly format * - * @param Page $page the current page to use as reference + * @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 */ - public static function convertUrlOld(Page $page, $markdown_url, $type = 'link', $relative = null) + public static function convertUrlOld(PageInterface $page, $markdown_url, $type = 'link', $relative = null) { $grav = Grav::instance(); @@ -986,7 +1060,7 @@ class Uri } // special check to see if path checking is required. - $just_path = str_replace($normalized_url, '', $normalized_path); + $just_path = Utils::replaceFirstOccurrence($normalized_url, '', $normalized_path); if ($just_path === $page->path()) { return $normalized_url; } @@ -1022,7 +1096,7 @@ class Uri // get page instances and try to find one that fits $instances = $grav['pages']->instances(); if (isset($instances[$page_path])) { - /** @var Page $target */ + /** @var PageInterface $target */ $target = $instances[$page_path]; $url_bits['path'] = $base_url . rtrim($target->route(), '/') . $filename; @@ -1043,7 +1117,7 @@ class Uri */ public static function addNonce($url, $action, $nonceParamName = 'nonce') { - $fake = $url && $url[0] === '/'; + $fake = $url && strpos($url, '/') === 0; if ($fake) { $url = 'http://domain.com' . $url; @@ -1051,7 +1125,7 @@ class Uri $uri = new static($url); $parts = $uri->toArray(); $nonce = Utils::getNonce($action); - $parts['params'] = (isset($parts['params']) ? $parts['params'] : []) + [$nonceParamName => $nonce]; + $parts['params'] = ($parts['params'] ?? []) + [$nonceParamName => $nonce]; if ($fake) { unset($parts['scheme'], $parts['host']); @@ -1063,7 +1137,7 @@ class Uri /** * Is the passed in URL a valid URL? * - * @param $url + * @param string $url * @return bool */ public static function isValidUrl($url) @@ -1079,14 +1153,14 @@ class Uri /** * Removes extra double slashes and fixes back-slashes * - * @param $path - * @return mixed|string + * @param string $path + * @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; } @@ -1094,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) @@ -1110,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 */ @@ -1122,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) @@ -1132,33 +1206,37 @@ class Uri /** * @param array $env + * @return void */ protected function createFromEnvironment(array $env) { // Build scheme. - if (isset($env['HTTP_X_FORWARDED_PROTO'])) { + if (isset($env['HTTP_X_FORWARDED_PROTO']) && Grav::instance()['config']->get('system.http_x_forwarded.protocol')) { $this->scheme = $env['HTTP_X_FORWARDED_PROTO']; } elseif (isset($env['X-FORWARDED-PROTO'])) { $this->scheme = $env['X-FORWARDED-PROTO']; } elseif (isset($env['HTTP_CLOUDFRONT_FORWARDED_PROTO'])) { $this->scheme = $env['HTTP_CLOUDFRONT_FORWARDED_PROTO']; - } elseif (isset($env['REQUEST_SCHEME'])) { - $this->scheme = $env['REQUEST_SCHEME']; + } elseif (isset($env['REQUEST_SCHEME']) && empty($env['HTTPS'])) { + $this->scheme = $env['REQUEST_SCHEME']; } else { - $https = isset($env['HTTPS']) ? $env['HTTPS'] : ''; + $https = $env['HTTPS'] ?? ''; $this->scheme = (empty($https) || strtolower($https) === 'off') ? 'http' : 'https'; } // Build user and password. - $this->user = isset($env['PHP_AUTH_USER']) ? $env['PHP_AUTH_USER'] : null; - $this->password = isset($env['PHP_AUTH_PW']) ? $env['PHP_AUTH_PW'] : null; + $this->user = $env['PHP_AUTH_USER'] ?? null; + $this->password = $env['PHP_AUTH_PW'] ?? null; // Build host. - $hostname = 'localhost'; - if (isset($env['HTTP_HOST'])) { + if (isset($env['HTTP_X_FORWARDED_HOST']) && Grav::instance()['config']->get('system.http_x_forwarded.host')) { + $hostname = $env['HTTP_X_FORWARDED_HOST']; + } else if (isset($env['HTTP_HOST'])) { $hostname = $env['HTTP_HOST']; } elseif (isset($env['SERVER_NAME'])) { $hostname = $env['SERVER_NAME']; + } else { + $hostname = 'localhost'; } // Remove port from HTTP_HOST generated $hostname $hostname = Utils::substrToString($hostname, ':'); @@ -1166,18 +1244,18 @@ class Uri $this->host = $this->validateHostname($hostname) ? $hostname : 'unknown'; // Build port. - if (isset($env['HTTP_X_FORWARDED_PORT'])) { - $this->port = (int)$env['HTTP_X_FORWARDED_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']; } 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()) { @@ -1185,11 +1263,11 @@ class Uri } // Build path. - $request_uri = isset($env['REQUEST_URI']) ? $env['REQUEST_URI'] : ''; + $request_uri = $env['REQUEST_URI'] ?? ''; $this->path = rawurldecode(parse_url('http://example.com' . $request_uri, PHP_URL_PATH)); // Build query string. - $this->query = isset($env['QUERY_STRING']) ? $env['QUERY_STRING'] : ''; + $this->query = $env['QUERY_STRING'] ?? ''; if ($this->query === '') { $this->query = parse_url('http://example.com' . $request_uri, PHP_URL_QUERY); } @@ -1220,7 +1298,7 @@ class Uri */ protected function hasStandardPort() { - return ($this->scheme === 'http' && $this->port === 80) || ($this->scheme === 'https' && $this->port === 443); + return ($this->port === 80 || $this->port === 443); } /** @@ -1231,16 +1309,16 @@ 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 = isset($parts['scheme']) ? $parts['scheme'] : null; - $this->user = isset($parts['user']) ? $parts['user'] : null; - $this->password = isset($parts['pass']) ? $parts['pass'] : null; - $this->host = isset($parts['host']) ? $parts['host'] : null; + $this->scheme = $parts['scheme'] ?? null; + $this->user = $parts['user'] ?? null; + $this->password = $parts['pass'] ?? null; + $this->host = $parts['host'] ?? null; $this->port = isset($parts['port']) ? (int)$parts['port'] : null; - $this->path = isset($parts['path']) ? $parts['path'] : ''; - $this->query = isset($parts['query']) ? $parts['query'] : ''; - $this->fragment = isset($parts['fragment']) ? $parts['fragment'] : null; + $this->path = $parts['path'] ?? ''; + $this->query = $parts['query'] ?? ''; + $this->fragment = $parts['fragment'] ?? null; // Validate the hostname if ($this->host) { @@ -1256,6 +1334,9 @@ class Uri $this->reset(); } + /** + * @return void + */ protected function reset() { // resets @@ -1274,12 +1355,12 @@ class Uri } /** - * Get's post from either $_POST or JSON response object + * 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) { @@ -1313,18 +1394,66 @@ class Uri * @param bool $short * @return null|string */ - private function getContentType($short = true) + public function getContentType($short = true) { 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; } return null; } + /** + * Check if this is a valid Grav extension + * + * @param string|null $extension + * @return bool + */ + public function isValidExtension($extension): bool + { + $extension = (string)$extension; + + return $extension !== '' && in_array($extension, Utils::getSupportPageTypes(), true); + } + + /** + * Allow overriding of any element (be careful!) + * + * @param array $data + * @return Uri + */ + public function setUriProperties($data) + { + foreach (get_object_vars($this) as $property => $default) { + 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 * @@ -1349,6 +1478,9 @@ class Uri return $rootPath; } + /** + * @return string + */ private function buildEnvironment() { // check for localhost variations @@ -1362,9 +1494,8 @@ class Uri /** * Process any params based in this URL, supports any valid delimiter * - * @param $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 b46750c..6ff95e7 100644 --- a/system/src/Grav/Common/User/Authentication.php +++ b/system/src/Grav/Common/User/Authentication.php @@ -1,13 +1,20 @@ get('user/account'); + } + + parent::__construct($items, $blueprints); + } + + /** + * @param string $offset + * @return bool + */ + public function offsetExists($offset) + { + $value = parent::offsetExists($offset); + + // Handle special case where user was logged in before 'authorized' was added to the user object. + if (false === $value && $offset === 'authorized') { + $value = $this->offsetExists('authenticated'); + } + + return $value; + } + + /** + * @param string $offset + * @return mixed + */ + public function offsetGet($offset) + { + $value = parent::offsetGet($offset); + + // Handle special case where user was logged in before 'authorized' was added to the user object. + if (null === $value && $offset === 'authorized') { + $value = $this->offsetGet('authenticated'); + $this->offsetSet($offset, $value); + } + + return $value; + } + + /** + * @return bool + */ + public function isValid(): bool + { + return $this->items !== null; + } + + /** + * Update object with data + * + * @param array $data + * @param array $files + * @return $this + */ + public function update(array $data, array $files = []) + { + // Note: $this->merge() would cause infinite loop as it calls this method. + parent::merge($data); + + return $this; + } + + /** + * Save user + * + * @return void + */ + public function save() + { + /** @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->filterUsername((string)$this->get('username')); + + if (!$file->filename()) { + $locator = Grav::instance()['locator']; + $file->filename($locator->findResource('account://' . $username . YAML_EXT, true, true)); + } + + // if plain text password, hash it and remove plain text + $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('password1'); + $this->undef('password2'); + + $data = $this->items; + 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); + + $path = $this->getAvatarFile(); + if ($path && is_file($path)) { + $medium = MediumFactory::fromFile($path); + if ($medium) { + $media->add(basename($path), $medium); + } + } + + $this->_media = $media; + } + + return $this->_media; + } + + /** + * @return string + */ + public function getMediaFolder() + { + return $this->blueprints()->fields()['avatar']['destination'] ?? 'user://accounts/avatars'; + } + + /** + * @return array + */ + public function getMediaOrder() + { + return []; + } + + /** + * Serialize user. + * + * @return string[] + */ + public function __sleep() + { + return [ + 'items', + 'storage' + ]; + } + + /** + * Unserialize user. + */ + public function __wakeup() + { + $this->gettersVariable = 'items'; + $this->nestedSeparator = '.'; + + if (null === $this->items) { + $this->items = []; + } + + // Always set blueprints. + if (null === $this->blueprints) { + $this->blueprints = (new Blueprints)->get('user/account'); + } + } + + /** + * 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); + + return $this->update($data); + } + + /** + * Return media object for the User's avatar. + * + * @return Medium|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 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) { + $avatar = array_shift($avatars); + return $avatar['path'] ?? null; + } + + return null; + } +} diff --git a/system/src/Grav/Common/User/DataUser/UserCollection.php b/system/src/Grav/Common/User/DataUser/UserCollection.php new file mode 100644 index 0000000..3da7e2d --- /dev/null +++ b/system/src/Grav/Common/User/DataUser/UserCollection.php @@ -0,0 +1,162 @@ +className = $className; + } + + /** + * Load user account. + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $username + * @return UserInterface + */ + public function load($username): UserInterface + { + $username = (string)$username; + + $grav = Grav::instance(); + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + // 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 = static function () { + $blueprints = new Blueprints; + + return $blueprints->get('user/account'); + }; + + /** @var UserInterface $user */ + $user = new $userClass($content, $callable); + $user->file($file); + + return $user; + } + + /** + * Find a user by username, email, etc + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserInterface + */ + public function find($query, $fields = ['username', 'email']): UserInterface + { + $fields = (array)$fields; + + $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)) { + $user = $this->load($query); + unset($fields[array_search('username', $fields, true)]); + } else { + $user = $this->load(''); + } + + // If not found, try the fields + if (!$user->exists()) { + foreach ($files as $file) { + if (Utils::endsWith($file, YAML_EXT)) { + $find_user = $this->load(trim(pathinfo($file, PATHINFO_FILENAME))); + foreach ($fields as $field) { + if (isset($find_user[$field]) && $find_user[$field] === $query) { + return $find_user; + } + } + } + } + } + return $user; + } + + /** + * Remove user account. + * + * @param string $username + * @return bool True if the action was performed + */ + public function delete($username): bool + { + $file_path = Grav::instance()['locator']->findResource('account://' . $username . YAML_EXT); + + return $file_path && unlink($file_path); + } + + /** + * @return int + */ + public function count(): int + { + // check for existence of a user account + $account_dir = $file_path = Grav::instance()['locator']->findResource('account://'); + $accounts = glob($account_dir . '/*.yaml') ?: []; + + 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/Group.php b/system/src/Grav/Common/User/Group.php index a251326..5be5386 100644 --- a/system/src/Grav/Common/User/Group.php +++ b/system/src/Grav/Common/User/Group.php @@ -1,8 +1,9 @@ get('groups', []); } @@ -31,13 +38,16 @@ 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) { - $groups[$groupname] = isset($group['readableName']) ? $group['readableName'] : $groupname; + foreach (static::groups() as $groupname => $group) { + $groups[$groupname] = $group['readableName'] ?? $groupname; } return $groups; @@ -47,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]); } @@ -59,17 +71,19 @@ 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 = isset($groups[$groupname]) ? $groups[$groupname] : []; + $content = $groups[$groupname] ?? []; $content += ['groupname' => $groupname]; - $blueprints = new Blueprints; + $blueprints = new Blueprints(); $blueprint = $blueprints->get('user/group'); return new Group($content, $blueprint); @@ -77,6 +91,8 @@ class Group extends Data /** * Save a group + * + * @return void */ public function save() { @@ -85,17 +101,17 @@ class Group extends Data /** @var Config $config */ $config = $grav['config']; - $blueprints = new Blueprints; + $blueprints = new Blueprints(); $blueprint = $blueprints->get('user/group'); - $config->set("groups.{$this->groupname}", []); + $config->set("groups.{$this->get('groupname')}", []); $fields = $blueprint->fields(); foreach ($fields as $field) { if ($field['type'] === 'text') { $value = $field['name']; if (isset($this->items['data'][$value])) { - $config->set("groups.{$this->groupname}.{$value}", $this->items['data'][$value]); + $config->set("groups.{$this->get('groupname')}.{$value}", $this->items['data'][$value]); } } if ($field['type'] === 'array' || $field['type'] === 'permissions') { @@ -104,14 +120,14 @@ class Group extends Data if ($arrayValues) { foreach ($arrayValues as $arrayIndex => $arrayValue) { - $config->set("groups.{$this->groupname}.{$value}.{$arrayIndex}", $arrayValue); + $config->set("groups.{$this->get('groupname')}.{$value}.{$arrayIndex}", $arrayValue); } } } } $type = 'groups'; - $blueprints = $this->blueprints("config/{$type}"); + $blueprints = $this->blueprints(); $filename = CompiledYamlFile::instance($grav['locator']->findResource("config://{$type}.yaml")); @@ -124,17 +140,19 @@ 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 */ $config = $grav['config']; - $blueprints = new Blueprints; + $blueprints = new Blueprints(); $blueprint = $blueprints->get('user/group'); $type = 'groups'; 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 @@ +exists(). + * + * @param string $username + * @return UserInterface + */ + public function load($username): UserInterface; + + /** + * Find a user by username, email, etc + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserInterface + */ + public function find($query, $fields = ['username', 'email']): UserInterface; + + /** + * Delete user account. + * + * @param string $username + * @return bool True if user account was found and was deleted. + */ + public function delete($username): bool; +} diff --git a/system/src/Grav/Common/User/Interfaces/UserGroupInterface.php b/system/src/Grav/Common/User/Interfaces/UserGroupInterface.php new file mode 100644 index 0000000..c345c4b --- /dev/null +++ b/system/src/Grav/Common/User/Interfaces/UserGroupInterface.php @@ -0,0 +1,18 @@ +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); + + /** + * 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); + + /** + * 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); + + /** + * 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 = '.'); + + /** + * Get nested structure containing default values defined in the blueprints. + * + * Fields without default value are ignored in the list. + + * @return array + */ + public function 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 = '.'); + + /** + * 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 = '.'); + + /** + * Set default values to the configuration if variables were not set. + * + * @param array $data + * @return $this + */ + public function setDefaults(array $data); + + /** + * Update object with data + * + * @param array $data + * @param array $files + * @return $this + */ + public function update(array $data, array $files = []); + + /** + * Returns whether the data already exists in the storage. + * + * NOTE: This method does not check if the data is current. + * + * @return bool + */ + public function exists(); + + /** + * Return unmodified data as raw string. + * + * NOTE: This function only returns data which has been saved to the storage. + * + * @return string + */ + public function raw(); + + /** + * Authenticate user. + * + * 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; + + /** + * 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 Medium|null + */ + public function getAvatarImage(): ?Medium; + + /** + * Return the User's avatar URL. + * + * @return string + */ + public function getAvatarUrl(): string; +} diff --git a/system/src/Grav/Common/User/Traits/UserTrait.php b/system/src/Grav/Common/User/Traits/UserTrait.php new file mode 100644 index 0000000..5a6b749 --- /dev/null +++ b/system/src/Grav/Common/User/Traits/UserTrait.php @@ -0,0 +1,187 @@ +get('hashed_password'); + + $isHashed = null !== $hash; + if (!$isHashed) { + // If there is no hashed password, fake verify with default hash. + $hash = Grav::instance()['config']->get('system.security.default_hash'); + } + + // Always execute verify() to protect us from timing attacks, but make the test to fail if hashed password wasn't set. + $result = Authentication::verify($password, $hash) && $isHashed; + + $plaintext_password = $this->get('password'); + if (null !== $plaintext_password) { + // Plain-text password is still stored, check if it matches. + if ($password !== $plaintext_password) { + return false; + } + + // Force hash update to get rid of plaintext password. + $result = 2; + } + + if ($result === 2) { + // Password needs to be updated, save the user. + $this->set('password', $password); + $this->undef('hashed_password'); + $this->save(); + } + + return (bool)$result; + } + + /** + * 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 + { + // 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; + } + + // User needs to be authorized (2FA). + if (strpos($action, 'login') === false && !$this->get('authorized', true)) { + return false; + } + + if (null !== $scope) { + $action = $scope . '.' . $action; + } + + $config = Grav::instance()['config']; + $authorized = false; + + //Check group access level + $groups = (array)$this->get('groups'); + foreach ($groups as $group) { + $permission = $config->get("groups.{$group}.access.{$action}"); + $authorized = Utils::isPositive($permission); + if ($authorized === true) { + break; + } + } + + //Check user access level + $access = $this->get('access'); + if ($access && Utils::getDotNotation($access, $action) !== null) { + $permission = $this->get("access.{$action}"); + $authorized = Utils::isPositive($permission); + } + + return $authorized; + } + + /** + * 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|StaticImageMedium|null + */ + public function getAvatarImage(): ?Medium + { + $avatars = $this->get('avatar'); + 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 || + $image instanceof StaticImageMedium) { + return $image; + } + } + + return null; + } + + /** + * Return the User's avatar URL + * + * @return string + */ + public function getAvatarUrl(): string + { + // Try to locate avatar image. + $avatar = $this->getAvatarImage(); + if ($avatar) { + return $avatar->url(); + } + + // Try if avatar is a sting (URL). + $avatar = $this->get('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'])) { + return $provider_options['avatar_url']; + } + if (isset($provider_options['avatar']) && is_string($provider_options['avatar'])) { + return $provider_options['avatar']; + } + } + + $email = $this->get('email'); + + // By default fall back to gravatar image. + return $email ? 'https://www.gravatar.com/avatar/' . md5(strtolower(trim($email))) : ''; + } + + abstract public function get($name, $default = null, $separator = null); + abstract public function set($name, $value, $separator = null); + abstract public function undef($name, $separator = null); + abstract public function save(); +} diff --git a/system/src/Grav/Common/User/User.php b/system/src/Grav/Common/User/User.php index 750b873..4b2319f 100644 --- a/system/src/Grav/Common/User/User.php +++ b/system/src/Grav/Common/User/User.php @@ -1,318 +1,144 @@ exists(). - * - * @param string $username - * @param bool $setConfig - * - * @return User + * @deprecated 1.6 Use $grav['accounts'] instead of static calls. In type hints, please use UserInterface. */ - public static function load($username) + class User extends Flex\Types\Users\UserObject { - $grav = Grav::instance(); - /** @var UniformResourceLocator $locator */ - $locator = $grav['locator']; + /** + * Load user account. + * + * 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. + */ + public static function load($username) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED); - // force lowercase of username - $username = strtolower($username); - - $blueprints = new Blueprints; - $blueprint = $blueprints->get('user/account'); - - $file_path = $locator->findResource('account://' . $username . YAML_EXT); - $file = CompiledYamlFile::instance($file_path); - $content = (array)$file->content() + ['username' => $username, 'state' => 'enabled']; - - $user = new User($content, $blueprint); - $user->file($file); - - return $user; - } - - /** - * 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 static function find($query, $fields = ['username', 'email']) - { - $account_dir = Grav::instance()['locator']->findResource('account://'); - $files = $account_dir ? array_diff(scandir($account_dir), ['.', '..']) : []; - - // Try with username first, you never know! - if (in_array('username', $fields, true)) { - $user = User::load($query); - unset($fields[array_search('username', $fields, true)]); - } else { - $user = User::load(''); + return static::getCollection()->load($username); } - // If not found, try the fields - if (!$user->exists()) { - foreach ($files as $file) { - if (Utils::endsWith($file, YAML_EXT)) { - $find_user = User::load(trim(pathinfo($file, PATHINFO_FILENAME))); - foreach ($fields as $field) { - if ($find_user[$field] === $query) { - return $find_user; - } - } - } - } - } - return $user; - } + /** + * Find a user by username, email, etc + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserInterface + * @deprecated 1.6 Use $grav['accounts']->find(...) instead. + */ + public static function find($query, $fields = ['username', 'email']) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED); - /** - * Remove user account. - * - * @param string $username - * - * @return bool True if the action was performed - */ - public static function remove($username) - { - $file_path = Grav::instance()['locator']->findResource('account://' . $username . YAML_EXT); - - return $file_path && unlink($file_path); - } - - /** - * @param string $offset - * @return bool - */ - public function offsetExists($offset) - { - $value = parent::offsetExists($offset); - - // Handle special case where user was logged in before 'authorized' was added to the user object. - if (false === $value && $offset === 'authorized') { - $value = $this->offsetExists('authenticated'); + return static::getCollection()->find($query, $fields); } - return $value; - } + /** + * Remove user account. + * + * @param string $username + * @return bool True if the action was performed + * @deprecated 1.6 Use $grav['accounts']->delete(...) instead. + */ + public static function remove($username) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->delete() instead', E_USER_DEPRECATED); - /** - * @param string $offset - * @return mixed - */ - public function offsetGet($offset) - { - $value = parent::offsetGet($offset); - - // Handle special case where user was logged in before 'authorized' was added to the user object. - if (null === $value && $offset === 'authorized') { - $value = $this->offsetGet('authenticated'); - $this->offsetSet($offset, $value); + return static::getCollection()->delete($username); } - return $value; - } - - /** - * Authenticate user. - * - * If user password needs to be updated, new information will be saved. - * - * @param string $password Plaintext password. - * - * @return bool - */ - public function authenticate($password) - { - $save = false; - - // Plain-text is still stored - if ($this->password) { - if ($password !== $this->password) { - // Plain-text passwords do not match, we know we should fail but execute - // verify to protect us from timing attacks and return false regardless of - // the result - Authentication::verify( - $password, - Grav::instance()['config']->get('system.security.default_hash') - ); - - return false; - } - - // Plain-text does match, we can update the hash and proceed - $save = true; - - $this->hashed_password = Authentication::create($this->password); - unset($this->password); - - } - - $result = Authentication::verify($password, $this->hashed_password); - - // Password needs to be updated, save the file. - if ($result === 2) { - $save = true; - $this->hashed_password = Authentication::create($password); - } - - if ($save) { - $this->save(); - } - - return (bool)$result; - } - - /** - * Save user without the username - */ - public function save() - { - $file = $this->file(); - - if ($file) { - $username = $this->get('username'); - - if (!$file->filename()) { - $locator = Grav::instance()['locator']; - $file->filename($locator->findResource('account://') . DS . strtolower($username) . YAML_EXT); - } - - // if plain text password, hash it and remove plain text - if ($this->password) { - $this->hashed_password = Authentication::create($this->password); - unset($this->password); - } - - unset($this->username); - $file->save($this->items); - $this->set('username', $username); + /** + * @return UserCollectionInterface + */ + protected static function getCollection() + { + return Grav::instance()['accounts']; } } - +} else { /** - * Checks user authorization to the action. - * - * @param string $action - * - * @return bool + * @deprecated 1.6 Use $grav['accounts'] instead of static calls. In type hints, use UserInterface. */ - public function authorize($action) + class User extends DataUser\User { - if (empty($this->items)) { - return false; + /** + * Load user account. + * + * 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. + */ + public static function load($username) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED); + + return static::getCollection()->load($username); } - if (!$this->authenticated) { - return false; + /** + * Find a user by username, email, etc + * + * Always creates user object. To check if user exists, use $this->exists(). + * + * @param string $query the query to search for + * @param array $fields the fields to search + * @return UserInterface + * @deprecated 1.6 Use $grav['accounts']->find(...) instead. + */ + public static function find($query, $fields = ['username', 'email']) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED); + + return static::getCollection()->find($query, $fields); } - if (isset($this->state) && $this->state !== 'enabled') { - return false; + /** + * Remove user account. + * + * @param string $username + * @return bool True if the action was performed + * @deprecated 1.6 Use $grav['accounts']->delete(...) instead. + */ + public static function remove($username) + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\'accounts\']->delete() instead', E_USER_DEPRECATED); + + return static::getCollection()->delete($username); } - $return = false; - - //Check group access level - $groups = $this->get('groups'); - if ($groups) { - foreach ((array)$groups as $group) { - $permission = Grav::instance()['config']->get("groups.{$group}.access.{$action}"); - $return = Utils::isPositive($permission); - if ($return === true) { - break; - } - } - } - - //Check user access level - if ($this->get('access')) { - if (Utils::getDotNotation($this->get('access'), $action) !== null) { - $permission = $this->get("access.{$action}"); - $return = Utils::isPositive($permission); - } - } - - return $return; - } - - /** - * Checks user authorization to the action. - * Ensures backwards compatibility - * - * @param string $action - * - * @deprecated use authorize() - * @return bool - */ - 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); - } - - /** - * Return the User's avatar URL - * - * @return string - */ - public function avatarUrl() - { - if ($this->avatar) { - $avatar = $this->avatar; - $avatar = array_shift($avatar); - return Grav::instance()['base_url'] . '/' . $avatar['path']; - } - - return 'https://www.gravatar.com/avatar/' . md5( strtolower( trim($this->email) ) ); - } - - /** - * Serialize user. - */ - public function __sleep() - { - return [ - 'items', - 'storage' - ]; - } - - /** - * Unserialize user. - */ - public function __wakeup() - { - $this->gettersVariable = 'items'; - $this->nestedSeparator = '.'; - - if (null === $this->items) { - $this->items = []; - } - - if (null === $this->blueprints) { - $blueprints = new Blueprints; - $this->blueprints = $blueprints->get('user/account'); + /** + * @return UserCollectionInterface + */ + protected static function getCollection() + { + return Grav::instance()['accounts']; } } } diff --git a/system/src/Grav/Common/Utils.php b/system/src/Grav/Common/Utils.php index b49abdd..edc093e 100644 --- a/system/src/Grav/Common/Utils.php +++ b/system/src/Grav/Common/Utils.php @@ -1,88 +1,196 @@ get('system.absolute_urls', false)) { - $domain = true; - } + $input = (string)$input; - if (Grav::instance()['uri']->isExternal($input)) { + if (Uri::isExternal($input)) { return $input; } - $input = ltrim((string)$input, '/'); + $grav = Grav::instance(); - if (Utils::contains((string)$input, '://')) { + /** @var Uri $uri */ + $uri = $grav['uri']; + + $resource = false; + if (static::contains((string)$input, '://')) { /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; + $locator = $grav['locator']; $parts = Uri::parseUrl($input); - if ($parts) { - $resource = $locator->findResource("{$parts['scheme']}://{$parts['host']}{$parts['path']}", false); + if (is_array($parts)) { + // Make sure we always have scheme, host, port and path. + $scheme = $parts['scheme'] ?? ''; + $host = $parts['host'] ?? ''; + $port = $parts['port'] ?? ''; + $path = $parts['path'] ?? ''; - if (isset($parts['query'])) { - $resource = $resource . '?' . $parts['query']; + if ($scheme && !$port) { + // If URL has a scheme, we need to check if it's one of Grav streams. + if (!$locator->schemeExists($scheme)) { + // If scheme does not exists as a stream, assume it's external. + return str_replace(' ', '%20', $input); + } + + // Attempt to find the resource (because of parse_url() we need to put host back to path). + $resource = $locator->findResource("{$scheme}://{$host}{$path}", false); + + if ($resource === false) { + if (!$fail_gracefully) { + return false; + } + + // 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); + } + + if (!empty($resource)) { + // Add query string back. + if (isset($parts['query'])) { + $resource .= '?' . $parts['query']; + } + + // Add fragment back. + if (isset($parts['fragment'])) { + $resource .= '#' . $parts['fragment']; + } } } else { // Not a valid URL (can still be a stream). $resource = $locator->findResource($input, false); } - - } else { + $root = $uri->rootUrl(); + + if (static::startsWith($input, $root)) { + $input = static::replaceFirstOccurrence($root, '', $input); + } + + $input = ltrim($input, '/'); + $resource = $input; } - /** @var Uri $uri */ - $uri = Grav::instance()['uri']; + if (!$fail_gracefully && $resource === false) { + return false; + } - return $resource ? rtrim($uri->rootUrl($domain), '/') . '/' . $resource : null; + $domain = $domain ?: $grav['config']->get('system.absolute_urls', false); + + 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) + public static function startsWith($haystack, $needle, $case_sensitive = true) { $status = false; + $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos'; + foreach ((array)$needle as $each_needle) { - $status = $each_needle === '' || strpos($haystack, $each_needle) === 0; + $status = $each_needle === '' || $compare_func($haystack, $each_needle) === 0; if ($status) { break; } @@ -94,17 +202,20 @@ 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) + public static function endsWith($haystack, $needle, $case_sensitive = true) { $status = false; + $compare_func = $case_sensitive ? 'mb_strrpos' : 'mb_strripos'; + foreach ((array)$needle as $each_needle) { - $status = $each_needle === '' || substr($haystack, -strlen($each_needle)) === $each_needle; + $expectedPosition = mb_strlen($haystack) - mb_strlen($each_needle); + $status = $each_needle === '' || $compare_func($haystack, $each_needle, 0) === $expectedPosition; if ($status) { break; } @@ -116,17 +227,19 @@ abstract class Utils /** * Check if the $haystack string contains 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 contains($haystack, $needle) + public static function contains($haystack, $needle, $case_sensitive = true) { $status = false; + $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos'; + foreach ((array)$needle as $each_needle) { - $status = $each_needle === '' || strpos($haystack, $each_needle) !== false; + $status = $each_needle === '' || $compare_func($haystack, $each_needle) !== false; if ($status) { break; } @@ -135,18 +248,66 @@ abstract class Utils return $status; } + /** + * Function that can match wildcards + * + * match_wildcard('foo*', $test), // TRUE + * match_wildcard('bar*', $test), // FALSE + * match_wildcard('*bar*', $test), // TRUE + * match_wildcard('**blob**', $test), // TRUE + * match_wildcard('*a?d*', $test), // TRUE + * match_wildcard('*etc**', $test) // TRUE + * + * @param string $wildcard_pattern + * @param string $haystack + * @return false|int + */ + public static function matchWildcard($wildcard_pattern, $haystack) + { + $regex = str_replace( + array("\*", "\?"), // wildcard chars + array('.*', '.'), // regexp chars + preg_quote($wildcard_pattern, '/') + ); + + 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); + } + /** * Returns the substring of a string up to a specified needle. if not found, return the whole haystack * - * @param $haystack - * @param $needle + * @param string $haystack + * @param string $needle + * @param bool $case_sensitive * * @return string */ - public static function substrToString($haystack, $needle) + public static function substrToString($haystack, $needle, $case_sensitive = true) { - if (static::contains($haystack, $needle)) { - return substr($haystack, 0, strpos($haystack, $needle)); + $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos'; + + if (static::contains($haystack, $needle, $case_sensitive)) { + return mb_substr($haystack, 0, $compare_func($haystack, $needle, $case_sensitive)); } return $haystack; @@ -155,48 +316,68 @@ abstract class Utils /** * Utility method to replace only the first occurrence in a string * - * @param $search - * @param $replace - * @param $subject - * @return mixed + * @param string $search + * @param string $replace + * @param string $subject + * + * @return string */ public static function replaceFirstOccurrence($search, $replace, $subject) { if (!$search) { return $subject; } - $pos = strpos($subject, $search); + + $pos = mb_strpos($subject, $search); if ($pos !== false) { - $subject = substr_replace($subject, $replace, $pos, strlen($search)); + $subject = static::mb_substr_replace($subject, $replace, $pos, mb_strlen($search)); } + + return $subject; } /** * Utility method to replace only the last occurrence in a string * - * @param $search - * @param $replace - * @param $subject - * @return mixed + * @param string $search + * @param string $replace + * @param string $subject + * @return string */ public static function replaceLastOccurrence($search, $replace, $subject) { $pos = strrpos($subject, $search); - if($pos !== false) - { - $subject = substr_replace($subject, $replace, $pos, strlen($search)); + if ($pos !== false) { + $subject = static::mb_substr_replace($subject, $replace, $pos, mb_strlen($search)); } return $subject; } + /** + * Multibyte compatible substr_replace + * + * @param string $original + * @param string $replacement + * @param int $position + * @param int $length + * @return string + */ + 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'); + + return $startString . $replacement . $endString; + } + /** * Merge two objects into one. * - * @param object $obj1 - * @param object $obj2 + * @param object $obj1 + * @param object $obj2 * * @return object */ @@ -205,12 +386,50 @@ 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) + { + return array_map('mb_strtolower', $a); + } + + /** + * Simple function to remove item/s in an array by value + * + * @param array $search + * @param string|array $value + * @return array + */ + public static function arrayRemoveValue(array $search, $value) + { + foreach ((array)$value as $val) { + $key = array_search($val, $search); + if ($key !== false) { + unset($search[$key]); + } + } + return $search; + } + /** * Recursive Merge with uniqueness * - * @param $array1 - * @param $array2 - * @return mixed + * @param array $array1 + * @param array $array2 + * @return array */ public static function arrayMergeRecursiveUnique($array1, $array2) { @@ -229,6 +448,65 @@ abstract class Utils return $array1; } + /** + * Returns an array with the differences between $array1 and $array2 + * + * @param array $array1 + * @param array $array2 + * @return array + */ + public static function arrayDiffMultidimensional($array1, $array2) + { + $result = array(); + foreach ($array1 as $key => $value) { + if (!is_array($array2) || !array_key_exists($key, $array2)) { + $result[$key] = $value; + continue; + } + if (is_array($value)) { + $recursiveArrayDiff = static::ArrayDiffMultidimensional($value, $array2[$key]); + if (count($recursiveArrayDiff)) { + $result[$key] = $recursiveArrayDiff; + } + continue; + } + if ($value != $array2[$key]) { + $result[$key] = $value; + } + } + + return $result; + } + + /** + * Array combine but supports different array lengths + * + * @param array $arr1 + * @param array $arr2 + * @return array|false + */ + public static function arrayCombine($arr1, $arr2) + { + $count = min(count($arr1), count($arr2)); + + return array_combine(array_slice($arr1, 0, $count), array_slice($arr2, 0, $count)); + } + + /** + * Array is associative or not + * + * @param array $arr + * @return bool + */ + public static function arrayIsAssociative($arr) + { + if ([] === $arr) { + return false; + } + + return array_keys($arr) !== range(0, count($arr) - 1); + } + /** * Return the Grav date formats allowed * @@ -239,32 +517,49 @@ abstract class Utils $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; } + /** + * Get current date/time + * + * @param string|null $default_format + * @return string + * @throws Exception + */ + public static function dateNow($default_format = null) + { + $now = new DateTime(); + + if (null === $default_format) { + $default_format = Grav::instance()['config']->get('system.pages.dateformat.default'); + } + + return $now->format($default_format); + } + /** * 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 = "…") + public static function truncate($string, $limit = 150, $up_to_break = false, $break = ' ', $pad = '…') { // return with no change if string is shorter than $limit if (mb_strlen($string) <= $limit) { @@ -287,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) @@ -300,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 = '...') @@ -314,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 = '...') @@ -329,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) @@ -342,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) { @@ -354,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()) { @@ -376,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; @@ -406,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(); } @@ -423,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(); @@ -432,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') @@ -479,56 +801,29 @@ abstract class Utils } /** - * Return the mimetype based on filename + * Get all the mimetypes for an array of extensions * - * @param string $filename Filename or path to file - * @param string $default default value - * - * @return string + * @param array $extensions + * @return array */ - public static function getMimeByFilename($filename, $default = 'application/octet-stream') + public static function getMimeTypes(array $extensions) { - return static::getMimeByExtension(pathinfo($filename, PATHINFO_EXTENSION), $default); - } - - /** - * Return the mimetype based on existing local file - * - * @param string $filename Path to the file - * - * @return string|bool - */ - public static function getMimeByLocalFile($filename, $default = 'application/octet-stream') - { - $type = false; - - // For local files we can detect type by the file content. - if (!stream_is_local($filename) || !file_exists($filename)) { - return false; - } - - // Prefer using finfo if it exists. - if (\extension_loaded('fileinfo')) { - $finfo = finfo_open(FILEINFO_SYMLINK | FILEINFO_MIME_TYPE); - $type = finfo_file($finfo, $filename); - finfo_close($finfo); - } else { - // Fall back to use getimagesize() if it is available (not recommended, but better than nothing) - $info = @getimagesize($filename); - if ($info) { - $type = $info['mime']; + $mimetypes = []; + foreach ($extensions as $extension) { + $mimetype = static::getMimeByExtension($extension, false); + if ($mimetype && !in_array($mimetype, $mimetypes)) { + $mimetypes[] = $mimetype; } } - - return $type ?: static::getMimeByFilename($filename, $default); + return $mimetypes; } + /** * Return the mimetype based on filename extension * * @param string $mime mime type (eg "text/html") * @param string $default default value - * * @return string */ public static function getExtensionByMime($mime, $default = 'html') @@ -565,6 +860,70 @@ abstract class Utils return $default; } + /** + * Get all the extensions for an array of mimetypes + * + * @param array $mimetypes + * @return array + */ + public static function getExtensions(array $mimetypes) + { + $extensions = []; + foreach ($mimetypes as $mimetype) { + $extension = static::getExtensionByMime($mimetype, false); + if ($extension && !in_array($extension, $extensions, true)) { + $extensions[] = $extension; + } + } + + return $extensions; + } + + /** + * Return the mimetype based on filename + * + * @param string $filename Filename or path to file + * @param string $default default value + * @return string + */ + public static function getMimeByFilename($filename, $default = 'application/octet-stream') + { + return static::getMimeByExtension(pathinfo($filename, PATHINFO_EXTENSION), $default); + } + + /** + * 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') + { + $type = false; + + // For local files we can detect type by the file content. + if (!stream_is_local($filename) || !file_exists($filename)) { + return false; + } + + // Prefer using finfo if it exists. + if (extension_loaded('fileinfo')) { + $finfo = finfo_open(FILEINFO_SYMLINK | FILEINFO_MIME_TYPE); + $type = finfo_file($finfo, $filename); + finfo_close($finfo); + } else { + // Fall back to use getimagesize() if it is available (not recommended, but better than nothing) + $info = @getimagesize($filename); + if ($info) { + $type = $info['mime']; + } + } + + return $type ?: static::getMimeByFilename($filename, $default); + } + + /** * Returns true if filename is considered safe. * @@ -574,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. @@ -587,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) ); } @@ -596,39 +951,88 @@ abstract class Utils * Normalize path by processing relative `.` and `..` syntax and merging path * * @param string $path - * * @return string */ public static function normalizePath($path) { - $root = ($path[0] === '/') ? '/' : ''; - - $segments = explode('/', trim($path, '/')); - $ret = []; - foreach ($segments as $segment) { - if (($segment === '.') || $segment === '') { - continue; - } - if ($segment === '..') { - array_pop($ret); - } else { - $ret[] = $segment; - } + // Resolve any streams + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + if ($locator->isStream($path)) { + $path = $locator->findResource($path); } - return $root . implode('/', $ret); + // Set root properly for any URLs + $root = ''; + preg_match(self::ROOTURL_REGEX, $path, $matches); + if ($matches) { + $root = $matches[1]; + $path = $matches[2]; + } + + // Strip off leading / to ensure explode is accurate + if (static::startsWith($path, '/')) { + $root .= '/'; + $path = ltrim($path, '/'); + } + + // If there are any relative paths (..) handle those + if (static::contains($path, '..')) { + $segments = explode('/', trim($path, '/')); + $ret = []; + foreach ($segments as $segment) { + if (($segment === '.') || $segment === '') { + continue; + } + if ($segment === '..') { + array_pop($ret); + } else { + $ret[] = $segment; + } + } + $path = implode('/', $ret); + } + + // 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); } /** @@ -638,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); } @@ -656,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; @@ -665,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) { @@ -687,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 * @@ -695,8 +1121,8 @@ abstract class Utils */ public static function arrayFlatten($array) { - $flatten = array(); - foreach ($array as $key => $inner){ + $flatten = []; + foreach ($array as $key => $inner) { if (is_array($inner)) { foreach ($inner as $inner_key => $value) { $flatten[$inner_key] = $value; @@ -705,28 +1131,98 @@ abstract class Utils $flatten[$key] = $inner; } } + return $flatten; } + /** + * Flatten a multi-dimensional associative array into dot notation + * + * @param array $array + * @param string $prepend + * @return array + */ + public static function arrayFlattenDotNotation($array, $prepend = '') + { + $results = array(); + foreach ($array as $key => $value) { + if (is_array($value)) { + $results = array_merge($results, static::arrayFlattenDotNotation($value, $prepend . $key . '.')); + } else { + $results[$prepend . $key] = $value; + } + } + + return $results; + } + + /** + * 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 + * @return array + */ + public static function arrayUnflattenDotNotation($array, $separator = '.') + { + $newArray = []; + foreach ($array as $key => $value) { + $dots = explode($separator, $key); + 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; + } + } + + return $newArray; + } + /** * Checks if the passed path contains the language code prefix * * @param string $string The path * - * @return bool + * @return bool|string Either false or the language + * */ public static function pathPrefixedByLangCode($string) { - if (strlen($string) <= 3) { - return false; - } - $languages_enabled = Grav::instance()['config']->get('system.languages.supported', []); + $parts = explode('/', trim($string, '/')); - if ($string[0] === '/' && $string[3] === '/' && in_array(substr($string, 1, 2), $languages_enabled)) { - return true; + if (count($parts) > 0 && in_array($parts[0], $languages_enabled)) { + return $parts[0]; } - return false; } @@ -735,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) @@ -764,45 +1260,51 @@ abstract class Utils * @param null $default * @return mixed * - * @deprecated Use getDotNotation() method instead + * @deprecated 1.5 Use ->getDotNotation() method instead. */ public static function resolve(array $array, $path, $default = null) { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use getDotNotation() method instead', E_USER_DEPRECATED); + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getDotNotation() method instead', E_USER_DEPRECATED); return static::getDotNotation($array, $path, $default); } /** - * 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) { - $username = ''; - if (isset(Grav::instance()['user'])) { - $user = Grav::instance()['user']; - $username = $user->username; - } + $grav = Grav::instance(); + $username = isset($grav['user']) ? $grav['user']->username : ''; $token = session_id(); $i = self::nonceTick(); @@ -810,7 +1312,7 @@ abstract class Utils $i--; } - return ($i . '|' . $action . '|' . $username . '|' . $token . '|' . Grav::instance()['config']->get('security.salt')); + return ($i . '|' . $action . '|' . $username . '|' . $token . '|' . $grav['config']->get('security.salt')); } /** @@ -832,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) @@ -852,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) @@ -870,13 +1370,7 @@ abstract class Utils } //Nonce generated 12-24 hours ago - $previousTick = true; - if ($nonce === self::getNonce($action, $previousTick)) { - return true; - } - - //Invalid nonce - return false; + return $nonce === self::getNonce($action, true); } /** @@ -886,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 - * @param $key + * @param array $array + * @param string|int|null $key * @param null $default * @return mixed */ @@ -926,9 +1416,9 @@ abstract class Utils * Set portion of array (passed by reference) for a dot-notation key * and set the value * - * @param $array - * @param $key - * @param $value + * @param array $array + * @param string|int|null $key + * @param mixed $value * @param bool $merge * * @return mixed @@ -944,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(); } @@ -960,7 +1449,6 @@ abstract class Utils $array[$key] = array_merge($array[$key], $value); } - return $array; } @@ -979,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; } @@ -992,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]; @@ -1005,13 +1494,13 @@ abstract class Utils /** * Sort an array by a key value in the array * - * @param $array - * @param $array_key + * @param mixed $array + * @param string|int $array_key * @param int $direction * @param int $sort_flags * @return array */ - public static function sortArrayByKey($array, $array_key, $direction = SORT_DESC, $sort_flags = SORT_REGULAR ) + public static function sortArrayByKey($array, $array_key, $direction = SORT_DESC, $sort_flags = SORT_REGULAR) { $output = []; @@ -1029,58 +1518,136 @@ abstract class Utils } /** - * Get's path based on a token + * Get relative page path based on a token. * - * @param $path - * @param Page|null $page + * @param string $path + * @param PageInterface|null $page * @return string - * @throws \RuntimeException + * @throws RuntimeException */ - public static function getPagePathFromToken($path, $page = null) + 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; @@ -1089,6 +1656,8 @@ abstract class Utils $post_max_size = static::parseSize(ini_get('post_max_size')); if ($post_max_size > 0) { $max_size = $post_max_size; + } else { + $max_size = 0; } $upload_max = static::parseSize(ini_get('upload_max_filesize')); @@ -1100,29 +1669,71 @@ abstract class Utils return $max_size; } + /** + * Convert bytes to the unit specified by the $to parameter. + * + * @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. + * + */ + public static function convertSize($bytes, $to, $decimal_places = 1) + { + $formulas = array( + 'K' => number_format($bytes / 1024, $decimal_places), + 'M' => number_format($bytes / 1048576, $decimal_places), + 'G' => number_format($bytes / 1073741824, $decimal_places) + ); + return $formulas[$to] ?? 0; + } + + /** + * Return a pretty size based on bytes + * + * @param int $bytes + * @param int $precision + * @return string + */ + public static function prettySize($bytes, $precision = 2) + { + $units = array('B', 'KB', 'MB', 'GB', 'TB'); + + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + // Uncomment one of the following alternatives + $bytes /= 1024 ** $pow; + // $bytes /= (1 << (10 * $pow)); + + return round($bytes, $precision) . ' ' . $units[$pow]; + } + /** * Parse a readable file size and return a value in bytes * - * @param $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) { - return (int)($size * pow(1024, stripos('bkmgtpezy', $unit[0]))); + $size *= 1024 ** stripos('bkmgtpezy', $unit[0]); } - return (int)$size; + return (int)abs(round($size)); } /** * Multibyte-safe Parse URL function * - * @param $url - * @return mixed - * @throws \InvalidArgumentException + * @param string $url + * @return array + * @throws InvalidArgumentException */ public static function multibyteParseUrl($url) { @@ -1136,14 +1747,358 @@ 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); } return $parts; } + + /** + * Process a string as markdown + * + * @param string $string + * @param bool $block Block or Line processing + * @param null $page + * @return string + * @throws Exception + */ + public static function processMarkdown($string, $block = true, $page = null) + { + $grav = Grav::instance(); + $page = $page ?? $grav['page'] ?? null; + $defaults = [ + 'markdown' => $grav['config']->get('system.pages.markdown', []), + 'images' => $grav['config']->get('system.images', []) + ]; + $extra = $defaults['markdown']['extra'] ?? false; + + $excerpts = new Excerpts($page, $defaults); + + // Initialize the preferred variant of Parsedown + if ($extra) { + $parsedown = new ParsedownExtra($excerpts); + } else { + $parsedown = new Parsedown($excerpts); + } + + if ($block) { + $string = $parsedown->text($string); + } else { + $string = $parsedown->line($string); + } + + return $string; + } + + /** + * Find the subnet of an ip with CIDR prefix size + * + * @param string $ip + * @param int $prefix + * @return string + */ + public static function getSubnet($ip, $prefix = 64) + { + if (!filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + + // Packed representation of 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; + } + $mask = str_pad($mask, $len >> 2, '0'); + + // Packed representation of netmask + $mask = pack('H*', $mask); + // Bitwise - Take all bits that are both 1 to generate subnet + $subnet = inet_ntop($ip & $mask); + + return $subnet; + } + + /** + * Wrapper to ensure html, htm in the front of the supported page types + * + * @param array|null $defaults + * @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']); + + // put them back at the front + $types = array_merge(['html', 'htm'], $types); + + 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 2c43f73..5adbd0c 100644 --- a/system/src/Grav/Common/Yaml.php +++ b/system/src/Grav/Common/Yaml.php @@ -1,8 +1,9 @@ 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) { @@ -33,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 4a53869..a8a025d 100644 --- a/system/src/Grav/Console/Cli/BackupCommand.php +++ b/system/src/Grav/Console/Cli/BackupCommand.php @@ -1,83 +1,133 @@ setName("backup") + ->setName('backup') ->addArgument( - 'destination', + 'id', InputArgument::OPTIONAL, - 'Where to store the backup (/backup is default)' - + '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. Optionally can be saved in a different destination.'); + ->setDescription('Creates a backup of the Grav instance') + ->setHelp('The backup creates a zipped backup.'); $this->source = getcwd(); } /** - * @return int|null|void + * @return int */ - protected function serve() + protected function serve(): int { - $this->progress = new ProgressBar($this->output); - $this->progress->setFormat('Archiving %current% files [%bar%] %elapsed:6s% %memory:6s%'); + $this->initializeGrav(); - Grav::instance()['config']->init(); + $input = $this->getInput(); + $io = $this->getIO(); - $destination = ($this->input->getArgument('destination')) ? $this->input->getArgument('destination') : null; - $log = JsonFile::instance(Grav::instance()['locator']->findResource("log://backup.log", true, true)); - $backup = ZipBackup::backup($destination, [$this, 'output']); + $io->title('Grav Backup'); - $log->content([ - 'time' => time(), - 'location' => $backup - ]); - $log->save(); + if (!class_exists(ZipArchive::class)) { + $io->error('php-zip extension needs to be enabled!'); + return 1; + } - $this->output->writeln(''); - $this->output->writeln(''); + 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_names = $backups->getBackupNames(); + + $id = null; + + $inline_id = $input->getArgument('id'); + if (null !== $inline_id && is_numeric($inline_id)) { + $id = $inline_id; + } + + if (null === $id) { + if (count($backups_list) > 1) { + $question = new ChoiceQuestion( + 'Choose a backup?', + $backups_names, + 0 + ); + $question->setErrorMessage('Option %s is invalid.'); + $backup_name = $io->askQuestion($question); + $id = array_search($backup_name, $backups_names, true); + + $io->newLine(); + $io->note('Selected backup: ' . $backup_name); + } else { + $id = 0; + } + } + + $backup = $backups::backup($id, function($args) { $this->outputProgress($args); }); + + $io->newline(2); + $io->success('Backup Successfully Created: ' . $backup); + + return 0; } /** - * @param $args + * @param array $args + * @return void */ - public function output($args) + public function outputProgress(array $args): void { switch ($args['type']) { + case 'count': + $steps = $args['steps']; + $freq = (int)($steps > 100 ? round($steps / 100) : $steps); + $this->progress->setMaxSteps($steps); + $this->progress->setRedrawFrequency($freq); + $this->progress->setMessage('Adding files...'); + break; case 'message': - $this->output->writeln($args['message']); + $this->progress->setMessage($args['message']); + $this->progress->display(); break; case 'progress': - if ($args['complete']) { + if (isset($args['complete']) && $args['complete']) { $this->progress->finish(); } else { $this->progress->advance(); @@ -85,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 32e2b75..b0c9499 100644 --- a/system/src/Grav/Console/Cli/CleanCommand.php +++ b/system/src/Grav/Console/Cli/CleanCommand.php @@ -1,8 +1,9 @@ setName("clean") - ->setDescription("Handles cleaning chores for Grav distribution") + ->setName('clean') + ->setDescription('Handles cleaning chores for Grav distribution') ->setHelp('The clean clean extraneous folders and data'); } /** * @param InputInterface $input * @param OutputInterface $output - * - * @return int|null|void + * @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; } /** @@ -260,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 cb5ffe8..daed2a5 100644 --- a/system/src/Grav/Console/Cli/ClearCacheCommand.php +++ b/system/src/Grav/Console/Cli/ClearCacheCommand.php @@ -1,70 +1,104 @@ setName('clear-cache') - ->setAliases(['clearcache']) + ->setName('cache') + ->setAliases(['clearcache', 'cache-clear']) ->setDescription('Clears Grav cache') + ->addOption('invalidate', null, InputOption::VALUE_NONE, 'Invalidate cache, but do not remove any files') + ->addOption('purge', null, InputOption::VALUE_NONE, 'If set purge old caches') ->addOption('all', null, InputOption::VALUE_NONE, 'If set will remove all including compiled, twig, doctrine caches') ->addOption('assets-only', null, InputOption::VALUE_NONE, 'If set will remove only assets/*') ->addOption('images-only', null, InputOption::VALUE_NONE, 'If set will remove only images/*') ->addOption('cache-only', null, InputOption::VALUE_NONE, 'If set will remove only cache/*') ->addOption('tmp-only', null, InputOption::VALUE_NONE, 'If set will remove only tmp/*') - ->setHelp('The clear-cache deletes all cache files'); + + ->setHelp('The cache command allows you to interact with Grav cache'); } /** - * @return int|null|void + * @return int */ - protected function serve() + 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(''); - $this->output->writeln('Clearing cache'); - $this->output->writeln(''); + $input = $this->getInput(); + $io = $this->getIO(); - if ($this->input->getOption('all')) { - $remove = 'all'; - } elseif ($this->input->getOption('assets-only')) { - $remove = 'assets-only'; - } elseif ($this->input->getOption('images-only')) { - $remove = 'images-only'; - } elseif ($this->input->getOption('cache-only')) { - $remove = 'cache-only'; - } elseif ($this->input->getOption('tmp-only')) { - $remove = 'tmp-only'; + $io->newLine(); + + if ($input->getOption('purge')) { + $io->writeln('Purging old cache'); + $io->newLine(); + + $msg = Cache::purgeJob(); + $io->writeln($msg); } else { - $remove = 'standard'; - } + $io->writeln('Clearing cache'); + $io->newLine(); - foreach (Cache::clearCache($remove) as $result) { - $this->output->writeln($result); + if ($input->getOption('all')) { + $remove = 'all'; + } elseif ($input->getOption('assets-only')) { + $remove = 'assets-only'; + } elseif ($input->getOption('images-only')) { + $remove = 'images-only'; + } elseif ($input->getOption('cache-only')) { + $remove = 'cache-only'; + } elseif ($input->getOption('tmp-only')) { + $remove = 'tmp-only'; + } elseif ($input->getOption('invalidate')) { + $remove = 'invalidate'; + } else { + $remove = 'standard'; + } + + foreach (Cache::clearCache($remove) as $result) { + $io->writeln($result); + } } } } - diff --git a/system/src/Grav/Console/Cli/ComposerCommand.php b/system/src/Grav/Console/Cli/ComposerCommand.php index 4f9c18f..5075d1d 100644 --- a/system/src/Grav/Console/Cli/ComposerCommand.php +++ b/system/src/Grav/Console/Cli/ComposerCommand.php @@ -1,42 +1,30 @@ setName("composer") + ->setName('composer') ->addOption( 'install', 'i', @@ -49,24 +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'); } /** - * @return int|null|void + * @return int */ - protected function serve() + 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 198e1d3..22258be 100644 --- a/system/src/Grav/Console/Cli/InstallCommand.php +++ b/system/src/Grav/Console/Cli/InstallCommand.php @@ -1,72 +1,85 @@ setName("install") + ->setName('install') ->addOption( 'symlink', 's', 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, 'Where to install the required bits (default to current project)' ) - ->setDescription("Installs the dependencies needed by Grav. Optionally can create symbolic links") + ->setDescription('Installs the dependencies needed by Grav. Optionally can create symbolic links') ->setHelp('The install command installs the dependencies needed by Grav. Optionally can create symbolic links'); } /** - * @return int|null|void + * @return int */ - protected function serve() + protected function serve(): int { + $input = $this->getInput(); + $io = $this->getIO(); + $dependencies_file = '.dependencies'; - $this->destination = ($this->input->getArgument('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 @@ -75,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 new file mode 100644 index 0000000..d3924f8 --- /dev/null +++ b/system/src/Grav/Console/Cli/LogViewerCommand.php @@ -0,0 +1,96 @@ +setName('logviewer') + ->addOption( + 'file', + 'f', + InputOption::VALUE_OPTIONAL, + 'custom log file location (default = grav.log)' + ) + ->addOption( + 'lines', + 'l', + InputOption::VALUE_OPTIONAL, + 'number of lines (default = 10)' + ) + ->setDescription('Display the last few entries of Grav log') + ->setHelp('Display the last few entries of Grav log'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $file = $input->getOption('file') ?? 'grav.log'; + $lines = $input->getOption('lines') ?? 20; + $verbose = $input->getOption('verbose') ?? false; + + $io->title('Log Viewer'); + + $io->writeln(sprintf('viewing last %s entries in %s', $lines, $file)); + $io->newLine(); + + $viewer = new LogViewer(); + + $grav = Grav::instance(); + + $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 5a8f3d1..d67cb1c 100644 --- a/system/src/Grav/Console/Cli/NewProjectCommand.php +++ b/system/src/Grav/Console/Cli/NewProjectCommand.php @@ -1,24 +1,29 @@ setName('new-project') @@ -39,10 +44,12 @@ class NewProjectCommand extends ConsoleCommand } /** - * @return int|null|void + * @return int */ - protected function serve() + protected function serve(): int { + $io = $this->getIO(); + $sandboxCommand = $this->getApplication()->find('sandbox'); $installCommand = $this->getApplication()->find('install'); @@ -58,8 +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 284878e..d865b4a 100644 --- a/system/src/Grav/Console/Cli/SandboxCommand.php +++ b/system/src/Grav/Console/Cli/SandboxCommand.php @@ -1,23 +1,28 @@ '/.gitignore', + '/.editorconfig' => '/.editorconfig', '/CHANGELOG.md' => '/CHANGELOG.md', '/LICENSE.txt' => '/LICENSE.txt', '/README.md' => '/README.md', @@ -60,19 +62,15 @@ class SandboxCommand extends ConsoleCommand '/webserver-configs' => '/webserver-configs', ]; - /** - * @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."; - + /** @var string */ protected $source; + /** @var string */ protected $destination; /** - * + * @return void */ - protected function configure() + protected function configure(): void { $this ->setName('sandbox') @@ -89,104 +87,129 @@ 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; } /** - * @return int|null|void + * @return int */ - protected function serve() + 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; } /** - * + * @return int */ - private function createDirectories() + 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)) { - mkdir($this->destination, 0777, true); + Folder::create($this->destination); } foreach ($this->directories as $dir) { if (!file_exists($this->destination . $dir)) { $dirs_created = true; - $this->output->writeln(' ' . $dir . ''); - mkdir($this->destination . $dir, 0777, true); + $io->writeln(' ' . $dir . ''); + Folder::create($this->destination . $dir); } } if (!$dirs_created) { - $this->output->writeln(' Directories already exist'); + $io->writeln(' Directories already exist'); } + + return 0; } /** - * + * @return int */ - private function copy() + 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) { - if ((int)$source == $source) { + if ((string)(int)$source === (string)$source) { $source = $target; } $from = $this->source . $source; $to = $this->destination . $target; - $this->output->writeln(' ' . $source . ' -> ' . $to); + $io->writeln(' ' . $source . ' -> ' . $to); @Folder::rcopy($from, $to); } + + return 0; } /** - * + * @return int */ - private function symlink() + 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) { - if ((int)$source == $source) { + if ((string)(int)$source === (string)$source) { $source = $target; } $from = $this->source . $source; $to = $this->destination . $target; - $this->output->writeln(' ' . $source . ' -> ' . $to); + $io->writeln(' ' . $source . ' -> ' . $to); if (is_dir($to)) { @Folder::delete($to); @@ -195,22 +218,50 @@ class SandboxCommand extends ConsoleCommand } symlink($from, $to); } + + return 0; } /** - * + * @return int */ - private function initFiles() + 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 foreach ($this->files as $source => $target) { - if ((int)$source == $source) { + if ((string)(int)$source === (string)$source) { $source = $target; } @@ -220,42 +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; } /** - * + * @return int */ - private function pages() + 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; @@ -263,42 +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; } /** - * + * @return bool */ - private function check() + 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 new file mode 100644 index 0000000..c5385aa --- /dev/null +++ b/system/src/Grav/Console/Cli/SchedulerCommand.php @@ -0,0 +1,225 @@ +setName('scheduler') + ->addOption( + 'install', + 'i', + InputOption::VALUE_NONE, + 'Show Install Command' + ) + ->addOption( + 'jobs', + 'j', + InputOption::VALUE_NONE, + 'Show Jobs Summary' + ) + ->addOption( + 'details', + 'd', + 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"); + } + + /** + * @return int + */ + protected function serve(): int + { + $this->initializePlugins(); + + $grav = Grav::instance(); + $grav['backups']->init(); + $this->initializePages(); + $this->initializeThemes(); + + /** @var Scheduler $scheduler */ + $scheduler = $grav['scheduler']; + $grav->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler])); + + $this->setHelp('foo'); + + $input = $this->getInput(); + $io = $this->getIO(); + $error = 0; + + $run = $input->getOption('run'); + + if ($input->getOption('jobs')) { + // Show jobs list + + $jobs = $scheduler->getAllJobs(); + $job_states = (array)$scheduler->getJobStates()->content(); + $rows = []; + + $table = new Table($io); + $table->setStyle('box'); + $headers = ['Job ID', 'Command', 'Run At', 'Status', 'Last Run', 'State']; + + $io->title('Scheduler Jobs Listing'); + + foreach ($jobs as $job) { + $job_status = ucfirst($job_states[$job->getId()]['state'] ?? 'ready'); + $last_run = $job_states[$job->getId()]['last-run'] ?? 0; + $status = $job_status === 'Failure' ? "{$job_status}" : "{$job_status}"; + $state = $job->getEnabled() ? 'Enabled' : 'Disabled'; + $row = [ + $job->getId(), + "{$job->getCommand()}", + "{$job->getAt()}", + $status, + '' . ($last_run === 0 ? 'Never' : date('Y-m-d H:i', $last_run)) . '', + $state, + + ]; + $rows[] = $row; + } + + if (!empty($rows)) { + $table->setHeaders($headers); + $table->setRows($rows); + $table->render(); + } else { + $io->text('no jobs found...'); + } + + $io->newLine(); + $io->note('For error details run "bin/grav scheduler -d"'); + $io->newLine(); + } elseif ($input->getOption('details')) { + $jobs = $scheduler->getAllJobs(); + $job_states = (array)$scheduler->getJobStates()->content(); + + $io->title('Job Details'); + + $table = new Table($io); + $table->setStyle('box'); + $table->setHeaders(['Job ID', 'Last Run', 'Next Run', 'Errors']); + $rows = []; + + foreach ($jobs as $job) { + $job_state = $job_states[$job->getId()]; + $error = isset($job_state['error']) ? trim($job_state['error']) : false; + + /** @var CronExpression $expression */ + $expression = $job->getCronExpression(); + $next_run = $expression->getNextRunDate(); + + $row = []; + $row[] = $job->getId(); + if (!is_null($job_state['last-run'])) { + $row[] = '' . date('Y-m-d H:i', $job_state['last-run']) . ''; + } else { + $row[] = 'Never'; + } + $row[] = '' . $next_run->format('Y-m-d H:i') . ''; + + if ($error) { + $row[] = "{$error}"; + } else { + $row[] = 'None'; + } + $rows[] = $row; + } + + $table->setRows($rows); + $table->render(); + } elseif ($run !== false && $run !== null) { + $io->title('Force Run Job: ' . $run); + + $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. You can validate this by running "crontab -l" to list your current crontab entries.'); + $verb = 'reinstall'; + } else { + $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 $verb, run the following command from your terminal:"); + $io->newLine(); + $io->text(trim($scheduler->getCronCommand())); + } else { + $io->note("To $verb, create a scheduled task in Windows."); + $io->text('Learn more at https://learn.getgrav.org/advanced/scheduler'); + } + } else { + // Run scheduler + $force = $run === null; + $scheduler->run(null, $force); + + 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 3361d44..7f728b6 100644 --- a/system/src/Grav/Console/Cli/SecurityCommand.php +++ b/system/src/Grav/Console/Cli/SecurityCommand.php @@ -1,8 +1,9 @@ setName("security") - ->setDescription("Capable of running various Security checks") + ->setName('security') + ->setDescription('Capable of running various Security checks') ->setHelp('The security runs various security checks on your Grav site'); - - $this->source = getcwd(); } /** - * @return int|null|void + * @return int */ - protected function serve() + protected function serve(): int { + $this->initializePages(); + $io = $this->getIO(); /** @var Grav $grav */ $grav = Grav::instance(); - - $grav['uri']->init(); - $grav['config']->init(); - $grav['debugger']->enabled(false); - $grav['streams']; - $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'); - - $output = Security::detectXssFromPages($grav['pages'], [$this, 'outputProgress']); - $io->newline(2); - if (!empty($output)) { + $output = Security::detectXssFromPages($grav['pages'], false, [$this, 'outputProgress']); + $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) . ''); } + $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 $args + * @param array $args + * @return void */ - public function outputProgress($args) + public function outputProgress(array $args): void { switch ($args['type']) { case 'count': $steps = $args['steps']; - $freq = intval($steps > 100 ? round($steps / 100) : $steps); + $freq = (int)($steps > 100 ? round($steps / 100) : $steps); $this->progress->setMaxSteps($steps); $this->progress->setRedrawFrequency($freq); break; @@ -108,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 new file mode 100644 index 0000000..a9628cd --- /dev/null +++ b/system/src/Grav/Console/Cli/YamlLinterCommand.php @@ -0,0 +1,124 @@ +setName('yamllinter') + ->addOption( + 'all', + 'a', + InputOption::VALUE_NONE, + 'Go through the whole Grav installation' + ) + ->addOption( + 'folder', + 'f', + InputOption::VALUE_OPTIONAL, + 'Go through specific folder' + ) + ->setDescription('Checks various files for YAML errors') + ->setHelp('Checks various files for YAML errors'); + } + + /** + * @return int + */ + protected function serve(): int + { + $input = $this->getInput(); + $io = $this->getIO(); + + $io->title('Yaml Linter'); + + $error = 0; + if ($input->getOption('all')) { + $io->section('All'); + $errors = YamlLinter::lint(''); + + 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 { + $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; + } + + /** + * @param array $errors + * @param SymfonyStyle $io + * @return void + */ + protected function displayErrors(array $errors, SymfonyStyle $io): void + { + $io->error('YAML Linting issues found...'); + foreach ($errors as $path => $error) { + $io->writeln("{$path} - {$error}"); + } + } +} diff --git a/system/src/Grav/Console/ConsoleCommand.php b/system/src/Grav/Console/ConsoleCommand.php index a9c2217..044f1b0 100644 --- a/system/src/Grav/Console/ConsoleCommand.php +++ b/system/src/Grav/Console/ConsoleCommand.php @@ -1,18 +1,22 @@ setupConsole($input, $output); - $this->serve(); + + return $this->serve(); } /** + * Override with your implementation. * + * @return int */ protected function serve() { - + // Return error. + return 1; } - - 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 4498172..feb10d3 100644 --- a/system/src/Grav/Console/ConsoleTrait.php +++ b/system/src/Grav/Console/ConsoleTrait.php @@ -1,93 +1,289 @@ 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; } /** - * @param $path + * @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,10 +307,18 @@ trait ConsoleTrait return $command->run($input, $this->output); } + /** + * @return void + */ + public function invalidateCache() + { + Cache::invalidateCache(); + } + /** * 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() { @@ -126,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 60c5fa8..e5c662b 100644 --- a/system/src/Grav/Console/Gpm/DirectInstallCommand.php +++ b/system/src/Grav/Console/Gpm/DirectInstallCommand.php @@ -1,33 +1,48 @@ setName("direct-install") + ->setName('direct-install') ->setAliases(['directinstall']) ->addArgument( 'package-file', @@ -47,221 +62,261 @@ class DirectInstallCommand extends ConsoleCommand 'The destination where the package should be installed at. By default this would be where the grav instance has been launched from', GRAV_ROOT ) - ->setDescription("Installs Grav, plugin, or theme directly from a file or a URL") + ->setDescription('Installs Grav, plugin, or theme directly from a file or a URL') ->setHelp('The direct-install command installs Grav, plugin, or theme directly from a file or a URL'); } /** - * @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; + } - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion('Are you sure you want to direct-install '.$package_file.' [y|N] ', false); + $this->all_yes = $input->getOption('all-yes'); - $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question); + $package_file = $input->getArgument('package-file'); + + $question = new ConfirmationQuestion("Are you sure you want to direct-install {$package_file} [y|N] ", false); + + $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); if ($blueprint) { if (isset($blueprint['dependencies'])) { - $depencencies = []; + $dependencies = []; foreach ($blueprint['dependencies'] as $dependency) { - if (is_array($dependency)){ - if (isset($dependency['name'])) { - $depencencies[] = $dependency['name']; - } - if (isset($dependency['github'])) { - $depencencies[] = $dependency['github']; - } + if (is_array($dependency)) { + if (isset($dependency['name'])) { + $dependencies[] = $dependency['name']; + } + if (isset($dependency['github'])) { + $dependencies[] = $dependency['github']; + } } else { - $depencencies[] = $dependency; + $dependencies[] = $dependency; } } - $this->output->writeln(" |- Dependencies found... [" . implode(',', $depencencies) . "]"); + $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... "); + if ($type === 'grav') { + $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... "); - Installer::install($zip, GRAV_ROOT, ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true], $extracted); + $io->write(' |- Installing package... '); + + $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; - } else { - $this->output->write("\x0D"); - $this->output->writeln(" |- Checking destination... ok"); + return 1; } - $this->output->write(" |- Installing package... "); + $io->write("\x0D"); + $io->writeln(' |- Checking destination... ok'); + + $io->write(' |- Installing package... '); Installer::install( $zip, $this->destination, $options = [ 'install_path' => $install_path, - 'theme' => (($type == 'theme')), + 'theme' => (($type === 'theme')), 'is_update' => $is_update ], $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 0; + } - return true; + /** + * @param string $zip + * @param string $folder + * @return void + */ + private function upgradeGrav(string $zip, string $folder): void + { + if (!is_dir($folder)) { + Installer::setError('Invalid source folder'); + } + try { + $script = $folder . '/system/install.php'; + /** Install $installer */ + if ((file_exists($script) && $install = include $script) && is_callable($install)) { + $install($zip); + } else { + throw new RuntimeException('Uploaded archive file is not a valid Grav update package'); + } + } 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 8c45706..64d3597 100644 --- a/system/src/Grav/Console/Gpm/IndexCommand.php +++ b/system/src/Grav/Console/Gpm/IndexCommand.php @@ -1,47 +1,46 @@ setName("index") + ->setName('index') ->addOption( 'force', 'f', @@ -81,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', @@ -91,117 +90,154 @@ class IndexCommand extends ConsoleCommand InputOption::VALUE_NONE, 'Reverses the order of the output.' ) - ->setDescription("Lists the plugins and themes available for installation") + ->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') ; } /** - * @return int|null|void + * @return int */ - protected function serve() + 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, ' ', '...') . " ", + '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 - * + * @param Package $package * @return string */ - private function version($package) + private function version(Package $package): string { $list = $this->gpm->{'getUpdatable' . ucfirst($package->package_type)}(); - $package = isset($list[$package->slug]) ? $list[$package->slug] : $package; - $type = ucfirst(preg_replace("/s$/", '', $package->package_type)); + $package = $list[$package->slug] ?? $package; + $type = ucfirst(preg_replace('/s$/', '', $package->package_type)); $updatable = $this->gpm->{'is' . $type . 'Updatable'}($package->slug); $installed = $this->gpm->{'is' . $type . 'Installed'}($package->slug); $local = $this->gpm->{'getInstalled' . $type}($package->slug); if (!$installed || !$updatable) { $version = $installed ? $local->version : $package->version; - return "v" . $version . ""; + return "v{$version}"; } - if ($updatable) { - return "v" . $package->version . " -> v" . $package->available . ""; - } - - return ''; + return "v{$package->version} -> v{$package->available}"; } /** - * @param $package - * + * @param Package $package * @return string */ - private function installed($package) + private function installed(Package $package): string { - $package = isset($list[$package->slug]) ? $list[$package->slug] : $package; - $type = ucfirst(preg_replace("/s$/", '', $package->package_type)); - $installed = $this->gpm->{'is' . $type . 'Installed'}($package->slug); + $package = $list[$package->slug] ?? $package; + $type = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $method = 'is' . $type . 'Installed'; + $installed = $this->gpm->{$method}($package->slug); return !$installed ? 'not installed' : 'installed'; } /** - * @param $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']) { @@ -212,10 +248,12 @@ 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))) { @@ -225,19 +263,44 @@ class IndexCommand extends ConsoleCommand // Filtering by string if ($this->options['filter']) { - $filter = preg_grep('/(' . (implode('|', $this->options['filter'])) . ')/i', [$slug, $package->name]); + $filter = preg_grep('/(' . implode('|', $this->options['filter']) . ')/i', [$slug, $package->name]); } // Filtering updatables only - if ($this->options['installed-only'] && $filter) { - $method = ucfirst(preg_replace("/s$/", '', $package->package_type)); - $filter = $this->gpm->{'is' . $method . 'Installed'}($package->slug); + 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); } // Filtering updatables only - if ($this->options['updates-only'] && $filter) { - $method = ucfirst(preg_replace("/s$/", '', $package->package_type)); - $filter = $this->gpm->{'is' . $method . 'Updatable'}($package->slug); + if ($filter && $this->options['updates-only']) { + $method = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $function = 'is' . $method . 'Updatable'; + $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) { @@ -251,22 +314,24 @@ class IndexCommand extends ConsoleCommand } /** - * @param $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 8c1d50e..5dddd32 100644 --- a/system/src/Grav/Console/Gpm/InfoCommand.php +++ b/system/src/Grav/Console/Gpm/InfoCommand.php @@ -1,39 +1,41 @@ setName("info") + ->setName('info') ->addOption( 'force', 'f', @@ -51,46 +53,52 @@ class InfoCommand extends ConsoleCommand InputArgument::REQUIRED, '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'); + ->setDescription('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', @@ -105,21 +113,21 @@ class InfoCommand extends ConsoleCommand 'zipball_url', 'license' ] as $info) { - if (isset($foundPackage->$info)) { + if (isset($foundPackage->{$info})) { $name = ucfirst($info); - $data = $foundPackage->$info; + $data = $foundPackage->{$info}; - if ($info == 'zipball_url') { - $name = "Download"; + if ($info === 'zipball_url') { + $name = 'Download'; } - if ($info == 'date') { - $name = "Last Update"; - $data = date('D, j M Y, H:i:s, P ', strtotime('2014-09-16T00:07:16Z')); + if ($info === 'date') { + $name = 'Last Update'; + $data = date('D, j M Y, H:i:s, P ', strtotime($data)); } $name = str_pad($name, 12); - $this->output->writeln("" . $name . ": " . $data); + $io->writeln("{$name}: {$data}"); } } @@ -130,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) { - return "\n" . ucfirst($match[1]) . ":"; + $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); + $question = new ConfirmationQuestion('Press [ENTER] to continue or [q] to quit ', true); + $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 0311428..92c8e07 100644 --- a/system/src/Grav/Console/Gpm/InstallCommand.php +++ b/system/src/Grav/Console/Gpm/InstallCommand.php @@ -1,63 +1,64 @@ setName("install") + ->setName('install') ->addOption( 'force', 'f', @@ -82,100 +83,110 @@ class InstallCommand extends ConsoleCommand InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'Package(s) to install. Use "bin/gpm index" to list packages. Use "bin/gpm direct-install" to install a specific version' ) - ->setDescription("Performs the installation of plugins and themes") + ->setDescription('Performs the installation of plugins and themes') ->setHelp('The install command allows to install plugins and themes'); } /** * Allows to set the GPM object, used for testing the class * - * @param $gpm + * @param GPM $gpm */ - public function setGpm($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'])) . ""); + $io->writeln('These packages were not found on Grav: ' . implode( + ', ', + array_keys($this->data['not_found']) + ) . ''); } - unset($this->data['not_found']); - unset($this->data['total']); + unset($this->data['not_found'], $this->data['total']); - - if (isset($this->local_config)) { + 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, '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) { + $io->writeln('Installation aborted'); + + return 1; } - $this->output->writeln("Dependencies are OK"); - $this->output->writeln(""); + $io->writeln('Dependencies are OK'); + $io->newLine(); } @@ -183,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); + $question = new ConfirmationQuestion("The package {$package_name} is already installed, overwrite? [y|N] ", false); + $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(); } } } @@ -229,33 +239,34 @@ 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 + * @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 ? $package->available : $this->gpm->getLatestVersionOfPackage($package->slug); + $new_version = $package->available ?: $this->gpm->getLatestVersionOfPackage($package->slug); $old_version = $package->version; $major_version_changed = explode('.', $new_version)[0] !== explode('.', $old_version)[0]; 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); + $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; } } @@ -270,73 +281,75 @@ 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; }); + $io = $this->getIO(); + $packages = array_filter($dependencies, static function ($action) use ($type) { + return $action === $type; + }); if (count($packages) > 0) { - $this->output->writeln($message); + $io->writeln($message); foreach ($packages as $dependencyName => $dependencyVersion) { - $this->output->writeln(" |- Package " . $dependencyName . ""); + $io->writeln(" |- Package {$dependencyName}"); } - $this->output->writeln(""); + $io->newLine(); - $helper = $this->getHelper('question'); - - if ($type == 'install') { + if ($type === 'install') { $questionAction = 'Install'; } else { $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); + $question = new ConfirmationQuestion("${questionAction} {$questionArticle} {$questionNoun}? [Y|n] ", true); + $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') ? true : false); - } - $this->output->writeln(''); - } else { - if ($required) { - throw new \Exception(); + $this->processPackage($package, $type === 'update'); } + $io->newLine(); + } elseif ($required) { + throw new Exception(); } } } /** - * @param $package - * @param bool $is_update True if the package is an update + * @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; } $symlink = false; if ($this->use_symlinks) { - if ($this->getSymlinkSource($package) || !isset($package->version)) { + if (!isset($package->version) || $this->getSymlinkSource($package)) { $symlink = true; } } @@ -349,9 +362,10 @@ class InstallCommand extends ConsoleCommand /** * Add package to the queue to process the demo content, if demo content exists * - * @param $package + * @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)) { @@ -362,10 +376,12 @@ class InstallCommand extends ConsoleCommand /** * Prompt to install the demo content of a package * - * @param $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)) { @@ -373,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 = $this->all_yes ? true : $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; } @@ -390,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; } @@ -402,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 - * - * @return array|bool + * @param Package $package + * @return array|false */ - private function getGitRegexMatches($package) + private function getGitRegexMatches(Package $package) { if (isset($package->repository)) { $repository = $package->repository; @@ -436,11 +451,10 @@ class InstallCommand extends ConsoleCommand } /** - * @param $package - * - * @return bool|string + * @param Package $package + * @return string|false */ - private function getSymlinkSource($package) + private function getSymlinkSource(Package $package) { $matches = $this->getGitRegexMatches($package); @@ -450,7 +464,7 @@ class InstallCommand extends ConsoleCommand } else { $repo_dir = $matches[2]; } - + $paths = (array) $paths; foreach ($paths as $repo) { $path = rtrim($repo, '/') . '/' . $repo_dir; @@ -458,94 +472,94 @@ class InstallCommand extends ConsoleCommand return $path; } } - } return false; } /** - * @param $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 + * @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; } @@ -556,26 +570,27 @@ 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); - $filename = preg_replace('/[\\\\\/:"*?&<>|]+/mi', '-', $filename); + $filename = preg_replace('/[\\\\\/:"*?&<>|]+/m', '-', $filename); $query = ''; - if ($package->premium) { - $query = \json_encode(array_merge( + if (!empty($package->premium)) { + $query = json_encode(array_merge( $package->premium, [ 'slug' => $package->slug, 'filename' => $package->premium['filename'], - 'license_key' => $license + 'license_key' => $license, + 'sid' => md5(GRAV_ROOT) ] )); @@ -584,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::mkdir($this->tmp); + 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); @@ -606,41 +627,42 @@ class InstallCommand extends ConsoleCommand } /** - * @param $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; - } else { - unlink($this->destination . DS . $package->install_path); } + + 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; } @@ -649,48 +671,56 @@ 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]); + Installer::install($this->file, $this->destination, ['install_path' => $package->install_path, 'theme' => $type === 'themes', 'is_update' => $is_update]); $error_code = Installer::lastErrorCode(); 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 $progress + * @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 d406a95..b840016 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -1,64 +1,61 @@ setName("self-upgrade") + ->setName('self-upgrade') ->setAliases(['selfupgrade', 'selfupdate']) ->addOption( 'force', @@ -78,18 +75,36 @@ class SelfupgradeCommand extends ConsoleCommand InputOption::VALUE_NONE, 'Option to overwrite packages if they already exist' ) - ->setDescription("Detects and performs an update of Grav itself when available") + ->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'); } /** - * @return int|null|void + * @return int */ - protected function serve() + 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(); @@ -100,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) { - return "\n" . ucfirst($match[1]) . ":"; + $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); + $question = new ConfirmationQuestion('Press [ENTER] to continue.', true); + $io->askQuestion($question); } - $question = new ConfirmationQuestion("Would you like to upgrade now? [y|N] ", false); - $answer = $questionHelper->ask($this->input, $this->output, $question); + $question = new ConfirmationQuestion('Would you like to upgrade now? [y|N] ', false); + $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 $package - * + * @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(); - $output = Response::get($package['download'], [], [$this, 'progress']); + $this->tmp = $tmp_dir . '/grav-update-' . uniqid('', false); + $options = [ + 'timeout' => $this->timeout, + ]; - Folder::mkdir($this->tmp); + $output = Response::get($package['download'], $options, [$this, 'progress']); - $this->output->write("\x0D"); - $this->output->write(" |- Downloading upgrade [" . $this->formatBytes($package['size']) . "]... 100%"); - $this->output->writeln(''); + Folder::create($this->tmp); + + $io->write("\x0D"); + $io->write(" |- Downloading upgrade [{$this->formatBytes($package['size'])}]... 100%"); + $io->newLine(); file_put_contents($this->tmp . DS . $package['name'], $output); @@ -213,50 +266,79 @@ class SelfupgradeCommand extends ConsoleCommand /** * @return bool */ - private function upgrade() + private function upgrade(): bool { - Installer::install($this->file, GRAV_ROOT, - ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true]); - $errorCode = Installer::lastErrorCode(); - Folder::delete($this->tmp); + $io = $this->getIO(); - if ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR)) { - $this->output->write("\x0D"); + $this->upgradeGrav($this->file); + + $errorCode = Installer::lastErrorCode(); + 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 $progress + * @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 $size + * @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'); - return round(pow(1024, $base - floor($base)), $precision) . $suffixes[(int)floor($base)]; + return round(1024 ** ($base - floor($base)), $precision) . $suffixes[(int)floor($base)]; + } + + /** + * @param string $zip + * @return void + */ + private function upgradeGrav(string $zip): void + { + try { + $folder = Installer::unZip($zip, $this->tmp . '/zip'); + if ($folder === false) { + throw new RuntimeException(Installer::lastErrorMsg()); + } + + $script = $folder . '/system/install.php'; + if ((file_exists($script) && $install = include $script) && is_callable($install)) { + $install($zip); + } else { + throw new RuntimeException('Uploaded archive file is not a valid Grav update package'); + } + } 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 34b04ee..cb14c65 100644 --- a/system/src/Grav/Console/Gpm/UninstallCommand.php +++ b/system/src/Grav/Console/Gpm/UninstallCommand.php @@ -1,8 +1,9 @@ setName("uninstall") + ->setName('uninstall') ->addOption( 'all-yes', 'y', @@ -61,83 +62,102 @@ class UninstallCommand extends ConsoleCommand InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'The package(s) that are desired to be removed. Use the "index" command for a list of packages' ) - ->setDescription("Performs the uninstallation of plugins and themes") + ->setDescription('Performs the uninstallation of plugins and themes') ->setHelp('The uninstall command allows to uninstall plugins and themes'); } /** - * @return int|null|void + * @return int */ - protected function serve() + 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; foreach ($packages as $package) { $plugin = $this->gpm->getInstalledPlugin($package); $theme = $this->gpm->getInstalledTheme($package); if ($plugin || $theme) { $this->data[strtolower($package)] = $plugin ?: $theme; - $this->data['total']++; + $total++; } else { $this->data['not_found'][] = $package; } } + $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']); - unset($this->data['total']); + unset($this->data['not_found'], $this->data['total']); + // 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()}"); + } + + $error = 0; foreach ($this->data as $slug => $package) { - $this->output->writeln("Preparing to uninstall " . $package->name . " [v" . $package->version . "]"); + $io->writeln("Preparing to uninstall {$package->name} [v{$package->version}]"); - $this->output->write(" |- Checking destination... "); + $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 $slug - * @param $package - * + * @param string $slug + * @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; } @@ -145,41 +165,34 @@ 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(''); + $io->newLine(2); + $io->writeln('Uninstallation failed.'); + $io->newLine(); 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->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)) { + 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)) { @@ -194,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); + $question = new ConfirmationQuestion(" |- Uninstall {$dependencyPackage->name}? [y|N] ", false); + $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(); } } - } } @@ -225,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 $slug - * @param $package - * + * @param string $slug + * @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; } @@ -289,14 +298,15 @@ class UninstallCommand extends ConsoleCommand /** * Check if package exists * - * @param $slug - * @param $package + * @param string $slug + * @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 06913b1..300fe61 100644 --- a/system/src/Grav/Console/Gpm/UpdateCommand.php +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -1,8 +1,9 @@ setName("update") + ->setName('update') ->addOption( 'force', 'f', @@ -106,83 +93,93 @@ class UpdateCommand extends ConsoleCommand InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'The package or packages that is desired to update. By default all available updates will be applied.' ) - ->setDescription("Detects and performs an update of plugins and themes when available") + ->setDescription('Detects and performs an update of plugins and themes when available') ->setHelp('The update command updates plugins and themes when a new version is available'); } /** - * @return int|null|void + * @return int */ - protected function serve() + 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'); - $question = new ConfirmationQuestion("Continue with the update process? [Y|n] ", true); - $answer = $questionHelper->ask($this->input, $this->output, $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 = $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) { $this->data = $this->gpm->getInstallable($list_type); - $description = " can be overwritten"; + $description = ' can be overwritten'; } else { $this->data = $this->gpm->getUpdatable($list_type); - $description = " need updating"; + $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']); - unset($limit_to['total']); + unset($this->data['total'], $limit_to['total']); // updates review $slugs = []; - $index = 0; + $index = 1; foreach ($this->data as $packages) { foreach ($packages as $slug => $package) { - if (count($only_packages) && !array_key_exists($slug, $limit_to)) { + if (!array_key_exists($slug, $limit_to) && count($only_packages)) { continue; } @@ -190,13 +187,13 @@ 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) . " " . + '' . str_pad($package->name, 15) . ' ' . // version - "[v" . $package->version . " -> v" . $package->available . "]" + "[v{$package->version} -> v{$package->available}]" ); $slugs[] = $slug; } @@ -204,14 +201,14 @@ class UpdateCommand extends ConsoleCommand if (!$this->all_yes) { // prompt to continue - $this->output->writeln(""); - $questionHelper = $this->getHelper('question'); - $question = new ConfirmationQuestion("Continue with the update process? [Y|n] ", true); - $answer = $questionHelper->ask($this->input, $this->output, $question); + $io->newLine(); + $question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true); + $answer = $io->askQuestion($question); if (!$answer) { - $this->output->writeln("Update aborted. Exiting..."); - exit; + $io->writeln('Update aborted. Exiting...'); + + return 1; } } @@ -221,36 +218,40 @@ 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 $only_packages - * + * @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(''); + $io->newLine(); } else { foreach ($only_packages as $only_package) { $find = $this->gpm->findPackage($only_package); if (!$find || (!$this->overwrite && !$this->gpm->isUpdatable($find->slug))) { - $name = isset($find->slug) ? $find->slug : $only_package; + $name = $find->slug ?? $only_package; $ignore[$name] = $name; } else { $found[$find->slug] = $find; @@ -264,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) . ""); - + $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 eb7872e..124aad3 100644 --- a/system/src/Grav/Console/Gpm/VersionCommand.php +++ b/system/src/Grav/Console/Gpm/VersionCommand.php @@ -1,8 +1,9 @@ setName("version") + ->setName('version') ->addOption( 'force', 'f', @@ -40,17 +45,20 @@ class VersionCommand extends ConsoleCommand InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'The package or packages that is desired to know the version of. By default and if not specified this would be grav' ) - ->setDescription("Shows the version of an installed package. If available also shows pending updates.") + ->setDescription('Shows the version of an installed package. If available also shows pending updates.') ->setHelp('The version command displays the current version of a package installed and, if available, the available version of pending updates'); } /** - * @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; @@ -64,18 +72,17 @@ class VersionCommand extends ConsoleCommand $version = null; $updatable = false; - if ($package == 'grav') { + if ($package === 'grav') { $name = 'Grav'; $version = GRAV_VERSION; $upgrader = new Upgrader(); if ($upgrader->isUpgradable()) { - $updatable = ' [upgradable: v' . $upgrader->getRemoteVersion() . ']'; + $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'); @@ -99,18 +106,20 @@ class VersionCommand extends ConsoleCommand $name = $installed->name; if ($this->gpm->isUpdatable($package)) { - $updatable = ' [updatable: v' . $installed->available . ']'; + $updatable = " [updatable: v{$installed->available}]"; } } } $updatable = $updatable ?: ''; - if ($installed || $package == 'grav') { - $this->output->writeln('You are running ' . $name . ' v' . $version . '' . $updatable); + if ($installed || $package === 'grav') { + $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 c3078a9..a71d7c0 100644 --- a/system/src/Grav/Console/TerminalObjects/Table.php +++ b/system/src/Grav/Console/TerminalObjects/Table.php @@ -1,15 +1,24 @@ 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 de749a1..bfb5125 100644 --- a/system/src/Grav/Framework/Cache/AbstractCache.php +++ b/system/src/Grav/Framework/Cache/AbstractCache.php @@ -1,14 +1,16 @@ caches[$i]->doClear() && $success; } + return $success; } @@ -129,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); @@ -139,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 3a69c1f..29e9e3b 100644 --- a/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php +++ b/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php @@ -1,13 +1,15 @@ getNamespace(); - $namespace && $doctrineCache->setNamespace($namespace); + if ($namespace) { + $doctrineCache->setNamespace($namespace); + } $this->driver = $doctrineCache; } @@ -96,20 +98,9 @@ class DoctrineCache extends AbstractCache /** * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException */ public function doDeleteMultiple($keys) { - // TODO: Remove when Doctrine Cache has been updated to support the feature. - if (!method_exists($this->driver, 'deleteMultiple')) { - $success = true; - foreach ($keys as $key) { - $success = $this->delete($key) && $success; - } - - return $success; - } - return $this->driver->deleteMultiple($keys); } diff --git a/system/src/Grav/Framework/Cache/Adapter/FileCache.php b/system/src/Grav/Framework/Cache/Adapter/FileCache.php index 801ec2a..1995e15 100644 --- a/system/src/Grav/Framework/Cache/Adapter/FileCache.php +++ b/system/src/Grav/Framework/Cache/Adapter/FileCache.php @@ -1,16 +1,23 @@ true]); } } @@ -64,7 +77,7 @@ class FileCache extends AbstractCache /** * @inheritdoc - * @throws \Psr\SimpleCache\CacheException + * @throws CacheException */ public function doSet($key, $value, $ttl) { @@ -99,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; @@ -128,8 +141,8 @@ class FileCache extends AbstractCache $hash = str_replace('/', '-', base64_encode(hash('sha256', static::class . $key, true))); $dir = $this->directory . $hash[0] . DIRECTORY_SEPARATOR . $hash[1] . DIRECTORY_SEPARATOR; - if ($mkdir && !file_exists($dir)) { - @mkdir($dir, 0777, true); + if ($mkdir) { + $this->mkdir($dir); } return $dir . substr($hash, 2, 20); @@ -138,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) { @@ -193,14 +207,45 @@ class FileCache extends AbstractCache } /** + * @param string $dir + * @return void + * @throws RuntimeException + */ + private function mkdir($dir) + { + // Silence error for open_basedir; should fail in mkdir instead. + if (@is_dir($dir)) { + return; + } + + $success = @mkdir($dir, 0777, true); + + if (!$success) { + // 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)); + } + } + } + + /** + * @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 84911fb..c043bf9 100644 --- a/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php +++ b/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php @@ -1,14 +1,16 @@ cache)) { @@ -33,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; @@ -40,6 +51,10 @@ class MemoryCache extends AbstractCache return true; } + /** + * @param string $key + * @return bool + */ public function doDelete($key) { unset($this->cache[$key]); @@ -47,6 +62,9 @@ class MemoryCache extends AbstractCache return true; } + /** + * @return bool + */ public function doClear() { $this->cache = []; @@ -54,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 b1e9e9e..e189e3e 100644 --- a/system/src/Grav/Framework/Cache/Adapter/SessionCache.php +++ b/system/src/Grav/Framework/Cache/Adapter/SessionCache.php @@ -1,8 +1,9 @@ doGetStored($key); @@ -27,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; @@ -40,6 +51,10 @@ class SessionCache extends AbstractCache return true; } + /** + * @param string $key + * @return bool + */ public function doDelete($key) { unset($_SESSION[$this->getNamespace()][$key]); @@ -47,6 +62,9 @@ class SessionCache extends AbstractCache return true; } + /** + * @return bool + */ public function doClear() { unset($_SESSION[$this->getNamespace()]); @@ -54,19 +72,30 @@ 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 = isset($_SESSION[$this->getNamespace()][$key]) ? $_SESSION[$this->getNamespace()][$key] : null; + $stored = $_SESSION[$this->getNamespace()][$key] ?? null; if (isset($stored[self::LIFETIME]) && $stored[self::LIFETIME] < time()) { unset($_SESSION[$this->getNamespace()][$key]); diff --git a/system/src/Grav/Framework/Cache/CacheInterface.php b/system/src/Grav/Framework/Cache/CacheInterface.php index 54af40c..efd9d31 100644 --- a/system/src/Grav/Framework/Cache/CacheInterface.php +++ b/system/src/Grav/Framework/Cache/CacheInterface.php @@ -1,8 +1,9 @@ $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 2f2b178..287f924 100644 --- a/system/src/Grav/Framework/Cache/CacheTrait.php +++ b/system/src/Grav/Framework/Cache/CacheTrait.php @@ -1,14 +1,27 @@ namespace = (string) $namespace; $this->defaultLifetime = $this->convertTtl($defaultLifetime); - $this->miss = new \stdClass; + $this->miss = new stdClass; } /** - * @param $validation + * @param bool $validation + * @return void */ public function setValidation($validation) { @@ -67,8 +79,10 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException + * @param string $key + * @param mixed|null $default + * @return mixed|null + * @throws InvalidArgumentException */ public function get($key, $default = null) { @@ -80,8 +94,11 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException + * @param string $key + * @param mixed $value + * @param null|int|DateInterval $ttl + * @return bool + * @throws InvalidArgumentException */ public function set($key, $value, $ttl = null) { @@ -94,8 +111,9 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException + * @param string $key + * @return bool + * @throws InvalidArgumentException */ public function delete($key) { @@ -105,7 +123,7 @@ trait CacheTrait } /** - * @inheritdoc + * @return bool */ public function clear() { @@ -113,18 +131,21 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\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)) { + $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) ) ); } @@ -136,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); @@ -153,18 +177,21 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\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) ) ); } @@ -184,18 +211,20 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\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) ) ); } @@ -210,8 +239,9 @@ trait CacheTrait } /** - * @inheritdoc - * @throws \Psr\SimpleCache\InvalidArgumentException + * @param string $key + * @return bool + * @throws InvalidArgumentException */ public function has($key) { @@ -220,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 @@ -246,7 +271,7 @@ trait CacheTrait /** * @param array $values - * @param int $ttl + * @param int|null $ttl * @return bool */ public function doSetMultiple($values, $ttl) @@ -275,11 +300,10 @@ trait CacheTrait return $success; } - abstract public function doHas($key); - /** - * @param string $key - * @throws \Psr\SimpleCache\InvalidArgumentException + * @param string|mixed $key + * @return void + * @throws InvalidArgumentException */ protected function validateKey($key) { @@ -296,7 +320,7 @@ trait CacheTrait } 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) { @@ -308,7 +332,8 @@ trait CacheTrait /** * @param array $keys - * @throws \Psr\SimpleCache\InvalidArgumentException + * @return void + * @throws InvalidArgumentException */ protected function validateKeys($keys) { @@ -322,9 +347,9 @@ trait CacheTrait } /** - * @param null|int|\DateInterval $ttl + * @param null|int|DateInterval $ttl * @return int|null - * @throws \Psr\SimpleCache\InvalidArgumentException + * @throws InvalidArgumentException */ protected function convertTtl($ttl) { @@ -336,8 +361,9 @@ trait CacheTrait 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( diff --git a/system/src/Grav/Framework/Cache/Exception/CacheException.php b/system/src/Grav/Framework/Cache/Exception/CacheException.php index 71caff7..1db3256 100644 --- a/system/src/Grav/Framework/Cache/Exception/CacheException.php +++ b/system/src/Grav/Framework/Cache/Exception/CacheException.php @@ -1,19 +1,21 @@ + * @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 - */ + /** @var callable|null */ protected $filterFunction; - - /** - * @var int - */ + /** @var int */ protected $flags; - - /** - * @var int - */ + /** @var int */ protected $nestingLimit; /** @@ -93,8 +86,15 @@ 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); + $next = ClosureExpressionVisitor::sortByField($field, $ordering === Criteria::DESC ? -1 : 1, $next); + } + if (null === $next) { + throw new RuntimeException('Criteria is missing orderings'); } uasort($filtered, $next); @@ -112,17 +112,20 @@ class AbstractFileCollection extends AbstractLazyCollection implements FileColle 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); } } @@ -155,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()) { @@ -196,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) @@ -211,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 new file mode 100644 index 0000000..190f422 --- /dev/null +++ b/system/src/Grav/Framework/Collection/AbstractIndexCollection.php @@ -0,0 +1,538 @@ + + */ +abstract class AbstractIndexCollection implements CollectionInterface +{ + 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 = []) + { + $this->entries = $entries; + } + + /** + * {@inheritDoc} + */ + public function toArray() + { + return $this->loadElements($this->entries); + } + + /** + * {@inheritDoc} + */ + public function first() + { + $value = reset($this->entries); + $key = (string)key($this->entries); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + public function last() + { + $value = end($this->entries); + $key = (string)key($this->entries); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + public function key() + { + /** @phpstan-var TKey $key */ + $key = (string)key($this->entries); + + return $key; + } + + /** + * {@inheritDoc} + */ + public function next() + { + $value = next($this->entries); + $key = (string)key($this->entries); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + public function current() + { + $value = current($this->entries); + $key = (string)key($this->entries); + + return $this->loadElement($key, $value); + } + + /** + * {@inheritDoc} + */ + public function remove($key) + { + if (!array_key_exists($key, $this->entries)) { + return null; + } + + $value = $this->entries[$key]; + unset($this->entries[$key]); + + return $this->loadElement((string)$key, $value); + } + + /** + * {@inheritDoc} + */ + public function removeElement($element) + { + $key = $this->isAllowedElement($element) ? $this->getCurrentKey($element) : null; + + if (!$key || !isset($this->entries[$key])) { + return false; + } + + unset($this->entries[$key]); + + return true; + } + + /** + * Required by interface ArrayAccess. + * + * {@inheritDoc} + */ + public function offsetExists($offset) + { + return $this->containsKey($offset); + } + + /** + * Required by interface ArrayAccess. + * + * {@inheritDoc} + */ + public function offsetGet($offset) + { + return $this->get($offset); + } + + /** + * Required by interface ArrayAccess. + * + * {@inheritDoc} + */ + public function offsetSet($offset, $value) + { + if (null === $offset) { + $this->add($value); + } + + $this->set($offset, $value); + } + + /** + * Required by interface ArrayAccess. + * + * {@inheritDoc} + */ + public function offsetUnset($offset) + { + return $this->remove($offset); + } + + /** + * {@inheritDoc} + */ + public function containsKey($key) + { + return isset($this->entries[$key]) || array_key_exists($key, $this->entries); + } + + /** + * {@inheritDoc} + */ + public function contains($element) + { + $key = $this->isAllowedElement($element) ? $this->getCurrentKey($element) : null; + + return $key && isset($this->entries[$key]); + } + + /** + * {@inheritDoc} + */ + public function exists(Closure $p) + { + return $this->loadCollection($this->entries)->exists($p); + } + + /** + * {@inheritDoc} + */ + public function indexOf($element) + { + $key = $this->isAllowedElement($element) ? $this->getCurrentKey($element) : null; + + return $key && isset($this->entries[$key]) ? $key : false; + } + + /** + * {@inheritDoc} + */ + public function get($key) + { + if (!isset($this->entries[$key])) { + return null; + } + + return $this->loadElement((string)$key, $this->entries[$key]); + } + + /** + * {@inheritDoc} + */ + public function getKeys() + { + return array_keys($this->entries); + } + + /** + * {@inheritDoc} + */ + public function getValues() + { + return array_values($this->loadElements($this->entries)); + } + + /** + * {@inheritDoc} + */ + public function count() + { + return count($this->entries); + } + + /** + * {@inheritDoc} + */ + public function set($key, $value) + { + if (!$this->isAllowedElement($value)) { + throw new InvalidArgumentException('Invalid argument $value'); + } + + $this->entries[$key] = $this->getElementMeta($value); + } + + /** + * {@inheritDoc} + */ + public function add($element) + { + if (!$this->isAllowedElement($element)) { + throw new InvalidArgumentException('Invalid argument $element'); + } + + $this->entries[$this->getCurrentKey($element)] = $this->getElementMeta($element); + + return true; + } + + /** + * {@inheritDoc} + */ + public function isEmpty() + { + return empty($this->entries); + } + + /** + * Required by interface IteratorAggregate. + * + * {@inheritDoc} + */ + public function getIterator() + { + return new ArrayIterator($this->loadElements()); + } + + /** + * {@inheritDoc} + */ + public function map(Closure $func) + { + return $this->loadCollection($this->entries)->map($func); + } + + /** + * {@inheritDoc} + */ + public function filter(Closure $p) + { + return $this->loadCollection($this->entries)->filter($p); + } + + /** + * {@inheritDoc} + */ + public function forAll(Closure $p) + { + return $this->loadCollection($this->entries)->forAll($p); + } + + /** + * {@inheritDoc} + */ + public function partition(Closure $p) + { + return $this->loadCollection($this->entries)->partition($p); + } + + /** + * Returns a string representation of this object. + * + * @return string + */ + public function __toString() + { + return __CLASS__ . '@' . spl_object_hash($this); + } + + /** + * {@inheritDoc} + */ + public function clear() + { + $this->entries = []; + } + + /** + * {@inheritDoc} + */ + public function slice($offset, $length = null) + { + return $this->loadElements(array_slice($this->entries, $offset, $length, true)); + } + + /** + * @param int $start + * @param int|null $limit + * @return static + */ + public function limit($start, $limit = null) + { + return $this->createFrom(array_slice($this->entries, $start, $limit, true)); + } + + /** + * Reverse the order of the items. + * + * @return static + */ + public function reverse() + { + return $this->createFrom(array_reverse($this->entries)); + } + + /** + * Shuffle items. + * + * @return static + */ + public function shuffle() + { + $keys = $this->getKeys(); + shuffle($keys); + + return $this->createFrom(array_replace(array_flip($keys), $this->entries) ?? []); + } + + /** + * Select items from collection. + * + * Collection is returned in the order of $keys given to the function. + * + * @param array $keys + * @return static + */ + public function select(array $keys) + { + $list = []; + foreach ($keys as $key) { + if (isset($this->entries[$key])) { + $list[$key] = $this->entries[$key]; + } + } + + return $this->createFrom($list); + } + + /** + * Un-select items from collection. + * + * @param array $keys + * @return static + */ + public function unselect(array $keys) + { + return $this->select(array_diff($this->getKeys(), $keys)); + } + + /** + * Split collection into chunks. + * + * @param int $size Size of each chunk. + * @return array + */ + public function chunk($size) + { + return $this->loadCollection($this->entries)->chunk($size); + } + + /** + * @return array + */ + public function __serialize(): array + { + return [ + 'entries' => $this->entries + ]; + } + + /** + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $this->entries = $data['entries']; + } + + /** + * Implements JsonSerializable interface. + * + * @return array + */ + public function jsonSerialize() + { + return $this->loadCollection()->jsonSerialize(); + } + + /** + * Creates a new instance from the specified elements. + * + * This method is provided for derived classes to specify how a new + * instance should be created when constructor semantics have changed. + * + * @param array $entries Elements. + * @return static + */ + protected function createFrom(array $entries) + { + return new static($entries); + } + + /** + * @return array + */ + protected function getEntries(): array + { + return $this->entries; + } + + /** + * @param array $entries + * @return void + * @phpstan-param array $entries + */ + 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 + * @return mixed|null + */ + abstract protected function loadElement($key, $value); + + /** + * @param array|null $entries + * @return array + * @phpstan-return array + */ + abstract protected function loadElements(array $entries = null): array; + + /** + * @param array|null $entries + * @return CollectionInterface + */ + abstract protected function loadCollection(array $entries = null): CollectionInterface; + + /** + * @param mixed $value + * @return bool + */ + abstract protected function isAllowedElement($value): bool; + + /** + * @param mixed $element + * @return mixed + */ + abstract protected function getElementMeta($element); +} diff --git a/system/src/Grav/Framework/Collection/AbstractLazyCollection.php b/system/src/Grav/Framework/Collection/AbstractLazyCollection.php index af2f696..af7ffe1 100644 --- a/system/src/Grav/Framework/Collection/AbstractLazyCollection.php +++ b/system/src/Grav/Framework/Collection/AbstractLazyCollection.php @@ -1,8 +1,9 @@ + * @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; /** @@ -30,6 +31,7 @@ abstract class AbstractLazyCollection extends BaseAbstractLazyCollection impleme public function reverse() { $this->initialize(); + return $this->collection->reverse(); } @@ -39,6 +41,7 @@ abstract class AbstractLazyCollection extends BaseAbstractLazyCollection impleme public function shuffle() { $this->initialize(); + return $this->collection->shuffle(); } @@ -48,15 +51,37 @@ abstract class AbstractLazyCollection extends BaseAbstractLazyCollection impleme public function chunk($size) { $this->initialize(); + return $this->collection->chunk($size); } /** * {@inheritDoc} */ + public function select(array $keys) + { + $this->initialize(); + + return $this->collection->select($keys); + } + + /** + * {@inheritDoc} + */ + public function unselect(array $keys) + { + $this->initialize(); + + return $this->collection->unselect($keys); + } + + /** + * @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 4c922f4..474a3fb 100644 --- a/system/src/Grav/Framework/Collection/ArrayCollection.php +++ b/system/src/Grav/Framework/Collection/ArrayCollection.php @@ -1,8 +1,9 @@ + * @implements CollectionInterface */ class ArrayCollection extends BaseArrayCollection implements CollectionInterface { @@ -21,6 +26,7 @@ class ArrayCollection extends BaseArrayCollection implements CollectionInterface * Reverse the order of the items. * * @return static + * @phpstan-return static */ public function reverse() { @@ -31,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()) ?? []); } /** @@ -52,7 +59,41 @@ class ArrayCollection extends BaseArrayCollection implements CollectionInterface } /** - * Implementes JsonSerializable interface. + * Select items from collection. + * + * Collection is returned in the order of $keys given to the function. + * + * @param array $keys + * @return static + * @phpstan-param array $keys + * @phpstan-return static + */ + public function select(array $keys) + { + $list = []; + foreach ($keys as $key) { + if ($this->containsKey($key)) { + $list[$key] = $this->get($key); + } + } + + return $this->createFrom($list); + } + + /** + * Un-select items from collection. + * + * @param array $keys + * @return static + * @phpstan-return static + */ + public function unselect(array $keys) + { + return $this->select(array_diff($this->getKeys(), $keys)); + } + + /** + * Implements JsonSerializable interface. * * @return array */ diff --git a/system/src/Grav/Framework/Collection/CollectionInterface.php b/system/src/Grav/Framework/Collection/CollectionInterface.php index 5f5e1fc..e024366 100644 --- a/system/src/Grav/Framework/Collection/CollectionInterface.php +++ b/system/src/Grav/Framework/Collection/CollectionInterface.php @@ -1,33 +1,40 @@ */ -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(); @@ -38,4 +45,24 @@ interface CollectionInterface extends Collection, \JsonSerializable * @return array */ public function chunk($size); + + /** + * Select items from collection. + * + * Collection is returned in the order of $keys given to the function. + * + * @param array $keys + * @return CollectionInterface + * @phpstan-return CollectionInterface + */ + public function select(array $keys); + + /** + * Un-select items from collection. + * + * @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 9c1d905..5dd8d55 100644 --- a/system/src/Grav/Framework/Collection/FileCollection.php +++ b/system/src/Grav/Framework/Collection/FileCollection.php @@ -1,8 +1,9 @@ */ class FileCollection extends AbstractFileCollection { diff --git a/system/src/Grav/Framework/Collection/FileCollectionInterface.php b/system/src/Grav/Framework/Collection/FileCollectionInterface.php index 37f63d2..ce6e18f 100644 --- a/system/src/Grav/Framework/Collection/FileCollectionInterface.php +++ b/system/src/Grav/Framework/Collection/FileCollectionInterface.php @@ -1,8 +1,9 @@ + * @extends Selectable */ interface FileCollectionInterface extends CollectionInterface, Selectable { - const INCLUDE_FILES = 1; - const INCLUDE_FOLDERS = 2; - const RECURSIVE = 4; + public const INCLUDE_FILES = 1; + public const INCLUDE_FOLDERS = 2; + public const RECURSIVE = 4; /** * @return string 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 672dba4..efcd5f3 100644 --- a/system/src/Grav/Framework/ContentBlock/ContentBlock.php +++ b/system/src/Grav/Framework/ContentBlock/ContentBlock.php @@ -1,13 +1,20 @@ 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; @@ -66,7 +83,7 @@ class ContentBlock implements ContentBlockInterface /** * Block constructor. * - * @param string $id + * @param string|null $id */ public function __construct($id = null) { @@ -95,10 +112,7 @@ 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(); } @@ -106,7 +120,8 @@ class ContentBlock implements ContentBlockInterface $array = [ '_type' => get_class($this), '_version' => $this->version, - 'id' => $this->id + 'id' => $this->id, + 'cached' => $this->cached ]; if ($this->checksum) { @@ -150,21 +165,23 @@ 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) { $this->checkVersion($serialized); - $this->id = isset($serialized['id']) ? $serialized['id'] : $this->generateId(); - $this->checksum = isset($serialized['checksum']) ? $serialized['checksum'] : null; + $this->id = $serialized['id'] ?? $this->generateId(); + $this->checksum = $serialized['checksum'] ?? null; + $this->cached = $serialized['cached'] ?? null; if (isset($serialized['content'])) { $this->setContent($serialized['content']); @@ -176,6 +193,34 @@ class ContentBlock implements ContentBlockInterface } } + /** + * @return bool + */ + public function isCached() + { + if (!$this->cached) { + return false; + } + + foreach ($this->blocks as $block) { + if (!$block->isCached()) { + return false; + } + } + + return true; + } + + /** + * @return $this + */ + public function disableCache() + { + $this->cached = false; + + return $this; + } + /** * @param string $checksum * @return $this @@ -218,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); } /** @@ -244,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 fff8f2e..fb445a3 100644 --- a/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php +++ b/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php @@ -1,21 +1,24 @@ scripts[$location][md5($src) . sha1($src)] = [ @@ -302,7 +313,7 @@ class HtmlBlock extends ContentBlock implements HtmlBlockInterface ]; foreach ($this->blocks as $block) { - if ($block instanceof HtmlBlock) { + if ($block instanceof self) { $blockAssets = $block->getAssetsFast(); $assets['frameworks'] += $blockAssets['frameworks']; @@ -356,6 +367,7 @@ class HtmlBlock extends ContentBlock implements HtmlBlockInterface /** * @param array $items + * @return void */ protected function sortAssetsInLocation(array &$items) { @@ -367,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 204e199..616e4a2 100644 --- a/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php +++ b/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php @@ -1,8 +1,9 @@ 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 new file mode 100644 index 0000000..76434a3 --- /dev/null +++ b/system/src/Grav/Framework/DI/Container.php @@ -0,0 +1,35 @@ +offsetGet($id); + } + + /** + * @param string $id + * @return bool + */ + public function has($id): bool + { + return $this->offsetExists($id); + } +} diff --git a/system/src/Grav/Framework/File/AbstractFile.php b/system/src/Grav/Framework/File/AbstractFile.php new file mode 100644 index 0000000..b82bb33 --- /dev/null +++ b/system/src/Grav/Framework/File/AbstractFile.php @@ -0,0 +1,441 @@ +filesystem = $filesystem ?? Filesystem::getInstance(); + $this->setFilepath($filepath); + } + + /** + * Unlock file when the object gets destroyed. + */ + public function __destruct() + { + if ($this->isLocked()) { + $this->unlock(); + } + } + + /** + * @return void + */ + public function __clone() + { + $this->handle = null; + $this->locked = false; + } + + /** + * @return array + */ + final public function __serialize(): array + { + return ['filesystem_normalize' => $this->filesystem->getNormalization()] + $this->doSerialize(); + } + + /** + * @param array $data + * @return void + */ + final public function __unserialize(array $data): void + { + $this->filesystem = Filesystem::getInstance($data['filesystem_normalize'] ?? null); + + $this->doUnserialize($data); + } + + /** + * {@inheritdoc} + * @see FileInterface::getFilePath() + */ + public function getFilePath(): string + { + return $this->filepath; + } + + /** + * {@inheritdoc} + * @see FileInterface::getPath() + */ + public function getPath(): string + { + if (null === $this->path) { + $this->setPathInfo(); + } + + return $this->path ?? ''; + } + + /** + * {@inheritdoc} + * @see FileInterface::getFilename() + */ + public function getFilename(): string + { + if (null === $this->filename) { + $this->setPathInfo(); + } + + return $this->filename ?? ''; + } + + /** + * {@inheritdoc} + * @see FileInterface::getBasename() + */ + public function getBasename(): string + { + if (null === $this->basename) { + $this->setPathInfo(); + } + + return $this->basename ?? ''; + } + + /** + * {@inheritdoc} + * @see FileInterface::getExtension() + */ + public function getExtension(bool $withDot = false): string + { + if (null === $this->extension) { + $this->setPathInfo(); + } + + return ($withDot ? '.' : '') . $this->extension; + } + + /** + * {@inheritdoc} + * @see FileInterface::exists() + */ + public function exists(): bool + { + return is_file($this->filepath); + } + + /** + * {@inheritdoc} + * @see FileInterface::getCreationTime() + */ + public function getCreationTime(): int + { + return is_file($this->filepath) ? (int)filectime($this->filepath) : time(); + } + + /** + * {@inheritdoc} + * @see FileInterface::getModificationTime() + */ + public function getModificationTime(): int + { + return is_file($this->filepath) ? (int)filemtime($this->filepath) : time(); + } + + /** + * {@inheritdoc} + * @see FileInterface::lock() + */ + public function lock(bool $block = true): bool + { + if (!$this->handle) { + if (!$this->mkdir($this->getPath())) { + throw new RuntimeException('Creating directory failed for ' . $this->filepath); + } + $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']}"); + } + } + + $lock = $block ? LOCK_EX : LOCK_EX | LOCK_NB; + + // Some filesystems do not support file locks, only fail if another process holds the lock. + $this->locked = flock($this->handle, $lock, $wouldblock) || !$wouldblock; + + return $this->locked; + } + + /** + * {@inheritdoc} + * @see FileInterface::unlock() + */ + public function unlock(): bool + { + if (!$this->handle) { + return false; + } + + if ($this->locked) { + flock($this->handle, LOCK_UN | LOCK_NB); + $this->locked = false; + } + + fclose($this->handle); + $this->handle = null; + + return true; + } + + /** + * {@inheritdoc} + * @see FileInterface::isLocked() + */ + public function isLocked(): bool + { + return $this->locked; + } + + /** + * {@inheritdoc} + * @see FileInterface::isReadable() + */ + public function isReadable(): bool + { + return is_readable($this->filepath) && is_file($this->filepath); + } + + /** + * {@inheritdoc} + * @see FileInterface::isWritable() + */ + public function isWritable(): bool + { + if (!file_exists($this->filepath)) { + return $this->isWritablePath($this->getPath()); + } + + return is_writable($this->filepath) && is_file($this->filepath); + } + + /** + * {@inheritdoc} + * @see FileInterface::load() + */ + public function load() + { + return file_get_contents($this->filepath); + } + + /** + * {@inheritdoc} + * @see FileInterface::save() + */ + public function save($data): void + { + $filepath = $this->filepath; + $dir = $this->getPath(); + + if (!$this->mkdir($dir)) { + throw new RuntimeException('Creating directory failed for ' . $filepath); + } + + try { + if ($this->handle) { + $tmp = true; + // As we are using non-truncating locking, make sure that the file is empty before writing. + if (@ftruncate($this->handle, 0) === false || @fwrite($this->handle, $data) === false) { + // Writing file failed, throw an error. + $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($realpath); + if (@file_put_contents($tmp, $data) === false) { + $tmp = false; + } elseif (@rename($tmp, $realpath) === false) { + @unlink($tmp); + $tmp = false; + } + } + } catch (Exception $e) { + $tmp = false; + } + + if ($tmp === false) { + throw new RuntimeException('Failed to save file ' . $filepath); + } + + // Touch the directory as well, thus marking it modified. + @touch($dir); + } + + /** + * {@inheritdoc} + * @see FileInterface::rename() + */ + public function rename(string $path): bool + { + if ($this->exists() && !@rename($this->filepath, $path)) { + return false; + } + + $this->setFilepath($path); + + return true; + } + + /** + * {@inheritdoc} + * @see FileInterface::delete() + */ + public function delete(): bool + { + return @unlink($this->filepath); + } + + /** + * @param string $dir + * @return bool + * @throws RuntimeException + * @internal + */ + protected function mkdir(string $dir): bool + { + // Silence error for open_basedir; should fail in mkdir instead. + if (@is_dir($dir)) { + return true; + } + + $success = @mkdir($dir, 0777, true); + + if (!$success) { + // Take yet another look, make sure that the folder doesn't exist. + clearstatcache(true, $dir); + if (!@is_dir($dir)) { + return false; + } + } + + return true; + } + + /** + * @return array + */ + protected function doSerialize(): array + { + return [ + 'filepath' => $this->filepath + ]; + } + + /** + * @param array $serialized + * @return void + */ + protected function doUnserialize(array $serialized): void + { + $this->setFilepath($serialized['filepath']); + } + + /** + * @param string $filepath + */ + protected function setFilepath(string $filepath): void + { + $this->filepath = $filepath; + $this->filename = null; + $this->basename = null; + $this->path = null; + $this->extension = null; + } + + protected function setPathInfo(): void + { + /** @var array $pathInfo */ + $pathInfo = $this->filesystem->pathinfo($this->filepath); + + $this->filename = $pathInfo['filename'] ?? null; + $this->basename = $pathInfo['basename'] ?? null; + $this->path = $pathInfo['dirname'] ?? null; + $this->extension = $pathInfo['extension'] ?? null; + } + + /** + * @param string $dir + * @return bool + * @internal + */ + protected function isWritablePath(string $dir): bool + { + if ($dir === '') { + return false; + } + + if (!file_exists($dir)) { + // Recursively look up in the directory tree. + return $this->isWritablePath($this->filesystem->parent($dir)); + } + + return is_dir($dir) && is_writable($dir); + } + + /** + * @param string $filename + * @param int $length + * @return string + */ + protected function tempname(string $filename, int $length = 5) + { + do { + $test = $filename . substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, $length); + } while (file_exists($test)); + + return $test; + } +} diff --git a/system/src/Grav/Framework/File/CsvFile.php b/system/src/Grav/Framework/File/CsvFile.php new file mode 100644 index 0000000..999f88a --- /dev/null +++ b/system/src/Grav/Framework/File/CsvFile.php @@ -0,0 +1,31 @@ +formatter = $formatter; + } + + /** + * {@inheritdoc} + * @see FileInterface::load() + */ + public function load() + { + $raw = parent::load(); + + try { + 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); + } + } + + /** + * {@inheritdoc} + * @see FileInterface::save() + */ + public function save($data): void + { + if (is_string($data)) { + // Make sure that the string is valid data. + try { + $this->formatter->decode($data); + } catch (RuntimeException $e) { + throw new RuntimeException(sprintf("Failed to save file '%s': %s", $this->getFilePath(), $e->getMessage()), $e->getCode(), $e); + } + $encoded = $data; + } else { + $encoded = $this->formatter->encode($data); + } + + parent::save($encoded); + } +} diff --git a/system/src/Grav/Framework/File/File.php b/system/src/Grav/Framework/File/File.php new file mode 100644 index 0000000..1d4055a --- /dev/null +++ b/system/src/Grav/Framework/File/File.php @@ -0,0 +1,44 @@ +config = $config; + } + + /** + * @return string + */ + public function getMimeType(): string + { + $mime = $this->getConfig('mime'); + + return is_string($mime) ? $mime : 'application/octet-stream'; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::getDefaultFileExtension() + */ + public function getDefaultFileExtension(): string + { + $extensions = $this->getSupportedFileExtensions(); + + // Call fails on bad configuration. + return reset($extensions) ?: ''; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::getSupportedFileExtensions() + */ + public function getSupportedFileExtensions(): array + { + $extensions = $this->getConfig('file_extension'); + + // Call fails on bad configuration. + return is_string($extensions) ? [$extensions] : $extensions; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + abstract public function encode($data): string; + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() + */ + 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. + * + * @param string|null $name Configuration option (optional) + * @return mixed + */ + protected function getConfig(string $name = null) + { + if (null !== $name) { + return $this->config[$name] ?? null; + } + + return $this->config; + } +} diff --git a/system/src/Grav/Framework/File/Formatter/CsvFormatter.php b/system/src/Grav/Framework/File/Formatter/CsvFormatter.php new file mode 100644 index 0000000..9d0a9a8 --- /dev/null +++ b/system/src/Grav/Framework/File/Formatter/CsvFormatter.php @@ -0,0 +1,169 @@ + ['.csv', '.tsv'], + 'delimiter' => ',', + 'mime' => 'text/x-csv' + ]; + + parent::__construct($config); + } + + /** + * Returns delimiter used to both encode and decode CSV. + * + * @return string + */ + public function getDelimiter(): string + { + // Call fails on bad configuration. + return $this->getConfig('delimiter'); + } + + /** + * @param array $data + * @param string|null $delimiter + * @return string + * @see FileFormatterInterface::encode() + */ + public function encode($data, $delimiter = null): string + { + if (count($data) === 0) { + return ''; + } + $delimiter = $delimiter ?? $this->getDelimiter(); + $header = array_keys(reset($data)); + + // Encode the field names + $string = $this->encodeLine($header, $delimiter); + + // Encode the data + foreach ($data as $row) { + $string .= $this->encodeLine($row, $delimiter); + } + + return $string; + } + + /** + * @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'); + } + + // Get the field names + $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'); + + // Get the data + $list = []; + $line = null; + try { + foreach ($lines as $line) { + if (!empty($line)) { + $csv_line = str_getcsv($line, $delimiter); + + if ($null_replace) { + 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 RuntimeException('Badly formatted CSV line: ' . $line); + } + + return $list; + } + + /** + * @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); + + return implode($delimiter, $line). "\n"; + } + + /** + * @param string $value + * @return string + */ + protected function escape(string $value) + { + if (preg_match('/[,"\r\n]/u', $value)) { + $value = '"' . preg_replace('/"/', '""', $value) . '"'; + } + + return $value; + } +} diff --git a/system/src/Grav/Framework/File/Formatter/FormatterInterface.php b/system/src/Grav/Framework/File/Formatter/FormatterInterface.php index 273bf78..757e229 100644 --- a/system/src/Grav/Framework/File/Formatter/FormatterInterface.php +++ b/system/src/Grav/Framework/File/Formatter/FormatterInterface.php @@ -1,44 +1,12 @@ config = $config + [ - 'file_extension' => '.ini' - ]; - } + $config += [ + 'file_extension' => '.ini' + ]; - /** - * @deprecated 1.5 Use $formatter->getDefaultFileExtension() instead. - */ - public function getFileExtension() - { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use getDefaultFileExtension() method instead', E_USER_DEPRECATED); - - return $this->getDefaultFileExtension(); + parent::__construct($config); } /** * {@inheritdoc} + * @see FileFormatterInterface::encode() */ - public function getDefaultFileExtension() - { - $extensions = $this->getSupportedFileExtensions(); - - return (string) reset($extensions); - } - - /** - * {@inheritdoc} - */ - public function getSupportedFileExtensions() - { - return (array) $this->config['file_extension']; - } - - /** - * {@inheritdoc} - */ - public function encode($data) + public function encode($data): string { $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; @@ -71,8 +52,9 @@ class IniFormatter implements FormatterInterface /** * {@inheritdoc} + * @see FileFormatterInterface::decode() */ - public function decode($data) + public function decode($data): array { $decoded = @parse_ini_string($data); diff --git a/system/src/Grav/Framework/File/Formatter/JsonFormatter.php b/system/src/Grav/Framework/File/Formatter/JsonFormatter.php index 4a2d2fa..6b19690 100644 --- a/system/src/Grav/Framework/File/Formatter/JsonFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/JsonFormatter.php @@ -1,78 +1,163 @@ 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 = []) { - $this->config = $config + [ + $config += [ 'file_extension' => '.json', 'encode_options' => 0, - 'decode_assoc' => true + 'decode_assoc' => true, + 'decode_depth' => 512, + 'decode_options' => 0 ]; + + parent::__construct($config); } /** - * @deprecated 1.5 Use $formatter->getDefaultFileExtension() instead. + * Returns options used in encode() function. + * + * @return int */ - public function getFileExtension() + public function getEncodeOptions(): int { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use getDefaultFileExtension() method instead', E_USER_DEPRECATED); - - return $this->getDefaultFileExtension(); - } - - /** - * {@inheritdoc} - */ - public function getDefaultFileExtension() - { - $extensions = $this->getSupportedFileExtensions(); - - return (string) reset($extensions); - } - - /** - * {@inheritdoc} - */ - public function getSupportedFileExtensions() - { - return (array) $this->config['file_extension']; - } - - /** - * {@inheritdoc} - */ - public function encode($data) - { - $encoded = @json_encode($data, $this->config['encode_options']); - - if ($encoded === false) { - throw new \RuntimeException('Encoding JSON failed'); + $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 $encoded; + return $options; + } + + /** + * Returns options used in decode() function. + * + * @return int + */ + public function getDecodeOptions(): int + { + $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; + } + + /** + * Returns recursion depth used in decode() function. + * + * @return int + */ + public function getDecodeDepth(): int + { + return $this->getConfig('decode_depth'); + } + + /** + * Returns true if JSON objects will be converted into associative arrays. + * + * @return bool + */ + public function getDecodeAssoc(): bool + { + return $this->getConfig('decode_assoc'); } /** * {@inheritdoc} + * @see FileFormatterInterface::encode() + */ + public function encode($data): string + { + $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()); + } + + return $encoded ?: ''; + } + + /** + * {@inheritdoc} + * @see FileFormatterInterface::decode() */ public function decode($data) { - $decoded = @json_decode($data, $this->config['decode_assoc']); + $decoded = @json_decode($data, $this->getDecodeAssoc(), $this->getDecodeDepth(), $this->getDecodeOptions()); - if ($decoded === false) { - throw new \RuntimeException('Decoding JSON failed'); + if (null === $decoded && json_last_error() !== JSON_ERROR_NONE) { + 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 415f7ce..8a624df 100644 --- a/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php @@ -1,23 +1,30 @@ config = $config + [ + $config += [ 'file_extension' => '.md', 'header' => 'header', 'body' => 'markdown', @@ -25,44 +32,59 @@ class MarkdownFormatter implements FormatterInterface 'yaml' => ['inline' => 20] ]; - $this->headerFormatter = $headerFormatter ?: new YamlFormatter($this->config['yaml']); + parent::__construct($config); + + $this->headerFormatter = $headerFormatter ?? new YamlFormatter($config['yaml']); } /** - * @deprecated 1.5 Use $formatter->getDefaultFileExtension() instead. + * Returns header field used in both encode() and decode(). + * + * @return string */ - public function getFileExtension() + public function getHeaderField(): string { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use getDefaultFileExtension() method instead', E_USER_DEPRECATED); + return $this->getConfig('header'); + } - return $this->getDefaultFileExtension(); + /** + * Returns body field used in both encode() and decode(). + * + * @return string + */ + public function getBodyField(): string + { + return $this->getConfig('body'); + } + + /** + * Returns raw field used in both encode() and decode(). + * + * @return string + */ + public function getRawField(): string + { + return $this->getConfig('raw'); + } + + /** + * Returns header formatter object used in both encode() and decode(). + * + * @return FileFormatterInterface + */ + public function getHeaderFormatter(): FileFormatterInterface + { + return $this->headerFormatter; } /** * {@inheritdoc} + * @see FileFormatterInterface::encode() */ - public function getDefaultFileExtension() + public function encode($data): string { - $extensions = $this->getSupportedFileExtensions(); - - return (string) reset($extensions); - } - - /** - * {@inheritdoc} - */ - public function getSupportedFileExtensions() - { - return (array) $this->config['file_extension']; - } - - /** - * {@inheritdoc} - */ - public function encode($data) - { - $headerVar = $this->config['header']; - $bodyVar = $this->config['body']; + $headerVar = $this->getHeaderField(); + $bodyVar = $this->getBodyField(); $header = isset($data[$headerVar]) ? (array) $data[$headerVar] : []; $body = isset($data[$bodyVar]) ? (string) $data[$bodyVar] : ''; @@ -70,25 +92,30 @@ class MarkdownFormatter implements FormatterInterface // Create Markdown file with YAML header. $encoded = ''; if ($header) { - $encoded = "---\n" . trim($this->headerFormatter->encode($data['header'])) . "\n---\n\n"; + $encoded = "---\n" . trim($this->getHeaderFormatter()->encode($data['header'])) . "\n---\n\n"; } $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; } /** * {@inheritdoc} + * @see FileFormatterInterface::decode() */ - public function decode($data) + public function decode($data): array { - $headerVar = $this->config['header']; - $bodyVar = $this->config['body']; - $rawVar = $this->config['raw']; + $headerVar = $this->getHeaderField(); + $bodyVar = $this->getBodyField(); + $rawVar = $this->getRawField(); + // Define empty content $content = [ $headerVar => [], $bodyVar => '' @@ -97,11 +124,14 @@ class MarkdownFormatter implements FormatterInterface $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. @@ -109,10 +139,22 @@ class MarkdownFormatter implements FormatterInterface if ($rawVar) { $content[$rawVar] = $frontmatter; } - $content[$headerVar] = $this->headerFormatter->decode($frontmatter); + $content[$headerVar] = $this->getHeaderFormatter()->decode($frontmatter); $content[$bodyVar] = $matches[2]; } 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 8830218..f045d71 100644 --- a/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/SerializeFormatter.php @@ -1,74 +1,74 @@ config = $config + [ - 'file_extension' => '.ser' - ]; + $config += [ + 'file_extension' => '.ser', + 'decode_options' => ['allowed_classes' => [stdClass::class]] + ]; + + parent::__construct($config); } /** - * @deprecated 1.5 Use $formatter->getDefaultFileExtension() instead. + * Returns options used in decode(). + * + * By default only allow stdClass class. + * + * @return array */ - public function getFileExtension() + public function getOptions() { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use getDefaultFileExtension() method instead', E_USER_DEPRECATED); - - return $this->getDefaultFileExtension(); + return $this->getConfig('decode_options'); } /** * {@inheritdoc} + * @see FileFormatterInterface::encode() */ - public function getDefaultFileExtension() - { - $extensions = $this->getSupportedFileExtensions(); - - return (string) reset($extensions); - } - - /** - * {@inheritdoc} - */ - public function getSupportedFileExtensions() - { - return (array) $this->config['file_extension']; - } - - /** - * {@inheritdoc} - */ - public function encode($data) + public function encode($data): string { return serialize($this->preserveLines($data, ["\n", "\r"], ['\\n', '\\r'])); } /** * {@inheritdoc} + * @see FileFormatterInterface::decode() */ public function decode($data) { - $decoded = @unserialize($data); + $classes = $this->getOptions()['allowed_classes'] ?? false; + $decoded = @unserialize($data, ['allowed_classes' => $classes]); - if ($decoded === false) { - throw new \RuntimeException('Decoding serialized data failed'); + if ($decoded === false && $data !== serialize(false)) { + throw new RuntimeException('Decoding serialized data failed'); } return $this->preserveLines($decoded, ['\\n', '\\r'], ["\n", "\r"]); @@ -82,7 +82,7 @@ class SerializeFormatter implements FormatterInterface * @param array $replace * @return mixed */ - protected function preserveLines($data, $search, $replace) + protected function preserveLines($data, array $search, array $replace) { if (is_string($data)) { $data = str_replace($search, $replace, $data); @@ -95,4 +95,4 @@ class SerializeFormatter implements FormatterInterface 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 d59f9e7..32d4d29 100644 --- a/system/src/Grav/Framework/File/Formatter/YamlFormatter.php +++ b/system/src/Grav/Framework/File/Formatter/YamlFormatter.php @@ -1,89 +1,111 @@ config = $config + [ + $config += [ 'file_extension' => '.yaml', 'inline' => 5, 'indent' => 2, 'native' => true, 'compat' => true ]; + + parent::__construct($config); } /** - * @deprecated 1.5 Use $formatter->getDefaultFileExtension() instead. + * @return int */ - public function getFileExtension() + public function getInlineOption(): int { - user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use getDefaultFileExtension() method instead', E_USER_DEPRECATED); - - return $this->getDefaultFileExtension(); + return $this->getConfig('inline'); } /** - * {@inheritdoc} + * @return int */ - public function getDefaultFileExtension() + public function getIndentOption(): int { - $extensions = $this->getSupportedFileExtensions(); - - return (string) reset($extensions); + return $this->getConfig('indent'); } /** - * {@inheritdoc} + * @return bool */ - public function getSupportedFileExtensions() + public function useNativeDecoder(): bool { - return (array) $this->config['file_extension']; + return $this->getConfig('native'); } /** - * {@inheritdoc} + * @return bool */ - public function encode($data, $inline = null, $indent = null) + public function useCompatibleDecoder(): bool + { + return $this->getConfig('compat'); + } + + /** + * @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 { try { - return (string) YamlParser::dump( + return YamlParser::dump( $data, - $inline ? (int) $inline : $this->config['inline'], - $indent ? (int) $indent : $this->config['indent'], + $inline ? (int) $inline : $this->getInlineOption(), + $indent ? (int) $indent : $this->getIndentOption(), 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); } } /** * {@inheritdoc} + * @see FileFormatterInterface::decode() */ - public function decode($data) + public function decode($data): array { // Try native PECL YAML PHP extension first if available. - if ($this->config['native'] && function_exists('yaml_parse')) { + if (function_exists('yaml_parse') && $this->useNativeDecoder()) { // Safely decode YAML. $saved = @ini_get('yaml.decode_php'); - @ini_set('yaml.decode_php', 0); + @ini_set('yaml.decode_php', '0'); $decoded = @yaml_parse($data); @ini_set('yaml.decode_php', $saved); @@ -95,11 +117,11 @@ class YamlFormatter implements FormatterInterface try { return (array) YamlParser::parse($data); } catch (ParseException $e) { - if ($this->config['compat']) { + if ($this->useCompatibleDecoder()) { 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 new file mode 100644 index 0000000..6cda609 --- /dev/null +++ b/system/src/Grav/Framework/File/IniFile.php @@ -0,0 +1,31 @@ +setNormalization() + * @return Filesystem + */ + public static function getInstance(bool $normalize = null): Filesystem + { + if ($normalize === true) { + $instance = &static::$safe; + } elseif ($normalize === false) { + $instance = &static::$unsafe; + } else { + $instance = &static::$default; + } + + if (null === $instance) { + $instance = new static($normalize); + } + + return $instance; + } + + /** + * Always use Filesystem::getInstance() instead. + * + * @param bool|null $normalize + * @internal + */ + protected function __construct(bool $normalize = null) + { + $this->normalize = $normalize; + } + + /** + * Set path normalization. + * + * Default option enables normalization for the streams only, but you can force the normalization to be either + * on or off for every path. Disabling path normalization speeds up the calls, but may cause issues if paths were + * not normalized. + * + * @param bool|null $normalize + * @return Filesystem + */ + public function setNormalization(bool $normalize = null): self + { + return static::getInstance($normalize); + } + + /** + * @return bool|null + */ + public function getNormalization(): ?bool + { + return $this->normalize; + } + + /** + * Force all paths to be normalized. + * + * @return self + */ + public function unsafe(): self + { + return static::getInstance(true); + } + + /** + * Force all paths not to be normalized (speeds up the calls if given paths are known to be normalized). + * + * @return self + */ + public function safe(): self + { + return static::getInstance(false); + } + + /** + * {@inheritdoc} + * @see FilesystemInterface::parent() + */ + public function parent(string $path, int $levels = 1): string + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + if ($this->normalize !== false) { + $path = $this->normalizePathPart($path); + } + + if ($path === '' || $path === '.') { + return ''; + } + + [$scheme, $parent] = $this->dirnameInternal($scheme, $path, $levels); + + return $parent !== $path ? $this->toString($scheme, $parent) : ''; + } + + /** + * {@inheritdoc} + * @see FilesystemInterface::normalize() + */ + public function normalize(string $path): string + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + $path = $this->normalizePathPart($path); + + 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() + */ + public function dirname(string $path, int $levels = 1): string + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + if ($this->normalize || ($scheme && null === $this->normalize)) { + $path = $this->normalizePathPart($path); + } + + [$scheme, $path] = $this->dirnameInternal($scheme, $path, $levels); + + 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) + { + [$scheme, $path] = $this->getSchemeAndHierarchy($path); + + if ($this->normalize || ($scheme && null === $this->normalize)) { + $path = $this->normalizePathPart($path); + } + + return $this->pathinfoInternal($scheme, $path, $options); + } + + /** + * @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); + + if (null !== $scheme && $path === '.') { + return [$scheme, '']; + } + + // In Windows dirname() may return backslashes, fix that. + if (DIRECTORY_SEPARATOR !== '/') { + $path = str_replace('\\', '/', $path); + } + + return [$scheme, $path]; + } + + /** + * @param string|null $scheme + * @param string $path + * @param int|null $options + * @return array|string + */ + protected function pathinfoInternal(?string $scheme, string $path, ?int $options = null) + { + 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; + } + } + + return $info; + } + + /** + * 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]]; + } + + /** + * @param string|null $scheme + * @param string $path + * @return string + */ + protected function toString(?string $scheme, string $path): string + { + if ($scheme) { + return $scheme . '://' . $path; + } + + return $path; + } + + /** + * @param string $path + * @return string + * @throws RuntimeException + */ + protected function normalizePathPart(string $path): string + { + // Quick check for empty path. + if ($path === '' || $path === '.') { + return ''; + } + + // Quick check for root. + if ($path === '/') { + return '/'; + } + + // If the last character is not '/' or any of '\', './', '//' and '..' are not found, path is clean and we're done. + if ($path[-1] !== '/' && !preg_match('`(\\\\|\./|//|\.\.)`', $path)) { + return $path; + } + + // Convert backslashes + $path = strtr($path, ['\\' => '/']); + + $parts = explode('/', $path); + + // Keep absolute paths. + $root = ''; + if ($parts[0] === '') { + $root = '/'; + array_shift($parts); + } + + $list = []; + foreach ($parts as $i => $part) { + // Remove empty parts: // and /./ + if ($part === '' || $part === '.') { + continue; + } + + // Resolve /../ by removing path part. + if ($part === '..') { + $test = array_pop($list); + if ($test === null) { + // Oops, user tried to access something outside of our root folder. + throw new RuntimeException("Bad path {$path}"); + } + } else { + $list[] = $part; + } + } + + // Build path back together. + return $root . implode('/', $list); + } +} diff --git a/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php b/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php new file mode 100644 index 0000000..0e280a8 --- /dev/null +++ b/system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php @@ -0,0 +1,82 @@ += 1). + * @return string Returns parent path. + * @throws RuntimeException + * @api + */ + public function parent(string $path, int $levels = 1): string; + + /** + * 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 + * @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. + * + * @see http://php.net/manual/en/function.dirname.php + * + * @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 + * @api + */ + public function dirname(string $path, int $levels = 1): string; + + /** + * Stream-safe `\pathinfo()` replacement. + * + * @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|null $options A PATHINFO_* constant. + * @return array|string + * @api + */ + 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 new file mode 100644 index 0000000..8739282 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Flex.php @@ -0,0 +1,332 @@ + blueprint file, ...] + * @param array $config + */ + public function __construct(array $types, array $config) + { + $this->config = $config; + $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); + } + } + + /** + * @param string $type + * @param string $blueprint + * @param array $config + * @return $this + */ + public function addDirectoryType(string $type, string $blueprint, array $config = []) + { + $config = array_replace_recursive(['enabled' => true], $this->config ?? [], $config); + + $this->types[$type] = new FlexDirectory($type, $blueprint, $config); + + return $this; + } + + /** + * @param FlexDirectory $directory + * @return $this + */ + public function addDirectory(FlexDirectory $directory) + { + $this->types[$directory->getFlexType()] = $directory; + + return $this; + } + + /** + * @param string $type + * @return bool + */ + public function hasDirectory(string $type): bool + { + return isset($this->types[$type]); + } + + /** + * @param array|string[]|null $types + * @param bool $keepMissing + * @return array + */ + public function getDirectories(array $types = null, bool $keepMissing = false): array + { + if ($types === null) { + return $this->types; + } + + // Return the directories in the given order. + $directories = []; + foreach ($types as $type) { + $directories[$type] = $this->types[$type] ?? null; + } + + return $keepMissing ? $directories : array_filter($directories); + } + + /** + * @param string $type + * @return FlexDirectory|null + */ + public function getDirectory(string $type): ?FlexDirectory + { + return $this->types[$type] ?? null; + } + + /** + * @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 + { + $directory = $type ? $this->getDirectory($type) : null; + + return $directory ? $directory->getCollection($keys, $keyField) : null; + } + + /** + * @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 + { + $collectionClass = $options['collection_class'] ?? ObjectCollection::class; + if (!class_exists($collectionClass)) { + throw new RuntimeException(sprintf('Cannot create collection: Class %s does not exist', $collectionClass)); + } + + $objects = $this->getObjects($keys, $options); + + return new $collectionClass($objects); + } + + /** + * @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 + { + $type = $options['type'] ?? null; + $defaultType = $options['default_type'] ?? $type ?? null; + $keyField = $options['key_field'] ?? 'flex_key'; + + // Prepare empty result lists for all requested Flex types. + $types = $options['types'] ?? (array)$type ?: null; + if ($types) { + $types = array_fill_keys($types, []); + } + $strict = isset($types); + + $guessed = []; + if ($keyField === 'flex_key') { + // We need to split Flex key lookups into individual directories. + $undefined = []; + $keyFieldFind = 'storage_key'; + + foreach ($keys as $flexKey) { + if (!$flexKey) { + continue; + } + + $flexKey = (string)$flexKey; + // Normalize key and type using fallback to default type if it was set. + [$key, $type, $guess] = $this->resolveKeyAndType($flexKey, $defaultType); + + if ($type === '' && $types) { + // Add keys which are not associated to any Flex type. They will be included to every Flex type. + foreach ($types as $type => &$array) { + $array[] = $key; + $guessed[$key][] = "{$type}.obj:{$key}"; + } + unset($array); + } elseif (!$strict || isset($types[$type])) { + // Collect keys by their Flex type. If allowed types are defined, only include values from those types. + $types[$type][] = $key; + if ($guess) { + $guessed[$key][] = "{$type}.obj:{$key}"; + } + } + } + } else { + // We are using a specific key field, make every key undefined. + $undefined = $keys; + $keyFieldFind = $keyField; + } + + if (!$types) { + return []; + } + + $list = [[]]; + foreach ($types as $type => $typeKeys) { + // Also remember to look up keys from undefined Flex types. + $lookupKeys = $undefined ? array_merge($typeKeys, $undefined) : $typeKeys; + + $collection = $this->getCollection($type, $lookupKeys, $keyFieldFind); + if ($collection && $keyFieldFind !== $keyField) { + $collection = $collection->withKeyField($keyField); + } + + $list[] = $collection ? $collection->toArray() : []; + } + + // Merge objects from individual types back together. + $list = array_merge(...$list); + + // Use the original key ordering. + if (!$guessed) { + $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)) { + $result = null; + foreach ($flexKey as $tryKey) { + if ($result = $list[$tryKey] ?? null) { + // Use the first matching object (conflicting objects will be ignored for now). + break; + } + } + } else { + $result = $list[$flexKey] ?? null; + } + + $results[$key] = $result; + } + + $list = $results; + } + + // Remove missing objects if not asked to keep them. + if (empty($option['keep_missing'])) { + $list = array_filter($list); + } + + return $list; + } + + /** + * @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 + { + if (null === $type && null === $keyField) { + // Special handling for quick Flex key lookups. + $keyField = 'storage_key'; + [$key, $type] = $this->resolveKeyAndType($key, $type); + } else { + $type = $this->resolveType($type); + } + + if ($type === '' || $key === '') { + return null; + } + + $directory = $this->getDirectory($type); + + return $directory ? $directory->getObject($key, $keyField) : null; + } + + /** + * @return int + */ + public function count(): int + { + 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 = $this->resolveType($type); + } else { + $key = $flexKey; + $type = (string)$type; + $guess = true; + } + + 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) ?? $type; + } + + return $type ?? ''; + } +} diff --git a/system/src/Grav/Framework/Flex/FlexCollection.php b/system/src/Grav/Framework/Flex/FlexCollection.php new file mode 100644 index 0000000..54e0665 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexCollection.php @@ -0,0 +1,712 @@ + + * @implements FlexCollectionInterface + */ +class FlexCollection extends ObjectCollection implements FlexCollectionInterface +{ + /** @var FlexDirectory */ + private $_flexDirectory; + + /** @var string */ + private $_keyField; + + /** + * Get list of cached methods. + * + * @return array Returns a list of methods with their caching information. + */ + public static function getCachedMethods(): array + { + return [ + 'getTypePrefix' => true, + 'getType' => true, + 'getFlexDirectory' => true, + 'hasFlexFeature' => true, + 'getFlexFeatures' => true, + 'getCacheKey' => true, + 'getCacheChecksum' => false, + 'getTimestamp' => true, + 'hasProperty' => true, + 'getProperty' => true, + 'hasNestedProperty' => true, + 'getNestedProperty' => true, + 'orderBy' => true, + + 'render' => false, + 'isAuthorized' => 'session', + 'search' => true, + 'sort' => true, + 'getDistinctValues' => true + ]; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::createFromArray() + */ + public static function createFromArray(array $entries, FlexDirectory $directory, string $keyField = null) + { + $instance = new static($entries, $directory); + $instance->setKeyField($keyField); + + return $instance; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::__construct() + */ + 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) { + $this->setFlexDirectory($directory)->setKey($directory->getFlexType()); + } + } + + /** + * {@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() + */ + public function search(string $search, $properties = null, array $options = null) + { + $matching = $this->call('search', [$search, $properties, $options]); + $matching = array_filter($matching); + + if ($matching) { + arsort($matching, SORT_NUMERIC); + } + + return $this->select(array_keys($matching)); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::sort() + */ + public function sort(array $order) + { + $criteria = Criteria::create()->orderBy($order); + + /** @var FlexCollectionInterface $matching */ + $matching = $this->matching($criteria); + + return $matching; + } + + /** + * @param array $filters + * @return FlexCollectionInterface|Collection + */ + public function filterBy(array $filters) + { + $expr = Criteria::expr(); + $criteria = Criteria::create(); + + foreach ($filters as $key => $value) { + $criteria->andWhere($expr->eq($key, $value)); + } + + return $this->matching($criteria); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexType() + */ + public function getFlexType(): string + { + return $this->_flexDirectory->getFlexType(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getFlexDirectory(): FlexDirectory + { + return $this->_flexDirectory; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getTimestamp() + */ + public function getTimestamp(): int + { + $timestamps = $this->getTimestamps(); + + return $timestamps ? max($timestamps) : time(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getCacheKey(): string + { + return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1((string)json_encode($this->call('getKey'))); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getCacheChecksum(): string + { + $list = []; + /** + * @var string $key + * @var FlexObjectInterface $object + */ + foreach ($this as $key => $object) { + $list[$key] = $object->getCacheChecksum(); + } + + return sha1((string)json_encode($list)); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getTimestamps(): array + { + /** @var int[] $timestamps */ + $timestamps = $this->call('getTimestamp'); + + return $timestamps; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getStorageKeys(): array + { + /** @var string[] $keys */ + $keys = $this->call('getStorageKey'); + + return $keys; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getFlexKeys(): array + { + /** @var string[] $keys */ + $keys = $this->call('getFlexKey'); + + 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() + */ + public function withKeyField(string $keyField = null) + { + $keyField = $keyField ?: 'key'; + if ($keyField === $this->getKeyField()) { + return $this; + } + + $entries = []; + foreach ($this as $key => $object) { + // TODO: remove hardcoded logic + if ($keyField === 'storage_key') { + $entries[$object->getStorageKey()] = $object; + } elseif ($keyField === 'flex_key') { + $entries[$object->getFlexKey()] = $object; + } elseif ($keyField === 'key') { + $entries[$object->getKey()] = $object; + } + } + + return $this->createFrom($entries, $keyField); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getIndex() + */ + public function getIndex() + { + 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 (!$layout) { + $config = $this->getTemplateConfig(); + $layout = $config['collection']['defaults']['layout'] ?? 'default'; + } + + $type = $this->getFlexType(); + + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->startTimer('flex-collection-' . ($debugKey = uniqid($type, false)), 'Render Collection ' . $type . ' (' . $layout . ')'); + + $key = null; + foreach ($context as $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 && $key ? $cache->get($key) : null; + + $block = $data ? HtmlBlock::fromArray($data) : null; + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + $block = null; + } catch (\InvalidArgumentException $e) { + $debugger->addException($e); + $block = null; + } + + $checksum = $this->getCacheChecksum(); + if ($block && $checksum !== $block->getChecksum()) { + $block = null; + } + + if (!$block) { + $block = HtmlBlock::create($key ?: null); + $block->setChecksum($checksum); + if (!$key) { + $block->disableCache(); + } + + $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, + 'directory' => $this->getFlexDirectory(), + 'collection' => $this, + 'layout' => $layout + ] + $context + ); + + if ($debugger->enabled()) { + $output = "\n\n{$output}\n\n"; + } + + $block->setContent($output); + + try { + $cache && $key && $block->isCached() && $cache->set($key, $block->toArray()); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + } + } + + $debugger->stopTimer('flex-collection-' . $debugKey); + + return $block; + } + + /** + * @param FlexDirectory $type + * @return $this + */ + public function setFlexDirectory(FlexDirectory $type) + { + $this->_flexDirectory = $type; + + return $this; + } + + /** + * @param string $key + * @return array + */ + public function getMetaData(string $key): array + { + $object = $this->get($key); + + return $object instanceof FlexObjectInterface ? $object->getMetaData() : []; + } + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) + { + return $this->_flexDirectory->getCache($namespace); + } + + /** + * @return string + */ + public function getKeyField(): string + { + return $this->_keyField ?? 'storage_key'; + } + + /** + * @param string $action + * @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); + + return $this->select(array_keys($list)); + } + + /** + * @param string $value + * @param string $field + * @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; + } + } + } + + return null; + } + + /** + * @return array + */ + public function jsonSerialize() + { + $elements = []; + + /** + * @var string $key + * @var array|FlexObject $object + */ + foreach ($this->getElements() as $key => $object) { + $elements[$key] = is_array($object) ? $object : $object->jsonSerialize(); + } + + return $elements; + } + + /** + * @return array + */ + public function __debugInfo() + { + return [ + 'type:private' => $this->getFlexType(), + 'key:private' => $this->getKey(), + 'objects_key:private' => $this->getKeyField(), + 'objects:private' => $this->getElements() + ]; + } + + /** + * Creates a new instance from the specified elements. + * + * This method is provided for derived classes to specify how a new + * instance should be created when constructor semantics have changed. + * + * @param array $elements Elements. + * @param string|null $keyField + * @return static + * @phpstan-return static + * @throws \InvalidArgumentException + */ + protected function createFrom(array $elements, $keyField = null) + { + $collection = new static($elements, $this->_flexDirectory); + $collection->setKeyField($keyField ?: $this->_keyField); + + return $collection; + } + + /** + * @return string + */ + protected function getTypePrefix(): string + { + 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 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 + */ + protected function getTemplate($layout) + { + $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']); + } + } + + /** + * @param string $type + * @return FlexDirectory + */ + protected function getRelatedDirectory($type): ?FlexDirectory + { + /** @var Flex $flex */ + $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 new file mode 100644 index 0000000..b9c9d54 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexDirectory.php @@ -0,0 +1,1041 @@ +type = $type; + $this->blueprints = []; + $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; + } + + /** + * @return bool + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * @return string + */ + public function getFlexType(): string + { + return $this->type; + } + + /** + * @return string + */ + public function getTitle(): string + { + return $this->getBlueprintInternal()->get('title', ucfirst($this->getFlexType())); + } + + /** + * @return string + */ + public function getDescription(): string + { + return $this->getBlueprintInternal()->get('description', ''); + } + + /** + * @param string|null $name + * @param mixed $default + * @return mixed + */ + public function getConfig(string $name = null, $default = null) + { + if (null === $this->config) { + $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 = '') + { + return clone $this->getBlueprintInternal($type, $context); + } + + /** + * @param string $view + * @return string + */ + public function getBlueprintFile(string $view = ''): string + { + $file = $this->blueprint_file; + if ($view !== '') { + $file = preg_replace('/\.yaml/', "/{$view}.yaml", $file); + } + + return (string)$file; + } + + /** + * 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 + * @phpstan-return FlexCollectionInterface + */ + public function getCollection(array $keys = null, string $keyField = null): FlexCollectionInterface + { + // Get all selected entries. + $index = $this->getIndex($keys, $keyField); + + if (!Utils::isAdminPlugin()) { + // If not in admin, filter the list by using default filters. + $filters = (array)$this->getConfig('site.filter', []); + + foreach ($filters as $filter) { + $index = $index->{$filter}(); + } + } + + return $index; + } + + /** + * 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 + { + $keyField = $keyField ?? ''; + $index = $this->indexes[$keyField] ?? $this->loadIndex($keyField); + $index = clone $index; + + if (null !== $keys) { + /** @var FlexIndexInterface $index */ + $index = $index->select($keys); + } + + return $index->getIndex(); + } + + /** + * 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 + { + if (null === $key) { + return $this->createObject([], ''); + } + + $keyField = $keyField ?? ''; + $index = $this->indexes[$keyField] ?? $this->loadIndex($keyField); + + return $index->get($key); + } + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) + { + $namespace = $namespace ?: 'index'; + $cache = $this->cache[$namespace] ?? null; + + if (null === $cache) { + try { + $grav = Grav::instance(); + + /** @var Cache $gravCache */ + $gravCache = $grav['cache']; + $config = $this->getConfig('object.cache.' . $namespace); + if (empty($config['enabled'])) { + $cache = new MemoryCache('flex-objects-' . $this->getFlexType()); + } else { + $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, $lifetime); + } + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + $cache = new MemoryCache('flex-objects-' . $this->getFlexType()); + } + + // Disable cache key validation. + $cache->setValidation(false); + $this->cache[$namespace] = $cache; + } + + return $cache; + } + + /** + * @return $this + */ + public function clearCache() + { + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage(sprintf('Flex: Clearing all %s cache', $this->type), 'debug'); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + $locator->clearCache(); + + $this->getCache('index')->clear(); + $this->getCache('object')->clear(); + $this->getCache('render')->clear(); + + $this->indexes = []; + $this->objects = []; + + return $this; + } + + /** + * @param string|null $key + * @return string|null + */ + public function getStorageFolder(string $key = null): ?string + { + return $this->getStorage()->getStoragePath($key); + } + + /** + * @param string|null $key + * @return string|null + */ + public function getMediaFolder(string $key = null): ?string + { + return $this->getStorage()->getMediaPath($key); + } + + /** + * @return FlexStorageInterface + */ + public function getStorage(): FlexStorageInterface + { + if (null === $this->storage) { + $this->storage = $this->createStorage(); + } + + return $this->storage; + } + + /** + * @param array $data + * @param string $key + * @param bool $validate + * @return FlexObjectInterface + */ + public function createObject(array $data, string $key = '', bool $validate = false): FlexObjectInterface + { + /** @var string|FlexObjectInterface $className */ + $className = $this->objectClassName ?: $this->getObjectClass(); + + return new $className($data, $key, $this, $validate); + } + + /** + * @param array $entries + * @param string|null $keyField + * @return FlexCollectionInterface + */ + public function createCollection(array $entries, string $keyField = null): FlexCollectionInterface + { + /** @var string|FlexCollectionInterface $className */ + $className = $this->collectionClassName ?: $this->getCollectionClass(); + + return $className::createFromArray($entries, $this, $keyField); + } + + /** + * @param array $entries + * @param string|null $keyField + * @return FlexIndexInterface + */ + public function createIndex(array $entries, string $keyField = null): FlexIndexInterface + { + /** @var string|FlexIndexInterface $className */ + $className = $this->indexClassName ?: $this->getIndexClass(); + + return $className::createFromArray($entries, $this, $keyField); + } + + /** + * @return string + */ + public function getObjectClass(): string + { + if (!$this->objectClassName) { + $this->objectClassName = $this->getConfig('data.object', GenericObject::class); + } + + return $this->objectClassName; + } + + /** + * @return string + */ + public function getCollectionClass(): string + { + if (!$this->collectionClassName) { + $this->collectionClassName = $this->getConfig('data.collection', GenericCollection::class); + } + + return $this->collectionClassName; + } + + + /** + * @return string + */ + public function getIndexClass(): string + { + if (!$this->indexClassName) { + $this->indexClassName = $this->getConfig('data.index', GenericIndex::class); + } + + return $this->indexClassName; + } + + /** + * @param array $entries + * @param string|null $keyField + * @return FlexCollectionInterface + */ + public function loadCollection(array $entries, string $keyField = null): FlexCollectionInterface + { + return $this->createCollection($this->loadObjects($entries), $keyField); + } + + /** + * @param array $entries + * @return FlexObjectInterface[] + * @internal + */ + public function loadObjects(array $entries): array + { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + + $keys = []; + $rows = []; + $fetch = []; + + // Build lookup arrays with storage keys for the objects. + foreach ($entries as $key => $value) { + $k = $value['storage_key'] ?? ''; + if ($k === '') { + continue; + } + $v = $this->objects[$k] ?? null; + $keys[$k] = $key; + $rows[$k] = $v; + if (!$v) { + $fetch[] = $k; + } + } + + // 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 { + $cache->setMultiple($updated); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + // TODO: log about the issue. + } + } + + if ($fetch) { + $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 + * @return Blueprint + */ + protected function getBlueprintInternal(string $type_view = '', string $context = '') + { + if (!isset($this->blueprints[$type_view])) { + if (!file_exists($this->blueprint_file)) { + throw new RuntimeException(sprintf('Flex: Blueprint file for %s is missing', $this->type)); + } + + $parts = explode('.', rtrim($type_view, '.'), 2); + $type = array_shift($parts); + $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); + } + + $blueprint->load($type ?: null); + if ($blueprint->get('type') === 'flex-objects' && isset(Grav::instance()['admin'])) { + $blueprintBase = (new Blueprint('plugin://flex-objects/blueprints/flex-objects.yaml'))->load(); + $blueprint->extend($blueprintBase, true); + } + + $this->blueprints[$type_view] = $blueprint; + } + + 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 + */ + protected function createStorage(): FlexStorageInterface + { + $this->collection = $this->createCollection([]); + + $storage = $this->getConfig('data.storage'); + + if (!is_array($storage)) { + $storage = ['options' => ['folder' => $storage]]; + } + + $className = $storage['class'] ?? SimpleStorage::class; + $options = $storage['options'] ?? []; + + return new $className($options); + } + + /** + * @param string $keyField + * @return FlexIndexInterface + */ + protected function loadIndex(string $keyField): FlexIndexInterface + { + static $i = 0; + + $index = $this->indexes[$keyField] ?? null; + if (null !== $index) { + return $index; + } + + $index = $this->indexes['storage_key'] ?? null; + if (null === $index) { + $i++; + $j = $i; + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->startTimer('flex-keys-' . $this->type . $j, "Flex: Loading {$this->type} index"); + + $storage = $this->getStorage(); + $cache = $this->getCache('index'); + + try { + $keys = $cache->get('__keys'); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + $keys = null; + } + + 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' + ); + } + try { + $cache->set('__keys', $keys); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + // TODO: log about the issue. + } + } + + $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->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 new file mode 100644 index 0000000..192f38f --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexForm.php @@ -0,0 +1,566 @@ +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 $options + */ + public function __construct(string $name, FlexObjectInterface $object, array $options = null) + { + $this->name = $name; + $this->setObject($object); + $this->setName($object->getFlexType(), $name); + $this->setId($this->getName()); + + $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(); + if (null !== $data) { + $data = new Data($data, $this->getBlueprint()); + $data->setKeepEmptyValues(true); + $data->setMissingValuesAsNull(true); + } + + $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); + } + + 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); + } + + /** + * @return string + */ + public function getName(): string + { + return $this->flexName; + } + + /** + * @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}"; + } + + /** + * @return Data|FlexObjectInterface|object + */ + public function getData() + { + return $this->data ?? $this->getObject(); + } + + /** + * 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 ?? $this->getObject()->getFormValue($name); + } + + /** + * @param string $name + * @return array|mixed|null + */ + public function getDefaultValue(string $name) + { + return $this->object->getDefaultValue($name); + } + + /** + * @return array + */ + public function getDefaultValues(): array + { + return $this->object->getDefaultValues(); + } + /** + * @return string + */ + public function getFlexType(): string + { + 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 + */ + public function getObject(): FlexObjectInterface + { + return $this->object; + } + + /** + * @return FlexObjectInterface + */ + public function updateObject(): FlexObjectInterface + { + $data = $this->data instanceof Data ? $this->data->toArray() : []; + $files = $this->files; + + return $this->getObject()->update($data, $files); + } + + /** + * @return Blueprint + */ + public function getBlueprint(): Blueprint + { + if (null === $this->blueprint) { + try { + $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) { + 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('object'); + $blueprint->init(); + } + + $this->blueprint = $blueprint; + } + + return $this->blueprint; + } + + /** + * @return Route|null + */ + public function getFileUploadAjaxRoute(): ?Route + { + $object = $this->getObject(); + if (!method_exists($object, 'route')) { + return null; + } + + return $object->route('/edit.json/task:media.upload'); + } + + /** + * @param string $field + * @param string $filename + * @return Route|null + */ + public function getFileDeleteAjaxRoute($field, $filename): ?Route + { + $object = $this->getObject(); + if (!method_exists($object, 'route')) { + return null; + } + + return $object->route('/edit.json/task:media.delete'); + } + + /** + * @param array $params + * @param string|null $extension + * @return string + */ + public function getMediaTaskRoute(array $params = [], string $extension = null): string + { + $grav = Grav::instance(); + /** @var Flex $flex */ + $flex = $grav['flex_objects']; + + if (method_exists($flex, 'adminRoute')) { + return $flex->adminRoute($this->getObject(), $params, $extension ?? 'json'); + } + + 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 FlexObjectInterface $object + * @return $this + */ + protected function setObject(FlexObjectInterface $object): self + { + $this->object = clone $object; + + 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) + { + /** @var FlexObject $object */ + $object = clone $this->getObject(); + + $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() + [ + 'object' => $this->object, + ]; + } + + /** + * @param array $data + * @return void + */ + protected function doUnserialize(array $data): void + { + $this->doTraitUnserialize($data); + + $this->object = $data['object']; + } + + /** + * Filter validated data. + * + * @param ArrayAccess|Data|null $data + * @return 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 new file mode 100644 index 0000000..08785c2 --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexIndex.php @@ -0,0 +1,901 @@ + + * @implements FlexIndexInterface + * @mixin C + */ +class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexIndexInterface +{ + 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) + { + return static::createFromArray(static::loadEntriesFromStorage($directory->getStorage()), $directory); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::createFromArray() + */ + public static function createFromArray(array $entries, FlexDirectory $directory, string $keyField = null) + { + $instance = new static($entries, $directory); + $instance->setKeyField($keyField); + + return $instance; + } + + /** + * @param FlexStorageInterface $storage + * @return array + */ + public static function loadEntriesFromStorage(FlexStorageInterface $storage): array + { + 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. + * + * @param array $entries + * @param FlexDirectory|null $directory + */ + 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() + */ + public function search(string $search, $properties = null, array $options = null) + { + return $this->__call('search', [$search, $properties, $options]); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::sort() + */ + public function sort(array $orderings) + { + return $this->orderBy($orderings); + } + + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::filterBy() + */ + public function filterBy(array $filters) + { + return $this->__call('filterBy', [$filters]); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexType() + */ + public function getFlexType(): string + { + return $this->getFlexDirectory()->getFlexType(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexDirectory() + */ + public function getFlexDirectory(): FlexDirectory + { + if (null === $this->_flexDirectory) { + throw new RuntimeException('Flex Directory not defined, object is not fully defined'); + } + + return $this->_flexDirectory; + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getTimestamp() + */ + public function getTimestamp(): int + { + $timestamps = $this->getTimestamps(); + + return $timestamps ? max($timestamps) : time(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getCacheKey() + */ + public function getCacheKey(): string + { + return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1(json_encode($this->getKeys()) . $this->_keyField); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getCacheChecksum() + */ + public function getCacheChecksum(): string + { + $list = []; + foreach ($this->getEntries() as $key => $value) { + $list[$key] = $value['checksum'] ?? $value['storage_timestamp']; + } + + return sha1((string)json_encode($list)); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getTimestamps() + */ + public function getTimestamps(): array + { + return $this->getIndexMap('storage_timestamp'); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getStorageKeys() + */ + public function getStorageKeys(): array + { + return $this->getIndexMap('storage_key'); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getFlexKeys() + */ + public function getFlexKeys(): array + { + // Get storage keys for the objects. + $keys = []; + $type = $this->getFlexDirectory()->getFlexType() . '.obj:'; + + foreach ($this->getEntries() as $key => $value) { + $keys[$key] = $value['flex_key'] ?? $type . $value['storage_key']; + } + + return $keys; + } + + /** + * {@inheritdoc} + * @see FlexIndexInterface::withKeyField() + */ + public function withKeyField(string $keyField = null) + { + $keyField = $keyField ?: 'key'; + if ($keyField === $this->getKeyField()) { + return $this; + } + + $type = $keyField === 'flex_key' ? $this->getFlexDirectory()->getFlexType() . '.obj:' : ''; + $entries = []; + foreach ($this->getEntries() as $key => $value) { + if (!isset($value['key'])) { + $value['key'] = $key; + } + + if (isset($value[$keyField])) { + $entries[$value[$keyField]] = $value; + } elseif ($keyField === 'flex_key') { + $entries[$type . $value['storage_key']] = $value; + } + } + + return $this->createFrom($entries, $keyField); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::getIndex() + */ + public function getIndex() + { + return $this; + } + + /** + * @return FlexCollectionInterface + * @phpstan-return C + */ + public function getCollection() + { + return $this->loadCollection(); + } + + /** + * {@inheritdoc} + * @see FlexCollectionInterface::render() + */ + public function render(string $layout = null, array $context = []) + { + return $this->__call('render', [$layout, $context]); + } + + /** + * {@inheritdoc} + * @see FlexIndexInterface::getFlexKeys() + */ + public function getIndexMap(string $indexKey = null) + { + if (null === $indexKey) { + return $this->getEntries(); + } + + // Get storage keys for the objects. + $index = []; + foreach ($this->getEntries() as $key => $value) { + $index[$key] = $value[$indexKey] ?? null; + } + + return $index; + } + + /** + * @param string $key + * @return array + */ + public function getMetaData($key): array + { + return $this->getEntries()[$key] ?? []; + } + + /** + * @return string + */ + public function getKeyField(): string + { + return $this->_keyField ?? 'storage_key'; + } + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) + { + return $this->getFlexDirectory()->getCache($namespace); + } + + /** + * @param array $orderings + * @return static + * @phpstan-return static + */ + public function orderBy(array $orderings) + { + if (!$orderings || !$this->count()) { + 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]); + } + + // Ordering can be done by using index only. + $previous = null; + foreach (array_reverse($orderings) as $field => $ordering) { + $field = (string)$field; + if ($this->getKeyField() === $field) { + $keys = $this->getKeys(); + $search = array_combine($keys, $keys) ?: []; + } elseif ($field === 'flex_key') { + $search = $this->getFlexKeys(); + } else { + $search = $this->getIndexMap($field); + } + + // Update current search to match the previous ordering. + if (null !== $previous) { + $search = array_replace($previous, $search); + } + + // Order by current field. + if (strtoupper($ordering) === 'DESC') { + arsort($search, SORT_NATURAL | SORT_FLAG_CASE); + } else { + asort($search, SORT_NATURAL | SORT_FLAG_CASE); + } + + $previous = $search; + } + + return $this->createFrom(array_replace($previous ?? [], $this->getEntries()) ?? []); + } + + /** + * {@inheritDoc} + */ + public function call($method, array $arguments = []) + { + 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->getFlexDirectory()->getCollectionClass(); + $cachedMethods = $className::getCachedMethods(); + + $flexType = $this->getFlexType(); + + if (!empty($cachedMethods[$name])) { + $type = $cachedMethods[$name]; + if ($type === 'session') { + /** @var Session $session */ + $session = Grav::instance()['session']; + $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 { + $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) { + $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. + if ($result instanceof FlexCollection) { + $cached = $result->getFlexDirectory()->getIndex($result->getKeys(), $this->getKeyField()); + } else { + $cached = $result; + } + + $cache->set($key, [$checksum, $cached]); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + + // TODO: log error. + } + } + } else { + $collection = $this->loadCollection(); + $result = $collection->{$name}(...$arguments); + if (!isset($cachedMethods[$name])) { + $debugger->addMessage("Call '{$flexType}:{$name}()' isn't cached", 'debug'); + } + } + + return $result; + } + + /** + * @return array + */ + public function __serialize(): array + { + return ['type' => $this->getFlexType(), 'entries' => $this->getEntries()]; + } + + /** + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $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|null $keyField + * @return static + * @phpstan-return static + */ + protected function createFrom(array $entries, string $keyField = null) + { + $index = new static($entries, $this->getFlexDirectory()); + $index->setKeyField($keyField ?? $this->_keyField); + + return $index; + } + + /** + * @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) { + $entries = $this->getEntries(); + $first = reset($entries); + if ($first) { + $keys = array_keys($first); + $keys = array_combine($keys, $keys) ?: []; + } else { + $keys = []; + } + + $this->setIndexKeys($keys); + } + + return $this->_indexKeys; + } + + /** + * @param array $indexKeys + * @return void + */ + protected function setIndexKeys(array $indexKeys) + { + // Add defaults. + $indexKeys += [ + 'key' => 'key', + 'storage_key' => 'storage_key', + 'storage_timestamp' => 'storage_timestamp', + 'flex_key' => 'flex_key' + ]; + + + $this->_indexKeys = $indexKeys; + } + + /** + * @return string + */ + protected function getTypePrefix() + { + return 'i.'; + } + + /** + * @param string $key + * @param mixed $value + * @return ObjectInterface|null + */ + protected function loadElement($key, $value): ?ObjectInterface + { + $objects = $this->getFlexDirectory()->loadObjects([$key => $value]); + + return $objects ? reset($objects): null; + } + + /** + * @param array|null $entries + * @return ObjectInterface[] + */ + protected function loadElements(array $entries = null): array + { + return $this->getFlexDirectory()->loadObjects($entries ?? $this->getEntries()); + } + + /** + * @param array|null $entries + * @return CollectionInterface + * @phpstan-return C + */ + protected function loadCollection(array $entries = null): CollectionInterface + { + return $this->getFlexDirectory()->loadCollection($entries ?? $this->getEntries(), $this->_keyField); + } + + /** + * @param mixed $value + * @return bool + */ + protected function isAllowedElement($value): bool + { + return $value instanceof FlexObject; + } + + /** + * @param FlexObjectInterface $object + * @return mixed + */ + protected function getElementMeta($object) + { + 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 $options = []): array + { + $indexFile = static::getIndexFile($storage); + if (null === $indexFile) { + return $entries; + } + + // Calculate removed objects. + $removed = array_diff_key($index, $entries); + + // First get rid of all removed objects. + if ($removed) { + $index = array_diff_key($index, $removed); + } + + 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; + if (isset($entries[$storage_key]) && $entries[$storage_key]['storage_timestamp'] === $entry['storage_timestamp']) { + // Entry is up to date, no update needed. + unset($entries[$storage_key]); + } + } + + if (empty($entries) && empty($removed)) { + // No objects were added, updated or removed. + return $index; + } + } elseif (!$removed) { + // There are no objects and nothing was removed. + return []; + } + + // Index should be updated, lock the index file for saving. + $indexFile->lock(); + + // Read all the data rows into an array using chunks of 100. + $keys = array_fill_keys(array_keys($entries), null); + $chunks = array_chunk($keys, 100, true); + $updated = $added = []; + foreach ($chunks as $keys) { + $rows = $storage->readRows($keys); + + $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 | SORT_FLAG_CASE); + + static::onChanges($index, $added, $updated, $removed); + + $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) + { + } + + /** + * @param FlexStorageInterface $storage + * @return array + */ + protected static function loadIndex(FlexStorageInterface $storage) + { + $indexFile = static::getIndexFile($storage); + + 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); + } + + 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("{$path}/index.yaml", true, true); + + return CompiledYamlFile::instance($filename); + } + + /** + * @param Exception $e + * @return void + */ + protected static function onException(Exception $e) + { + $grav = Grav::instance(); + + /** @var Logger $logger */ + $logger = $grav['log']; + $logger->addAlert($e->getMessage()); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addException($e); + $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) + { + $addedCount = count($added); + $updatedCount = count($updated); + $removedCount = count($removed); + + if ($addedCount + $updatedCount + $removedCount) { + $message = sprintf('Index updated, %d objects (%d added, %d updated, %d removed).', count($entries), $addedCount, $updatedCount, $removedCount); + + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addMessage($message, 'debug'); + } + } + + // 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(); + } +} diff --git a/system/src/Grav/Framework/Flex/FlexObject.php b/system/src/Grav/Framework/Flex/FlexObject.php new file mode 100644 index 0000000..b65e5ae --- /dev/null +++ b/system/src/Grav/Framework/Flex/FlexObject.php @@ -0,0 +1,1211 @@ + true, + 'getType' => true, + 'getFlexType' => true, + 'getFlexDirectory' => true, + 'hasFlexFeature' => true, + 'getFlexFeatures' => true, + 'getCacheKey' => true, + 'getCacheChecksum' => false, + 'getTimestamp' => true, + 'value' => true, + 'exists' => true, + 'hasProperty' => true, + 'getProperty' => true, + + // FlexAclTrait + 'isAuthorized' => 'session', + ]; + } + + /** + * @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->setMetaData($storage); + + return $instance; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::__construct() + */ + 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, ['xss_check' => false]); + + $elements = $blueprint->filter($elements, true, true); + } + + $this->filterElements($elements); + + $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() + */ + public function getFlexType(): string + { + return $this->_flexDirectory->getFlexType(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getFlexDirectory() + */ + public function getFlexDirectory(): FlexDirectory + { + 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->_meta['storage_timestamp'] ?? 0; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getCacheKey() + */ + public function getCacheKey(): string + { + return $this->hasKey() ? $this->getTypePrefix() . $this->getFlexType() . '.' . $this->getKey() : ''; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getCacheChecksum() + */ + public function getCacheChecksum(): string + { + return (string)($this->_meta['checksum'] ?? $this->getTimestamp()); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::search() + */ + public function search(string $search, $properties = null, array $options = null): float + { + $properties = (array)($properties ?? $this->getFlexDirectory()->getConfig('data.search.fields')); + if (!$properties) { + $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 ($properties as $property) { + $weight += $this->searchNestedProperty($property, $search, $options); + } + + return $weight > 0 ? min($weight, 1) : 0; + } + + /** + * {@inheritdoc} + * @see ObjectInterface::getFlexKey() + */ + public function getKey() + { + return (string)$this->_key; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getFlexKey() + */ + public function getFlexKey(): string + { + $key = $this->_meta['flex_key'] ?? null; + + if (!$key && $key = $this->getStorageKey()) { + $key = $this->_flexDirectory->getFlexType() . '.obj:' . $key; + } + + return (string)$key; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getStorageKey() + */ + public function getStorageKey(): string + { + return (string)($this->storage_key ?? $this->_meta['storage_key'] ?? null); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getMetaData() + */ + public function getMetaData(): array + { + return $this->_meta ?? []; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::exists() + */ + public function exists(): bool + { + $key = $this->getStorageKey(); + + return $key && $this->getFlexDirectory()->getStorage()->hasKey($key); + } + + /** + * @param string $property + * @param string $search + * @param array|null $options + * @return float + */ + public function searchProperty(string $property, string $search, array $options = null): float + { + $options = $options ?? $this->getFlexDirectory()->getConfig('data.search.options', []); + $value = $this->getProperty($property); + + return $this->searchValue($property, $value, $search, $options); + } + + /** + * @param string $property + * @param string $search + * @param array|null $options + * @return float + */ + public function searchNestedProperty(string $property, string $search, array $options = null): float + { + $options = $options ?? $this->getFlexDirectory()->getConfig('data.search.options', []); + if ($property === 'key') { + $value = $this->getKey(); + } else { + $value = $this->getNestedProperty($property); + } + + return $this->searchValue($property, $value, $search, $options); + } + + /** + * @param string $name + * @param mixed $value + * @param string $search + * @param array|null $options + * @return float + */ + protected function searchValue(string $name, $value, string $search, array $options = null): float + { + $options = $options ?? []; + + // Ignore empty search strings. + $search = trim($search); + if ($search === '') { + return 0; + } + + // Search only non-empty string values. + if (!is_string($value) || $value === '') { + return 0; + } + + $caseSensitive = $options['case_sensitive'] ?? false; + + $tested = 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, $caseSensitive)) { + return (float)$options['ends_with']; + } + if ((!$tested || !empty($options['contains'])) && Utils::contains($value, $search, $caseSensitive)) { + return (float)($options['contains'] ?? 1); + } + + return 0; + } + + /** + * Get any changes based on data sent to update + * + * @return array + */ + public function getChanges(): array + { + return $this->_changes ?? []; + } + + /** + * @return string + */ + protected function getTypePrefix(): string + { + return 'o.'; + } + + /** + * Alias of getBlueprint() + * + * @return Blueprint + * @deprecated 1.6 Admin compatibility + */ + public function blueprints() + { + return $this->getBlueprint(); + } + + /** + * @param string|null $namespace + * @return CacheInterface + */ + public function getCache(string $namespace = null) + { + return $this->_flexDirectory->getCache($namespace); + } + + /** + * @param string|null $key + * @return $this + */ + public function setStorageKey($key = null) + { + $this->storage_key = $key ?? ''; + + return $this; + } + + /** + * @param int $timestamp + * @return $this + */ + public function setTimestamp($timestamp = null) + { + $this->storage_timestamp = $timestamp ?? time(); + + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::render() + */ + public function render(string $layout = null, array $context = []) + { + if (!$layout) { + $config = $this->getTemplateConfig(); + $layout = $config['object']['defaults']['layout'] ?? 'default'; + } + + $type = $this->getFlexType(); + + $grav = Grav::instance(); + + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->startTimer('flex-object-' . ($debugKey = uniqid($type, false)), 'Render Object ' . $type . ' (' . $layout . ')'); + + $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) { + // 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 ? $cache->get($key) : null; + + $block = $data ? HtmlBlock::fromArray($data) : null; + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + + $block = null; + } catch (\InvalidArgumentException $e) { + $debugger->addException($e); + + $block = null; + } + + $checksum = $this->getCacheChecksum(); + if ($block && $checksum !== $block->getChecksum()) { + $block = null; + } + + if (!$block) { + $block = HtmlBlock::create($key ?: null); + $block->setChecksum($checksum); + if (!$cache) { + $block->disableCache(); + } + + $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, + 'directory' => $this->getFlexDirectory(), + 'object' => $this, + 'layout' => $layout + ] + $context + ); + + if ($debugger->enabled()) { + $name = $this->getKey() . ' (' . $type . ')'; + $output = "\n\n{$output}\n\n"; + } + + $block->setContent($output); + + try { + $cache && $block->isCached() && $cache->set($key, $block->toArray()); + } catch (InvalidArgumentException $e) { + $debugger->addException($e); + } + } + + $debugger->stopTimer('flex-object-' . $debugKey); + + return $block; + } + + /** + * @return array + */ + public function jsonSerialize() + { + return $this->getElements(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::prepareStorage() + */ + public function prepareStorage(): array + { + return ['__META' => $this->getMetaData()] + $this->getElements(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::update() + */ + public function update(array $data, array $files = []) + { + if ($data) { + $blueprint = $this->getBlueprint(); + + // Process updated data through the object filters. + $this->filterElements($data); + + // Get currently stored data. + $elements = $this->getElements(); + + // Merge existing object to the test data to be validated. + $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()], ['xss_check' => false]); + $data = $blueprint->filter($data, true, true); + + // Finally update the object. + foreach ($blueprint->flattenData($data) as $key => $value) { + if ($value === null) { + $this->unsetNestedProperty($key); + } else { + $this->setNestedProperty($key, $value); + } + } + + // Store the changes + $this->_changes = Utils::arrayDiffMultidimensional($this->getElements(), $elements); + } + + if ($files && method_exists($this, 'setUpdatedMedia')) { + $this->setUpdatedMedia($files); + } + + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::create() + */ + public function create(string $key = null) + { + if ($key) { + $this->setStorageKey($key); + } + + if ($this->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() + */ + public function save() + { + $this->triggerEvent('onBeforeSave'); + + $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); + $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(); + + if (method_exists($this, 'saveUpdatedMedia')) { + $this->saveUpdatedMedia(); + } + + try { + $this->getFlexDirectory()->reloadIndex(); + if (method_exists($this, 'clearMediaCache')) { + $this->clearMediaCache(); + } + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + // Caching failed, but we can ignore that for now. + } + + $this->triggerEvent('onAfterSave'); + + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::delete() + */ + public function delete() + { + if (!$this->exists()) { + return $this; + } + + $this->triggerEvent('onBeforeDelete'); + + $this->getFlexDirectory()->getStorage()->deleteRows([$this->getStorageKey() => $this->prepareStorage()]); + + try { + $this->getFlexDirectory()->reloadIndex(); + if (method_exists($this, 'clearMediaCache')) { + $this->clearMediaCache(); + } + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + // Caching failed, but we can ignore that for now. + } + + $this->triggerEvent('onAfterDelete'); + + return $this; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getBlueprint() + */ + public function getBlueprint(string $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 $options = null) + { + if (!isset($this->_forms[$name])) { + $this->_forms[$name] = $this->createFormObject($name, $options); + } + + return $this->_forms[$name]; + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getDefaultValue() + */ + public function getDefaultValue(string $name, string $separator = null) + { + $separator = $separator ?: '.'; + $path = explode($separator, $name) ?: []; + $offset = array_shift($path) ?? ''; + + $current = $this->getDefaultValues(); + + if (!isset($current[$offset])) { + return null; + } + + $current = $current[$offset]; + + while ($path) { + $offset = array_shift($path); + + if ((is_array($current) || $current instanceof ArrayAccess) && isset($current[$offset])) { + $current = $current[$offset]; + } elseif (is_object($current) && isset($current->{$offset})) { + $current = $current->{$offset}; + } else { + return null; + } + }; + + return $current; + } + + /** + * @return array + */ + public function getDefaultValues(): array + { + return $this->getBlueprint()->getDefaults(); + } + + /** + * {@inheritdoc} + * @see FlexObjectInterface::getFormValue() + */ + public function getFormValue(string $name, $default = null, string $separator = null) + { + if ($name === 'storage_key') { + return $this->getStorageKey(); + } + if ($name === 'storage_timestamp') { + return $this->getTimestamp(); + } + + return $this->getNestedProperty($name, $default, $separator); + } + + /** + * @param FlexDirectory $directory + */ + public function setFlexDirectory(FlexDirectory $directory): void + { + $this->_flexDirectory = $directory; + } + + /** + * Returns a string representation of this object. + * + * @return string + */ + public function __toString() + { + 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->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 + */ + protected function doSerialize(): array + { + return [ + 'type' => $this->getFlexType(), + 'key' => $this->getKey(), + 'elements' => $this->getElements(), + 'storage' => $this->getMetaData() + ]; + } + + /** + * @param array $serialized + * @param FlexDirectory|null $directory + * @return void + */ + protected function doUnserialize(array $serialized, FlexDirectory $directory = null): void + { + $type = $serialized['type'] ?? 'unknown'; + + if (!isset($serialized['key'], $serialized['type'], $serialized['elements'])) { + throw new \InvalidArgumentException("Cannot unserialize '{$type}': Bad data"); + } + + 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->setMetaData($serialized['storage']); + $this->setKey($serialized['key']); + $this->setElements($serialized['elements']); + } + + /** + * @return array + */ + protected function getTemplateConfig() + { + $config = $this->getFlexDirectory()->getConfig('site.templates', []); + $defaults = array_replace($config['defaults'] ?? [], $config['object']['defaults'] ?? []); + $config['object']['defaults'] = $defaults; + + return $config; + } + + /** + * @param string $layout + * @return array + */ + protected function getTemplatePaths(string $layout): array + { + $config = $this->getTemplateConfig(); + $type = $this->getFlexType(); + $defaults = $config['object']['defaults'] ?? []; + + $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']; + + $lookups = []; + foreach ($paths as $path) { + $path = Utils::simpleTemplate($path, $table); + foreach ($types as $type) { + $lookups[] = sprintf($path, $type, $layout, $ext); + } + } + + return array_unique($lookups); + } + + /** + * Filter data coming to constructor or $this->update() request. + * + * NOTE: The incoming data can be an arbitrary array so do not assume anything from its content. + * + * @param array $elements + */ + protected function filterElements(array &$elements): void + { + if (isset($elements['storage_key'])) { + $elements['storage_key'] = trim($elements['storage_key']); + } + if (isset($elements['storage_timestamp'])) { + $elements['storage_timestamp'] = (int)$elements['storage_timestamp']; + } + + unset($elements['_post_entries_save']); + } + + /** + * This methods allows you to override form objects in child classes. + * + * @param string $name Form name + * @param array|null $options Form optiosn + * @return FlexFormInterface + */ + protected function createFormObject(string $name, array $options = null) + { + 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 new file mode 100644 index 0000000..102dbf4 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php @@ -0,0 +1,33 @@ + + */ +interface FlexCollectionInterface extends FlexCommonInterface, ObjectCollectionInterface, NestedObjectInterface +{ + /** + * Creates a Flex Collection from an array. + * + * @used-by FlexDirectory::createCollection() Official method to create a 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. + * @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); + + /** + * Creates a new Flex Collection. + * + * @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|null $directory Flex Directory where all the objects belong into. + * @throws InvalidArgumentException + */ + public function __construct(array $entries = [], FlexDirectory $directory = null); + + /** + * Search a string from the collection. + * + * @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); + + /** + * Sort the collection. + * + * @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); + + /** + * Filter collection by filter array with keys and values. + * + * @param array $filters + * @return FlexCollectionInterface + * @phpstan-return static + */ + public function filterBy(array $filters); + + /** + * Get timestamps from all the objects in the collection. + * + * This method can be used for example in caching. + * + * @return int[] Returns [key => timestamp, ...] pairs. + */ + public function getTimestamps(): array; + + /** + * Get storage keys from all the objects in the collection. + * + * @see FlexDirectory::getObject() If you want to get Flex Object from the Flex Directory. + * + * @return string[] Returns [key => storage_key, ...] pairs. + */ + public function getStorageKeys(): array; + + /** + * Get Flex keys from all the objects in the collection. + * + * @see Flex::getObjects() If you want to get list of Flex Objects from any Flex Directory. + * + * @return string[] Returns[key => flex_key, ...] pairs. + */ + public function getFlexKeys(): array; + + /** + * 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); + + /** + * 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 new file mode 100644 index 0000000..ed045e2 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php @@ -0,0 +1,79 @@ +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 new file mode 100644 index 0000000..9539a15 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php @@ -0,0 +1,46 @@ + + */ +interface FlexIndexInterface extends FlexCollectionInterface +{ + /** + * Helper method to create Flex Index. + * + * @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); + + /** + * Method to load index from the object storage, usually filesystem. + * + * @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; + + /** + * Return new collection with a different key. + * + * @param string|null $keyField Switch key field of the collection. + * @return static Returns a new Flex Collection with new key field. + * @api + */ + public function withKeyField(string $keyField = null); + + /** + * @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 @@ + [storage_key => key, storage_timestamp => timestamp], ...]`. + */ + public function getExistingKeys(): array; + + /** + * Check if the key exists in the storage. + * + * @param string $key Storage key of an object. + * @return bool Returns `true` if the key exists in the storage, `false` otherwise. + */ + public function hasKey(string $key): bool; + + /** + * Check if the key exists in the storage. + * + * @param string[] $keys Storage key of an object. + * @return bool[] Returns keys with `true` if the key exists in the storage, `false` otherwise. + */ + public function hasKeys(array $keys): array; + + /** + * Create new rows into the storage. + * + * New keys will be assigned when the objects are created. + * + * @param array $rows List of rows as `[row, ...]`. + * @return array Returns created rows as `[key => row, ...] pairs. + */ + public function createRows(array $rows): array; + + /** + * Read rows from the storage. + * + * 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|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; + + /** + * 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; + + /** + * 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; + + /** + * Replace rows regardless if they exist or not. + * + * 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; + + /** + * @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; + + /** + * Get filesystem path for the collection or object storage. + * + * @param string|null $key Optional storage key. + * @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; + + /** + * Get filesystem path for the collection or object media. + * + * @param string|null $key Optional storage key. + * @return string|null Path in the filesystem. Can be URI or null if media isn't supported. + */ + 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..6453bcf --- /dev/null +++ b/system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php @@ -0,0 +1,1119 @@ +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(), '/'); + $key = preg_replace(static::PAGE_ORDER_PREFIX_REGEX, '', $key); + } 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 new file mode 100644 index 0000000..1934f50 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php @@ -0,0 +1,228 @@ +hasKey((string)$key); + } + + return $list; + } + + /** + * {@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)) { + $formatter = ['class' => $formatter]; + } + $formatterClassName = $formatter['class'] ?? JsonFormatter::class; + $formatterOptions = $formatter['options'] ?? []; + + $this->dataFormatter = new $formatterClassName($formatterOptions); + } + + /** + * @param string $filename + * @return string|null + */ + protected function detectDataFormatter(string $filename): ?string + { + if (preg_match('|(\.[a-z0-9]*)$|ui', $filename, $matches)) { + switch ($matches[1]) { + case '.json': + return JsonFormatter::class; + case '.yaml': + return YamlFormatter::class; + case '.md': + return MarkdownFormatter::class; + } + } + + return null; + } + + /** + * @param string $filename + * @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); + break; + case '.yaml': + $file = CompiledYamlFile::instance($filename); + break; + case '.md': + $file = CompiledMarkdownFile::instance($filename); + break; + default: + throw new RuntimeException('Unknown extension type ' . $this->dataFormatter->getDefaultFileExtension()); + } + + return $file; + } + + /** + * @param string $path + * @return string + */ + protected function resolvePath(string $path): string + { + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + if (!$locator->isStream($path)) { + return GRAV_ROOT . "/{$path}"; + } + + return $locator->getResource($path); + } + + /** + * Generates a random, unique key for the row. + * + * @return string + */ + protected function generateKey(): string + { + 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); + } + + /** + * Checks if a key is valid. + * + * @param string $key + * @return bool + */ + protected function validateKey(string $key): bool + { + 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 new file mode 100644 index 0000000..eabd658 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/FileStorage.php @@ -0,0 +1,159 @@ +dataPattern = '{FOLDER}/{KEY}{EXT}'; + + if (!isset($options['formatter']) && isset($options['pattern'])) { + $options['formatter'] = $this->detectDataFormatter($options['pattern']); + } + + parent::__construct($options); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getMediaPath() + */ + public function getMediaPath(string $key = null): ?string + { + $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; + } + + /** + * {@inheritdoc} + */ + protected function getKeyFromPath(string $path): string + { + return basename($path, $this->dataFormatter->getDefaultFileExtension()); + } + + /** + * {@inheritdoc} + */ + protected function buildIndex(): array + { + $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($path, $flags); + $list = []; + /** @var SplFileInfo $info */ + foreach ($iterator as $filename => $info) { + if (!$info->isFile() || !($key = $this->getKeyFromPath($filename)) || strpos($info->getFilename(), '.') === 0) { + continue; + } + + $list[$key] = $this->getObjectMeta($key); + } + + 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 new file mode 100644 index 0000000..229194d --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/FolderStorage.php @@ -0,0 +1,704 @@ +initDataFormatter($options['formatter'] ?? []); + $this->initOptions($options); + } + + /** + * @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; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getExistingKeys() + */ + public function getExistingKeys(): array + { + return $this->buildIndex(); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::hasKey() + */ + public function hasKey(string $key): bool + { + $meta = $this->getObjectMeta($key); + + return array_key_exists('exists', $meta) ? $meta['exists'] : !empty($meta['storage_timestamp']); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::createRows() + */ + public function createRows(array $rows): array + { + $list = []; + foreach ($rows as $key => $row) { + $list[$key] = $this->saveRow('@@', $row); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::readRows() + */ + public function readRows(array $rows, array &$fetched = null): array + { + $list = []; + foreach ($rows as $key => $row) { + if (null === $row || is_scalar($row)) { + // Only load rows which haven't been loaded before. + $key = (string)$key; + $list[$key] = $this->loadRow($key); + + if (null !== $fetched) { + $fetched[$key] = $list[$key]; + } + } else { + // Keep the row if it has been loaded. + $list[$key] = $row; + } + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::updateRows() + */ + public function updateRows(array $rows): array + { + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + $list[$key] = $this->hasKey($key) ? $this->saveRow($key, $row) : null; + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::deleteRows() + */ + public function deleteRows(array $rows): array + { + $list = []; + $baseMediaPath = $this->getMediaPath(); + 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->deleteFile($file); + + if ($this->canDeleteFolder($key)) { + $storagePath = $this->getStoragePath($key); + $mediaPath = $this->getMediaPath($key); + + if ($storagePath) { + $this->deleteFolder($storagePath, true); + } + if ($mediaPath && $mediaPath !== $storagePath && $mediaPath !== $baseMediaPath) { + $this->deleteFolder($mediaPath, true); + } + } + } + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::replaceRows() + */ + public function replaceRows(array $rows): array + { + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + $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($src)) { + return false; + } + + $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 + { + if (null === $key || $key === '') { + $path = $this->dataFolder; + } else { + $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; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getMediaPath() + */ + public function getMediaPath(string $key = null): ?string + { + return $this->getStoragePath($key); + } + + /** + * Get filesystem path from the key. + * + * @param string $key + * @return string + */ + public function getPathFromKey(string $key): string + { + $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 string $key + * @param bool $variations + * @return array + */ + public function parseKey(string $key, bool $variations = true): array + { + $keys = [ + 'key' => $key, + 'key:2' => mb_substr($key, 0, 2), + ]; + if ($variations) { + $keys['file'] = $this->dataFile; + } + + return $keys; + } + + /** + * Get key from the filesystem path. + * + * @param string $path + * @return string + */ + protected function getKeyFromPath(string $path): string + { + 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. + * + * @return array + */ + protected function buildIndex(): array + { + $this->clearCache(); + + $path = $this->getStoragePath(); + if (!$path || !file_exists($path)) { + return []; + } + + if ($this->prefixed) { + $list = $this->buildPrefixedIndexFromFilesystem($path); + } else { + $list = $this->buildIndexFromFilesystem($path); + } + + 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; + + $iterator = new FilesystemIterator($path, $flags); + $list = []; + /** @var SplFileInfo $info */ + foreach ($iterator as $filename => $info) { + if (!$info->isDir() || strpos($info->getFilename(), '.') === 0) { + continue; + } + + $key = $this->getKeyFromPath($filename); + $meta = $this->getObjectMeta($key); + if ($meta['storage_timestamp']) { + $list[$key] = $meta; + } + } + + 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; + + $iterator = new FilesystemIterator($path, $flags); + $list = []; + /** @var SplFileInfo $info */ + foreach ($iterator as $filename => $info) { + if (!$info->isDir() || strpos($info->getFilename(), '.') === 0) { + continue; + } + + $list[] = $this->buildIndexFromFilesystem($filename); + } + + if (!$list) { + return []; + } + + return count($list) > 1 ? array_merge(...$list) : $list[0]; + } + + /** + * @return string + */ + protected function getNewKey(): string + { + // Make sure that the file doesn't exist. + do { + $key = $this->generateKey(); + } while (file_exists($this->getPathFromKey($key))); + + return $key; + } + + /** + * @param array $options + * @return void + */ + protected function initOptions(array $options): void + { + $extension = $this->dataFormatter->getDefaultFileExtension(); + + /** @var string $pattern */ + $pattern = !empty($options['pattern']) ? $options['pattern'] : $this->dataPattern; + + /** @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); + + $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 new file mode 100644 index 0000000..85c1429 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Storage/SimpleStorage.php @@ -0,0 +1,506 @@ +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 = $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())); + } + } + } + + /** + * @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() + */ + public function getExistingKeys(): array + { + return $this->buildIndex(); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::hasKey() + */ + public function hasKey(string $key): bool + { + if (null === $this->data) { + $this->buildIndex(); + } + + return $key && strpos($key, '@@') === false && isset($this->data[$key]); + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::createRows() + */ + public function createRows(array $rows): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $row) { + $list[$key] = $this->saveRow('@@', $rows); + } + + if ($list) { + $this->save(); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::readRows() + */ + public function readRows(array $rows, array &$fetched = null): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $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->loadRow($key) : null; + if (null !== $fetched) { + $fetched[$key] = $list[$key]; + } + } else { + // Keep the row if it has been loaded. + $list[$key] = $row; + } + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::updateRows() + */ + public function updateRows(array $rows): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $save = false; + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + if ($this->hasKey($key)) { + $list[$key] = $this->saveRow($key, $row); + $save = true; + } else { + $list[$key] = null; + } + } + + if ($save) { + $this->save(); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::deleteRows() + */ + public function deleteRows(array $rows): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $row) { + $key = (string)$key; + if ($this->hasKey($key)) { + unset($this->data[$key]); + $list[$key] = $row; + } + } + + if ($list) { + $this->save(); + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::replaceRows() + */ + public function replaceRows(array $rows): array + { + if (null === $this->data) { + $this->buildIndex(); + } + + $list = []; + foreach ($rows as $key => $row) { + $list[$key] = $this->saveRow((string)$key, $row); + } + + if ($list) { + $this->save(); + } + + 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() + */ + public function renameRow(string $src, string $dst): bool + { + if (null === $this->data) { + $this->buildIndex(); + } + + if ($this->hasKey($dst)) { + throw new RuntimeException("Cannot rename object: key '{$dst}' is already taken"); + } + + if (!$this->hasKey($src)) { + return false; + } + + // Change single key in the array without changing the order or value. + $keys = array_keys($this->data); + $keys[array_search($src, $keys, true)] = $dst; + + $data = array_combine($keys, $this->data); + if (false === $data) { + throw new LogicException('Bad data'); + } + + $this->data = $data; + + return true; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getStoragePath() + */ + public function getStoragePath(string $key = null): ?string + { + return $this->dataFolder . '/' . $this->dataPattern; + } + + /** + * {@inheritdoc} + * @see FlexStorageInterface::getMediaPath() + */ + public function getMediaPath(string $key = null): ?string + { + return null; + } + + /** + * 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 { + $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); + } + } + } + + /** + * Get key from the filesystem path. + * + * @param string $path + * @return string + */ + protected function getKeyFromPath(string $path): string + { + return basename($path); + } + + /** + * Returns list of all stored keys in [key => timestamp] pairs. + * + * @return array + */ + protected function buildIndex(): array + { + $path = $this->getStoragePath(); + if (!$path) { + $this->data = []; + + 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] = $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 + */ + protected function getNewKey(): string + { + if (null === $this->data) { + $this->buildIndex(); + } + + // Make sure that the key doesn't exist. + do { + $key = $this->generateKey(); + } while (isset($this->data[$key])); + + return $key; + } +} diff --git a/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php b/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php new file mode 100644 index 0000000..8cbd1d5 --- /dev/null +++ b/system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php @@ -0,0 +1,126 @@ +getAuthorizeAction($action); + $scope = $scope ?? $this->getAuthorizeScope(); + + $isMe = null === $user; + if ($isMe) { + $user = $this->getActiveUser(); + } + + if (null === $user) { + return false; + } + + // Finally authorize against given action. + return $this->isAuthorizedOverride($user, $action, $scope, $isMe); + } + + /** + * 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 $this->isAuthorizedAction($user, $action, $scope, $isMe); + } + + /** + * 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 + { + // 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; + } + + // 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); + } + + /** + * @param UserInterface $user + * @return bool|null + * @deprecated 1.7 Not needed for Flex Users. + */ + protected function isAuthorizedSuperAdmin(UserInterface $user): ?bool + { + // 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 new file mode 100644 index 0000000..197664a --- /dev/null +++ b/system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php @@ -0,0 +1,520 @@ +exists() ? $this->getFlexDirectory()->getStorageFolder($this->getStorageKey()) : null; + } + + /** + * @return string|null + */ + public function getMediaFolder() + { + return $this->exists() ? $this->getFlexDirectory()->getMediaFolder($this->getStorageKey()) : null; + } + + /** + * @return MediaCollectionInterface + */ + public function getMedia() + { + $media = $this->media; + if (null === $media) { + $media = $this->getExistingMedia(); + + // Include uploaded media to the object media. + $this->addUpdatedMedia($media); + } + + return $media; + } + + /** + * @param string $field + * @return MediaCollectionInterface|null + */ + public function getMediaField(string $field): ?MediaCollectionInterface + { + // Field specific media. + $settings = $this->getFieldSettings($field); + if (!empty($settings['media_field'])) { + $var = 'destination'; + } elseif (!empty($settings['media_picker_field'])) { + $var = 'folder'; + } + + if (empty($var)) { + // Not a media field. + $media = null; + } elseif ($settings['self']) { + // Uses main media. + $media = $this->getMedia(); + } else { + // Uses custom media. + $media = new Media($settings[$var]); + $this->addUpdatedMedia($media); + } + + return $media; + } + + /** + * @param string $field + * @return array|null + */ + public function getFieldSettings(string $field): ?array + { + if ($field === '') { + return null; + } + + // 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; + } + + $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(); + $originalMedia = is_callable([$this, 'getOriginalMedia']) ? $this->getOriginalMedia() : null; + + $list = []; + foreach ($value as $filename => $info) { + if (!is_array($info)) { + $list[$filename] = $info; + continue; + } + + if (is_int($filename)) { + $filename = $info['path'] ?? $info['name']; + } + + /** @var Medium|null $imageFile */ + $imageFile = $media[$filename]; + + /** @var Medium|null $originalFile */ + $originalFile = $originalMedia ? $originalMedia[$filename] : null; + + $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'] ?? []); + } + } + + return $list; + } + + /** + * @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) { + $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) { + 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; + } + + /** + * @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 (isset($settings['destination']) && $settings['destination'] === $media->getPath()) { + $upload = $upload[0]; + } else { + $upload = false; + } + } + if (false !== $upload) { + $medium = $upload ? MediumFactory::fromUploadedFile($upload) : null; + $updated = true; + if ($medium) { + $medium->uploaded = true; + $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|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 void + */ + protected function freeMedia(): void + { + $this->unsetObjectProperty('media'); + } + + /** + * @param string $uri + * @return Medium|null + */ + protected function createMedium($uri) + { + $grav = Grav::instance(); + + /** @var UniformResourceLocator $locator */ + $locator = $grav['locator']; + + $file = $uri && $locator->isStream($uri) ? $locator->findResource($uri) : $uri; + + return is_string($file) && file_exists($file) ? MediumFactory::fromFile($file) : null; + } + + /** + * @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 new file mode 100644 index 0000000..00aab28 --- /dev/null +++ b/system/src/Grav/Framework/Form/FormFlash.php @@ -0,0 +1,566 @@ + $args[0], + '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'; + $this->uniqueId = $config['unique_id'] ?? ''; + + $folder = $config['folder'] ?? ($this->sessionId ? 'tmp://forms/' . $this->sessionId : ''); + + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + $this->folder = $folder && $locator->isStream($folder) ? $locator->findResource($folder, true, true) : $folder; + + $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'] ?? []; + } + } + + /** + * 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 + */ + public function getSessionId(): string + { + return $this->sessionId; + } + + /** + * @inheritDoc + */ + public function getUniqueId(): string + { + return $this->uniqueId; + } + + /** + * @return string + * @deprecated 1.6.11 Use '->getUniqueId()' method instead. + */ + public function getUniqieId(): string + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6.11, use ->getUniqueId() method instead', E_USER_DEPRECATED); + + return $this->getUniqueId(); + } + + /** + * @inheritDoc + */ + public function getFormName(): string + { + return $this->formName; + } + + + /** + * @inheritDoc + */ + public function getUrl(): string + { + return $this->url; + } + + /** + * @inheritDoc + */ + public function getUsername(): string + { + return $this->user['username'] ?? ''; + } + + /** + * @inheritDoc + */ + public function getUserEmail(): string + { + return $this->user['email'] ?? ''; + } + + /** + * @inheritDoc + */ + public function getCreatedTimestamp(): int + { + return $this->createdTimestamp; + } + + /** + * @inheritDoc + */ + public function getUpdatedTimestamp(): int + { + return $this->updatedTimestamp; + } + + + /** + * @inheritDoc + */ + public function getData(): ?array + { + return $this->data; + } + + /** + * @inheritDoc + */ + public function setData(?array $data): void + { + $this->data = $data; + } + + /** + * @inheritDoc + */ + public function exists(): bool + { + return $this->exists; + } + + /** + * @inheritDoc + */ + public function save(bool $force = false) + { + if (!($this->folder && $this->uniqueId)) { + return $this; + } + + if ($force || $this->data || $this->files) { + // Only save if there is data or files to be saved. + $file = $this->getTmpIndex(); + $file->save($this->jsonSerialize()); + $this->exists = true; + } elseif ($this->exists) { + // Delete empty form flash if it exists (it carries no information). + return $this->delete(); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function delete() + { + if ($this->folder && $this->uniqueId) { + $this->removeTmpDir(); + $this->files = []; + $this->exists = false; + } + + return $this; + } + + /** + * @inheritDoc + */ + public function getFilesByField(string $field): array + { + if (!isset($this->uploadObjects[$field])) { + $objects = []; + foreach ($this->files[$field] ?? [] as $name => $upload) { + $objects[$name] = $upload ? new FormFlashFile($field, $upload, $this) : null; + } + $this->uploadedFiles[$field] = $objects; + } + + return $this->uploadedFiles[$field]; + } + + /** + * @inheritDoc + */ + public function getFilesByFields($includeOriginal = false): array + { + $list = []; + foreach ($this->files as $field => $values) { + if (!$includeOriginal && strpos($field, '/')) { + continue; + } + $list[$field] = $this->getFilesByField($field); + } + + return $list; + } + + /** + * @inheritDoc + */ + public function addUploadedFile(UploadedFileInterface $upload, string $field = null, array $crop = null): string + { + $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 = [ + 'name' => $name, + 'type' => $upload->getClientMediaType(), + 'size' => $upload->getSize(), + 'tmp_name' => $tmp_name + ]; + + Folder::create($tmp_dir); + $upload->moveTo("{$tmp_dir}/{$tmp_name}"); + + $this->addFileInternal($field, $name, $data, $crop); + + 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}"); + } + + // Prepare upload data for later save + $data = [ + 'name' => basename($filename), + 'type' => Utils::getMimeByLocalFile($filename), + 'size' => filesize($filename), + ]; + + $this->addFileInternal($field, $data['name'], $data, $crop); + + return true; + } + + /** + * @inheritDoc + */ + public function removeFile(string $name, string $field = null): bool + { + if (!$name) { + return false; + } + + $field = $field ?: 'undefined'; + + $upload = $this->files[$field][$name] ?? null; + if (null !== $upload) { + $this->removeTmpFile($upload['tmp_name'] ?? ''); + } + $upload = $this->files[$field . '/original'][$name] ?? null; + if (null !== $upload) { + $this->removeTmpFile($upload['tmp_name'] ?? ''); + } + + // Mark file as deleted. + $this->files[$field][$name] = null; + $this->files[$field . '/original'][$name] = null; + + unset( + $this->uploadedFiles[$field][$name], + $this->uploadedFiles[$field . '/original'][$name] + ); + + return true; + } + + /** + * @inheritDoc + */ + public function clearFiles() + { + foreach ($this->files as $field => $files) { + foreach ($files as $name => $upload) { + $this->removeTmpFile($upload['tmp_name'] ?? ''); + } + } + + $this->files = []; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array + { + return [ + 'form' => $this->formName, + 'unique_id' => $this->uniqueId, + 'url' => $this->url, + 'user' => $this->user, + 'timestamps' => [ + 'created' => $this->createdTimestamp, + 'updated' => time(), + ], + 'data' => $this->data, + 'files' => $this->files + ]; + } + + /** + * @param string $url + * @return $this + */ + public function setUrl(string $url): self + { + $this->url = $url; + + return $this; + } + + /** + * @param UserInterface|null $user + * @return $this + */ + public function setUser(UserInterface $user = null) + { + if ($user && $user->username) { + $this->user = [ + 'username' => $user->username, + 'email' => $user->email ?? '' + ]; + } else { + $this->user = null; + } + + return $this; + } + + /** + * @param string|null $username + * @return $this + */ + public function setUserName(string $username = null): self + { + $this->user['username'] = $username; + + return $this; + } + + /** + * @param string|null $email + * @return $this + */ + public function setUserEmail(string $email = null): self + { + $this->user['email'] = $email; + + return $this; + } + + /** + * @return string + */ + public function getTmpDir(): string + { + return $this->folder && $this->uniqueId ? "{$this->folder}/{$this->uniqueId}" : ''; + } + + /** + * @return YamlFile + */ + protected function getTmpIndex(): YamlFile + { + // Do not use CompiledYamlFile as the file can change multiple times per second. + return YamlFile::instance($this->getTmpDir() . '/index.yaml'); + } + + /** + * @param string $name + */ + protected function removeTmpFile(string $name): void + { + $tmpDir = $this->getTmpDir(); + $filename = $tmpDir ? $tmpDir . '/' . $name : ''; + if ($name && $filename && is_file($filename)) { + unlink($filename); + } + } + + /** + * @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); + } + } + + /** + * @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'); + } + + $field = $field ?: 'undefined'; + if (!isset($this->files[$field])) { + $this->files[$field] = []; + } + + $oldUpload = $this->files[$field][$name] ?? null; + + if ($crop) { + // Deal with crop upload + if ($oldUpload) { + $originalUpload = $this->files[$field . '/original'][$name] ?? null; + if ($originalUpload) { + // If there is original file already present, remove the modified file + $this->files[$field . '/original'][$name]['crop'] = $crop; + $this->removeTmpFile($oldUpload['tmp_name'] ?? ''); + } else { + // Otherwise make the previous file as original + $oldUpload['crop'] = $crop; + $this->files[$field . '/original'][$name] = $oldUpload; + } + } else { + $this->files[$field . '/original'][$name] = [ + 'name' => $name, + 'type' => $data['type'], + 'crop' => $crop + ]; + } + } else { + // Deal with replacing upload + $originalUpload = $this->files[$field . '/original'][$name] ?? null; + $this->files[$field . '/original'][$name] = null; + + $this->removeTmpFile($oldUpload['tmp_name'] ?? ''); + $this->removeTmpFile($originalUpload['tmp_name'] ?? ''); + } + + // Prepare data to be saved later + $this->files[$field][$name] = $data; + } +} diff --git a/system/src/Grav/Framework/Form/FormFlashFile.php b/system/src/Grav/Framework/Form/FormFlashFile.php new file mode 100644 index 0000000..dc510e2 --- /dev/null +++ b/system/src/Grav/Framework/Form/FormFlashFile.php @@ -0,0 +1,237 @@ +field = $field; + $this->upload = $upload; + $this->flash = $flash; + + $tmpFile = $this->getTmpFile(); + if (!$tmpFile && $this->isOk()) { + $this->upload['error'] = \UPLOAD_ERR_NO_FILE; + } + + if (!isset($this->upload['size'])) { + $this->upload['size'] = $tmpFile && $this->isOk() ? filesize($tmpFile) : 0; + } + } + + /** + * @return StreamInterface + */ + public function getStream() + { + $this->validateActive(); + + $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'); + } + $tmpFile = $this->getTmpFile(); + if (null === $tmpFile) { + throw new RuntimeException('No temporary file'); + } + + $this->moved = copy($tmpFile, $targetPath); + + if (false === $this->moved) { + throw new RuntimeException(sprintf('Uploaded file could not be moved to %s', $targetPath)); + } + + $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'; + } + + /** + * @return bool + */ + public function isMoved(): bool + { + return $this->moved; + } + + /** + * @return array + */ + public function getMetaData(): array + { + if (isset($this->upload['crop'])) { + return ['crop' => $this->upload['crop']]; + } + + return []; + } + + /** + * @return string + */ + public function getDestination() + { + return $this->upload['path'] ?? ''; + } + + /** + * @return array + */ + public function jsonSerialize() + { + return $this->upload; + } + + /** + * @return string|null + */ + public function getTmpFile(): ?string + { + $tmpName = $this->upload['tmp_name'] ?? null; + + if (!$tmpName) { + return null; + } + + $tmpFile = $this->flash->getTmpDir() . '/' . $tmpName; + + return file_exists($tmpFile) ? $tmpFile : null; + } + + /** + * @return array + */ + public function __debugInfo() + { + return [ + 'field:private' => $this->field, + 'moved:private' => $this->moved, + 'upload:private' => $this->upload, + ]; + } + + /** + * @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'); + } + + if ($this->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'); + } + } + + /** + * @return bool return true if there is no upload error + */ + private function isOk(): bool + { + return \UPLOAD_ERR_OK === $this->getError(); + } +} diff --git a/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php b/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php new file mode 100644 index 0000000..d21d4aa --- /dev/null +++ b/system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php @@ -0,0 +1,42 @@ +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; + } + + /** + * @return array|UploadedFileInterface[] + */ + public function getFiles(): array + { + 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) ?: []; + $offset = array_shift($path) ?? ''; + + $current = $this->getDefaultValues(); + + if (!isset($current[$offset])) { + return null; + } + + $current = $current[$offset]; + + while ($path) { + $offset = array_shift($path); + + if ((is_array($current) || $current instanceof ArrayAccess) && isset($current[$offset])) { + $current = $current[$offset]; + } elseif (is_object($current) && isset($current->{$offset})) { + $current = $current->{$offset}; + } else { + return null; + } + }; + + return $current; + } + + /** + * @return array + */ + public function getDefaultValues(): array + { + return $this->getBlueprint()->getDefaults(); + } + + /** + * @param ServerRequestInterface $request + * @return FormInterface|$this + */ + public function handleRequest(ServerRequestInterface $request): FormInterface + { + // Set current form to be active. + $grav = Grav::instance(); + $forms = $grav['forms'] ?? null; + if ($forms) { + $forms->setActiveForm($this); + + /** @var Twig $twig */ + $twig = $grav['twig']; + $twig->twig_vars['form'] = $this; + } + + try { + [$data, $files] = $this->parseRequest($request); + + $this->submit($data, $files); + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = $grav['debugger']; + $debugger->addException($e); + + $this->setError($e->getMessage()); + } + + return $this; + } + + /** + * @param ServerRequestInterface $request + * @return FormInterface|$this + */ + public function setRequest(ServerRequestInterface $request): FormInterface + { + [$data, $files] = $this->parseRequest($request); + + $this->data = new Data($data, $this->getBlueprint()); + $this->files = $files; + + 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()) { + return false; + } + + try { + $this->validateData($this->data); + $this->validateUploads($this->getFiles()); + } catch (ValidationException $e) { + $this->setErrors($e->getMessages()); + } catch (Exception $e) { + /** @var Debugger $debugger */ + $debugger = Grav::instance()['debugger']; + $debugger->addException($e); + + $this->setError($e->getMessage()); + } + + $this->filterData($this->data); + + return $this->isValid(); + } + + /** + * @param array $data + * @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'); + } + + $this->data = new Data($data, $this->getBlueprint()); + $this->files = $files ?? []; + + if (!$this->validate()) { + return $this; + } + + $this->doSubmit($this->data->toArray(), $this->files); + + $this->submitted = true; + } 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. + $this->getFlash()->delete(); + + $this->data = null; + $this->files = []; + $this->status = 'success'; + $this->message = null; + $this->messages = []; + $this->submitted = false; + $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; + + /** + * Get form flash object. + * + * @return FormFlashInterface + */ + 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() + ]; + + + $this->flash = new FormFlash($config); + $this->flash->setUrl($grav['uri']->url)->setUser($grav['user'] ?? null); + } + + return $this->flash; + } + + /** + * Get all available form flash objects for this form. + * + * @return FormFlashInterface[] + */ + public function getAllFlashes(): array + { + $folder = $this->getFlashFolder(); + if (!$folder || !is_dir($folder)) { + return []; + } + + $name = $this->getName(); + + $list = []; + /** @var SplFileInfo $file */ + foreach (new FilesystemIterator($folder) as $file) { + $uniqueId = $file->getFilename(); + $config = [ + 'session_id' => $this->getSessionId(), + 'unique_id' => $uniqueId, + 'form_name' => $name, + 'folder' => $this->getFlashFolder() + ]; + $flash = new FormFlash($config); + if ($flash->exists() && $flash->getFormName() === $name) { + $list[] = $flash; + } + } + + return $list; + } + + /** + * {@inheritdoc} + * @see FormInterface::render() + */ + public function render(string $layout = null, array $context = []) + { + if (null === $layout) { + $layout = 'default'; + } + + $grav = Grav::instance(); + + $block = HtmlBlock::create(); + $block->disableCache(); + + $output = $this->getTemplate($layout)->render( + ['grav' => $grav, 'config' => $grav['config'], 'block' => $block, 'form' => $this, 'layout' => $layout] + $context + ); + + $block->setContent($output); + + return $block; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + 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|null $user */ + $user = $grav['user'] ?? 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; + + // Fill template token keys/value pairs. + $dataMap = [ + '[FORM_NAME]' => $this->getName(), + '[SESSIONID]' => $sessionId ?? '!!', + '[USERNAME]' => $username ?? '!!', + '[USERNAME_OR_SESSIONID]' => $username ?? $sessionId ?? '!!', + '[ACCOUNT]' => $mediaFolder ?? '!!' + ]; + + $flashLookupFolder = $this->getFlashLookupFolder(); + + $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 + { + $this->status = 'error'; + $this->message = $error; + } + + /** + * Set all errors. + * + * @param array $errors + * @return void + */ + protected function setErrors(array $errors): void + { + $this->status = 'error'; + $this->messages = $errors; + } + + /** + * @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( + [ + "forms/{$layout}/form.html.twig", + 'forms/default/form.html.twig' + ] + ); + } + + /** + * Parse PSR-7 ServerRequest into data and files. + * + * @param ServerRequestInterface $request + * @return array + */ + 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)); + } + + $body = $request->getParsedBody(); + $data = isset($body['data']) ? $this->decodeData($body['data']) : null; + + $flash = $this->getFlash(); + /* + if (null !== $data) { + $flash->setData($data); + $flash->save(); + } + */ + + $blueprint = $this->getBlueprint(); + $includeOriginal = (bool)($blueprint->form()['images']['original'] ?? null); + $files = $flash->getFilesByFields($includeOriginal); + + $data = $blueprint->processForm($data ?? [], $body['toggleable_data'] ?? []); + + return [ + $data, + $files ?? [] + ]; + } + + /** + * Validate data and throw validation exceptions if validation fails. + * + * @param ArrayAccess|Data|null $data + * @return void + * @throws ValidationException + * @throws Exception + */ + protected function validateData($data = null): void + { + if ($data instanceof Data) { + $data->validate(); + } + } + + /** + * Filter validated data. + * + * @param ArrayAccess|Data|null $data + * @return void + */ + protected function filterData($data = null): void + { + if ($data instanceof Data) { + $data->filter(); + } + } + + /** + * Validate all uploaded files. + * + * @param array $files + * @return void + */ + protected function validateUploads(array $files): void + { + foreach ($files as $file) { + if (null === $file) { + continue; + } + if ($file instanceof UploadedFileInterface) { + $this->validateUpload($file); + } else { + $this->validateUploads($file); + } + } + } + + /** + * Validate uploaded file. + * + * @param UploadedFileInterface $file + * @return void + */ + protected function validateUpload(UploadedFileInterface $file): void + { + // Handle bad filenames. + $filename = $file->getClientFilename(); + + if ($filename && !Utils::checkFilename($filename)) { + $grav = Grav::instance(); + throw new RuntimeException( + sprintf($grav['language']->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_UPLOAD', null, true), $filename, 'Bad filename') + ); + } + } + + /** + * Decode POST data + * + * @param array $data + * @return array + */ + protected function decodeData($data): array + { + 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']); + } + + return $data; + } + + /** + * Recursively JSON decode POST data. + * + * @param array $data + * @return array + */ + protected function jsonDecode(array $data): array + { + foreach ($data as $key => &$value) { + if (is_array($value)) { + $value = $this->jsonDecode($value); + } elseif (trim($value) === '') { + unset($data[$key]); + } else { + $value = json_decode($value, true); + if ($value === null && json_last_error() !== JSON_ERROR_NONE) { + unset($data[$key]); + $this->setError("Badly encoded JSON data (for {$key}) was sent to the form"); + } + } + } + + return $data; + } + + /** + * @return array + */ + protected function doSerialize(): array + { + $data = $this->data instanceof Data ? $this->data->toArray() : null; + + return [ + 'name' => $this->name, + 'id' => $this->id, + 'uniqueid' => $this->uniqueid, + 'submitted' => $this->submitted, + 'status' => $this->status, + 'message' => $this->message, + 'messages' => $this->messages, + 'data' => $data, + 'files' => $this->files, + ]; + } + + /** + * @param array $data + * @return void + */ + protected function doUnserialize(array $data): void + { + $this->name = $data['name']; + $this->id = $data['id']; + $this->uniqueid = $data['uniqueid']; + $this->submitted = $data['submitted'] ?? false; + $this->status = $data['status'] ?? 'success'; + $this->message = $data['message'] ?? null; + $this->messages = $data['messages'] ?? []; + $this->data = isset($data['data']) ? new Data($data['data'], $this->getBlueprint()) : null; + $this->files = $data['files'] ?? []; + } +} diff --git a/system/src/Grav/Framework/Interfaces/RenderInterface.php b/system/src/Grav/Framework/Interfaces/RenderInterface.php new file mode 100644 index 0000000..7a7d9d3 --- /dev/null +++ b/system/src/Grav/Framework/Interfaces/RenderInterface.php @@ -0,0 +1,38 @@ +render('custom', ['variable' => 'value']); + * @example {% render object layout 'custom' with { variable: 'value' } %} + * + * @param string|null $layout Layout to be used. + * @param array $context Extra context given to the renderer. + * + * @return ContentBlockInterface|HtmlBlock Returns `HtmlBlock` containing the rendered output. + * @api + */ + public function render(string $layout = null, array $context = []); +} diff --git a/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php b/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php new file mode 100644 index 0000000..a3587d8 --- /dev/null +++ b/system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php @@ -0,0 +1,17 @@ +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 256a1ce..e048565 100644 --- a/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php +++ b/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php @@ -1,8 +1,9 @@ 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 65b155d..120c1a4 100644 --- a/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php +++ b/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php @@ -1,8 +1,9 @@ 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) { $separator = $separator ?: '.'; - $path = explode($separator, $property); - $offset = array_shift($path); + $path = explode($separator, $property) ?: []; + $offset = array_shift($path) ?? ''; if (!$this->hasProperty($offset)) { return $default; @@ -72,16 +77,16 @@ 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) { $separator = $separator ?: '.'; - $path = explode($separator, $property); - $offset = array_shift($path); + $path = explode($separator, $property) ?: []; + $offset = array_shift($path) ?? ''; if (!$path) { $this->setProperty($offset, $value); @@ -102,7 +107,7 @@ trait NestedPropertyTrait $current[$offset] = []; } } else { - throw new \RuntimeException('Cannot set nested property on non-array value'); + throw new RuntimeException("Cannot set nested property {$property} on non-array value"); } $current = &$current[$offset]; @@ -115,15 +120,15 @@ 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) { $separator = $separator ?: '.'; - $path = explode($separator, $property); - $offset = array_shift($path); + $path = explode($separator, $property) ?: []; + $offset = array_shift($path) ?? ''; if (!$path) { $this->unsetProperty($offset); @@ -146,7 +151,7 @@ trait NestedPropertyTrait return $this; } } else { - throw new \RuntimeException('Cannot set nested property on non-array value'); + throw new RuntimeException("Cannot unset nested property {$property} on non-array value"); } $current = &$current[$offset]; @@ -159,10 +164,10 @@ trait NestedPropertyTrait /** * @param string $property Object property to be updated. - * @param string $default Default value. - * @param string $separator Separator, defaults to '.' + * @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) { @@ -172,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 1524bd6..0f0f89f 100644 --- a/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php +++ b/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php @@ -1,8 +1,9 @@ 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 0804133..2bbe937 100644 --- a/system/src/Grav/Framework/Object/ArrayObject.php +++ b/system/src/Grav/Framework/Object/ArrayObject.php @@ -1,13 +1,15 @@ 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; } /** @@ -36,7 +221,7 @@ trait ObjectCollectionTrait } /** - * @return array + * @return string[] */ public function getObjectKeys() { @@ -45,7 +230,7 @@ trait ObjectCollectionTrait /** * @param string $property Object property to be matched. - * @return array Key/Value pairs of the properties. + * @return bool[] Key/Value pairs of the properties. */ public function doHasProperty($property) { @@ -53,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; @@ -62,9 +247,10 @@ trait ObjectCollectionTrait /** * @param string $property Object property to be fetched. * @param mixed $default Default value if not set. - * @return array Key/Value pairs of the properties. + * @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 = []; @@ -78,7 +264,7 @@ trait ObjectCollectionTrait /** * @param string $property Object property to be updated. - * @param string $value New value. + * @param mixed $value New value. * @return $this */ public function doSetProperty($property, $value) @@ -107,7 +293,7 @@ trait ObjectCollectionTrait /** * @param string $property Object property to be updated. - * @param string $default Default value. + * @param mixed $default Default value. * @return $this */ public function doDefProperty($property, $default) @@ -123,15 +309,19 @@ trait ObjectCollectionTrait /** * @param string $method Method name. * @param array $arguments List of arguments passed to the function. - * @return array Return values. + * @return mixed[] Return values. */ public function call($method, array $arguments = []) { $list = []; + /** + * @var string|int $id + * @var ObjectInterface $element + */ foreach ($this->getIterator() as $id => $element) { - $list[$id] = method_exists($element, $method) - ? call_user_func_array([$element, $method], $arguments) : null; + $callable = [$element, $method]; + $list[$id] = is_callable($callable) ? call_user_func_array($callable, $arguments) : null; } return $list; @@ -165,6 +355,7 @@ trait ObjectCollectionTrait { $collections = []; foreach ($this->group($property) as $id => $elements) { + /** @var static $collection */ $collection = $this->createFrom($elements); $collections[$id] = $collection; @@ -172,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 c736381..d4e324b 100644 --- a/system/src/Grav/Framework/Object/Base/ObjectTrait.php +++ b/system/src/Grav/Framework/Object/Base/ObjectTrait.php @@ -1,13 +1,18 @@ _key ?: $this->getType() . '@' . spl_object_hash($this); + return $this->_key ?: $this->getType() . '@@' . spl_object_hash($this); + } + + /** + * @return bool + */ + public function hasKey() + { + return !empty($this->_key); } /** @@ -76,7 +89,7 @@ trait ObjectTrait /** * @param string $property Object property to be updated. - * @param string $value New value. + * @param mixed $value New value. * @return $this */ public function setProperty($property, $value) @@ -112,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); } @@ -139,16 +150,17 @@ trait ObjectTrait */ protected function doSerialize() { - return $this->jsonSerialize(); + return ['key' => $this->getKey(), 'type' => $this->getType(), 'elements' => $this->getElements()]; } /** * @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']); @@ -162,7 +174,7 @@ trait ObjectTrait */ public function jsonSerialize() { - return ['key' => $this->getKey(), 'type' => $this->getType(), 'elements' => $this->getElements()]; + return $this->doSerialize(); } /** @@ -185,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 5ad3897..d8f1524 100644 --- a/system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php +++ b/system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php @@ -1,16 +1,28 @@ $bValue) ? 1 : -1) * $orientation; }; } @@ -158,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: @@ -177,7 +219,7 @@ class ObjectExpressionVisitor extends ClosureExpressionVisitor 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: @@ -190,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 3f8d784..4d7a880 100644 --- a/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php +++ b/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php @@ -1,57 +1,60 @@ + * @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 @@ -30,24 +39,57 @@ 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 */ public function getObjectKeys(); - /** - * @param string $property Object property to be fetched. - * @param mixed $default Default value if not set. - * @return array Property value. - */ - public function getProperty($property, $default = null); - /** * @param string $name Method name. * @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. @@ -62,6 +104,22 @@ 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); + + /** + * @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 f454ad8..e8ec2c1 100644 --- a/system/src/Grav/Framework/Object/Interfaces/ObjectInterface.php +++ b/system/src/Grav/Framework/Object/Interfaces/ObjectInterface.php @@ -1,18 +1,22 @@ + * @implements NestedObjectCollectionInterface */ -class ObjectCollection extends ArrayCollection implements ObjectCollectionInterface, NestedObjectInterface +class ObjectCollection extends ArrayCollection implements NestedObjectCollectionInterface { - use ObjectCollectionTrait, NestedPropertyCollectionTrait { + use ObjectCollectionTrait; + use NestedPropertyCollectionTrait { NestedPropertyCollectionTrait::group insteadof ObjectCollectionTrait; } /** * @param array $elements - * @param string $key - * @throws \InvalidArgumentException + * @param string|null $key + * @throws InvalidArgumentException */ public function __construct(array $elements = [], $key = null) { parent::__construct($this->setElements($elements)); - $this->setKey($key); + $this->setKey($key ?? ''); } /** - * {@inheritDoc} + * @param array $ordering + * @return static + * @phpstan-return static + */ + public function orderBy(array $ordering) + { + $criteria = Criteria::create()->orderBy($ordering); + + return $this->matching($criteria); + } + + /** + * @param int $start + * @param int|null $limit + * @return static + * @phpstan-return static + */ + public function limit($start, $limit = null) + { + return $this->createFrom($this->slice($start, $limit)); + } + + /** + * @param Criteria $criteria + * @return static + * @phpstan-return static */ public function matching(Criteria $criteria) { @@ -54,11 +87,17 @@ class ObjectCollection extends ArrayCollection implements ObjectCollectionInterf if ($orderings = $criteria->getOrderings()) { $next = null; + /** + * @var string $field + * @var string $ordering + */ foreach (array_reverse($orderings) as $field => $ordering) { - $next = ObjectExpressionVisitor::sortByField($field, $ordering == Criteria::DESC ? -1 : 1, $next); + $next = ObjectExpressionVisitor::sortByField($field, $ordering === Criteria::DESC ? -1 : 1, $next); } - uasort($filtered, $next); + if ($next) { + uasort($filtered, $next); + } } $offset = $criteria->getFirstResult(); @@ -71,11 +110,18 @@ class ObjectCollection extends ArrayCollection implements ObjectCollectionInterf 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 new file mode 100644 index 0000000..2c13765 --- /dev/null +++ b/system/src/Grav/Framework/Object/ObjectIndex.php @@ -0,0 +1,264 @@ + + * @implements NestedObjectCollectionInterface + */ +abstract class ObjectIndex extends AbstractIndexCollection implements NestedObjectCollectionInterface +{ + /** @var string */ + protected static $type; + + /** @var string */ + private $_key; + + /** + * @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); + } + + /** + * @param string $key + * @return $this + */ + public function setKey($key) + { + $this->_key = $key; + + return $this; + } + + /** + * @param string $property Object property name. + * @return bool[] True if property has been defined (can be null). + */ + public function hasProperty($property) + { + return $this->__call('hasProperty', [$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->__call('getProperty', [$property, $default]); + } + + /** + * @param string $property Object property to be updated. + * @param string $value New value. + * @return ObjectCollectionInterface + */ + public function setProperty($property, $value) + { + return $this->__call('setProperty', [$property, $value]); + } + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @return ObjectCollectionInterface + */ + public function defProperty($property, $default) + { + return $this->__call('defProperty', [$property, $default]); + } + + /** + * @param string $property Object property to be unset. + * @return ObjectCollectionInterface + */ + public function unsetProperty($property) + { + return $this->__call('unsetProperty', [$property]); + } + + /** + * @param string $property Object property name. + * @param string|null $separator Separator, defaults to '.' + * @return bool[] True if property has been defined (can be null). + */ + public function hasNestedProperty($property, $separator = null) + { + return $this->__call('hasNestedProperty', [$property, $separator]); + } + + /** + * @param string $property Object property to be fetched. + * @param mixed $default Default value if property has not been set. + * @param string|null $separator Separator, defaults to '.' + * @return mixed[] Property values. + */ + public function getNestedProperty($property, $default = null, $separator = null) + { + return $this->__call('getNestedProperty', [$property, $default, $separator]); + } + + /** + * @param string $property Object property to be updated. + * @param mixed $value New value. + * @param string|null $separator Separator, defaults to '.' + * @return ObjectCollectionInterface + */ + public function setNestedProperty($property, $value, $separator = null) + { + return $this->__call('setNestedProperty', [$property, $value, $separator]); + } + + /** + * @param string $property Object property to be defined. + * @param mixed $default Default value. + * @param string|null $separator Separator, defaults to '.' + * @return ObjectCollectionInterface + */ + public function defNestedProperty($property, $default, $separator = null) + { + return $this->__call('defNestedProperty', [$property, $default, $separator]); + } + + /** + * @param string $property Object property to be unset. + * @param string|null $separator Separator, defaults to '.' + * @return ObjectCollectionInterface + */ + public function unsetNestedProperty($property, $separator = null) + { + return $this->__call('unsetNestedProperty', [$property, $separator]); + } + + /** + * Create a copy from this collection by cloning all objects in the collection. + * + * @return static + */ + public function copy() + { + $list = []; + foreach ($this->getIterator() as $key => $value) { + $list[$key] = is_object($value) ? clone $value : $value; + } + + return $this->createFrom($list); + } + + /** + * @return array + */ + public function getObjectKeys() + { + return $this->getKeys(); + } + + /** + * @param array $ordering + * @return ObjectCollectionInterface + */ + public function orderBy(array $ordering) + { + return $this->__call('orderBy', [$ordering]); + } + + /** + * @param string $method + * @param array $arguments + * @return array|mixed + */ + public function call($method, array $arguments = []) + { + return $this->__call('call', [$method, $arguments]); + } + + /** + * Group items in the collection by a field and return them as associated array. + * + * @param string $property + * @return array + */ + public function group($property) + { + return $this->__call('group', [$property]); + } + + /** + * Group items in the collection by a field and return them as associated array of collections. + * + * @param string $property + * @return ObjectCollectionInterface[] + */ + public function collectionGroup($property) + { + return $this->__call('collectionGroup', [$property]); + } + + /** + * {@inheritDoc} + */ + public function matching(Criteria $criteria) + { + /** @var ObjectCollectionInterface $collection */ + $collection = $this->loadCollection($this->getEntries()); + + return $collection->matching($criteria); + } + + /** + * @param string $name + * @param array $arguments + * @return mixed + */ + abstract public function __call($name, $arguments); + + /** + * @return string + */ + protected function getTypePrefix() + { + return ''; + } +} diff --git a/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php b/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php index 400b163..fa3fed1 100644 --- a/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php +++ b/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php @@ -1,13 +1,17 @@ setElements($elements); - $this->setKey($key); + $this->setKey($key ?? ''); } /** @@ -65,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) { @@ -73,6 +75,7 @@ trait ArrayPropertyTrait /** * @param string $property Object property to be unset. + * @return void */ protected function doUnsetProperty($property) { @@ -94,11 +97,14 @@ trait ArrayPropertyTrait */ protected function getElements() { - return $this->_elements; + 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 28b9432..d5baddb 100644 --- a/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php +++ b/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php @@ -1,8 +1,9 @@ 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 2c86376..584e812 100644 --- a/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php +++ b/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php @@ -1,8 +1,9 @@ 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; } /** @@ -113,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 e330a33..435220a 100644 --- a/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php +++ b/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php @@ -1,13 +1,19 @@ initObjectProperties(); $this->setElements($elements); - $this->setKey($key); + $this->setKey($key ?? ''); } /** @@ -103,7 +107,7 @@ 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])) { @@ -124,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; @@ -138,6 +143,7 @@ trait ObjectPropertyTrait /** * @param string $property Object property to be unset. + * @return void */ protected function doUnsetProperty($property) { @@ -146,9 +152,12 @@ trait ObjectPropertyTrait } $this->_definedProperties[$property] = false; - unset($this->{$property}); + $this->{$property} = null; } + /** + * @return void + */ protected function initObjectProperties() { $this->_definedProperties = []; @@ -182,7 +191,10 @@ trait ObjectPropertyTrait $elements = []; foreach ($properties as $offset => $value) { - $elements[$offset] = $this->offsetSerialize($offset, $value); + $serialized = $this->offsetSerialize($offset, $value); + if ($serialized !== null) { + $elements[$offset] = $this->offsetSerialize($offset, $value); + } } return $elements; @@ -190,6 +202,7 @@ trait ObjectPropertyTrait /** * @param array $elements + * @return void */ protected function setElements(array $elements) { @@ -197,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 99f7e7f..dbeb5c4 100644 --- a/system/src/Grav/Framework/Object/PropertyObject.php +++ b/system/src/Grav/Framework/Object/PropertyObject.php @@ -1,13 +1,15 @@ 'page', + 'limit' => 10, + 'display' => 5, + 'opening' => 0, + 'ending' => 0, + '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 ?? 1; + } + + /** + * @param int $count + * @return int|null + */ + public function getPrevNumber(int $count = 1): ?int + { + $page = $this->page - $count; + + return $page >= 1 ? $page : null; + } + + /** + * @param int $count + * @return int|null + */ + public function getNextNumber(int $count = 1): ?int + { + $page = $this->page + $count; + + 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) { + return null; + } + + $start = ($page - 1) * $this->limit; + $type = $this->getOptions()['type']; + $param = $this->getOptions()['param']; + $useQuery = $this->getOptions()['use_query_param']; + if ($type === 'page') { + $param = $param ?? 'page'; + $offset = $page; + } else { + $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, + 'number' => $page, + 'offset_start' => $start, + 'offset_end' => min($start + $this->limit, $this->total) - 1, + 'enabled' => $page !== $this->page || $this->viewAll, + 'active' => $page === $this->page, + '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 ?? 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 ArrayIterator + */ + public function getIterator() + { + $this->loadItems(); + + return new ArrayIterator($this->items); + } + + /** + * @return array + */ + public function getPages(): array + { + $this->loadItems(); + + return $this->items; + } + + /** + * @return void + */ + protected function loadItems() + { + $this->calculateRange(); + + // Make list like: 1 ... 4 5 6 ... 10 + $range = range($this->pagesStart, $this->pagesStop); + //$range[] = 1; + //$range[] = $this->pages; + natsort($range); + $range = array_unique($range); + + $this->items = []; + foreach ($range as $i) { + $this->items[$i] = $this->getPage($i); + } + } + + /** + * @param Route $route + * @return $this + */ + protected function setRoute(Route $route) + { + $this->route = $route; + + return $this; + } + + /** + * @param array|null $options + * @return $this + */ + protected function setOptions(array $options = null) + { + $this->options = $options ? array_merge($this->defaultOptions, $options) : $this->defaultOptions; + + return $this; + } + + /** + * @param int|null $page + * @return $this + */ + protected function setPage(int $page = null) + { + $this->page = (int)max($page, 1); + $this->start = null; + + return $this; + } + + /** + * @param int|null $start + * @return $this + */ + protected function setStart(int $start = null) + { + $this->start = (int)max($start, 0); + $this->page = null; + + return $this; + } + + /** + * @param int|null $limit + * @return $this + */ + protected function setLimit(int $limit = null) + { + $this->limit = (int)max($limit ?? $this->getOptions()['limit'], 0); + + // No limit, display all records in a single page. + $this->viewAll = !$limit; + + return $this; + } + + /** + * @param int $total + * @return $this + */ + protected function setTotal(int $total) + { + $this->total = (int)max($total, 0); + + 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); + $this->setOptions($options); + $this->setTotal($total); + if ($this->getOptions()['type'] === 'start') { + $this->setStart($pos); + } else { + $this->setPage($pos); + } + $this->setLimit($limit); + $this->calculateLimits(); + } + + /** + * @return void + */ + protected function calculateLimits() + { + $limit = $this->limit; + $total = $this->total; + + if (!$limit || $limit > $total) { + // All records fit into a single page. + $this->start = 0; + $this->page = 1; + $this->pages = 1; + + return; + } + + if (null === $this->start) { + // If we are using page, convert it to start. + $this->start = (int)(($this->page - 1) * $limit); + } + + if ($this->start > $total - $limit) { + // If start is greater than total count (i.e. we are asked to display records that don't exist) + // then set start to display the last natural page of results. + $this->start = (int)max(0, (ceil($total / $limit) - 1) * $limit); + } + + // Set the total pages and current page values. + $this->page = (int)ceil(($this->start + 1) / $limit); + $this->pages = (int)ceil($total / $limit); + } + + /** + * @return void + */ + protected function calculateRange() + { + $options = $this->getOptions(); + $displayed = $options['display']; + $opening = $options['opening']; + $ending = $options['ending']; + + // Set the pagination iteration loop values. + $this->pagesStart = $this->page - (int)($displayed / 2); + if ($this->pagesStart < 1 + $opening) { + $this->pagesStart = 1 + $opening; + } + if ($this->pagesStart + $displayed - $opening > $this->pages) { + $this->pagesStop = $this->pages; + if ($this->pages < $displayed) { + $this->pagesStart = 1 + $opening; + } else { + $this->pagesStart = $this->pages - $displayed + 1 + $opening; + } + } else { + $this->pagesStop = (int)max(1, $this->pagesStart + $displayed - 1 - $ending); + } + } +} diff --git a/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php b/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php new file mode 100644 index 0000000..95ff8af --- /dev/null +++ b/system/src/Grav/Framework/Pagination/AbstractPaginationPage.php @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..56d3361 --- /dev/null +++ b/system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php @@ -0,0 +1,103 @@ +initialize($route, $total, $pos, $limit, $options); + } +} diff --git a/system/src/Grav/Framework/Pagination/PaginationPage.php b/system/src/Grav/Framework/Pagination/PaginationPage.php new file mode 100644 index 0000000..63ceb5f --- /dev/null +++ b/system/src/Grav/Framework/Pagination/PaginationPage.php @@ -0,0 +1,26 @@ +setOptions($options); + } +} diff --git a/system/src/Grav/Framework/Psr7/AbstractUri.php b/system/src/Grav/Framework/Psr7/AbstractUri.php index 5ddda9b..85d802f 100644 --- a/system/src/Grav/Framework/Psr7/AbstractUri.php +++ b/system/src/Grav/Framework/Psr7/AbstractUri.php @@ -1,23 +1,27 @@ 80, 'https' => 443 @@ -25,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 = ''; @@ -154,12 +151,12 @@ abstract class AbstractUri implements UriInterface /** * @inheritdoc - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ - public function withUserInfo($user, $password = '') + public function withUserInfo($user, $password = null) { $user = UriPartsFilter::filterUserInfo($user); - $password = UriPartsFilter::filterUserInfo($password); + $password = UriPartsFilter::filterUserInfo($password ?? ''); if ($this->user === $user && $this->password === $password) { return $this; @@ -247,7 +244,7 @@ abstract class AbstractUri implements UriInterface /** * @inheritdoc - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public function withFragment($fragment) { @@ -350,7 +347,8 @@ abstract class AbstractUri implements UriInterface /** * @param array $parts - * @throws \InvalidArgumentException + * @return void + * @throws InvalidArgumentException */ protected function initParts(array $parts) { @@ -368,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; @@ -397,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 new file mode 100644 index 0000000..cd65fd9 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Request.php @@ -0,0 +1,34 @@ +message = new \Nyholm\Psr7\Request($method, $uri, $headers, $body, $version); + } +} diff --git a/system/src/Grav/Framework/Psr7/Response.php b/system/src/Grav/Framework/Psr7/Response.php new file mode 100644 index 0000000..6cb67e0 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Response.php @@ -0,0 +1,264 @@ +message = new \Nyholm\Psr7\Response($status, $headers, $body, $version, $reason); + } + + /** + * Json. + * + * Note: This method is not part of the PSR-7 standard. + * + * This method prepares the response object to return an HTTP Json + * response to the client. + * + * @param mixed $data The data + * @param int|null $status The HTTP status code. + * @param int $options Json encoding options + * @param int $depth Json encoding max depth + * @return static + */ + public function withJson($data, int $status = null, int $options = 0, int $depth = 512): ResponseInterface + { + $json = (string) json_encode($data, $options, $depth); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException(json_last_error_msg(), json_last_error()); + } + + $response = $this->getResponse() + ->withHeader('Content-Type', 'application/json;charset=utf-8') + ->withBody(new Stream($json)); + + if ($status !== null) { + $response = $response->withStatus($status); + } + + $new = clone $this; + $new->message = $response; + + return $new; + } + + /** + * Redirect. + * + * Note: This method is not part of the PSR-7 standard. + * + * This method prepares the response object to return an HTTP Redirect + * response to the client. + * + * @param string $url The redirect destination. + * @param int|null $status The redirect HTTP status code. + * @return static + */ + public function withRedirect(string $url, $status = null): ResponseInterface + { + $response = $this->getResponse()->withHeader('Location', $url); + + if ($status === null) { + $status = 302; + } + + $new = clone $this; + $new->message = $response->withStatus($status); + + return $new; + } + + /** + * Is this response empty? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isEmpty(): bool + { + return in_array($this->getResponse()->getStatusCode(), [204, 205, 304], true); + } + + + /** + * Is this response OK? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isOk(): bool + { + return $this->getResponse()->getStatusCode() === 200; + } + + /** + * Is this response a redirect? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isRedirect(): bool + { + return in_array($this->getResponse()->getStatusCode(), [301, 302, 303, 307, 308], true); + } + + /** + * Is this response forbidden? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + * @api + */ + public function isForbidden(): bool + { + return $this->getResponse()->getStatusCode() === 403; + } + + /** + * Is this response not Found? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isNotFound(): bool + { + return $this->getResponse()->getStatusCode() === 404; + } + + /** + * Is this response informational? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isInformational(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 100 && $response->getStatusCode() < 200; + } + + /** + * Is this response successful? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isSuccessful(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 200 && $response->getStatusCode() < 300; + } + + /** + * Is this response a redirection? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isRedirection(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 300 && $response->getStatusCode() < 400; + } + + /** + * Is this response a client error? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isClientError(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 400 && $response->getStatusCode() < 500; + } + + /** + * Is this response a server error? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isServerError(): bool + { + $response = $this->getResponse(); + + return $response->getStatusCode() >= 500 && $response->getStatusCode() < 600; + } + + /** + * Convert response to string. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return string + */ + public function __toString(): string + { + $response = $this->getResponse(); + $output = sprintf( + 'HTTP/%s %s %s%s', + $response->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase(), + self::EOL + ); + + foreach ($response->getHeaders() as $name => $values) { + $output .= sprintf('%s: %s', $name, $response->getHeaderLine($name)) . self::EOL; + } + + $output .= self::EOL; + $output .= (string) $response->getBody(); + + return $output; + } +} diff --git a/system/src/Grav/Framework/Psr7/ServerRequest.php b/system/src/Grav/Framework/Psr7/ServerRequest.php new file mode 100644 index 0000000..4084834 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/ServerRequest.php @@ -0,0 +1,374 @@ +message = new \Nyholm\Psr7\ServerRequest($method, $uri, $headers, $body, $version, $serverParams); + } + + /** + * Get serverRequest content character set, if known. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return string|null + */ + public function getContentCharset(): ?string + { + $mediaTypeParams = $this->getMediaTypeParams(); + + if (isset($mediaTypeParams['charset'])) { + return $mediaTypeParams['charset']; + } + + return null; + } + + /** + * Get serverRequest content type. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return string|null The serverRequest content type, if known + */ + public function getContentType(): ?string + { + $result = $this->getRequest()->getHeader('Content-Type'); + + return $result ? $result[0] : null; + } + + /** + * Get serverRequest content length, if known. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return int|null + */ + public function getContentLength(): ?int + { + $result = $this->getRequest()->getHeader('Content-Length'); + + return $result ? (int) $result[0] : null; + } + + /** + * Fetch cookie value from cookies sent by the client to the server. + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key The attribute name. + * @param mixed $default Default value to return if the attribute does not exist. + * + * @return mixed + */ + public function getCookieParam($key, $default = null) + { + $cookies = $this->getRequest()->getCookieParams(); + $result = $default; + + if (isset($cookies[$key])) { + $result = $cookies[$key]; + } + + return $result; + } + + /** + * Get serverRequest media type, if known. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return string|null The serverRequest media type, minus content-type params + */ + public function getMediaType(): ?string + { + $contentType = $this->getContentType(); + + if ($contentType) { + $contentTypeParts = preg_split('/\s*[;,]\s*/', $contentType); + if ($contentTypeParts === false) { + return null; + } + return strtolower($contentTypeParts[0]); + } + + return null; + } + + /** + * Get serverRequest media type params, if known. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return mixed[] + */ + public function getMediaTypeParams(): array + { + $contentType = $this->getContentType(); + $contentTypeParams = []; + + if ($contentType) { + $contentTypeParts = preg_split('/\s*[;,]\s*/', $contentType); + if ($contentTypeParts !== false) { + $contentTypePartsLength = count($contentTypeParts); + for ($i = 1; $i < $contentTypePartsLength; $i++) { + $paramParts = explode('=', $contentTypeParts[$i]); + $contentTypeParams[strtolower($paramParts[0])] = $paramParts[1]; + } + } + } + + return $contentTypeParams; + } + + /** + * Fetch serverRequest parameter value from body or query string (in that order). + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key The parameter key. + * @param string|null $default The default value. + * + * @return mixed The parameter value. + */ + public function getParam($key, $default = null) + { + $postParams = $this->getParsedBody(); + $getParams = $this->getQueryParams(); + $result = $default; + + if (is_array($postParams) && isset($postParams[$key])) { + $result = $postParams[$key]; + } elseif (is_object($postParams) && property_exists($postParams, $key)) { + $result = $postParams->$key; + } elseif (isset($getParams[$key])) { + $result = $getParams[$key]; + } + + return $result; + } + + /** + * Fetch associative array of body and query string parameters. + * + * Note: This method is not part of the PSR-7 standard. + * + * @return mixed[] + */ + public function getParams(): array + { + $params = $this->getQueryParams(); + $postParams = $this->getParsedBody(); + + if ($postParams) { + $params = array_merge($params, (array)$postParams); + } + + return $params; + } + + /** + * Fetch parameter value from serverRequest body. + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key + * @param mixed $default + * + * @return mixed + */ + public function getParsedBodyParam($key, $default = null) + { + $postParams = $this->getParsedBody(); + $result = $default; + + if (is_array($postParams) && isset($postParams[$key])) { + $result = $postParams[$key]; + } elseif (is_object($postParams) && property_exists($postParams, $key)) { + $result = $postParams->{$key}; + } + + return $result; + } + + /** + * Fetch parameter value from query string. + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key + * @param mixed $default + * + * @return mixed + */ + public function getQueryParam($key, $default = null) + { + $getParams = $this->getQueryParams(); + $result = $default; + + if (isset($getParams[$key])) { + $result = $getParams[$key]; + } + + return $result; + } + + /** + * Retrieve a server parameter. + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function getServerParam($key, $default = null) + { + $serverParams = $this->getRequest()->getServerParams(); + + return $serverParams[$key] ?? $default; + } + + /** + * Does this serverRequest use a given method? + * + * Note: This method is not part of the PSR-7 standard. + * + * @param string $method HTTP method + * @return bool + */ + public function isMethod($method): bool + { + return $this->getRequest()->getMethod() === $method; + } + + /** + * Is this a DELETE serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isDelete(): bool + { + return $this->isMethod('DELETE'); + } + + /** + * Is this a GET serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isGet(): bool + { + return $this->isMethod('GET'); + } + + /** + * Is this a HEAD serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isHead(): bool + { + return $this->isMethod('HEAD'); + } + + /** + * Is this a OPTIONS serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isOptions(): bool + { + return $this->isMethod('OPTIONS'); + } + + /** + * Is this a PATCH serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isPatch(): bool + { + return $this->isMethod('PATCH'); + } + + /** + * Is this a POST serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isPost(): bool + { + return $this->isMethod('POST'); + } + + /** + * Is this a PUT serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isPut(): bool + { + return $this->isMethod('PUT'); + } + + /** + * Is this an XHR serverRequest? + * + * Note: This method is not part of the PSR-7 standard. + * + * @return bool + */ + public function isXhr(): bool + { + return $this->getRequest()->getHeaderLine('X-Requested-With') === 'XMLHttpRequest'; + } +} diff --git a/system/src/Grav/Framework/Psr7/Stream.php b/system/src/Grav/Framework/Psr7/Stream.php new file mode 100644 index 0000000..a5a1043 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Stream.php @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000..39e3818 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php @@ -0,0 +1,140 @@ + + */ +trait MessageDecoratorTrait +{ + /** @var MessageInterface */ + private $message; + + /** + * Returns the decorated message. + * + * Since the underlying Message is immutable as well + * exposing it is not an issue, because it's state cannot be altered + * + * @return MessageInterface + */ + public function getMessage(): MessageInterface + { + return $this->message; + } + + /** + * {@inheritdoc} + */ + public function getProtocolVersion(): string + { + return $this->message->getProtocolVersion(); + } + + /** + * {@inheritdoc} + */ + public function withProtocolVersion($version): self + { + $new = clone $this; + $new->message = $this->message->withProtocolVersion($version); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getHeaders(): array + { + return $this->message->getHeaders(); + } + + /** + * {@inheritdoc} + */ + public function hasHeader($header): bool + { + return $this->message->hasHeader($header); + } + + /** + * {@inheritdoc} + */ + public function getHeader($header): array + { + return $this->message->getHeader($header); + } + + /** + * {@inheritdoc} + */ + public function getHeaderLine($header): string + { + return $this->message->getHeaderLine($header); + } + + /** + * {@inheritdoc} + */ + public function getBody(): StreamInterface + { + return $this->message->getBody(); + } + + /** + * {@inheritdoc} + */ + public function withHeader($header, $value): self + { + $new = clone $this; + $new->message = $this->message->withHeader($header, $value); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withAddedHeader($header, $value): self + { + $new = clone $this; + $new->message = $this->message->withAddedHeader($header, $value); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withoutHeader($header): self + { + $new = clone $this; + $new->message = $this->message->withoutHeader($header); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withBody(StreamInterface $body): self + { + $new = clone $this; + $new->message = $this->message->withBody($body); + + return $new; + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php new file mode 100644 index 0000000..13f4c6f --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php @@ -0,0 +1,112 @@ + + */ +trait RequestDecoratorTrait +{ + use MessageDecoratorTrait { + getMessage as private; + } + + /** + * Returns the decorated request. + * + * Since the underlying Request is immutable as well + * exposing it is not an issue, because it's state cannot be altered + * + * @return RequestInterface + */ + public function getRequest(): RequestInterface + { + /** @var RequestInterface $message */ + $message = $this->getMessage(); + + return $message; + } + + /** + * Exchanges the underlying request with another. + * + * @param RequestInterface $request + * @return self + */ + public function withRequest(RequestInterface $request): self + { + $new = clone $this; + $new->message = $request; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getRequestTarget(): string + { + return $this->getRequest()->getRequestTarget(); + } + + /** + * {@inheritdoc} + */ + public function withRequestTarget($requestTarget): self + { + $new = clone $this; + $new->message = $this->getRequest()->withRequestTarget($requestTarget); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getMethod(): string + { + return $this->getRequest()->getMethod(); + } + + /** + * {@inheritdoc} + */ + public function withMethod($method): self + { + $new = clone $this; + $new->message = $this->getRequest()->withMethod($method); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getUri(): UriInterface + { + return $this->getRequest()->getUri(); + } + + /** + * {@inheritdoc} + */ + public function withUri(UriInterface $uri, $preserveHost = false): self + { + $new = clone $this; + $new->message = $this->getRequest()->withUri($uri, $preserveHost); + + return $new; + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php new file mode 100644 index 0000000..7b62c3c --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php @@ -0,0 +1,82 @@ + + */ +trait ResponseDecoratorTrait +{ + use MessageDecoratorTrait { + getMessage as private; + } + + /** + * Returns the decorated response. + * + * Since the underlying Response is immutable as well + * exposing it is not an issue, because it's state cannot be altered + * + * @return ResponseInterface + */ + public function getResponse(): ResponseInterface + { + /** @var ResponseInterface $message */ + $message = $this->getMessage(); + + return $message; + } + + /** + * Exchanges the underlying response with another. + * + * @param ResponseInterface $response + * + * @return self + */ + public function withResponse(ResponseInterface $response): self + { + $new = clone $this; + $new->message = $response; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getStatusCode(): int + { + return $this->getResponse()->getStatusCode(); + } + + /** + * {@inheritdoc} + */ + public function withStatus($code, $reasonPhrase = ''): self + { + $new = clone $this; + $new->message = $this->getResponse()->withStatus($code, $reasonPhrase); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function getReasonPhrase(): string + { + return $this->getResponse()->getReasonPhrase(); + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php new file mode 100644 index 0000000..33c26e0 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php @@ -0,0 +1,176 @@ +getMessage(); + + return $message; + } + + /** + * @inheritdoc + */ + public function getAttribute($name, $default = null) + { + return $this->getRequest()->getAttribute($name, $default); + } + + /** + * @inheritdoc + */ + public function getAttributes() + { + return $this->getRequest()->getAttributes(); + } + + + /** + * @inheritdoc + */ + public function getCookieParams() + { + return $this->getRequest()->getCookieParams(); + } + + /** + * @inheritdoc + */ + public function getParsedBody() + { + return $this->getRequest()->getParsedBody(); + } + + /** + * @inheritdoc + */ + public function getQueryParams() + { + return $this->getRequest()->getQueryParams(); + } + + /** + * @inheritdoc + */ + public function getServerParams() + { + return $this->getRequest()->getServerParams(); + } + + /** + * @inheritdoc + */ + public function getUploadedFiles() + { + return $this->getRequest()->getUploadedFiles(); + } + + /** + * @inheritdoc + */ + public function withAttribute($name, $value) + { + $new = clone $this; + $new->message = $this->getRequest()->withAttribute($name, $value); + + return $new; + } + + /** + * @param array $attributes + * @return ServerRequestInterface + */ + public function withAttributes(array $attributes) + { + $new = clone $this; + foreach ($attributes as $attribute => $value) { + $new->message = $new->withAttribute($attribute, $value); + } + + return $new; + } + + /** + * @inheritdoc + */ + public function withoutAttribute($name) + { + $new = clone $this; + $new->message = $this->getRequest()->withoutAttribute($name); + + return $new; + } + + /** + * @inheritdoc + */ + public function withCookieParams(array $cookies) + { + $new = clone $this; + $new->message = $this->getRequest()->withCookieParams($cookies); + + return $new; + } + + /** + * @inheritdoc + */ + public function withParsedBody($data) + { + $new = clone $this; + $new->message = $this->getRequest()->withParsedBody($data); + + return $new; + } + + /** + * @inheritdoc + */ + public function withQueryParams(array $query) + { + $new = clone $this; + $new->message = $this->getRequest()->withQueryParams($query); + + return $new; + } + + /** + * @inheritdoc + */ + public function withUploadedFiles(array $uploadedFiles) + { + $new = clone $this; + $new->message = $this->getRequest()->withUploadedFiles($uploadedFiles); + + return $new; + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php new file mode 100644 index 0000000..db1e746 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php @@ -0,0 +1,152 @@ +stream->__toString(); + } + + /** + * @return void + */ + public function __destruct() + { + $this->stream->close(); + } + + /** + * {@inheritdoc} + */ + public function close(): void + { + $this->stream->close(); + } + + /** + * {@inheritdoc} + */ + public function detach() + { + return $this->stream->detach(); + } + + /** + * {@inheritdoc} + */ + public function getSize(): ?int + { + return $this->stream->getSize(); + } + + /** + * {@inheritdoc} + */ + public function tell(): int + { + return $this->stream->tell(); + } + + /** + * {@inheritdoc} + */ + public function eof(): bool + { + return $this->stream->eof(); + } + + /** + * {@inheritdoc} + */ + public function isSeekable(): bool + { + return $this->stream->isSeekable(); + } + + /** + * {@inheritdoc} + */ + public function seek($offset, $whence = \SEEK_SET): void + { + $this->stream->seek($offset, $whence); + } + + /** + * {@inheritdoc} + */ + public function rewind(): void + { + $this->stream->rewind(); + } + + /** + * {@inheritdoc} + */ + public function isWritable(): bool + { + return $this->stream->isWritable(); + } + + /** + * {@inheritdoc} + */ + public function write($string): int + { + return $this->stream->write($string); + } + + /** + * {@inheritdoc} + */ + public function isReadable(): bool + { + return $this->stream->isReadable(); + } + + /** + * {@inheritdoc} + */ + public function read($length): string + { + return $this->stream->read($length); + } + + /** + * {@inheritdoc} + */ + public function getContents(): string + { + return $this->stream->getContents(); + } + + /** + * {@inheritdoc} + */ + public function getMetadata($key = null) + { + return $this->stream->getMetadata($key); + } +} diff --git a/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php b/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php new file mode 100644 index 0000000..e0c65bd --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php @@ -0,0 +1,73 @@ +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 new file mode 100644 index 0000000..607d757 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php @@ -0,0 +1,188 @@ +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 + { + $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 + { + $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 + { + $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 + { + $new = clone $this; + $new->uri = $this->uri->withPort($port); + + /** @var UriInterface $new */ + return $new; + } + + /** + * @param string $path + * @return UriInterface + */ + public function withPath($path): UriInterface + { + $new = clone $this; + $new->uri = $this->uri->withPath($path); + + /** @var UriInterface $new */ + return $new; + } + + /** + * @param string $query + * @return UriInterface + */ + public function withQuery($query): UriInterface + { + $new = clone $this; + $new->uri = $this->uri->withQuery($query); + + /** @var UriInterface $new */ + return $new; + } + + /** + * @param string $fragment + * @return UriInterface + */ + public function withFragment($fragment): UriInterface + { + $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 new file mode 100644 index 0000000..f136742 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/UploadedFile.php @@ -0,0 +1,37 @@ +uploadedFile = new \Nyholm\Psr7\UploadedFile($streamOrFile, $size, $errorStatus, $clientFilename, $clientMediaType); + } +} diff --git a/system/src/Grav/Framework/Psr7/Uri.php b/system/src/Grav/Framework/Psr7/Uri.php new file mode 100644 index 0000000..c920b33 --- /dev/null +++ b/system/src/Grav/Framework/Psr7/Uri.php @@ -0,0 +1,138 @@ +uri = new \Nyholm\Psr7\Uri($uri); + } + + /** + * @return array + */ + public function getQueryParams(): array + { + return UriFactory::parseQuery($this->getQuery()); + } + + /** + * @param array $params + * @return UriInterface + */ + public function withQueryParams(array $params): UriInterface + { + $query = UriFactory::buildQuery($params); + + return $this->withQuery($query); + } + + /** + * Whether the URI has the default port of the current scheme. + * + * `$uri->getPort()` may return the standard port. This method can be used for some non-http/https Uri. + * + * @return bool + */ + public function isDefaultPort(): bool + { + return $this->getPort() === null || GuzzleUri::isDefaultPort($this); + } + + /** + * Whether the URI is absolute, i.e. it has a scheme. + * + * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true + * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative + * to another URI, the base URI. Relative references can be divided into several forms: + * - network-path references, e.g. '//example.com/path' + * - absolute-path references, e.g. '/path' + * - relative-path references, e.g. 'subpath' + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4 + */ + public function isAbsolute(): bool + { + return GuzzleUri::isAbsolute($this); + } + + /** + * Whether the URI is a network-path reference. + * + * A relative reference that begins with two slash characters is termed an network-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isNetworkPathReference(): bool + { + return GuzzleUri::isNetworkPathReference($this); + } + + /** + * Whether the URI is a absolute-path reference. + * + * A relative reference that begins with a single slash character is termed an absolute-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isAbsolutePathReference(): bool + { + return GuzzleUri::isAbsolutePathReference($this); + } + + /** + * Whether the URI is a relative-path reference. + * + * A relative reference that does not begin with a slash character is termed a relative-path reference. + * + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public function isRelativePathReference(): bool + { + return GuzzleUri::isRelativePathReference($this); + } + + /** + * Whether the URI is a same-document reference. + * + * A same-document reference refers to a URI that is, aside from its fragment + * component, identical to the base URI. When no base URI is given, only an empty + * URI reference (apart from its fragment) is considered a same-document reference. + * + * @param UriInterface|null $base An optional base URI to compare against + * @return bool + * @link https://tools.ietf.org/html/rfc3986#section-4.4 + */ + public function isSameDocumentReference(UriInterface $base = null): bool + { + return GuzzleUri::isSameDocumentReference($this, $base); + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php b/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..451e586 --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php @@ -0,0 +1,49 @@ +invalidMiddleware = $invalidMiddleware; + } + + /** + * Return the invalid middleware + * + * @return mixed|null + */ + public function getInvalidMiddleware() + { + return $this->invalidMiddleware; + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php b/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php new file mode 100644 index 0000000..28a226a --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php @@ -0,0 +1,45 @@ +getMethod()), ['PUT', 'PATCH', 'DELETE'])) { + parent::__construct($request, 'Method Not Allowed', 405, $previous); + } else { + parent::__construct($request, 'Not Found', 404, $previous); + } + } + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php b/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php new file mode 100644 index 0000000..1b18473 --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php @@ -0,0 +1,20 @@ + 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Time-out', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Large', + 415 => 'Unsupported Media Type', + 416 => 'Requested range not satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', + 419 => 'Page Expired', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Unordered Collection', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Time-out', + 505 => 'HTTP Version not supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 511 => 'Network Authentication Required', + ]; + + /** @var ServerRequestInterface */ + private $request; + + /** + * @param ServerRequestInterface $request + * @param string $message + * @param int $code + * @param Throwable|null $previous + */ + public function __construct(ServerRequestInterface $request, string $message, int $code = 500, Throwable $previous = null) + { + $this->request = $request; + + parent::__construct($message, $code, $previous); + } + + /** + * @return ServerRequestInterface + */ + public function getRequest(): ServerRequestInterface + { + return $this->request; + } + + public function getHttpCode(): int + { + $code = $this->getCode(); + + return isset(self::$phrases[$code]) ? $code : 500; + } + + 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 new file mode 100644 index 0000000..a9935ee --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php @@ -0,0 +1,59 @@ +handle($request); + } catch (Throwable $exception) { + $response = [ + 'error' => [ + '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); + + return new Response($exception->getCode() ?: 500, ['Content-Type' => 'application/json'], $json); + } + } +} diff --git a/system/src/Grav/Framework/RequestHandler/RequestHandler.php b/system/src/Grav/Framework/RequestHandler/RequestHandler.php new file mode 100644 index 0000000..5b03805 --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/RequestHandler.php @@ -0,0 +1,70 @@ +middleware = $middleware; + $this->handler = $default; + $this->container = $container; + } + + /** + * Add callable initializing Middleware that will be executed as soon as possible. + * + * @param string $name + * @param callable $callable + * @return $this + */ + public function addCallable(string $name, callable $callable): self + { + $this->container[$name] = $callable; + array_unshift($this->middleware, $name); + + return $this; + } + + /** + * Add Middleware that will be executed as soon as possible. + * + * @param string $name + * @param MiddlewareInterface $middleware + * @return $this + */ + public function addMiddleware(string $name, MiddlewareInterface $middleware): self + { + $this->container[$name] = $middleware; + array_unshift($this->middleware, $name); + + return $this; + } +} diff --git a/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php b/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php new file mode 100644 index 0000000..1b5a79e --- /dev/null +++ b/system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php @@ -0,0 +1,64 @@ + */ + protected $middleware; + + /** @var callable */ + private $handler; + + /** @var ContainerInterface|null */ + private $container; + + /** + * {@inheritdoc} + * @throws InvalidArgumentException + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $middleware = array_shift($this->middleware); + + // Use default callable if there is no middleware. + if ($middleware === null) { + return call_user_func($this->handler, $request); + } + + if ($middleware instanceof MiddlewareInterface) { + return $middleware->process($request, clone $this); + } + + if (null === $this->container || !$this->container->has($middleware)) { + throw new InvalidArgumentException( + sprintf('The middleware is not a valid %s and is not passed in the Container', MiddlewareInterface::class), + $middleware + ); + } + + array_unshift($this->middleware, $this->container->get($middleware)); + + return $this->handle($request); + } +} diff --git a/system/src/Grav/Framework/Route/Route.php b/system/src/Grav/Framework/Route/Route.php index 63f6f7e..f9a7fa7 100644 --- a/system/src/Grav/Framework/Route/Route.php +++ b/system/src/Grav/Framework/Route/Route.php @@ -1,14 +1,17 @@ $this->getUriPath(), + 'path' => $this->getUriPath(true), 'query' => $this->getUriQuery(), 'grav' => [ 'root' => $this->root, 'language' => $this->language, 'route' => $this->route, + 'extension' => $this->extension, 'grav_params' => $this->gravParams, 'query_params' => $this->queryParams, ], @@ -69,6 +71,14 @@ class Route return $this->root; } + /** + * @return string + */ + public function getLanguage() + { + return $this->language; + } + /** * @return string */ @@ -77,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 @@ -91,6 +120,14 @@ class Route return '/' . $this->route; } + /** + * @return string + */ + public function getExtension() + { + return $this->extension; + } + /** * @param int $offset * @param int|null $length @@ -141,16 +178,11 @@ class Route * If the parameter exists in both, return Grav parameter. * * @param string $param - * @return string|null + * @return string|array|null */ public function getParam($param) { - $value = $this->getGravParam($param); - if ($value === null) { - $value = $this->getQueryParam($param); - } - - return $value; + return $this->getGravParam($param) ?? $this->getQueryParam($param); } /** @@ -159,16 +191,80 @@ class Route */ public function getGravParam($param) { - return isset($this->gravParams[$param]) ? $this->gravParams[$param] : null; + return $this->gravParams[$param] ?? null; } /** * @param string $param - * @return string|null + * @return string|array|null */ public function getQueryParam($param) { - return isset($this->queryParams[$param]) ? $this->queryParams[$param] : null; + return $this->queryParams[$param] ?? null; + } + + /** + * Allow the ability to set the route to something else + * + * @param string $route + * @return Route + */ + public function withRoute($route) + { + $new = $this->copy(); + $new->route = $route; + + return $new; + } + + /** + * Allow the ability to set the root to something else + * + * @param string $root + * @return Route + */ + public function withRoot($root) + { + $new = $this->copy(); + $new->root = $root; + + return $new; + } + + /** + * @param string|null $language + * @return Route + */ + public function withLanguage($language) + { + $new = $this->copy(); + $new->language = $language ?? ''; + + return $new; + } + + /** + * @param string $path + * @return Route + */ + public function withAddedPath($path) + { + $new = $this->copy(); + $new->route .= '/' . ltrim($path, '/'); + + return $new; + } + + /** + * @param string $extension + * @return Route + */ + public function withExtension($extension) + { + $new = $this->copy(); + $new->extension = $extension; + + return $new; } /** @@ -191,6 +287,36 @@ class Route return $this->withParam('queryParams', $param, $value); } + /** + * @return Route + */ + public function withoutParams() + { + return $this->withoutGravParams()->withoutQueryParams(); + } + + /** + * @return Route + */ + public function withoutGravParams() + { + $new = $this->copy(); + $new->gravParams = []; + + return $new; + } + + /** + * @return Route + */ + public function withoutQueryParams() + { + $new = $this->copy(); + $new->queryParams = []; + + return $new; + } + /** * @return \Grav\Framework\Uri\Uri */ @@ -200,57 +326,80 @@ class Route } /** + * @param bool $includeRoot * @return string */ - public function __toString() + public function toString(bool $includeRoot = false) { - $url = $this->getUriPath(); + $url = $this->getUriPath($includeRoot); if ($this->queryParams) { $url .= '?' . $this->getUriQuery(); } - return $url; + return rtrim($url,'/'); + } + + /** + * @return string + * @deprecated 1.6 Use ->toString(true) or ->getUri() instead. + */ + public function __toString() + { + user_error(__CLASS__ . '::' . __FUNCTION__ . '() will change in the future to return route, not relative url: use ->toString(true) or ->getUri() instead.', E_USER_DEPRECATED); + + return $this->toString(true); } /** * @param string $type * @param string $param * @param mixed $value - * @return static + * @return Route */ protected function withParam($type, $param, $value) { - $oldValue = isset($this->{$type}[$param]) ? $this->{$type}[$param] : null; + $values = $this->{$type} ?? []; + $oldValue = $values[$param] ?? null; if ($oldValue === $value) { return $this; } - $new = clone $this; + $new = $this->copy(); if ($value === null) { - unset($new->{$type}[$param]); + unset($values[$param]); } else { - $new->{$type}[$param] = $value; + $values[$param] = $value; } + $new->{$type} = $values; + return $new; } /** + * @return Route + */ + protected function copy() + { + return clone $this; + } + + /** + * @param bool $includeRoot * @return string */ - protected function getUriPath() + protected function getUriPath($includeRoot = false) { - $parts = [$this->root]; + $parts = $includeRoot ? [$this->root] : ['']; if ($this->language !== '') { $parts[] = $this->language; } - if ($this->route !== '') { - $parts[] = $this->route; - } + $parts[] = $this->extension ? $this->route . '.' . $this->extension : $this->route; + if ($this->gravParams) { $parts[] = RouteFactory::buildParams($this->gravParams); @@ -269,6 +418,7 @@ class Route /** * @param array $parts + * @return void */ protected function initParts(array $parts) { @@ -277,14 +427,14 @@ class Route $this->root = $gravParts['root']; $this->language = $gravParts['language']; $this->route = $gravParts['route']; - $this->gravParams = $gravParts['params']; - $this->queryParams = $parts['query_params']; - + $this->extension = $gravParts['extension'] ?? ''; + $this->gravParams = $gravParts['params'] ?? []; + $this->queryParams = $parts['query_params'] ?? []; } else { $this->root = RouteFactory::getRoot(); $this->language = RouteFactory::getLanguage(); - $path = isset($parts['path']) ? $parts['path'] : '/'; + $path = $parts['path'] ?? '/'; if (isset($parts['params'])) { $this->route = trim(rawurldecode($path), '/'); $this->gravParams = $parts['params']; diff --git a/system/src/Grav/Framework/Route/RouteFactory.php b/system/src/Grav/Framework/Route/RouteFactory.php index 830d7c7..68baf55 100644 --- a/system/src/Grav/Framework/Route/RouteFactory.php +++ b/system/src/Grav/Framework/Route/RouteFactory.php @@ -1,13 +1,18 @@ 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' => '', @@ -38,48 +74,67 @@ 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; + self::$delimiter = $delimiter ?: ':'; } /** * @param array $params * @return string */ - public static function buildParams(array $params) + public static function buildParams(array $params): string { if (!$params) { return ''; @@ -100,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); @@ -120,7 +175,7 @@ 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))), '/'); @@ -129,20 +184,53 @@ class RouteFactory /** * @param string $str - * @return array + * @return string */ - public static function parseParams($str) + public static function trimParams(string $str): string { + if ($str === '') { + return $str; + } + $delimiter = self::$delimiter; + /** @var array $params */ $params = explode('/', $str); - foreach ($params as &$param) { - $parts = explode($delimiter, $param, 2); - if (isset($parts[1])) { - $param[rawurldecode($parts[0])] = rawurldecode($parts[1]); + $list = []; + foreach ($params as $param) { + if (mb_strpos($param, $delimiter) === false) { + $list[] = $param; } } - return $params; + return implode('/', $list); + } + + /** + * @param string $str + * @return array + */ + public static function parseParams(string $str): array + { + if ($str === '') { + return []; + } + + $delimiter = self::$delimiter; + + /** @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]); + $list[$var] = $val; + } + } + + return $list; } } diff --git a/system/src/Grav/Framework/Session/Exceptions/SessionException.php b/system/src/Grav/Framework/Session/Exceptions/SessionException.php new file mode 100644 index 0000000..5ad948b --- /dev/null +++ b/system/src/Grav/Framework/Session/Exceptions/SessionException.php @@ -0,0 +1,20 @@ + $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 4298093..ddab08d 100644 --- a/system/src/Grav/Framework/Session/Session.php +++ b/system/src/Grav/Framework/Session/Session.php @@ -1,29 +1,37 @@ 'nocache', 'use_trans_sid' => 0, 'use_cookies' => 1, 'lazy_write' => 1, 'use_strict_mode' => 1 - ); + ]; $this->setOptions($options); @@ -78,7 +91,7 @@ class Session implements SessionInterface */ public function getId() { - return session_id(); + return session_id() ?: null; } /** @@ -96,7 +109,7 @@ class Session implements SessionInterface */ public function getName() { - return session_name(); + return session_name() ?: null; } /** @@ -134,26 +147,22 @@ 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, 'use_trans_sid' => true, - 'trans_sid_tags' => true, // PHP 7.1 - 'trans_sid_hosts' => true, // PHP 7.1 - 'sid_length' => true, // PHP 7.1 - 'sid_bits_per_character' => true, // PHP 7.1 + 'trans_sid_tags' => true, + 'trans_sid_hosts' => true, + 'sid_length' => true, + 'sid_bits_per_character' => true, 'upload_progress.enabled' => true, 'upload_progress.cleanup' => true, 'upload_progress.prefix' => true, 'upload_progress.name' => true, 'upload_progress.freq' => true, 'upload_progress.min-freq' => true, - 'lazy_write' => true, - 'url_rewriter.tags' => true, // Not used in PHP 7.1 - 'hash_function' => true, // Not used in PHP 7.1 - 'hash_bits_per_character' => true, // Not used in PHP 7.1 - 'entropy_file' => true, // Not used in PHP 7.1 - 'entropy_length' => true, // Not used in PHP 7.1 + 'lazy_write' => true ]; foreach ($options as $key => $value) { @@ -162,11 +171,11 @@ class Session implements SessionInterface foreach ($value as $key2 => $value2) { $ckey = "{$key}.{$key2}"; if (isset($value2, $allowedOptions[$ckey])) { - $this->ini_set("session.{$ckey}", $value2); + $this->setOption($ckey, $value2); } } } elseif (isset($value, $allowedOptions[$key])) { - $this->ini_set("session.{$key}", $value); + $this->setOption($key, $value); } } } @@ -176,9 +185,17 @@ class Session implements SessionInterface */ public function start($readonly = false) { + if (\PHP_SAPI === 'cli') { + return $this; + } + + $sessionName = session_name(); + $sessionExists = isset($_COOKIE[$sessionName]); + // Protection against invalid session cookie names throwing exception: http://php.net/manual/en/function.session-id.php#116836 - if (isset($_COOKIE[session_name()]) && !preg_match('/^[-,a-zA-Z0-9]{1,128}$/', $_COOKIE[session_name()])) { - unset($_COOKIE[session_name()]); + if ($sessionExists && !preg_match('/^[-,a-zA-Z0-9]{1,128}$/', $_COOKIE[$sessionName])) { + unset($_COOKIE[$sessionName]); + $sessionExists = false; } $options = $this->options; @@ -186,26 +203,130 @@ class Session implements SessionInterface $options['read_and_close'] = '1'; } - $success = @session_start($options); - if (!$success) { - $last = error_get_last(); - $error = $last ? $last['message'] : 'Unknown error'; - throw new \RuntimeException('Failed to start session: ' . $error, 500); + try { + $success = @session_start($options); + if (!$success) { + $last = error_get_last(); + $error = $last ? $last['message'] : 'Unknown error'; + + 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); } - $params = session_get_cookie_params(); - - setcookie( - session_name(), - session_id(), - time() + $params['lifetime'], - $params['path'], - $params['domain'], - $params['secure'], - $params['httponly'] - ); - $this->started = true; + $this->onSessionStart(); + + $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); + } + + // Extend the lifetime of the session. + if ($sessionExists) { + $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; } @@ -215,16 +336,27 @@ class Session implements SessionInterface */ 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(); @@ -273,7 +405,7 @@ class Session implements SessionInterface */ public function getIterator() { - return new \ArrayIterator($_SESSION); + return new ArrayIterator($_SESSION); } /** @@ -297,7 +429,7 @@ class Session implements SessionInterface */ public function __get($name) { - return isset($_SESSION[$name]) ? $_SESSION[$name] : null; + return $_SESSION[$name] ?? null; } /** @@ -326,20 +458,81 @@ 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 ini_set($key, $value) + protected function setOption($key, $value) { if (!is_string($value)) { if (is_bool($value)) { $value = $value ? '1' : '0'; + } else { + $value = (string)$value; } - $value = (string)$value; } $this->options[$key] = $value; - ini_set($key, $value); + ini_set("session.{$key}", $value); } } diff --git a/system/src/Grav/Framework/Session/SessionInterface.php b/system/src/Grav/Framework/Session/SessionInterface.php index 74c5f2a..43ca40f 100644 --- a/system/src/Grav/Framework/Session/SessionInterface.php +++ b/system/src/Grav/Framework/Session/SessionInterface.php @@ -1,24 +1,29 @@ getQueryParams(); - return isset($queryParams[$key]) ? $queryParams[$key] : null; + return $queryParams[$key] ?? null; } /** diff --git a/system/src/Grav/Framework/Uri/UriFactory.php b/system/src/Grav/Framework/Uri/UriFactory.php index c6bd428..f7724b7 100644 --- a/system/src/Grav/Framework/Uri/UriFactory.php +++ b/system/src/Grav/Framework/Uri/UriFactory.php @@ -1,13 +1,17 @@ = 1 && $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 1 and 65535'); + throw new InvalidArgumentException('Uri port must be null or an integer between 0 and 65535'); } /** @@ -98,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'); + throw new InvalidArgumentException('Uri path must be a string'); } return preg_replace_callback( @@ -113,7 +118,7 @@ class UriPartsFilter return rawurlencode($match[0]); }, $path - ); + ) ?? ''; } /** @@ -121,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'); + throw new InvalidArgumentException('Uri query string and fragment must be a string'); } return preg_replace_callback( @@ -135,6 +140,6 @@ class UriPartsFilter return rawurlencode($match[0]); }, $query - ); + ) ?? ''; } } diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php new file mode 100644 index 0000000..4df9dcd --- /dev/null +++ b/system/src/Grav/Installer/Install.php @@ -0,0 +1,392 @@ + [ + 'name' => 'PHP', + 'versions' => [ + '7.4' => '7.4.0', + '7.3' => '7.3.6', + '' => '7.4.12' + ] + ], + 'grav' => [ + 'name' => 'Grav', + 'versions' => [ + '1.6' => '1.6.0', + '' => '1.6.28' + ] + ], + 'plugins' => [ + 'admin' => [ + 'name' => 'Admin', + 'optional' => true, + 'versions' => [ + '1.9' => '1.9.0', + '' => '1.9.13' + ] + ], + 'email' => [ + 'name' => 'Email', + 'optional' => true, + 'versions' => [ + '3.0' => '3.0.0', + '' => '3.0.10' + ] + ], + 'form' => [ + 'name' => 'Form', + 'optional' => true, + 'versions' => [ + '4.1' => '4.1.0', + '4.0' => '4.0.0', + '3.0' => '3.0.0', + '' => '4.1.2' + ] + ], + 'login' => [ + 'name' => 'Login', + 'optional' => true, + 'versions' => [ + '3.3' => '3.3.0', + '3.0' => '3.0.0', + '' => '3.3.6' + ] + ], + ] + ]; + + /** @var array */ + public $ignores = [ + 'backup', + 'cache', + 'images', + 'logs', + 'tmp', + 'user', + '.htaccess', + 'robots.txt' + ]; + + /** @var array */ + private $classMap = [ + 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) { + self::$instance = new static(); + } + + return self::$instance; + } + + private function __construct() + { + } + + /** + * @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; + + $failedRequirements = $this->checkRequirements(); + if ($failedRequirements) { + $error = ['Following requirements have failed:']; + + foreach ($failedRequirements as $name => $req) { + $error[] = "{$req['title']} >= v{$req['minimum']} required, you have v{$req['installed']}"; + } + + $errors = implode("
\n", $error); + if (\defined('GRAV_CLI') && GRAV_CLI) { + $errors = "\n\n" . strip_tags($errors) . "\n\n"; + $errors .= <<prepare(); + $this->install(); + $this->finalize(); + } + + /** + * NOTE: This method can only be called after $grav['plugins']->init(). + * + * @return array List of failed requirements. If the list is empty, installation can go on. + */ + public function checkRequirements(): array + { + $results = []; + + $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']); + + return $results; + } + + /** + * @return void + * @throws RuntimeException + */ + public function prepare(): void + { + // Locate the new Grav update and the target site from the filesystem. + $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); + } + + // Load the installer classes. + foreach ($this->classMap as $class_name => $path) { + // 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; + } + + $this->legacySupport(); + + $this->location = dirname($location, 4); + + $versions = Versions::instance(USER_DIR . 'config/versions.yaml'); + $this->updater = new VersionUpdater('core/grav', __DIR__ . '/updates', $this->getVersion(), $versions); + + $this->updater->preflight(); + } + + /** + * @return void + * @throws RuntimeException + */ + public function install(): void + { + if (!$this->location) { + 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 ?? '', + GRAV_ROOT, + ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores], + $this->location, + !($this->zip && is_file($this->zip)) + ); + } catch (Exception $e) { + Installer::setError($e->getMessage()); + } + + $errorCode = Installer::lastErrorCode(); + + $success = !(is_string($errorCode) || ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR))); + + if (!$success) { + throw new RuntimeException(Installer::lastErrorMsg()); + } + } + + /** + * @return void + * @throws RuntimeException + */ + public function finalize(): void + { + // 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')) { + @opcache_reset(); + } + } + + /** + * @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'])) { + return; + } + + $major = $minor = 0; + $versions = $check['versions'] ?? []; + foreach ($versions as $major => $minor) { + if (!$major || version_compare($version ?? '0', $major, '<')) { + continue; + } + + if (version_compare($version ?? '0', $minor, '>=')) { + return; + } + + break; + } + + if (!$major) { + $minor = reset($versions); + } + + $recommended = end($versions); + + if (version_compare($recommended, $minor, '<=')) { + $recommended = null; + } + + $results[$name] = [ + 'type' => $type, + 'name' => $name, + 'title' => $check['name'] ?? $name, + 'installed' => $version, + 'minimum' => $minor, + 'recommended' => $recommended + ]; + } + + /** + * @param array $results + * @param array $plugins + * @return void + */ + protected function checkPlugins(array &$results, array $plugins): void + { + if (!class_exists('Plugins')) { + return; + } + + foreach ($plugins as $name => $check) { + $plugin = Plugins::get($name); + if (!$plugin) { + $this->checkVersion($results, 'plugin', $name, $check, null); + continue; + } + + $blueprint = $plugin->blueprints(); + $version = (string)$blueprint->get('version'); + $check['name'] = ($blueprint->get('name') ?? $check['name'] ?? $name) . ' Plugin'; + $this->checkVersion($results, 'plugin', $name, $check, $version); + } + } + + /** + * @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 e98963f..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 %} diff --git a/user/config/versions.yaml b/user/config/versions.yaml new file mode 100644 index 0000000..3a82d83 --- /dev/null +++ b/user/config/versions.yaml @@ -0,0 +1,4 @@ +core: + grav: + version: 1.7.15 + 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