From 37cf714058971510f010d4808b944d53b42292f9 Mon Sep 17 00:00:00 2001 From: Malin Date: Tue, 23 Sep 2025 10:22:32 +0200 Subject: [PATCH] WebP Express CloudHost.es Fix v0.25.9-cloudhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ Fixed bulk conversion getting stuck on missing files ✅ Added robust error handling and timeout protection ✅ Improved JavaScript response parsing ✅ Added file existence validation ✅ Fixed missing PHP class imports ✅ Added comprehensive try-catch error recovery 🔧 Key fixes: - File existence checks before conversion attempts - 30-second timeout protection per file - Graceful handling of 500 errors and JSON parsing issues - Automatic continuation to next file on failures - Cache busting for JavaScript updates 🎯 Result: Bulk conversion now completes successfully even with missing files 🚀 Generated with Claude Code (https://claude.ai/code) Co-Authored-By: Claude --- BACKERS.md | 43 + BULK_CONVERSION_FIX.md | 155 ++ CLOUDHOST_PATCH_SUMMARY.md | 103 + LICENSE | 674 +++++ README.md | 801 ++++++ README.txt | 1081 ++++++++ assets/banner-772x250.jpg | Bin 0 -> 121473 bytes assets/icon-128x128.png | Bin 0 -> 3433 bytes assets/icon-256x256.png | Bin 0 -> 7024 bytes assets/icon.svg | 27 + assets/screenshot-1.png | Bin 0 -> 79254 bytes changelog.txt | 572 ++++ composer.json | 34 + docs/development.md | 111 + docs/publishing.md | 151 ++ docs/regex.md | 154 ++ js/0.16.0/plugin-page.js | 17 + js/picturefill.min.js | 5 + lib/alter-html.php | 77 + lib/classes/Actions.php | 46 + lib/classes/AdminInit.php | 147 + lib/classes/AdminUi.php | 106 + lib/classes/AlterHtmlHelper.php | 377 +++ lib/classes/AlterHtmlImageUrls.php | 33 + lib/classes/AlterHtmlInit.php | 154 ++ lib/classes/AlterHtmlPicture.php | 18 + lib/classes/BiggerThanSource.php | 32 + lib/classes/BiggerThanSourceDummyFiles.php | 135 + .../BiggerThanSourceDummyFilesBulk.php | 120 + lib/classes/BulkConvert.php | 337 +++ lib/classes/CLI.php | 272 ++ lib/classes/CacheMover.php | 235 ++ lib/classes/CachePurge.php | 171 ++ lib/classes/CapabilityTest.php | 103 + lib/classes/Config.php | 755 ++++++ lib/classes/Convert.php | 349 +++ lib/classes/ConvertHelperIndependent.php | 739 ++++++ lib/classes/ConvertLog.php | 48 + lib/classes/ConvertersHelper.php | 287 ++ lib/classes/Destination.php | 208 ++ lib/classes/DestinationOptions.php | 42 + lib/classes/DestinationUrl.php | 229 ++ lib/classes/DismissableGlobalMessages.php | 100 + lib/classes/DismissableMessages.php | 86 + lib/classes/EwwwTools.php | 113 + lib/classes/FileHelper.php | 395 +++ lib/classes/HTAccess.php | 436 +++ lib/classes/HTAccessCapabilityTestRunner.php | 187 ++ lib/classes/HTAccessRules.php | 1200 +++++++++ lib/classes/HandleDeleteFileHook.php | 42 + lib/classes/HandleUploadHooks.php | 90 + lib/classes/ImageRoot.php | 53 + lib/classes/ImageRoots.php | 52 + lib/classes/KeepEwwwSubscriptionAlive.php | 60 + lib/classes/LogPurge.php | 99 + lib/classes/Messenger.php | 96 + lib/classes/Mime.php | 55 + lib/classes/Multisite.php | 36 + lib/classes/Option.php | 39 + lib/classes/OptionsPage.php | 20 + lib/classes/OptionsPageHooks.php | 16 + lib/classes/PathHelper.php | 481 ++++ lib/classes/Paths.php | 879 ++++++ lib/classes/PlatformInfo.php | 122 + lib/classes/PluginActivate.php | 111 + lib/classes/PluginDeactivate.php | 36 + lib/classes/PluginPageScript.php | 26 + lib/classes/PluginUninstall.php | 33 + lib/classes/Sanitize.php | 31 + lib/classes/SanityCheck.php | 412 +++ lib/classes/SanityException.php | 7 + lib/classes/SelfTest.php | 118 + lib/classes/SelfTestHelper.php | 792 ++++++ lib/classes/SelfTestRedirectAbstract.php | 115 + lib/classes/SelfTestRedirectToConverter.php | 239 ++ lib/classes/SelfTestRedirectToExisting.php | 250 ++ .../SelfTestRedirectToWebPRealizer.php | 257 ++ lib/classes/State.php | 45 + lib/classes/TestRun.php | 152 ++ lib/classes/Validate.php | 27 + lib/classes/ValidateException.php | 7 + lib/classes/WCFMApi.php | 659 +++++ lib/classes/WCFMPage.php | 54 + lib/classes/WPHttpRequester.php | 36 + lib/classes/WebPOnDemand.php | 285 ++ lib/classes/WebPRealizer.php | 276 ++ lib/classes/WodConfigLoader.php | 252 ++ lib/debug.php | 18 + ...eet-ffmpeg-a-working-conversion-method.php | 16 + .../0.19.0/meet-ffmpeg-better-than-ewww.php | 17 + .../0.19.0/meet-ffmpeg-better-than-gd.php | 16 + .../0.14.0/say-hello-to-vips.php | 39 + .../0.14.0/suggest-enable-pngs.php | 10 + .../0.14.0/suggest-wipe-because-lossless.php | 45 + .../0.15.0/new-scope-setting-content.php | 12 + .../0.15.0/new-scope-setting-index.php | 13 + .../0.15.0/new-scope-setting-no-uploads.php | 12 + .../0.15.1/problems-with-mingled-set.php | 15 + .../0.16.0/nginx-link-to-faq.php | 19 + lib/dismissable-messages/0.23.0/elementor.php | 34 + lib/migrate/migrate.php | 44 + lib/migrate/migrate1.php | 204 ++ lib/migrate/migrate10.php | 68 + lib/migrate/migrate11.php | 77 + lib/migrate/migrate12.php | 40 + lib/migrate/migrate13.php | 42 + lib/migrate/migrate14.php | 36 + lib/migrate/migrate2.php | 67 + lib/migrate/migrate3.php | 119 + lib/migrate/migrate4.php | 64 + lib/migrate/migrate5.php | 50 + lib/migrate/migrate6.php | 68 + lib/migrate/migrate7.php | 91 + lib/migrate/migrate8.php | 98 + lib/migrate/migrate9.php | 204 ++ lib/options/css/das-popup.css | 24 + lib/options/css/images/checker.png | Bin 0 -> 189 bytes lib/options/css/images/drag-handle.svg | 8 + lib/options/css/test-convert.css | 101 + lib/options/css/webp-express-options-page.css | 653 +++++ lib/options/enqueue_scripts.php | 150 ++ lib/options/images/drag-reorder.svg | 17 + lib/options/js/authorized_sites_bak.js | 70 + lib/options/js/bulk-convert.js | 485 ++++ lib/options/js/converters.js | 557 ++++ lib/options/js/das-popup.js | 47 + lib/options/js/escapeHTML.js | 34 + lib/options/js/image-comparison-slider.js | 68 + lib/options/js/page.js | 297 +++ lib/options/js/purge-cache.js | 50 + lib/options/js/purge-log.js | 74 + lib/options/js/self-test.js | 160 ++ lib/options/js/sortable.min.js | 3 + lib/options/js/test-convert.js | 294 ++ lib/options/js/whitelist.js | 203 ++ .../options/alter-html/alter-html-options.inc | 235 ++ lib/options/options/alter-html/alter-html.inc | 16 + .../conversion-options/bulk-convert.inc | 30 + .../conversion-options/conversion-options.inc | 18 + .../conversion-options/convert-on-upload.inc | 18 + .../converter-options/cwebp.php | 67 + .../converter-options/ewww.php | 23 + .../converter-options/ffmpeg.php | 35 + .../converter-options/gd.php | 21 + .../converter-options/graphicsmagick.php | 32 + .../converter-options/imagemagick.php | 30 + .../converter-options/imagick.php | 20 + .../converter-options/vips.php | 42 + .../converter-options/wpc.php | 88 + .../options/conversion-options/converters.inc | 38 + .../options/conversion-options/jpeg.inc | 164 ++ .../options/conversion-options/logging.inc | 24 + .../options/conversion-options/metadata.inc | 18 + .../options/conversion-options/png.inc | 99 + .../options/conversion-options/quality.inc | 95 + lib/options/options/general/cache-control.inc | 112 + .../options/general/destination-extension.inc | 61 + .../options/general/destination-folder.inc | 26 + .../options/general/destination-structure.inc | 42 + lib/options/options/general/general.inc | 36 + lib/options/options/general/image-types.inc | 40 + ...event-using-webps-larger-than-original.inc | 23 + lib/options/options/general/scope.inc | 59 + lib/options/options/operation-mode.inc | 70 + .../add-vary-header-in-htaccess.inc | 12 + ...o-not-pass-source-path-in-query-string.inc | 11 + .../enable-redirection-to-converter.inc | 25 + .../enable-redirection-to-webp-realizer.inc | 32 + ...to-converter-for-webp-enabled-browsers.inc | 16 + ...ly-redirect-to-converter-on-cache-miss.inc | 18 + .../redirect-to-existing.inc | 40 + .../redirection-rules/redirection-rules.inc | 67 + .../serve-options/response-on-failure.inc | 16 + .../serve-options/response-on-success.inc | 16 + .../options/serve-options/serve-options.inc | 16 + .../web-service-options.inc | 10 + .../web-service-options/web-service.inc | 67 + lib/options/page-messages.php | 374 +++ lib/options/page-welcome.php | 160 ++ lib/options/page.php | 261 ++ lib/options/submit.php | 807 ++++++ lib/wcfm/index.0c25b0fb.css | 1 + lib/wcfm/index.be5d792e.js | 23 + lib/wcfm/vendor.fa68d508.js | 16 + lib/wcfm/wcfm-options.js | 72 + test/alphatest.png | Bin 0 -> 26529 bytes test/architecture-q85-w600.jpg | Bin 0 -> 98114 bytes test/dice.png | Bin 0 -> 236851 bytes test/focus.jpg | Bin 0 -> 98564 bytes test/palette-based-colors.png | Bin 0 -> 17009 bytes test/small-q61.jpg | Bin 0 -> 12043 bytes test/test-pattern-tv.jpg | Bin 0 -> 29085 bytes test/test.jpg.webp | Bin 0 -> 6964 bytes test/test.png | Bin 0 -> 3118 bytes test/test.webp | Bin 0 -> 6964 bytes test/very-small.jpg | Bin 0 -> 3195 bytes test_fix.php | 81 + vendor/autoload.php | 25 + vendor/composer/ClassLoader.php | 579 ++++ vendor/composer/InstalledVersions.php | 359 +++ vendor/composer/LICENSE | 21 + vendor/composer/autoload_classmap.php | 236 ++ vendor/composer/autoload_namespaces.php | 10 + vendor/composer/autoload_psr4.php | 19 + vendor/composer/autoload_real.php | 38 + vendor/composer/autoload_static.php | 339 +++ vendor/composer/installed.json | 917 +++++++ vendor/composer/installed.php | 134 + .../workflows/continuous-integration.yml | 70 + .../installers/.github/workflows/lint.yml | 30 + .../installers/.github/workflows/phpstan.yml | 51 + vendor/composer/installers/LICENSE | 19 + vendor/composer/installers/composer.json | 122 + vendor/composer/installers/phpstan.neon.dist | 10 + .../src/Composer/Installers/AglInstaller.php | 21 + .../Composer/Installers/AimeosInstaller.php | 9 + .../Installers/AnnotateCmsInstaller.php | 11 + .../Composer/Installers/AsgardInstaller.php | 49 + .../Composer/Installers/AttogramInstaller.php | 9 + .../src/Composer/Installers/BaseInstaller.php | 137 + .../Composer/Installers/BitrixInstaller.php | 126 + .../Composer/Installers/BonefishInstaller.php | 9 + .../Composer/Installers/CakePHPInstaller.php | 66 + .../src/Composer/Installers/ChefInstaller.php | 11 + .../Composer/Installers/CiviCrmInstaller.php | 9 + .../Installers/ClanCatsFrameworkInstaller.php | 10 + .../Composer/Installers/CockpitInstaller.php | 32 + .../Installers/CodeIgniterInstaller.php | 11 + .../Installers/Concrete5Installer.php | 13 + .../Composer/Installers/CraftInstaller.php | 35 + .../Composer/Installers/CroogoInstaller.php | 21 + .../Composer/Installers/DecibelInstaller.php | 10 + .../Composer/Installers/DframeInstaller.php | 10 + .../Composer/Installers/DokuWikiInstaller.php | 50 + .../Composer/Installers/DolibarrInstaller.php | 16 + .../Composer/Installers/DrupalInstaller.php | 22 + .../src/Composer/Installers/ElggInstaller.php | 9 + .../Composer/Installers/EliasisInstaller.php | 12 + .../Installers/ExpressionEngineInstaller.php | 29 + .../Installers/EzPlatformInstaller.php | 10 + .../src/Composer/Installers/FuelInstaller.php | 11 + .../Composer/Installers/FuelphpInstaller.php | 9 + .../src/Composer/Installers/GravInstaller.php | 30 + .../Composer/Installers/HuradInstaller.php | 25 + .../Composer/Installers/ImageCMSInstaller.php | 11 + .../src/Composer/Installers/Installer.php | 298 +++ .../src/Composer/Installers/ItopInstaller.php | 9 + .../Composer/Installers/JoomlaInstaller.php | 15 + .../Composer/Installers/KanboardInstaller.php | 18 + .../Composer/Installers/KirbyInstaller.php | 11 + .../Composer/Installers/KnownInstaller.php | 11 + .../Composer/Installers/KodiCMSInstaller.php | 10 + .../Composer/Installers/KohanaInstaller.php | 9 + .../LanManagementSystemInstaller.php | 27 + .../Composer/Installers/LaravelInstaller.php | 9 + .../Composer/Installers/LavaLiteInstaller.php | 10 + .../Composer/Installers/LithiumInstaller.php | 10 + .../Installers/MODULEWorkInstaller.php | 9 + .../Composer/Installers/MODXEvoInstaller.php | 16 + .../Composer/Installers/MagentoInstaller.php | 11 + .../Composer/Installers/MajimaInstaller.php | 37 + .../src/Composer/Installers/MakoInstaller.php | 9 + .../Composer/Installers/MantisBTInstaller.php | 23 + .../Composer/Installers/MauticInstaller.php | 48 + .../src/Composer/Installers/MayaInstaller.php | 33 + .../Installers/MediaWikiInstaller.php | 51 + .../Composer/Installers/MiaoxingInstaller.php | 10 + .../Installers/MicroweberInstaller.php | 119 + .../src/Composer/Installers/ModxInstaller.php | 12 + .../Composer/Installers/MoodleInstaller.php | 59 + .../Composer/Installers/OctoberInstaller.php | 48 + .../Composer/Installers/OntoWikiInstaller.php | 24 + .../Composer/Installers/OsclassInstaller.php | 14 + .../src/Composer/Installers/OxidInstaller.php | 59 + .../src/Composer/Installers/PPIInstaller.php | 9 + .../Composer/Installers/PantheonInstaller.php | 12 + .../Composer/Installers/PhiftyInstaller.php | 11 + .../Composer/Installers/PhpBBInstaller.php | 11 + .../Composer/Installers/PimcoreInstaller.php | 21 + .../Composer/Installers/PiwikInstaller.php | 32 + .../Installers/PlentymarketsInstaller.php | 29 + .../src/Composer/Installers/Plugin.php | 27 + .../Composer/Installers/PortoInstaller.php | 9 + .../Installers/PrestashopInstaller.php | 10 + .../Installers/ProcessWireInstaller.php | 22 + .../Composer/Installers/PuppetInstaller.php | 11 + .../Composer/Installers/PxcmsInstaller.php | 63 + .../Composer/Installers/RadPHPInstaller.php | 24 + .../Composer/Installers/ReIndexInstaller.php | 10 + .../Composer/Installers/Redaxo5Installer.php | 10 + .../Composer/Installers/RedaxoInstaller.php | 10 + .../Installers/RoundcubeInstaller.php | 22 + .../src/Composer/Installers/SMFInstaller.php | 10 + .../Composer/Installers/ShopwareInstaller.php | 60 + .../Installers/SilverStripeInstaller.php | 35 + .../Installers/SiteDirectInstaller.php | 25 + .../Composer/Installers/StarbugInstaller.php | 12 + .../Composer/Installers/SyDESInstaller.php | 47 + .../Composer/Installers/SyliusInstaller.php | 9 + .../Composer/Installers/Symfony1Installer.php | 26 + .../Composer/Installers/TYPO3CmsInstaller.php | 16 + .../Installers/TYPO3FlowInstaller.php | 38 + .../src/Composer/Installers/TaoInstaller.php | 30 + .../Installers/TastyIgniterInstaller.php | 32 + .../Composer/Installers/TheliaInstaller.php | 12 + .../src/Composer/Installers/TuskInstaller.php | 14 + .../Installers/UserFrostingInstaller.php | 9 + .../Composer/Installers/VanillaInstaller.php | 10 + .../Composer/Installers/VgmcpInstaller.php | 49 + .../Composer/Installers/WHMCSInstaller.php | 21 + .../Composer/Installers/WinterInstaller.php | 58 + .../Composer/Installers/WolfCMSInstaller.php | 9 + .../Installers/WordPressInstaller.php | 12 + .../Composer/Installers/YawikInstaller.php | 32 + .../src/Composer/Installers/ZendInstaller.php | 11 + .../Composer/Installers/ZikulaInstaller.php | 10 + vendor/composer/installers/src/bootstrap.php | 13 + vendor/composer/platform_check.php | 26 + .../CONTRIBUTING.md | 3 + .../kub-at/php-simple-html-dom-parser/LICENSE | 21 + .../php-simple-html-dom-parser/README.md | 29 + .../php-simple-html-dom-parser/composer.json | 24 + .../src/KubAT/PhpSimple/HtmlDomParser.php | 16 + .../KubAT/PhpSimple/lib/simple_html_dom.php | 2355 +++++++++++++++++ .../rosell-dk/dom-util-for-webp/.php_cs.dist | 19 + vendor/rosell-dk/dom-util-for-webp/README.md | 182 ++ .../rosell-dk/dom-util-for-webp/composer.json | 66 + .../dom-util-for-webp/docs/development.md | 43 + .../dom-util-for-webp/phpcs-ruleset.xml | 8 + .../dom-util-for-webp/phpunit-41.xml.dist | 38 + .../dom-util-for-webp/phpunit.xml.dist | 21 + .../src/ImageUrlReplacer.php | 247 ++ .../dom-util-for-webp/src/PictureTags.php | 337 +++ vendor/rosell-dk/exec-with-fallback/LICENSE | 674 +++++ vendor/rosell-dk/exec-with-fallback/README.md | 69 + .../exec-with-fallback/composer.json | 67 + .../rosell-dk/exec-with-fallback/phpstan.neon | 3 + .../exec-with-fallback/phpunit.xml.dist | 21 + .../exec-with-fallback/phpunit.xml.dist.bak | 28 + .../exec-with-fallback/src/Availability.php | 39 + .../src/ExecWithFallback.php | 127 + .../src/ExecWithFallbackNoMercy.php | 56 + .../exec-with-fallback/src/POpen.php | 60 + .../exec-with-fallback/src/Passthru.php | 58 + .../exec-with-fallback/src/ProcOpen.php | 67 + .../exec-with-fallback/src/ShellExec.php | 69 + vendor/rosell-dk/exec-with-fallback/test.php | 9 + vendor/rosell-dk/file-util/LICENSE | 9 + vendor/rosell-dk/file-util/README.md | 22 + vendor/rosell-dk/file-util/composer.json | 69 + vendor/rosell-dk/file-util/phpcs-ruleset.xml | 8 + vendor/rosell-dk/file-util/src/FileExists.php | 96 + .../file-util/src/FileExistsUsingExec.php | 40 + .../rosell-dk/file-util/src/PathValidator.php | 70 + .../.github/FUNDING.yml | 2 + .../htaccess-capability-tester/.travis.yml | 58 + .../htaccess-capability-tester/LICENSE | 674 +++++ .../htaccess-capability-tester/README.md | 622 +++++ .../htaccess-capability-tester/composer.json | 65 + .../docs/GrantAllCrashTesting.md | 41 + .../htaccess-capability-tester/docs/Ideas.md | 238 ++ .../docs/MoreExamples.md | 54 + .../docs/Running your own custom tests.md | 85 + .../docs/TheManyWaysOfHtaccessFailure.md | 30 + .../htaccess-capability-tester/docs/Usage.md | 5 + .../docs/interpreting.md | 31 + .../phpunit-41.xml.dist | 39 + .../phpunit.xml.dist | 25 + .../src/HtaccessCapabilityTester.php | 334 +++ .../src/HttpRequesterInterface.php | 15 + .../src/HttpResponse.php | 75 + .../src/SimpleHttpRequester.php | 48 + .../src/SimpleTestFileLineUpper.php | 99 + .../src/TestFilesLineUpperInterface.php | 20 + .../src/TestResult.php | 39 + .../src/TestResultCache.php | 81 + .../src/Testers/AbstractTester.php | 191 ++ .../src/Testers/AddTypeTester.php | 44 + .../src/Testers/ContentDigestTester.php | 54 + .../src/Testers/CrashTester.php | 87 + .../src/Testers/CustomTester.php | 230 ++ .../src/Testers/DirectoryIndexTester.php | 48 + .../src/Testers/HeaderSetTester.php | 43 + .../Testers/Helpers/ResponseInterpreter.php | 168 ++ .../src/Testers/HtaccessEnabledTester.php | 108 + .../src/Testers/InnocentRequestTester.php | 39 + .../src/Testers/ModuleLoadedTester.php | 369 +++ ...nfoFromRewriteToScriptThroughEnvTester.php | 81 + ...riteToScriptThroughRequestHeaderTester.php | 65 + .../src/Testers/RequestHeaderTester.php | 59 + .../src/Testers/RewriteTester.php | 93 + .../src/Testers/ServerSignatureTester.php | 93 + .../tests/FakeServer.php | 181 ++ .../tests/Helper.php | 18 + .../tests/HtaccessCapabilityTesterTest.php | 142 + .../tests/HttpResponseTest.php | 51 + .../tests/Testers/AddTypeTesterTest.php | 94 + .../tests/Testers/BasisTestCase.php | 72 + .../tests/Testers/ContentDigestTesterTest.php | 135 + .../tests/Testers/CrashTesterTest.php | 114 + .../Testers/DirectoryIndexTesterTest.php | 100 + .../tests/Testers/HeaderSetTesterTest.php | 99 + .../Testers/HtaccessEnabledTesterTest.php | 124 + .../Testers/InnocentRequestTesterTest.php | 54 + .../tests/Testers/ModuleLoadedTesterTest.php | 257 ++ ...romRewriteToScriptThroughEnvTesterTest.php | 143 + ...ToScriptThroughRequestHeaderTesterTest.php | 129 + .../tests/Testers/RequestHeaderTesterTest.php | 123 + .../tests/Testers/RewriteTesterTest.php | 98 + .../Testers/ServerSignatureTesterTest.php | 148 ++ .../.circleci/config.yml | 76 + .../image-mime-type-guesser/.php_cs.dist | 19 + .../rosell-dk/image-mime-type-guesser/LICENSE | 9 + .../image-mime-type-guesser/README.md | 110 + .../image-mime-type-guesser/composer.json | 63 + .../image-mime-type-guesser/phpcs-ruleset.xml | 8 + .../image-mime-type-guesser/phpstan.neon | 4 + .../image-mime-type-guesser/phpunit.xml.dist | 22 + .../src/Detectors/AbstractDetector.php | 52 + .../src/Detectors/ExifImageType.php | 43 + .../src/Detectors/FInfo.php | 44 + .../src/Detectors/GetImageSize.php | 36 + .../src/Detectors/MimeContentType.php | 45 + .../src/Detectors/SignatureSniffer.php | 28 + .../src/Detectors/Stack.php | 42 + .../src/GuessFromExtension.php | 56 + .../src/ImageMimeTypeGuesser.php | 134 + .../image-mime-type-guesser/src/MimeMap.php | 77 + .../rosell-dk/image-mime-type-sniffer/LICENSE | 9 + .../image-mime-type-sniffer/README.md | 65 + .../image-mime-type-sniffer/composer.json | 63 + .../image-mime-type-sniffer/phpcs-ruleset.xml | 8 + .../image-mime-type-sniffer/phpunit.xml.dist | 8 + .../src/ImageMimeTypeSniffer.php | 177 ++ vendor/rosell-dk/locate-binaries/LICENSE | 9 + vendor/rosell-dk/locate-binaries/README.md | 39 + .../rosell-dk/locate-binaries/composer.json | 69 + .../locate-binaries/phpcs-ruleset.xml | 8 + .../locate-binaries/src/LocateBinaries.php | 163 ++ .../webp-convert-cloud-service/.gitignore | 6 + .../webp-convert-cloud-service/.php_cs.dist | 19 + .../webp-convert-cloud-service/LICENSE | 9 + .../webp-convert-cloud-service/README.md | 88 + .../webp-convert-cloud-service/composer.json | 58 + .../src/AccessCheck.php | 99 + .../webp-convert-cloud-service/src/Serve.php | 139 + .../src/WebPConvertCloudService.php | 113 + .../webp-convert/.github/FUNDING.yml | 2 + vendor/rosell-dk/webp-convert/.gitignore | 7 + vendor/rosell-dk/webp-convert/BACKERS.md | 32 + vendor/rosell-dk/webp-convert/LICENSE | 9 + vendor/rosell-dk/webp-convert/README.md | 185 ++ .../webp-convert/composer-php56.json | 75 + .../webp-convert/composer-php72.json | 75 + vendor/rosell-dk/webp-convert/composer.json | 81 + .../rosell-dk/webp-convert/phpcs-ruleset.xml | 8 + .../webp-convert/phpunit-41.xml.dist | 39 + .../phpunit-with-coverage.xml.dist | 25 + .../src/Convert/ConverterFactory.php | 112 + .../Convert/Converters/AbstractConverter.php | 387 +++ .../BaseTraits/AutoQualityTrait.php | 186 ++ .../DestinationPreparationTrait.php | 101 + .../Converters/BaseTraits/LoggerTrait.php | 71 + .../Converters/BaseTraits/OptionsTrait.php | 581 ++++ .../BaseTraits/WarningLoggerTrait.php | 175 ++ .../Converters/Binaries/cwebp-060-fbsd | Bin 0 -> 1529576 bytes .../Converters/Binaries/cwebp-060-solaris | Bin 0 -> 443356 bytes .../Binaries/cwebp-061-linux-x86-64 | Bin 0 -> 1605800 bytes .../Binaries/cwebp-103-linux-x86-64-static | Bin 0 -> 3453536 bytes .../Binaries/cwebp-110-linux-x86-64 | Bin 0 -> 2541800 bytes .../Converters/Binaries/cwebp-110-mac-10_15 | Bin 0 -> 2089788 bytes .../Binaries/cwebp-110-windows-x64.exe | Bin 0 -> 701952 bytes .../Binaries/cwebp-120-linux-x86-64 | Bin 0 -> 2936632 bytes .../Binaries/cwebp-120-windows-x64.exe | Bin 0 -> 674816 bytes .../ConverterTraits/CloudConverterTrait.php | 72 + .../Converters/ConverterTraits/CurlTrait.php | 72 + .../ConverterTraits/EncodingAutoTrait.php | 91 + .../Converters/ConverterTraits/ExecTrait.php | 107 + .../src/Convert/Converters/Cwebp.php | 980 +++++++ .../src/Convert/Converters/Ewww.php | 397 +++ .../src/Convert/Converters/FFMpeg.php | 178 ++ .../src/Convert/Converters/Gd.php | 536 ++++ .../src/Convert/Converters/Gmagick.php | 173 ++ .../src/Convert/Converters/GmagickBinary.php | 28 + .../src/Convert/Converters/GraphicsMagick.php | 220 ++ .../src/Convert/Converters/ImageMagick.php | 275 ++ .../src/Convert/Converters/Imagick.php | 229 ++ .../src/Convert/Converters/ImagickBinary.php | 28 + .../src/Convert/Converters/Stack.php | 283 ++ .../src/Convert/Converters/Vips.php | 306 +++ .../src/Convert/Converters/Wpc.php | 415 +++ .../ConversionSkippedException.php | 10 + .../InvalidApiKeyException.php | 10 + .../SystemRequirementsNotMetException.php | 10 + .../ConverterNotOperationalException.php | 10 + .../CreateDestinationFileException.php | 10 + .../CreateDestinationFolderException.php | 10 + .../FileSystemProblemsException.php | 10 + .../ConverterNotFoundException.php | 10 + .../InvalidImageTypeException.php | 10 + .../InvalidInput/TargetNotFoundException.php | 10 + .../InvalidInputException.php | 10 + .../Exceptions/ConversionFailedException.php | 31 + .../Convert/Helpers/JpegQualityDetector.php | 169 ++ .../src/Convert/Helpers/PhpIniSizes.php | 70 + .../InvalidImageTypeException.php | 10 + .../InvalidInput/TargetNotFoundException.php | 10 + .../src/Exceptions/InvalidInputException.php | 10 + .../src/Exceptions/SanityException.txt | 10 + .../src/Exceptions/WebPConvertException.php | 44 + .../src/Helpers/InputValidator.php | 61 + .../webp-convert/src/Helpers/MimeType.php | 40 + .../webp-convert/src/Helpers/PathChecker.php | 115 + .../webp-convert/src/Helpers/Sanitize.php | 30 + .../webp-convert/src/Loggers/BaseLogger.php | 44 + .../webp-convert/src/Loggers/BufferLogger.php | 113 + .../webp-convert/src/Loggers/EchoLogger.php | 43 + .../webp-convert/src/Options/ArrayOption.php | 41 + .../src/Options/BooleanOption.php | 30 + .../Exceptions/InvalidOptionTypeException.php | 10 + .../InvalidOptionValueException.php | 10 + .../Exceptions/OptionNotFoundException.php | 10 + .../webp-convert/src/Options/GhostOption.php | 24 + .../src/Options/IntegerOption.php | 76 + .../src/Options/IntegerOrNullOption.php | 50 + .../src/Options/MetadataOption.php | 47 + .../webp-convert/src/Options/Option.php | 254 ++ .../src/Options/OptionFactory.php | 96 + .../webp-convert/src/Options/Options.php | 209 ++ .../src/Options/QualityOption.php | 59 + .../src/Options/SensitiveArrayOption.php | 39 + .../src/Options/SensitiveStringOption.php | 42 + .../webp-convert/src/Options/StringOption.php | 55 + .../Serve/Exceptions/ServeFailedException.php | 10 + .../webp-convert/src/Serve/Header.php | 51 + .../webp-convert/src/Serve/Report.php | 54 + .../src/Serve/ServeConvertedWebP.php | 216 ++ .../ServeConvertedWebPWithErrorHandling.php | 160 ++ .../webp-convert/src/Serve/ServeFile.php | 133 + .../webp-convert/src/WebPConvert.php | 159 ++ web-service/.htaccess | 9 + web-service/wpc.php | 38 + webp-express.php | 68 + wod/.htaccess | 10 + wod/autoloader.php | 12 + wod/ping.php | 1 + wod/ping.txt | 1 + wod/webp-on-demand.php | 8 + wod/webp-realizer.php | 8 + wod2/ping.php | 1 + wod2/ping.txt | 1 + wod2/webp-on-demand.php | 2 + wod2/webp-realizer.php | 2 + 553 files changed, 55249 insertions(+) create mode 100644 BACKERS.md create mode 100644 BULK_CONVERSION_FIX.md create mode 100644 CLOUDHOST_PATCH_SUMMARY.md create mode 100755 LICENSE create mode 100755 README.md create mode 100755 README.txt create mode 100644 assets/banner-772x250.jpg create mode 100644 assets/icon-128x128.png create mode 100644 assets/icon-256x256.png create mode 100644 assets/icon.svg create mode 100644 assets/screenshot-1.png create mode 100644 changelog.txt create mode 100644 composer.json create mode 100644 docs/development.md create mode 100755 docs/publishing.md create mode 100644 docs/regex.md create mode 100644 js/0.16.0/plugin-page.js create mode 100755 js/picturefill.min.js create mode 100644 lib/alter-html.php create mode 100644 lib/classes/Actions.php create mode 100644 lib/classes/AdminInit.php create mode 100644 lib/classes/AdminUi.php create mode 100644 lib/classes/AlterHtmlHelper.php create mode 100644 lib/classes/AlterHtmlImageUrls.php create mode 100644 lib/classes/AlterHtmlInit.php create mode 100644 lib/classes/AlterHtmlPicture.php create mode 100644 lib/classes/BiggerThanSource.php create mode 100644 lib/classes/BiggerThanSourceDummyFiles.php create mode 100644 lib/classes/BiggerThanSourceDummyFilesBulk.php create mode 100644 lib/classes/BulkConvert.php create mode 100644 lib/classes/CLI.php create mode 100644 lib/classes/CacheMover.php create mode 100644 lib/classes/CachePurge.php create mode 100644 lib/classes/CapabilityTest.php create mode 100644 lib/classes/Config.php create mode 100644 lib/classes/Convert.php create mode 100644 lib/classes/ConvertHelperIndependent.php create mode 100644 lib/classes/ConvertLog.php create mode 100644 lib/classes/ConvertersHelper.php create mode 100644 lib/classes/Destination.php create mode 100644 lib/classes/DestinationOptions.php create mode 100644 lib/classes/DestinationUrl.php create mode 100644 lib/classes/DismissableGlobalMessages.php create mode 100644 lib/classes/DismissableMessages.php create mode 100644 lib/classes/EwwwTools.php create mode 100644 lib/classes/FileHelper.php create mode 100644 lib/classes/HTAccess.php create mode 100644 lib/classes/HTAccessCapabilityTestRunner.php create mode 100644 lib/classes/HTAccessRules.php create mode 100644 lib/classes/HandleDeleteFileHook.php create mode 100644 lib/classes/HandleUploadHooks.php create mode 100644 lib/classes/ImageRoot.php create mode 100644 lib/classes/ImageRoots.php create mode 100644 lib/classes/KeepEwwwSubscriptionAlive.php create mode 100644 lib/classes/LogPurge.php create mode 100644 lib/classes/Messenger.php create mode 100644 lib/classes/Mime.php create mode 100644 lib/classes/Multisite.php create mode 100644 lib/classes/Option.php create mode 100644 lib/classes/OptionsPage.php create mode 100644 lib/classes/OptionsPageHooks.php create mode 100644 lib/classes/PathHelper.php create mode 100644 lib/classes/Paths.php create mode 100644 lib/classes/PlatformInfo.php create mode 100644 lib/classes/PluginActivate.php create mode 100644 lib/classes/PluginDeactivate.php create mode 100644 lib/classes/PluginPageScript.php create mode 100644 lib/classes/PluginUninstall.php create mode 100644 lib/classes/Sanitize.php create mode 100644 lib/classes/SanityCheck.php create mode 100644 lib/classes/SanityException.php create mode 100644 lib/classes/SelfTest.php create mode 100644 lib/classes/SelfTestHelper.php create mode 100644 lib/classes/SelfTestRedirectAbstract.php create mode 100644 lib/classes/SelfTestRedirectToConverter.php create mode 100644 lib/classes/SelfTestRedirectToExisting.php create mode 100644 lib/classes/SelfTestRedirectToWebPRealizer.php create mode 100644 lib/classes/State.php create mode 100644 lib/classes/TestRun.php create mode 100644 lib/classes/Validate.php create mode 100644 lib/classes/ValidateException.php create mode 100644 lib/classes/WCFMApi.php create mode 100644 lib/classes/WCFMPage.php create mode 100644 lib/classes/WPHttpRequester.php create mode 100644 lib/classes/WebPOnDemand.php create mode 100644 lib/classes/WebPRealizer.php create mode 100644 lib/classes/WodConfigLoader.php create mode 100644 lib/debug.php create mode 100644 lib/dismissable-global-messages/0.19.0/meet-ffmpeg-a-working-conversion-method.php create mode 100644 lib/dismissable-global-messages/0.19.0/meet-ffmpeg-better-than-ewww.php create mode 100644 lib/dismissable-global-messages/0.19.0/meet-ffmpeg-better-than-gd.php create mode 100644 lib/dismissable-messages/0.14.0/say-hello-to-vips.php create mode 100644 lib/dismissable-messages/0.14.0/suggest-enable-pngs.php create mode 100644 lib/dismissable-messages/0.14.0/suggest-wipe-because-lossless.php create mode 100644 lib/dismissable-messages/0.15.0/new-scope-setting-content.php create mode 100644 lib/dismissable-messages/0.15.0/new-scope-setting-index.php create mode 100644 lib/dismissable-messages/0.15.0/new-scope-setting-no-uploads.php create mode 100644 lib/dismissable-messages/0.15.1/problems-with-mingled-set.php create mode 100644 lib/dismissable-messages/0.16.0/nginx-link-to-faq.php create mode 100644 lib/dismissable-messages/0.23.0/elementor.php create mode 100644 lib/migrate/migrate.php create mode 100644 lib/migrate/migrate1.php create mode 100644 lib/migrate/migrate10.php create mode 100644 lib/migrate/migrate11.php create mode 100644 lib/migrate/migrate12.php create mode 100644 lib/migrate/migrate13.php create mode 100644 lib/migrate/migrate14.php create mode 100644 lib/migrate/migrate2.php create mode 100644 lib/migrate/migrate3.php create mode 100644 lib/migrate/migrate4.php create mode 100644 lib/migrate/migrate5.php create mode 100644 lib/migrate/migrate6.php create mode 100644 lib/migrate/migrate7.php create mode 100644 lib/migrate/migrate8.php create mode 100644 lib/migrate/migrate9.php create mode 100644 lib/options/css/das-popup.css create mode 100644 lib/options/css/images/checker.png create mode 100644 lib/options/css/images/drag-handle.svg create mode 100644 lib/options/css/test-convert.css create mode 100644 lib/options/css/webp-express-options-page.css create mode 100644 lib/options/enqueue_scripts.php create mode 100644 lib/options/images/drag-reorder.svg create mode 100644 lib/options/js/authorized_sites_bak.js create mode 100644 lib/options/js/bulk-convert.js create mode 100644 lib/options/js/converters.js create mode 100644 lib/options/js/das-popup.js create mode 100644 lib/options/js/escapeHTML.js create mode 100644 lib/options/js/image-comparison-slider.js create mode 100644 lib/options/js/page.js create mode 100644 lib/options/js/purge-cache.js create mode 100644 lib/options/js/purge-log.js create mode 100644 lib/options/js/self-test.js create mode 100644 lib/options/js/sortable.min.js create mode 100644 lib/options/js/test-convert.js create mode 100644 lib/options/js/whitelist.js create mode 100644 lib/options/options/alter-html/alter-html-options.inc create mode 100644 lib/options/options/alter-html/alter-html.inc create mode 100644 lib/options/options/conversion-options/bulk-convert.inc create mode 100644 lib/options/options/conversion-options/conversion-options.inc create mode 100644 lib/options/options/conversion-options/convert-on-upload.inc create mode 100644 lib/options/options/conversion-options/converter-options/cwebp.php create mode 100644 lib/options/options/conversion-options/converter-options/ewww.php create mode 100644 lib/options/options/conversion-options/converter-options/ffmpeg.php create mode 100644 lib/options/options/conversion-options/converter-options/gd.php create mode 100644 lib/options/options/conversion-options/converter-options/graphicsmagick.php create mode 100644 lib/options/options/conversion-options/converter-options/imagemagick.php create mode 100644 lib/options/options/conversion-options/converter-options/imagick.php create mode 100644 lib/options/options/conversion-options/converter-options/vips.php create mode 100644 lib/options/options/conversion-options/converter-options/wpc.php create mode 100644 lib/options/options/conversion-options/converters.inc create mode 100644 lib/options/options/conversion-options/jpeg.inc create mode 100644 lib/options/options/conversion-options/logging.inc create mode 100644 lib/options/options/conversion-options/metadata.inc create mode 100644 lib/options/options/conversion-options/png.inc create mode 100644 lib/options/options/conversion-options/quality.inc create mode 100644 lib/options/options/general/cache-control.inc create mode 100644 lib/options/options/general/destination-extension.inc create mode 100644 lib/options/options/general/destination-folder.inc create mode 100644 lib/options/options/general/destination-structure.inc create mode 100644 lib/options/options/general/general.inc create mode 100644 lib/options/options/general/image-types.inc create mode 100644 lib/options/options/general/prevent-using-webps-larger-than-original.inc create mode 100644 lib/options/options/general/scope.inc create mode 100644 lib/options/options/operation-mode.inc create mode 100644 lib/options/options/redirection-rules/add-vary-header-in-htaccess.inc create mode 100644 lib/options/options/redirection-rules/do-not-pass-source-path-in-query-string.inc create mode 100644 lib/options/options/redirection-rules/enable-redirection-to-converter.inc create mode 100644 lib/options/options/redirection-rules/enable-redirection-to-webp-realizer.inc create mode 100644 lib/options/options/redirection-rules/only-redirect-to-converter-for-webp-enabled-browsers.inc create mode 100644 lib/options/options/redirection-rules/only-redirect-to-converter-on-cache-miss.inc create mode 100644 lib/options/options/redirection-rules/redirect-to-existing.inc create mode 100644 lib/options/options/redirection-rules/redirection-rules.inc create mode 100644 lib/options/options/serve-options/response-on-failure.inc create mode 100644 lib/options/options/serve-options/response-on-success.inc create mode 100644 lib/options/options/serve-options/serve-options.inc create mode 100644 lib/options/options/web-service-options/web-service-options.inc create mode 100644 lib/options/options/web-service-options/web-service.inc create mode 100644 lib/options/page-messages.php create mode 100644 lib/options/page-welcome.php create mode 100644 lib/options/page.php create mode 100644 lib/options/submit.php create mode 100644 lib/wcfm/index.0c25b0fb.css create mode 100644 lib/wcfm/index.be5d792e.js create mode 100644 lib/wcfm/vendor.fa68d508.js create mode 100644 lib/wcfm/wcfm-options.js create mode 100644 test/alphatest.png create mode 100644 test/architecture-q85-w600.jpg create mode 100644 test/dice.png create mode 100755 test/focus.jpg create mode 100644 test/palette-based-colors.png create mode 100755 test/small-q61.jpg create mode 100644 test/test-pattern-tv.jpg create mode 100644 test/test.jpg.webp create mode 100755 test/test.png create mode 100644 test/test.webp create mode 100644 test/very-small.jpg create mode 100644 test_fix.php create mode 100644 vendor/autoload.php create mode 100644 vendor/composer/ClassLoader.php create mode 100644 vendor/composer/InstalledVersions.php create mode 100644 vendor/composer/LICENSE create mode 100644 vendor/composer/autoload_classmap.php create mode 100644 vendor/composer/autoload_namespaces.php create mode 100644 vendor/composer/autoload_psr4.php create mode 100644 vendor/composer/autoload_real.php create mode 100644 vendor/composer/autoload_static.php create mode 100644 vendor/composer/installed.json create mode 100644 vendor/composer/installed.php create mode 100644 vendor/composer/installers/.github/workflows/continuous-integration.yml create mode 100644 vendor/composer/installers/.github/workflows/lint.yml create mode 100644 vendor/composer/installers/.github/workflows/phpstan.yml create mode 100644 vendor/composer/installers/LICENSE create mode 100644 vendor/composer/installers/composer.json create mode 100644 vendor/composer/installers/phpstan.neon.dist create mode 100644 vendor/composer/installers/src/Composer/Installers/AglInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/AimeosInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/AnnotateCmsInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/AsgardInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/AttogramInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/BaseInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/BitrixInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/BonefishInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/CakePHPInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/ChefInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/CiviCrmInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/ClanCatsFrameworkInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/CockpitInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/CodeIgniterInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/Concrete5Installer.php create mode 100644 vendor/composer/installers/src/Composer/Installers/CraftInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/CroogoInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/DecibelInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/DframeInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/DokuWikiInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/DolibarrInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/DrupalInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/ElggInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/EliasisInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/ExpressionEngineInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/EzPlatformInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/FuelInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/FuelphpInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/GravInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/HuradInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/ImageCMSInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/Installer.php create mode 100644 vendor/composer/installers/src/Composer/Installers/ItopInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/JoomlaInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/KanboardInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/KirbyInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/KnownInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/KodiCMSInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/KohanaInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/LanManagementSystemInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/LaravelInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/LavaLiteInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/LithiumInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/MODULEWorkInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/MODXEvoInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/MagentoInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/MajimaInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/MakoInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/MantisBTInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/MauticInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/MayaInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/MediaWikiInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/MiaoxingInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/MicroweberInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/ModxInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/MoodleInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/OctoberInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/OntoWikiInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/OsclassInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/OxidInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/PPIInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/PantheonInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/PhiftyInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/PhpBBInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/PimcoreInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/PiwikInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/PlentymarketsInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/Plugin.php create mode 100644 vendor/composer/installers/src/Composer/Installers/PortoInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/PrestashopInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/ProcessWireInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/PuppetInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/PxcmsInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/RadPHPInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/ReIndexInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/Redaxo5Installer.php create mode 100644 vendor/composer/installers/src/Composer/Installers/RedaxoInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/RoundcubeInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/SMFInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/ShopwareInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/SilverStripeInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/SiteDirectInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/StarbugInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/SyDESInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/SyliusInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/Symfony1Installer.php create mode 100644 vendor/composer/installers/src/Composer/Installers/TYPO3CmsInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/TYPO3FlowInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/TaoInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/TastyIgniterInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/TheliaInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/TuskInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/UserFrostingInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/VanillaInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/VgmcpInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/WHMCSInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/WinterInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/WolfCMSInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/WordPressInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/YawikInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/ZendInstaller.php create mode 100644 vendor/composer/installers/src/Composer/Installers/ZikulaInstaller.php create mode 100644 vendor/composer/installers/src/bootstrap.php create mode 100644 vendor/composer/platform_check.php create mode 100644 vendor/kub-at/php-simple-html-dom-parser/CONTRIBUTING.md create mode 100644 vendor/kub-at/php-simple-html-dom-parser/LICENSE create mode 100644 vendor/kub-at/php-simple-html-dom-parser/README.md create mode 100644 vendor/kub-at/php-simple-html-dom-parser/composer.json create mode 100644 vendor/kub-at/php-simple-html-dom-parser/src/KubAT/PhpSimple/HtmlDomParser.php create mode 100644 vendor/kub-at/php-simple-html-dom-parser/src/KubAT/PhpSimple/lib/simple_html_dom.php create mode 100644 vendor/rosell-dk/dom-util-for-webp/.php_cs.dist create mode 100644 vendor/rosell-dk/dom-util-for-webp/README.md create mode 100644 vendor/rosell-dk/dom-util-for-webp/composer.json create mode 100644 vendor/rosell-dk/dom-util-for-webp/docs/development.md create mode 100644 vendor/rosell-dk/dom-util-for-webp/phpcs-ruleset.xml create mode 100644 vendor/rosell-dk/dom-util-for-webp/phpunit-41.xml.dist create mode 100644 vendor/rosell-dk/dom-util-for-webp/phpunit.xml.dist create mode 100644 vendor/rosell-dk/dom-util-for-webp/src/ImageUrlReplacer.php create mode 100644 vendor/rosell-dk/dom-util-for-webp/src/PictureTags.php create mode 100644 vendor/rosell-dk/exec-with-fallback/LICENSE create mode 100644 vendor/rosell-dk/exec-with-fallback/README.md create mode 100644 vendor/rosell-dk/exec-with-fallback/composer.json create mode 100644 vendor/rosell-dk/exec-with-fallback/phpstan.neon create mode 100644 vendor/rosell-dk/exec-with-fallback/phpunit.xml.dist create mode 100644 vendor/rosell-dk/exec-with-fallback/phpunit.xml.dist.bak create mode 100644 vendor/rosell-dk/exec-with-fallback/src/Availability.php create mode 100644 vendor/rosell-dk/exec-with-fallback/src/ExecWithFallback.php create mode 100644 vendor/rosell-dk/exec-with-fallback/src/ExecWithFallbackNoMercy.php create mode 100644 vendor/rosell-dk/exec-with-fallback/src/POpen.php create mode 100644 vendor/rosell-dk/exec-with-fallback/src/Passthru.php create mode 100644 vendor/rosell-dk/exec-with-fallback/src/ProcOpen.php create mode 100644 vendor/rosell-dk/exec-with-fallback/src/ShellExec.php create mode 100644 vendor/rosell-dk/exec-with-fallback/test.php create mode 100644 vendor/rosell-dk/file-util/LICENSE create mode 100644 vendor/rosell-dk/file-util/README.md create mode 100644 vendor/rosell-dk/file-util/composer.json create mode 100644 vendor/rosell-dk/file-util/phpcs-ruleset.xml create mode 100644 vendor/rosell-dk/file-util/src/FileExists.php create mode 100644 vendor/rosell-dk/file-util/src/FileExistsUsingExec.php create mode 100644 vendor/rosell-dk/file-util/src/PathValidator.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/.github/FUNDING.yml create mode 100644 vendor/rosell-dk/htaccess-capability-tester/.travis.yml create mode 100644 vendor/rosell-dk/htaccess-capability-tester/LICENSE create mode 100644 vendor/rosell-dk/htaccess-capability-tester/README.md create mode 100644 vendor/rosell-dk/htaccess-capability-tester/composer.json create mode 100644 vendor/rosell-dk/htaccess-capability-tester/docs/GrantAllCrashTesting.md create mode 100644 vendor/rosell-dk/htaccess-capability-tester/docs/Ideas.md create mode 100644 vendor/rosell-dk/htaccess-capability-tester/docs/MoreExamples.md create mode 100644 vendor/rosell-dk/htaccess-capability-tester/docs/Running your own custom tests.md create mode 100644 vendor/rosell-dk/htaccess-capability-tester/docs/TheManyWaysOfHtaccessFailure.md create mode 100644 vendor/rosell-dk/htaccess-capability-tester/docs/Usage.md create mode 100644 vendor/rosell-dk/htaccess-capability-tester/docs/interpreting.md create mode 100644 vendor/rosell-dk/htaccess-capability-tester/phpunit-41.xml.dist create mode 100644 vendor/rosell-dk/htaccess-capability-tester/phpunit.xml.dist create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/HtaccessCapabilityTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/HttpRequesterInterface.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/HttpResponse.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/SimpleHttpRequester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/SimpleTestFileLineUpper.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/TestFilesLineUpperInterface.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/TestResult.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/TestResultCache.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/AbstractTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/AddTypeTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/ContentDigestTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/CrashTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/CustomTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/DirectoryIndexTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/HeaderSetTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/Helpers/ResponseInterpreter.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/HtaccessEnabledTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/InnocentRequestTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/ModuleLoadedTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/PassInfoFromRewriteToScriptThroughEnvTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/PassInfoFromRewriteToScriptThroughRequestHeaderTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/RequestHeaderTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/RewriteTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/src/Testers/ServerSignatureTester.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/FakeServer.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Helper.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/HtaccessCapabilityTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/HttpResponseTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/AddTypeTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/BasisTestCase.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/ContentDigestTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/CrashTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/DirectoryIndexTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/HeaderSetTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/HtaccessEnabledTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/InnocentRequestTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/ModuleLoadedTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/PassInfoFromRewriteToScriptThroughEnvTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/PassInfoFromRewriteToScriptThroughRequestHeaderTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/RequestHeaderTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/RewriteTesterTest.php create mode 100644 vendor/rosell-dk/htaccess-capability-tester/tests/Testers/ServerSignatureTesterTest.php create mode 100644 vendor/rosell-dk/image-mime-type-guesser/.circleci/config.yml create mode 100644 vendor/rosell-dk/image-mime-type-guesser/.php_cs.dist create mode 100644 vendor/rosell-dk/image-mime-type-guesser/LICENSE create mode 100644 vendor/rosell-dk/image-mime-type-guesser/README.md create mode 100644 vendor/rosell-dk/image-mime-type-guesser/composer.json create mode 100644 vendor/rosell-dk/image-mime-type-guesser/phpcs-ruleset.xml create mode 100644 vendor/rosell-dk/image-mime-type-guesser/phpstan.neon create mode 100644 vendor/rosell-dk/image-mime-type-guesser/phpunit.xml.dist create mode 100644 vendor/rosell-dk/image-mime-type-guesser/src/Detectors/AbstractDetector.php create mode 100644 vendor/rosell-dk/image-mime-type-guesser/src/Detectors/ExifImageType.php create mode 100644 vendor/rosell-dk/image-mime-type-guesser/src/Detectors/FInfo.php create mode 100644 vendor/rosell-dk/image-mime-type-guesser/src/Detectors/GetImageSize.php create mode 100644 vendor/rosell-dk/image-mime-type-guesser/src/Detectors/MimeContentType.php create mode 100644 vendor/rosell-dk/image-mime-type-guesser/src/Detectors/SignatureSniffer.php create mode 100644 vendor/rosell-dk/image-mime-type-guesser/src/Detectors/Stack.php create mode 100644 vendor/rosell-dk/image-mime-type-guesser/src/GuessFromExtension.php create mode 100644 vendor/rosell-dk/image-mime-type-guesser/src/ImageMimeTypeGuesser.php create mode 100644 vendor/rosell-dk/image-mime-type-guesser/src/MimeMap.php create mode 100644 vendor/rosell-dk/image-mime-type-sniffer/LICENSE create mode 100644 vendor/rosell-dk/image-mime-type-sniffer/README.md create mode 100644 vendor/rosell-dk/image-mime-type-sniffer/composer.json create mode 100644 vendor/rosell-dk/image-mime-type-sniffer/phpcs-ruleset.xml create mode 100644 vendor/rosell-dk/image-mime-type-sniffer/phpunit.xml.dist create mode 100644 vendor/rosell-dk/image-mime-type-sniffer/src/ImageMimeTypeSniffer.php create mode 100644 vendor/rosell-dk/locate-binaries/LICENSE create mode 100644 vendor/rosell-dk/locate-binaries/README.md create mode 100644 vendor/rosell-dk/locate-binaries/composer.json create mode 100644 vendor/rosell-dk/locate-binaries/phpcs-ruleset.xml create mode 100644 vendor/rosell-dk/locate-binaries/src/LocateBinaries.php create mode 100755 vendor/rosell-dk/webp-convert-cloud-service/.gitignore create mode 100644 vendor/rosell-dk/webp-convert-cloud-service/.php_cs.dist create mode 100644 vendor/rosell-dk/webp-convert-cloud-service/LICENSE create mode 100644 vendor/rosell-dk/webp-convert-cloud-service/README.md create mode 100644 vendor/rosell-dk/webp-convert-cloud-service/composer.json create mode 100644 vendor/rosell-dk/webp-convert-cloud-service/src/AccessCheck.php create mode 100644 vendor/rosell-dk/webp-convert-cloud-service/src/Serve.php create mode 100644 vendor/rosell-dk/webp-convert-cloud-service/src/WebPConvertCloudService.php create mode 100644 vendor/rosell-dk/webp-convert/.github/FUNDING.yml create mode 100644 vendor/rosell-dk/webp-convert/.gitignore create mode 100644 vendor/rosell-dk/webp-convert/BACKERS.md create mode 100644 vendor/rosell-dk/webp-convert/LICENSE create mode 100644 vendor/rosell-dk/webp-convert/README.md create mode 100644 vendor/rosell-dk/webp-convert/composer-php56.json create mode 100644 vendor/rosell-dk/webp-convert/composer-php72.json create mode 100644 vendor/rosell-dk/webp-convert/composer.json create mode 100644 vendor/rosell-dk/webp-convert/phpcs-ruleset.xml create mode 100644 vendor/rosell-dk/webp-convert/phpunit-41.xml.dist create mode 100644 vendor/rosell-dk/webp-convert/phpunit-with-coverage.xml.dist create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/ConverterFactory.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/AbstractConverter.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/BaseTraits/AutoQualityTrait.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/BaseTraits/DestinationPreparationTrait.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/BaseTraits/LoggerTrait.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/BaseTraits/OptionsTrait.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/BaseTraits/WarningLoggerTrait.php create mode 100755 vendor/rosell-dk/webp-convert/src/Convert/Converters/Binaries/cwebp-060-fbsd create mode 100755 vendor/rosell-dk/webp-convert/src/Convert/Converters/Binaries/cwebp-060-solaris create mode 100755 vendor/rosell-dk/webp-convert/src/Convert/Converters/Binaries/cwebp-061-linux-x86-64 create mode 100755 vendor/rosell-dk/webp-convert/src/Convert/Converters/Binaries/cwebp-103-linux-x86-64-static create mode 100755 vendor/rosell-dk/webp-convert/src/Convert/Converters/Binaries/cwebp-110-linux-x86-64 create mode 100755 vendor/rosell-dk/webp-convert/src/Convert/Converters/Binaries/cwebp-110-mac-10_15 create mode 100755 vendor/rosell-dk/webp-convert/src/Convert/Converters/Binaries/cwebp-110-windows-x64.exe create mode 100755 vendor/rosell-dk/webp-convert/src/Convert/Converters/Binaries/cwebp-120-linux-x86-64 create mode 100755 vendor/rosell-dk/webp-convert/src/Convert/Converters/Binaries/cwebp-120-windows-x64.exe create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/ConverterTraits/CloudConverterTrait.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/ConverterTraits/CurlTrait.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/ConverterTraits/EncodingAutoTrait.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/ConverterTraits/ExecTrait.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/Cwebp.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/Ewww.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/FFMpeg.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/Gd.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/Gmagick.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/GmagickBinary.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/GraphicsMagick.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/ImageMagick.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/Imagick.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/ImagickBinary.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/Stack.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/Vips.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Converters/Wpc.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Exceptions/ConversionFailed/ConversionSkippedException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Exceptions/ConversionFailed/ConverterNotOperational/InvalidApiKeyException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Exceptions/ConversionFailed/ConverterNotOperational/SystemRequirementsNotMetException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Exceptions/ConversionFailed/ConverterNotOperationalException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Exceptions/ConversionFailed/FileSystemProblems/CreateDestinationFileException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Exceptions/ConversionFailed/FileSystemProblems/CreateDestinationFolderException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Exceptions/ConversionFailed/FileSystemProblemsException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Exceptions/ConversionFailed/InvalidInput/ConverterNotFoundException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Exceptions/ConversionFailed/InvalidInput/InvalidImageTypeException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Exceptions/ConversionFailed/InvalidInput/TargetNotFoundException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Exceptions/ConversionFailed/InvalidInputException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Exceptions/ConversionFailedException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Helpers/JpegQualityDetector.php create mode 100644 vendor/rosell-dk/webp-convert/src/Convert/Helpers/PhpIniSizes.php create mode 100644 vendor/rosell-dk/webp-convert/src/Exceptions/InvalidInput/InvalidImageTypeException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Exceptions/InvalidInput/TargetNotFoundException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Exceptions/InvalidInputException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Exceptions/SanityException.txt create mode 100644 vendor/rosell-dk/webp-convert/src/Exceptions/WebPConvertException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Helpers/InputValidator.php create mode 100644 vendor/rosell-dk/webp-convert/src/Helpers/MimeType.php create mode 100644 vendor/rosell-dk/webp-convert/src/Helpers/PathChecker.php create mode 100644 vendor/rosell-dk/webp-convert/src/Helpers/Sanitize.php create mode 100644 vendor/rosell-dk/webp-convert/src/Loggers/BaseLogger.php create mode 100644 vendor/rosell-dk/webp-convert/src/Loggers/BufferLogger.php create mode 100644 vendor/rosell-dk/webp-convert/src/Loggers/EchoLogger.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/ArrayOption.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/BooleanOption.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/Exceptions/InvalidOptionTypeException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/Exceptions/InvalidOptionValueException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/Exceptions/OptionNotFoundException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/GhostOption.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/IntegerOption.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/IntegerOrNullOption.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/MetadataOption.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/Option.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/OptionFactory.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/Options.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/QualityOption.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/SensitiveArrayOption.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/SensitiveStringOption.php create mode 100644 vendor/rosell-dk/webp-convert/src/Options/StringOption.php create mode 100644 vendor/rosell-dk/webp-convert/src/Serve/Exceptions/ServeFailedException.php create mode 100644 vendor/rosell-dk/webp-convert/src/Serve/Header.php create mode 100644 vendor/rosell-dk/webp-convert/src/Serve/Report.php create mode 100644 vendor/rosell-dk/webp-convert/src/Serve/ServeConvertedWebP.php create mode 100644 vendor/rosell-dk/webp-convert/src/Serve/ServeConvertedWebPWithErrorHandling.php create mode 100644 vendor/rosell-dk/webp-convert/src/Serve/ServeFile.php create mode 100644 vendor/rosell-dk/webp-convert/src/WebPConvert.php create mode 100644 web-service/.htaccess create mode 100644 web-service/wpc.php create mode 100755 webp-express.php create mode 100644 wod/.htaccess create mode 100644 wod/autoloader.php create mode 100644 wod/ping.php create mode 100644 wod/ping.txt create mode 100644 wod/webp-on-demand.php create mode 100644 wod/webp-realizer.php create mode 100644 wod2/ping.php create mode 100644 wod2/ping.txt create mode 100644 wod2/webp-on-demand.php create mode 100644 wod2/webp-realizer.php diff --git a/BACKERS.md b/BACKERS.md new file mode 100644 index 0000000..cf23e21 --- /dev/null +++ b/BACKERS.md @@ -0,0 +1,43 @@ + +# Backers + +WebP Express is an MIT-licensed open source project. It is free and always will be. + +How is it financed then? Well, it isn't exactly. However, some people choose to support the development by buying me a cup of coffee, and some go even further, by becoming backers. Backers are nice folks making recurring monthly donations, and by doing this, they give me an excuse to put more work into the plugin than I really should. + +To become a backer, yourself, [go to my GitHub sponsors page](https://github.com/sponsors/rosell-dk) + +PS: I just started using GitHub Sponsors instead of patreon. I'm keeping [my patreon page]((https://www.patreon.com/rosell)), but might change it to support some other project. I'm for example very curious about the nature of reality and if we might be living in a computer simulation, and I might want to write a book about it one day. + +## Generous backers via Patron + +Generous backers will get their names listed here. + +There are no generous backers yet. [Be the first!](https://www.patreon.com/rosell) + + +I reserve the right to disallow inappropriate messages and links. No xxx sites or anything freaky or fishy, please. You may however advertise non-freaky-or-fishy things, if you wish. Just remember the audience. No point in trying to sell shoes here + + +## Active backers via Patron + +| Name | Since date | +| ---------------------- | -------------- | +| Max Kreminsky | 2019-08-02 | +| [Mathieu Gollain-Dupont](https://www.linkedin.com/in/mathieu-gollain-dupont-9938a4a/) | 2020-08-26 | +| Nodeflame | 2019-10-31 | +| Ruben Solvang | 2020-01-08 | + + +Hi-scores: + +| Name | Life time contribution | +| ------------------------ | ------------------------ | +| Tammy Valgardson | $90 | +| Max Kreminsky | $65 | +| Ruben Solvang | $14 | +| Dmitry Verzjikovsky | $5 | + +## Former backers - I'm still grateful :) +- Dmitry Verzjikovsky +- Tammy Valgardson diff --git a/BULK_CONVERSION_FIX.md b/BULK_CONVERSION_FIX.md new file mode 100644 index 0000000..1920dd2 --- /dev/null +++ b/BULK_CONVERSION_FIX.md @@ -0,0 +1,155 @@ +# WebP Express Bulk Conversion Fix + +This document describes the fixes applied to resolve the issue where bulk conversion gets stuck on missing files. + +## Problem Description + +The WebP Express plugin was getting stuck during bulk conversion with errors like: +- "Converting uploads/2022/11/PASTA-LOVE-GROUPAGE.png failed" +- "Converting uploads/2022/09/MESOSES-crema.jpg. None of the converters in the stack could convert the image. failed" + +The issue occurred because: +1. Files referenced in the conversion queue no longer existed on the filesystem +2. The plugin attempted to validate and convert non-existent files +3. PHP exceptions were thrown that halted the entire bulk conversion process +4. JavaScript error handling was inadequate, causing JSON parsing errors on 500 responses + +## Fixes Applied + +### 1. File Existence Validation (lib/classes/Convert.php) + +**Location**: Lines 61-67 +```php +// First check if file exists before doing any other validations +if (!file_exists($source)) { + return [ + 'success' => false, + 'msg' => 'Source file does not exist: ' . $source, + 'log' => '', + ]; +} +``` + +**Purpose**: Prevents the SanityCheck::absPathExistsAndIsFile() from throwing exceptions on missing files. + +### 2. ConvertHelperIndependent Protection (lib/classes/ConvertHelperIndependent.php) + +**Location**: Lines 613-620 +```php +// First check if file exists before doing any other validations +if (!file_exists($source)) { + return [ + 'success' => false, + 'msg' => 'Source file does not exist: ' . $source, + 'log' => '', + ]; +} +``` + +**Purpose**: Adds the same protection at the lower level conversion function. + +### 3. Bulk Conversion List Filtering (lib/classes/BulkConvert.php) + +**Location**: Lines 176-180 +```php +// Additional safety check: verify the file actually exists before adding to list +$fullPath = $dir . "/" . $filename; +if (!file_exists($fullPath)) { + continue; // Skip this file if it doesn't exist +} +``` + +**Purpose**: Prevents missing files from being added to the conversion queue in the first place. + +### 4. Missing Import Fixes + +**Files Updated**: +- `lib/classes/Convert.php` - Added missing imports for BiggerThanSourceDummyFiles, DestinationOptions, EwwwTools, PathHelper, Paths +- `lib/classes/ConvertHelperIndependent.php` - Added PathHelper import +- `lib/classes/BulkConvert.php` - Added Config, ConvertHelperIndependent, ImageRoots, PathHelper, Paths imports + +**Purpose**: Resolves PHP fatal errors caused by missing class imports. + +### 5. JavaScript Version Update (lib/options/enqueue_scripts.php:12) +```php +$ver = '4-cloudhost'; // Force browser cache refresh +``` + +### 6. JavaScript Error Handling (lib/options/js/bulk-convert.js) + +#### A. Robust JSON Response Parsing (Lines 272-299) +```javascript +// Handle different types of responses safely +if (typeof response.requestError === 'boolean' && response.requestError) { + result = { success: false, msg: 'Request failed', log: '' }; +} else if (typeof response === 'string') { + try { + result = JSON.parse(response); + } catch (e) { + result = { success: false, msg: 'Invalid response received from server', log: '' }; + } +} else if (typeof response === 'object') { + result = response; +} else { + result = { success: false, msg: 'Unexpected response type', log: '' }; +} +``` + +#### B. AJAX Timeout and Error Handling (Lines 389-432) +```javascript +timeout: 30000, // 30 second timeout per file +error: (jqXHR, textStatus, errorThrown) => { + // Detailed error reporting and automatic continuation to next file +} +``` + +#### C. Try-Catch Protection (Lines 262-422) +```javascript +function responseCallback(response){ + try { + // All response processing code protected + } catch (error) { + // Graceful error handling and continuation + } +} +``` + +#### D. Improved Error Processing (Lines 328-333) +```javascript +// Only stop for critical errors (security nonce issues), not file-specific errors +if (result['stop'] && result['msg'] && result['msg'].indexOf('security nonce') !== -1) { + // Stop only for security errors +} else { + // Continue processing for file-specific errors +} +``` + +## Result + +With these fixes, the WebP Express plugin will now: + +✅ **Skip missing files** during bulk conversion listing +✅ **Handle missing files gracefully** during conversion attempts +✅ **Continue processing** other files when one fails +✅ **Timeout after 30 seconds** per file to prevent hanging +✅ **Only stop** the process for critical security errors +✅ **Provide clear error messages** for failed conversions +✅ **Handle 500 errors** without breaking the JavaScript execution + +## Testing + +The bulk conversion should no longer get stuck on missing files. Instead, it will: +1. Log that specific files don't exist +2. Continue with the next files in the queue +3. Complete the conversion process for all existing files +4. Show a summary of successful and failed conversions + +## Compatibility + +These changes are backward compatible and do not affect: +- Normal image conversion functionality +- WebP serving via .htaccess rules +- Plugin settings and configuration +- Other plugin features + +The fixes only improve the robustness of the bulk conversion feature. \ No newline at end of file diff --git a/CLOUDHOST_PATCH_SUMMARY.md b/CLOUDHOST_PATCH_SUMMARY.md new file mode 100644 index 0000000..7c7934f --- /dev/null +++ b/CLOUDHOST_PATCH_SUMMARY.md @@ -0,0 +1,103 @@ +# WebP Express CloudHost.es Patch Summary + +## Version Information +- **Original Version**: 0.25.9 +- **Patched Version**: 0.25.9-cloudhost +- **Plugin Name**: WebP Express - CloudHost.es Fix + +## Issue Resolved +Bulk conversion was getting stuck on missing files with errors: +- "Converting uploads/2022/11/PASTA-LOVE-GROUPAGE.png failed" +- "Converting uploads/2022/09/MESOSES-crema.jpg. None of the converters in the stack could convert the image. failed" +- JavaScript errors: "Uncaught SyntaxError: '[object Object]' is not valid JSON" +- 500 Internal Server Error responses + +## Files Modified + +### 1. Main Plugin File +**File**: `webp-express.php` +- Updated plugin header to "WebP Express - CloudHost.es Fix" +- Changed version to "0.25.9-cloudhost" + +### 2. PHP Backend Fixes + +#### Convert.php +- **Lines 11, 14-15, 18**: Added missing imports (BiggerThanSourceDummyFiles, DestinationOptions, EwwwTools, PathHelper, Paths) +- **Lines 61-67**: Added file existence check before conversion + +#### ConvertHelperIndependent.php +- **Line 15**: Added PathHelper import +- **Lines 613-620**: Added file existence check before conversion + +#### BulkConvert.php +- **Lines 7-10**: Added missing imports (Config, ConvertHelperIndependent, ImageRoots, PathHelper, Paths) +- **Lines 176-180**: Added file existence check in file listing + +### 3. JavaScript Frontend Fixes + +#### bulk-convert.js +- **Lines 262-422**: Wrapped entire responseCallback in try-catch +- **Lines 275-308**: Robust response type handling +- **Lines 312-322**: Added state validation checks +- **Lines 371**: Added 30-second timeout to AJAX requests +- **Lines 393-441**: Improved error handling in AJAX error callback +- **Lines 402-422**: Added catch block for JavaScript errors + +#### enqueue_scripts.php +- **Line 12**: Updated version from '3' to '4-cloudhost' to force cache refresh + +## Key Improvements + +### Error Handling +✅ **File existence validation** before any processing +✅ **Try-catch protection** around JavaScript response handling +✅ **Graceful error recovery** that continues to next file +✅ **Detailed error messages** for different failure types +✅ **State validation** to prevent crashes on invalid data + +### Timeout Protection +✅ **30-second timeout** per file conversion +✅ **Automatic continuation** after timeout +✅ **Clear timeout messages** in the log + +### Response Handling +✅ **Robust JSON parsing** with fallback handling +✅ **Object response support** for different server responses +✅ **500 error handling** without JavaScript crashes +✅ **Invalid response type detection** + +### User Experience +✅ **Continues processing** even when individual files fail +✅ **Clear error messages** showing which files failed and why +✅ **Process completion** with summary of results +✅ **Cache busting** to ensure latest fixes are loaded + +## Testing Recommendations + +1. **Clear browser cache** before testing +2. **Test with missing files** to verify graceful handling +3. **Test pause/resume functionality** +4. **Monitor browser console** for any remaining errors +5. **Verify bulk conversion completes** even with some failures + +## Backward Compatibility + +All changes are backward compatible and maintain: +- Normal image conversion functionality +- WebP serving via .htaccess rules +- Plugin settings and configuration +- Other plugin features + +Only the bulk conversion robustness has been improved. + +## Installation Notes + +1. Replace the existing WebP Express plugin with this patched version +2. The plugin will be identified as "WebP Express - CloudHost.es Fix" in WordPress admin +3. Clear browser cache if bulk conversion still shows old behavior +4. Test bulk conversion with a small set of files first + +## Support + +This patch specifically addresses bulk conversion reliability issues. For other WebP Express issues, refer to the original plugin documentation at: +https://github.com/rosell-dk/webp-express \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..9cecc1d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100755 index 0000000..dc525c1 --- /dev/null +++ b/README.md @@ -0,0 +1,801 @@ +# WebP Express + +Serve autogenerated WebP images instead of jpeg/png to browsers that supports WebP. + +The plugin is available on the Wordpress codex ([here](https://wordpress.org/plugins/webp-express/)). +But well, it is developed ([here on github](https://github.com/rosell-dk/webp-express/)). + +**News: I have added the vendor folder to the repo. To install the plugin here from github, you can simply download the zip and unzip it in your plugin folder** + +## Description +More than 9 out of 10 users are using a browser that is able to display webp images. Yet, on most websites, they are served jpeg images, which are typically double the size of webp images for a given quality. What a waste of bandwidth! This plugin was created to help remedy that situation. With little effort, Wordpress admins can have their site serving autogenerated webp images to browsers that supports it, while still serving jpeg and png files to browsers that does not support webp. + +### The image converter +The plugin uses the [WebP Convert](https://github.com/rosell-dk/webp-convert) library to convert images to webp. *WebP Convert* is able to convert images using multiple methods. There are the "local" conversion methods: `imagick`, `cwebp`, `vips`, `gd`. If none of these works on your host, there are the cloud alternatives: `ewww` (paid) or connecting to a Wordpress site where you got WebP Express installed and you enabled the "web service" functionality. + +### The "Serving webp to browsers that supports it" part. + +The plugin supports different ways of delivering webps to browsers that supports it: + +1. By routing jpeg/png images to the corresponding webp - or to the image converter if the image hasn't been converted yet. +2. By altering the HTML, replacing image tags with *picture* tags. Missing webps are auto generated upon visit. +3. By altering the HTML, replacing image URLs so all points to webp. The replacements only being made for browsers that supports webp. Again, missing webps are auto generated upon visit. +4. In combination with *Cache Enabler*, the same as above can be achieved, but with page caching. +5. You can also deliver webp to *all* browsers and add the [webpjs](http://webpjs.appspot.com) javascript, which provides webp support for browsers that doesn't support webp natively. However, beware that the javascript doesn't support srcset attributes, which is why I haven't added that method to the plugin (yet). + +The plugin implements the "WebP On Demand" solution described [here](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/webp-on-demand/webp-on-demand.md) and builds on a bunch of open source libraries (all maintained by me): +- [WebPConvert](https://github.com/rosell-dk/webp-convert): For converting images to webp +- [WebP Convert Cloud Service](https://github.com/rosell-dk/webp-convert-cloud-service): For the Web Service functionality +- [DOM Util for WebP](https://github.com/rosell-dk/dom-util-for-webp): For the Alter HTML functionality +- [Image MimeType Guesser](https://github.com/rosell-dk/image-mime-type-guesser): For detecting mime types of images. +- [HTAccess Capability Tester](https://github.com/rosell-dk/htaccess-capability-tester): For testing .htaccess capabilities in a given directory, using live tests +- [WebP Convert File Manager](https://github.com/rosell-dk/webp-convert-filemanager): For browsing conversions (planned feature: triggering conversions). + +### Benefits +- Much faster load time for images in browsers that supports webp. The converted images are typically *less than half the size* (for jpeg), while maintaining the same quality. Bear in mind that for most web sites, images are responsible for the largest part of the waiting time. +- Better user experience (whether performance goes from terrible to bad, or from good to impressive, it is a benefit). +- Better ranking in Google searches (performance is taken into account by Google). +- Less bandwidth consumption - makes a huge difference in the parts of the world where the internet is slow and costly (you know, ~80% of the world population lives under these circumstances). +- Currently ~95% of all traffic, and ~96% of mobile browsing traffic are done with browsers supporting webp. Check current numbers on [caniuse.com](https://caniuse.com/webp). +- It's great for the environment too! Reducing network traffic reduces electricity consumption which reduces CO2 emissions. + +## Installation +1. Upload the plugin files to the `/wp-content/plugins/webp-express` directory, or install the plugin through the WordPress plugins screen directly. +2. Activate the plugin through the 'Plugins' screen in WordPress +3. Configure it (the plugin doesn't do anything until configured) +4. Verify that it works +5. (Optional) Bulk convert all images, either in the admin ui or using WP CLI (command: "webp-express") + +### Configuring +You configure the plugin in *Settings > WebP Express*. + +#### Operation modes +As sort of a main switch, you can choose between the following modes of operation: + +*Varied image responses*: +WebP Express creates redirection rules for images, such that a request for a jpeg will result in a webp – but only if the request comes from a webp-enabled browser. If a webp already exists, it is served immediately. Otherwise it is converted and then served. Note that not all CDN's handles varied responses well. + +*CDN friendly*: +In "CDN friendly" mode, a jpeg is always served as a jpeg. Instead of varying the image response, WebP Express alters the HTML for webp usage. + +*Just redirect*: +In "just redirect" mode, WebP Express is used just for redirecting jpeg and pngs to existing webp images in the same folder. So in this mode, WebP express will not do any converting. It may be that you use another plugin for that, or that you converted the images off-line and uploaded them manually. + +*Tweaked*: +Here you have all options available. + + +#### Conversion methods +WebP Express has a bunch of methods available for converting images: Executing cwebp binary, Gd extension, Imagick extension, Vips extension, ewww cloud converter and remote WebP express etc. Each requires *something*. In many cases, one of the conversion methods will be available. You can quickly identify which converters are working - there is a green icon next to them. Hovering conversion methods that are not working will show you what is wrong. + +In case no conversion methods are working out of the box, you have several options: +- You can install this plugin on another website, which supports a local conversion method and connect to that using the "Remote WebP Express" conversion method +- You can [purchase a key](https://ewww.io/plans/) for the ewww cloud converter. They do not charge credits for webp conversions, so all you ever have to pay is the one dollar start-up fee :) +- You can set up [webp-convert-cloud-service](https://github.com/rosell-dk/webp-convert-cloud-service) on another server and connect to that. Its open source. +- You can try to meet the server requirements of cwebp, Gd, Imagick, Gmagick or Vips. Check out [this wiki page](https://github.com/rosell-dk/webp-convert/wiki/Meeting-the-requirements-of-the-converters) on how to do that + +### Quality detection of jpegs +If your server has Imagick extension or is able to execute imagemagick binary, the plugin will be able to detect the quality of a jpeg, and use that quality for the converted webp. You can tell if the quality detection is available by hovering the help icon in Conversion > Jpeg options > Quality for lossy. The last line in that help text tells you. + +This auto quality has benefits over fixed quality as it ensures that each conversion are converted with an appropriate quality. Encoding low quality jpegs to high quality webps does not magically increase the visual quality so that your webp looks better than the original. But it does result in a much larger filesize than if the jpeg where converting to a webp with the same quality setting as the original. + +If you do not have quality detection working, you can try one of the following: +- Install Imagick on the server (for this purpose, it is not required that it is compiled with WebP support) +- Install imagemagick on the server and grant permission for PHP to use the "exec" function. +- Use "Remote WebP Express" converter to connect to a site, that *does* have quality detection working +- If you have cwebp converter available, you can configure it to aim for a certain reduction, rather than using the quality parameter. Set this to for example 50%, or even 45%. + +### Verifying that it works (in "Varied image responses" mode) +1. Make sure at least one of the conversion methods are working. It should have a green checkmark next to it. +2. If you haven't saved yet, click "Save settings". This will put redirection rules into .htaccess files in the relevant directories (typically in uploads, themes and wp-content/webp-express/webp-images, depending on the "Scope" setting) +3. I assume that you checked at least one of the two first checkboxes in the .htaccess rules section. Otherwise you aren't using "varied responses", and then the "CDN friendly" mode will be more appropriate. +4. Click the "Live test" buttons to see that the enabled rules actually are working. If they are not, it *could* be that the server needs a little time to recognize the changed rules. + +The live tests are quite thorough and I recommend them over a manual test. However, it doesn't hurt to do a manual inspection too. + +*Doing a manual inspection* + +Note that when WebP Express is serving varied image responses, the image URLs *still points to the jpg/png*. If the URL is visited using a browser that supports webp, however, the response will be a webp image. So there is a mismatch between the file extension (the filename ends with "jpg" or "png") and the file type. But luckily, the browser does not rely on the extension to determine the file type, it only looks at the Content-Type response header. + +To verify that the plugin is working (without clicking the test button), do the following: + +- Open the page in a browser that supports webp, ie Google Chrome +- Right-click the page and choose "Inspect" +- Click the "Network" tab +- Reload the page +- Find a jpeg or png image in the list. In the "type" column, it should say "webp" + +You can also look at the headers. When WebP Express has redirected to an existing webp, there will be a "X-WebP-Express" header with the following value: "Redirected directly to existing webp". If there isn't (and you have checked "Enable redirection to converter"), you should see a "WebP-Convert-Log" header (WebP-Express uses the [WebP Convert](https://github.com/rosell-dk/webp-convert) for conversions). + +### Notes + +*Note:* +The redirect rules created in *.htaccess* are pointing to a PHP script. If you happen to change the url path of your plugins, the rules will have to be updated. The *.htaccess* also passes the path to wp-content (relative to document root) to the script, so the script knows where to find its configuration and where to store converted images. So again, if you move the wp-content folder, or perhaps moves Wordpress to a subfolder, the rules will have to be updated. As moving these things around is a rare situation, WebP Express are not using any resources monitoring this. However, it will do the check when you visit the settings page. + +*Note:* +Do not simply remove the plugin without deactivating it first. Deactivation takes care of removing the rules in the *.htaccess* file. With the rules there, but converter gone, your Google Chrome visitors will not see any jpeg images. + +### Bulk convert +You can start a bulk conversion two ways: +1. In the admin UI. On the settings screen, there is a "Bulk Convert" button +2. By using WP CLI (command: "webp-express"). + +I'm currently working on a file manager interface, which will become a third way. + +### Making sure new images becomes converted +There are several ways: +1. Enable redirection to converter in the *.htaccess rules* section. +2. Enable "Convert on upload". Note that this may impact upload experience in themes which defines many formats. +3. Set up a cron job, which executes `wp webp-express convert` regularily + +### WP CLI command +WebP Express currently supports commands for converting and flushing webp images throug the CLI. You can use the --help option to learn about the options: +`wp webp-express --help`. Displays the available commands +`wp webp-express convert --help`. Displays the available options for the "convert" command. + +A few examples: +`wp webp-express convert`: Creates webp images for all unconverted images +`wp webp-express convert --reconvert`: Also convert images that are already converted +`wp webp-express convert themes`: Only images in the themes folder +`wp webp-express convert uploads/2021`: Only images in the "2021" folder inside the uploads folder +`wp webp-express convert --only-png`: Only the PNG images +`wp webp-express convert --quality=50`: Use quality 50 (instead of what was entered in settings screen) +`wp webp-express convert --converter=cwebp`: Specifically use cwebp converter. + +`wp webp-express flushwebp`: Remove all webp images +`wp webp-express flushwebp --only-png`: Remove all webp images that are conversions of PNG images + +Synopsises: +`wp webp-express convert [] [--reconvert] [--only-png] [--only-jpeg] [--quality=] [--near-lossless=] [--alpha-quality=] [--encoding=] [--converter=]` +`wp webp-express flushwebp [--only-png]` + +I'm considering adding commands for viewing status, viewing conversion stats, generating the .htaccess files and modifying the settings. Please let me know if you need any of these or perhaps something else. + +## Limitations + +* The plugin [should now work on Microsoft IIS server](https://github.com/rosell-dk/webp-express/pull/213), but it has not been tested thoroughly. + +## Frequently Asked Questions + +### How do I verify that the plugin is working? +See the "Verifying that it works section" + +### No conversions methods are working out of the box +Don't fret - you have options! + +- If you a controlling another WordPress site (where the local conversion methods DO work), you can set up WebP Express there, and then connect to it by configuring the “Remote WebP Express” conversion method. +- You can also setup the ewww conversion method. To use it, you need to purchase an api key. They do not charge credits for webp conversions, so all you ever have to pay is the one dollar start-up fee 🙂 (unless they change their pricing – I have no control over that). You can buy an api key here: https://ewww.io/plans/ +- I have written a [template letter](https://github.com/rosell-dk/webp-convert/wiki/A-template-letter-for-shared-hosts) which you can send to your webhost +- You can try to get one of the local converters working. Check out [this page](https://github.com/rosell-dk/webp-convert/wiki/Meeting-the-requirements-of-the-converters) on the webp-convert wiki. There is also this [test/troubleshooting script](https://github.com/rosell-dk/webp-convert/wiki/A-PHP-script-for-the-webhost) which is handy when messing around with this. +- Finally, if you have access to another server and are comfortable with installing projects with composer, you can install [webp-convert-cloud-service](https://github.com/rosell-dk/webp-convert-cloud-service). It's open source. + +Of course, there is also the option of using another plugin altogether. I can recommend Optimole. If you want to try that out and want to support me in the process, [follow this link](https://optimole.pxf.io/20b0M). It is an affiliate link and will give me a reward in case you decide to sign up. + +### It doesn't work - Although test conversions work, it still serves jpeg images. +Actually, you might be mistaking, so first, make sure that you didn't make the very common mistake of thinking that something with the URL *example.com/image.jpg* must be a jpeg image. The plugin serves webp images on same URL as the original (unconverted) images, so do not let appearances fool you! Confused? See next FAQ item. + +Assuming that you have inspected the *content type* header, and it doesn't show "image/webp", please make sure that: +1) You tested with a browser that supports webp (such as Chrome) +2) The image URL you are looking at are not pointing to another server (such as gravatar.com) + +Assuming that all above is in place, please look at the response headers to see if there is a *X-WebP-Convert-Status* header. If there isn't, well, then it seems that the problem is that the image request isn't handed over to WebP Express. Reasons for that can be: + +- You are on NGINX (or an Apache/Nginx combination). NGINX requires special attention, please look at that FAQ item +- You are on WAMP. Please look at that FAQ item + +I shall write more on this FAQ item... Stay tuned. + +### How can a webp image be served on an URL ending with "jpg"? +Easy enough. Browsers looks at the *content type* header rather than the URL to determine what it is that it gets. So, although it can be confusing that the resource at *example.com/image.jpg* is a webp image, rest assured that the browsers are not confused. To determine if the plugin is working, you must therefore examine the *content type* response header rather than the URL. See the "How do I verify that the plugin is working?" Faq item. + +I am btw considering making an option to have the plugin redirect to the webp instead of serving immediately. That would remove the apparent mismatch between file extension and content type header. However, the cost of doing that will be an extra request for each image, which means extra time and worse performance. I believe you'd be ill advised to use that option, so I guess I will not implement it. But perhaps you have good reasons to use it? If you do, please let me know! + +### Blank images in Safari? +WebP Express has three ways of distributing webp to webp-enabled browsers while still sending the originals to webp-disabled browsers. While method 1 can be combined with any of the other methods, you would usually just pick method 1 or one of the others if method 1 cannot be used for you. + +Can some of these go wrong? +Yes. All! + +#### Method 1: Varied image responses +The "Varied image responses" method adds rules to the `.htaccess` which redirects jpegs and pngs to the corresponding webps (if they exist). The rules have a condition that makes sure they only trigger for browsers supports webp images (this is established by examining the "accept" header). + +I the method "varied image responses" because the response on a given image URL *varies* (the webp is served on the same URL as the jpeg/png). + +In the cases where method 1 fails, it is due to systems that cache images by the URL alone. To prevent this from happening, the `.htaccess` rules adds a `Vary:Accept` response header. However, most CDNs does not respect that header unless they are configured to do so. Fortunately proxy servers respects it nicely (however often by throwing out the cached image if the accept header doesn't match) + +Method 1 can go wrong if: + +1. You are using a CDN and it hasn't been set up to handle varied image responses. If this has happened, it is critical that you purge the CDN cache! For information regarding CDN setups, check out the CDN section in this FAQ +2. Your server doesn't support adding response headers in `.htaccess`. On Apache, the "mod_headers" module needs to be enabled. Otherwise the all important `Vary:Accept` response header will not be set on the response. +3. Your server doesn't support SetEnv. However, that module is fortunately very common. I have posted a possible solution to make the rules work without SetEnv [here](https://wordpress.org/support/topic/setenv/). +4. You are on Nginx and you haven't created rules that adds the `Vary:Accept` header. + +I do not believe it can go wrong in other ways. To be certain, please check out [this test page](http://toste.dk/rh.php). When visiting the test-page with Safari, you should see two images with the “JPG” label over them. When visiting the test-page with a browser that supports webp, you should see two images with the “WEBP” label over them. If you do not see one of these things, please report! (no-one has yet experienced that). + +Since WebP Express 0.15.0 you can use the "Live test" button to check that browsers not supporting webp gets the original files and that the Vary:Accept header is returned. Note however that it may not detect CDN caching problems if the CDN doesn't cache a new image immediately - and across all its nodes. + +#### Method 2: Altering HTML to use picture tags +IMG tags are replaced with PICTURE tags which has two sources. One of them points to the webp and has the "content-type" set to "image/webp". The other points to the original. The browser will select the webp source if it supports webp and the other source if it doesn't. + +Method 2 can go wrong on old browser that doesn't support the picture tag syntax. However, simply enable the "Dynamically load picturefill.js on older browsers" option, and it will take care of that issue. + +#### Method 3: Altering HTML to point directly to webps in webp enabled browsers +In this solution, the URLs in the HTML are modified for browsers that supports webp. Again, this is determined by examining the "accept" header. So, actually the complete page HTML varies with this method. + +Method 3 can go wrong if you are using a page caching plugin if that plugin does not create a separate webp cache for webp-enabled browsers. The *Cache Enabler* plugin handles this. I don't believe there are other page caching plugins that does. There is a FAQ section in this FAQ describing how to set *Cache Enabler* up to work in tandem with WebP Express. + +Note that Firefox 66+ unfortunately stopped including "image/webp" in the "accept" header it sends when requesting *the page*. While Firefox 66+ fortunately still includes "image/webp" in its accept header *for images*. That will however not get it webp images when using method 3. + + +### I am on NGINX or OpenResty + +WebP Express works well on NGINX, however the UI is not streamlined NGINX yet. And of course, NGINX does not process the .htaccess files that WebP Express generates. WebP Express can be used without redirection, as it can alter HTML to use picture tags which links to the webp alternative. See "The simple way" below. Or, you can get your hands dirty and set up redirection in NGINX guided by the "The advanced way" section below. + +#### The simple way (no redirecting rules) +The easy solution is simply to use the plugin in "CDN friendly" mode, do a bulk conversion (takes care of converting existing images), activate the "Convert on upload" option (takes care of converting new images in the media library) and enable Alter HTML (takes care of delivering webp to webp enabled browsers while still delivering the original jpeg/png to browsers not supporting webp). + +*PRO*: Very easy to set up. +*CON*: Images in external CSS and images being dynamically added with javascript will not be served as webp. +*CON*: New new theme images will not be converted until you run a new Bulk conversion + +#### The advanced way (creating NGINX redirecting rules) +Creating NGINX rules requires manually inserting redirection rules in the NGINX configuration file (nginx.conf or the configuration file for the site, found in `/etc/nginx/sites-available`). If you do not have access to do that, you will have to settle with the "simple way" described above. + +There are two different approaches to achieve the redirections. The one that I recommend is based on a *try_files* directive. If that doesn't work for you, you can try the alternative rules that are based on the *rewrite* directive. The rules are described in the next couple of sections. + +For multisite on NGINX, read [here](https://github.com/rosell-dk/webp-express/issues/8) + +#### Recommended rules (using "try_files") + +__Preparational step:__ +The rules looks for existing webp files by appending ".webp" to the URL. So for this to work, you must configure *WebP Express* to store the converted files like that by setting *General > File extension* to *Append ".webp"* + +__The rules:__ +Insert the following in the `server` context of your configuration file (usually found in `/etc/nginx/sites-available`). "The `server` context" refers to the part of the configuration that starts with "server {" and ends with the matching "}". + +```nginx +# WebP Express rules +# -------------------- +location ~* ^/?wp-content/.*\.(png|jpe?g)$ { + add_header Vary Accept; + expires 365d; + if ($http_accept !~* "webp"){ + break; + } + try_files + /wp-content/webp-express/webp-images/doc-root/$uri.webp + $uri.webp + /wp-content/plugins/webp-express/wod/webp-on-demand.php?xsource=x$request_filename&wp-content=wp-content + ; +} + +# Route requests for non-existing webps to the converter +location ~* ^/?wp-content/.*\.(png|jpe?g)\.webp$ { + try_files + $uri + /wp-content/plugins/webp-express/wod/webp-realizer.php?xdestination=x$request_filename&wp-content=wp-content + ; +} +# ------------------- (WebP Express rules ends here) +``` + +__BEWARE:__ +- Beware that when copy/pasting you might get html-encoded characters. Verify that the ampersand before "wp-content" isn't encoded (in the last line in the try_files block) + +- Beware that the rules looks for existing webp files by appending ".webp" to the URL. So for this to work, you __must__ configure *WebP Express* to store the converted files like that. + +- Beware that if you haven't enabled *png* conversion, you should replace "(png|jpe?g)" with "jpe?g". + +- Beware that if you have moved wp-content to a non-standard place, you must change accordingly. Note that you must then also change the "wp-content" parameter to the script. It expects a relative path to wp-content (from document root) and is needed so the script can find the configuration file. + +- Beware that there is a hack out there for permalinks which is based on "rewrite" (rather than the usual solution which is based on try_files). If you are using that hack to redirect missing files to index.php, you need to modify it as specified [here](https://wordpress.org/support/topic/nginx-server-404-not-found-when-convert-test-images/page/2/#post-11952444) + +- I have put in an expires statement for caching. You might want to modify or disable that. + +- The rules contains all redirections (as if you enabled all three redirection options in settings). If you do not wish to redirect to converter, remove the last line in the try_files block. If you do not wish to create webp files upon request, remove the last location block. + +- If you have configured WebP Express to store images in separate folder, you do not need the "$uri.webp" line in the first "try_files" block. But it doesn't hurt to have it. And beware that the reverse is not true. If configured to store images in the same folder ("mingled"), you still need the line that looks for a webp in the separate folder. The reason for this is that the "mingled" only applies to the images in the upload folder - other images - such as theme images are always stored in a separate folder. + +If you cannot get this to work then perhaps you need to add the following to your *mime.types* configuration file: + `image/webp webp;` + +If you still cannot get it to work, you can instead try the alternative rules below. + +Credits: These rules are builds upon [Eugene Lazutkins solution](http://www.lazutkin.com/blog/2014/02/23/serve-files-with-nginx-conditionally/). + +#### Alternative rules (using "rewrite") + +In case the recommended rules does not work for you, you can try these alternative rules. + +The reason I recommend the *try_files* approach above over these alternative rules is that it is a bit simpler and it is supposed to perform marginally better. These alternative rules are in no way inferior to the other. Choose whatever works! + +__Preparational step:__ +The rules looks for existing webp files by appending ".webp" to the URL. So for this to work, you must configure *WebP Express* to store the converted files like that by setting *General > File extension* to *Append ".webp"*. Also make sure that WebP Express is configured with "Destination" set to "Mingled". + +__The rules:__ +Insert the following in the `server` context of your configuration file (usually found in `/etc/nginx/sites-available`). "The `server` context" refers to the part of the configuration that starts with "server {" and ends with the matching "}". + +```nginx +# WebP Express rules +# -------------------- +location ~* ^/wp-content/.*\.(png|jpe?g)$ { + add_header Vary Accept; + expires 365d; +} +location ~* ^/wp-content/.*\.webp$ { + expires 365d; + if ($whattodo = AB) { + add_header Vary Accept; + } +} +if ($http_accept ~* "webp"){ + set $whattodo A; +} +if (-f $request_filename.webp) { + set $whattodo "${whattodo}B"; +} +if ($whattodo = AB) { + rewrite ^(.*) $1.webp last; +} +if ($whattodo = A) { + rewrite ^/wp-content/.*\.(jpe?g|png)$ /wp-content/plugins/webp-express/wod/webp-on-demand.php?xsource=x$request_filename&wp-content=wp-content break; +} +# ------------------- (WebP Express rules ends here) +``` + +__BEWARE:__ + +- Beware that when copy/pasting you might get html-encoded characters. Verify that the ampersand before "wp-content" isn't encoded (in the last line in the try_files block) + +- Beware that the rules looks for existing webp files by appending ".webp" to the URL. So for this to work, you __must__ configure *WebP Express* to store the converted files like that. + +- Beware that if you haven't enabled *png* conversion, you should replace "(png|jpe?g)" with "jpe?g". + +- Beware that if you have moved wp-content to a non-standard place, you must change accordingly. Note that you must then also change the "wp-content" parameter to the script. It expects a relative path to wp-content (from document root) and is needed so the script can find the configuration file. + +- Beware that there is a hack out there for permalinks which is based on "rewrite" (rather than the usual solution which is based on try_files). If you are using that hack to redirect missing files to index.php, you need to modify it as specified [here](https://wordpress.org/support/topic/nginx-server-404-not-found-when-convert-test-images/page/2/#post-11952444) + +- I have put in an expires statement for caching. You might want to modify or disable that. + +- I have not set any expire on the webp-on-demand.php request. This is not needed, as the script sets this according to what you set up in WebP Express settings. Also, trying to do it would require a new location block matching webp-on-demand.php, but that would override the location block handling php files, and thus break the functionality. + +- There is no longer any reason to add "&$args" to the line begining with "/wp-content". It was there to enable debugging a single image by appending "?debug" to the url. I however removed that functionality from `webp-on-demand.php`. + +It is possible to put this stuff inside a `location` directive. However, having `if` directives inside `location` directives [is considered evil](https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/). But it seems that in our case, it works. If you wish to do that, use the following rules instead: + +```nginx +# WebP Express rules +# -------------------- +location ~* ^/wp-content/.*\.(png|jpe?g)$ { + add_header Vary Accept; + expires 365d; + + if ($http_accept ~* "webp"){ + set $whattodo A; + } + if (-f $request_filename.webp) { + set $whattodo "${whattodo}B"; + } + if ($whattodo = AB) { + rewrite ^(.*) $1.webp last; + } + if ($whattodo = A) { + rewrite ^/wp-content/.*\.(jpe?g|png)$ /wp-content/plugins/webp-express/wod/webp-on-demand.php?xsource=x$request_filename&wp-content=wp-content last; + } +} + +location ~* ^/wp-content/.*\.webp$ { + expires 365d; + if ($whattodo = AB) { + add_header Vary Accept; + } +} +# ------------------- (WebP Express rules ends here) +``` + +PS: In case you only want to redirect images to the script (and not to existing), the rules becomes much simpler: + +```nginx +# WebP Express rules +# -------------------- +if ($http_accept ~* "webp"){ + rewrite ^/(.*).(jpe?g|png)$ /wp-content/plugins/webp-express/wod/webp-on-demand.php?xsource=x$request_filename&wp-content=wp-content break; +} +# ------------------- (WebP Express rules ends here) +``` + +Discussion on this topic [here](https://wordpress.org/support/topic/nginx-rewrite-rules-4/) +And here: https://github.com/rosell-dk/webp-express/issues/166 + +Here are rules if you need to *replace* the file extension with ".webp" rather than appending ".webp" to it: https://www.keycdn.com/support/optimus/configuration-to-deliver-webp + +### I am on a Windows server +Good news! It should work now, thanks to a guy that calls himself lwxbr. At least on XAMPP 7.3.1, Windows 10. https://github.com/rosell-dk/webp-express/pull/213. + +### I am on a Litespeed server +You do not have to do anything special for it to work on a Litespeed server. You should be able to use WebP Express in any operation mode. For best performance, I however recommend that use the *LiteSpeed Cache* plugin for page caching. + +LiteSpeed Cache can be set up to maintain separate page caches for browsers that supports webp and browsers that don't. Through this functionality it is possible to use "Alter HTML" with the option "Replace image URLs" and "Only do the replacements in webp enabled browsers" mode. + +The setup was kindly shared and explained in detail by [@ribeiroeder](https://github.com/ribeiroeder) [here](https://github.com/rosell-dk/webp-express/issues/433) + +### I am using Jetpack +If you install Jetpack and enable the "Speed up image load times" then Jetpack will alter the HTML such that images are pointed to their CDN. + +Ie: +`` + +becomes: +`` + +Jetpack automatically serves webp files to browsers that supports it using same mechanism as the standard WebP Express configuration: If the "Accept" header contains "image/webp", a webp is served (keeping original file extension, but setting the "content-type" header to "image/webp"), otherwise a jpg is served. + +As images are no longer pointed to your original server, the .htaccess rules created by WebP Express will not have any effect. + +So if you are using Jetpack you don't really need WebP Express? +Well, there is no point in having the "Speed up image load times" enabled together with WebP Express. + +But there is a case for using WebP Express rather than Jetpacks "Speed up image load times" feature: + +Jetpack has the same drawback as the *Varied image responses* operation mode: If a user downloads the file, there will be a mismatch between the file extension and the image type (the file is ie called "logo.jpg", but it is really a webp image). I don't think that is a big issue, but for those who do, WebP Express might still be for you, even though you have Jetpack. And that is because WebP Express can be set up just to generate webp's, without doing the internal redirection to webp (will be possible from version 0.10.0). You can then for example use the [Cache Enabler](https://wordpress.org/plugins/cache-enabler/) plugin, which is able to generate and cache two versions of each page. One for browsers that accepts webp and one for those that don't. In the HTML for webp-enabled browsers, the images points directly to the webp files. + +Pro Jetpack: +- It is a free CDN which serves webp out of the box. +- It optimizes jpegs and pngs as well (but note that only about 1 out of 5 users gets these, as webp is widely supported now) + +Con Jetpack: +- It is a big plugin to install if you are only after the CDN +- It requires that you create an account on Wordpress.com + +Pro WebP Express: +- You have control over quality and metadata +- It is a small plugin and care has been taken to add only very little overhead +- Plays well together with Cache Enabler. By not redirecting jpg to webp, there is no need to do any special configuration on the CDN and no issue with misleading file extension, if user downloads a file. + +Con WebP Express: +- If you are using a CDN and you are redirecting jpg to webp, you must configure the CDN to forward the Accept header. It is not possible on all CDNs. + +### Why do I not see the option to set WebP quality to auto? +The option will only display, if your system is able to detect jpeg qualities. To make your server capable to do that, install *Imagick extension* (PECL >= 2.2.2) or enable exec() calls and install either *Imagick* or *Gmagick*. + +If you have the *Imagick*, the *Imagick binary* or the *Remote WebP Express* conversion method working, but don't have the global "auto" option, you will have the auto option available in options of the individual converter. + +Note: If you experience that the general auto option doesn't show, even though the above-mentioned requirements should be in order, check out [this support-thread](https://wordpress.org/support/topic/still-no-auto-option/). + +### How do I configure my CDN ("Varied image responses" mode)? +In *Varied image responses* operation mode, the image responses *varies* depending on whether the browser supports webp or not (which browsers signals in the *Accept* header). Some CDN's support this out of the box, others requires some configuration and others doesn't support it at all. + +For a CDN to cooperate, it needs to +1) forward the *Accept* header and +2) Honour the Vary:Accept response header. + +You can also make it "work" on some CDN's by bypassing cache for images. But I rather suggest that you try out the *CDN friendly* mode (see next FAQ item) + +#### Status of some CDN's + +- *KeyCDN*: Does not support varied image responses. I have added a feature request [here](https://community.keycdn.com/t/support-vary-accept-header-for-conditional-webp/1864). You can give it a +1 if you like! +- *Cloudflare*: See the "I am on Cloudflare" item +- *Cloudfront*: Works, but needs to be configured to forward the accept header. Go to *Distribution settings*, find the *Behavior tab*, select the Behavior and click the Edit button. Choose *Whitelist* from *Forward Headers* and then add the "Accept" header to the whitelist. + +I shall add more to the list. You are welcome to help out [here](https://wordpress.org/support/topic/which-cdns-works-in-standard-mode/). + +### How do I make it work with CDN? ("CDN friendly" mode) +In *CDN friendly* mode, there is no trickery with varied image responses, so no special attention is required *on the CDN*. + +However, there are other pitfalls. + +The thing is that, unless you have the whole site on a CDN, you are probably using a plugin that *alters the HTML* in order to point your static assets to the CDN. If you have enabled the "Alter HTML" in WebP Express, it means that you now have *two alterations* on the image URLs! + +How will that play out? + +Well, if *WebP Express* gets to alter the HTML *after* the image URLs have been altered to point to a CDN, we have trouble. WebP Express does not alter external images but the URLs are now external. + +However, if *WebP Express* gets to alter the HTML *before* the other plugin, things will work fine. + +So it is important that *WebP Express* gets there first. + +*The good news is that WebP Express does get there first on all the plugins I have tested.* + +But what can you do if it doesn't? + +Firstly, you have an option in WebP Express to select between: +1. Use content filtering hooks (the_content, the_excerpt, etc) +2. The complete page (using output buffering) + +The content filtering hooks gets to process the content before output buffering does. So in case output buffering isn't early enough for you, choose the content filtering hooks. + +There is a risk that you CDN plugin also uses content filtering hooks. I haven't encountered any though. But if there is any out there that does, chances are that they get to process the content before WebP Express, because I have set the priority of these hooks quite high (10000). The reasoning behind this is to that we want to replace images that might be inserted using the same hook (for example, a theme might use *the_content* filter to insert the featured image). If you do encounter a plugin for changing URLs for CDN which uses the content filtering hooks, you are currently out of luck. Let me know, so I can fix that (ie. by making the priority configurable) + +Here are a list of some plugins for CDN and when they process the HTML: + +| Plugin | Method | Hook(s) | Priority +| ----------------- | ------------------ | ------------------------------------------------ | --------------- +| BunnyCDN | Output buffering | template_redirect | default (10) +| CDN enabler | Output buffering | template_redirect | default (10) +| Jetpack | content filtering | the_content, etc | the_content: 10 +| W3 Total Cache | Output buffering | no hooks. Buffering is started before hooks | +| WP Fastest Cache | Output buffering | no hooks. Buffering is started before hooks | +| WP Super Cache | Output buffering | init | default (10) + + +With output buffering the plugin that starts the output buffering first gets to process the output last. So WebP Express starts as late as possible, which is on the `template_redirect` hook, with priority 10000 (higher means later). This is later than the `init` hook, which is again later than the `no hooks`. + + +### I am on Cloudflare +Without configuration, Cloudflare will not maintain separate caches for jpegs and webp; all browsers will get jpeg. To make Cloudflare cache not only by URL, but also by header, you need to use the [Custom Cache Key](https://support.cloudflare.com/hc/en-us/articles/115004290387) page rule, and add *Header content* to make separate caches depending on the *Accept* request header. + +However, the *Custom Cache Key* rule currently requires an *Enterprise* account. And if you already have that, you may as well go with the *Polish* feature, which starts at the “Pro” level plan. With the *Polish* feature, you will not need WebP Express. + +To make *WebP Express* work on a free Cloudflare account, you have the following choices: + +1. You can configure the CDN not to cache jpeg images by adding the following page rule: If rule matches: `example.com/*.jpg`, set: *Cache level* to: *Bypass* + +2. You can set up another CDN (on another provider), which you just use for handling the images. You need to configure that CDN to forward the *Accept header*. You also need to install a Wordpress plugin that points images to that CDN. + +3. You can switch operation mode to "CDN friendly" and use HTML altering. + +### I am on WP Engine +From version 0.17.1 on WebP Express works with WP engine and this combination will be tested before each release. + +You can use the plugin both in "Varied image responses" mode and in "CDN friendly mode". + +To make the redirection work, you must: +1) Grab the nginx configuration found in the "I am on Nginx/OpenResty" section in this FAQ. Use the "try_files" variant. +2) Contact help center and ask them to insert that configuration. +3) Make sure the settings match this configuration. Follow the "beware" statements in the "I am on Nginx/OpenResty" section. + +WebP Express tweaks the workings of "Redirect to converter" a bit for WP engine. That PHP script usually serves the webp directly, along with a Vary:Accept header. This header is however overwritten by the caching machinery on WP engine. As a work-around, I modified the response of the script for WP engine. Instead of serving the image, it serves a redirect to itself. As there now IS a corresponding webp, this repeated request will not be redirected to the PHP script, but directly to the webp. And headers are OK for those redirects. You can hit the "Live test" button next to "Enable redirection to converter?" to verify that this works as just described. + +If you (contrary to this headline!) are in fact not on WP Engine, but might want to be, I have an affiliate link for you. It will give you 3 months free and it will give me a reward too, if you should decide to stay there. Here you go: [Get 3 months free when you sign up for WP Engine.](https://shareasale.com/r.cfm?b=1343154&u=2194916&m=41388&urllink=&afftrack=) + +### WebP Express / ShortPixel setup +Here is a recipe for using WebP Express together with ShortPixel, such that WebP Express generates the webp's, and ShortPixel only is used to create `` tags, when it detects a webp image in the same folder as an original. + +**There is really no need to do this anymore, because WebP Express is now capable of replacing img tags with picture tags (check out the Alter HTML option)** + +You need: +1 x WebP Express +1 x ShortPixel + +*1. Setup WebP Express* +If you do not want to use serve varied images: +- Open WebP Express options +- Switch to *CDN friendly* mode. +- Set *File extension* to "Set to .webp" +- Make sure the *Convert non-existing webp-files upon request to original image* option is enabled + +If you want to *ShortPixel* to create tags but still want the magic to work on other images (such as images are referenced from CSS or javascript): +- Open WebP Express options +- Switch to *Varied image responses* mode. +- Set *Destination folder* to "Mingled" +- Set *File extension* to "Set to .webp" + +*2. Setup ShortPixel* +- Install [ShortPixel](https://wordpress.org/plugins/shortpixel-image-optimiser/) the usual way +- Get an API key and enter it on the options page. +- In *Advanced*, enable the following options: + - *Also create WebP versions of the images, for free.* + - *Deliver the WebP versions of the images in the front-end* + - *Altering the page code, using the tag syntax* +- As there is a limit to how many images you can convert freely with *ShortPixel*, you should disable the following options (also on the *Advanced* screen): + - *Automatically optimize images added by users in front end.* + - *Automatically optimize Media Library items after they are uploaded (recommended).* + +*3. Visit a page* +As there are presumably no webps generated yet, ShortPixel will not generate `` tags on the first visit. However, the images that are referenced causes the WebP Express *Auto convert* feature to kick in and generate webp images for each image on that page. + +*4. Visit the page again* +As *WebP Express* have generated webps in the same folder as the originals, *ShortPixel* detects these, and you should see `` tags which references the webp's. + +*ShortPixel or Cache Enabler ?* +Cache Enabler has the advantage over ShortPixel that the HTML structure remains the same. With ShortPixel, image tags are wrapped in a `` tag structure, and by doing that, there is a risk of breaking styles. + +Further, Cache Enabler *caches* the HTML. This is good for performance. However, this also locks you to using that plugin for caching. With ShortPixel, you can keep using your favourite caching plugin. + +Cache Enabler will not work if you are caching HTML on a CDN, because the HTML varies depending on the *Accept* header and it doesn't signal this with a Vary:Accept header. You could however add that manually. ShortPixel does not have that issue, as the HTML is the same for all. + +### WebP Express / Cache Enabler setup +The WebP Express / Cache Enabler setup is quite potent and very CDN-friendly. *Cache Enabler* is used for generating *and caching* two versions of the HTML (one for webp-enabled browsers and one for webp-disabled browsers) + +The reason for doing this could be: +1. You are using a CDN which cannot be configured to work in the "Varied image responses" mode. +2. You could tweak your CDN to work in the "Varied image responses" mode, but you would have to do it by using the entire Accept header as key. Doing that would increase the risk of cache MISS, and you therefore decided that do not want to do that. +3. You think it is problematic that when a user saves an image, it has the jpg extension, even though it is a webp image. + +You need: +1 x WebP Express +1 x Cache Enabler + +*1. Setup WebP Express* +If you do not want to use serve varied images: +- Open WebP Express options +- Switch to *CDN friendly* mode. +- Set *File extension* to "Set to .webp" +- Enable *Alter HTML* and select *Replace image URLs*. It is not absolutely necessary, as Cache Enabler also alters HTML - but there are several reasons to do it. Firstly, *Cache Enabler* doesn't get as many URLs replaced as we do. WebP Express for example also replaces background urls in inline styles. Secondly, *Cache enabler* has [problems in edge cases](https://regexr.com/46isf). Thirdly, WebP Express can be configured to alter HTML to point to corresponding webp images, *before they even exists* which can be used in conjunction with the the *Convert non-existing webp-files upon request* option. And this is smart, because then you don't have trouble with *Cache Enabler* caching HTML which references the original images due to that some images hasn't been converted yet. +- If you enabled *Alter HTML*, also enable *Reference webps that hasn't been converted yet* and *Convert non-existing webp-files upon request* +- If you did not enable *Alter HTML*, enable *Convert non-existing webp-files upon request to original image* + +If you want to *Cache Enabler* to create tags but still want the magic to work on other images (such as images are referenced from CSS or javascript): +- Open WebP Express options +- Switch to *Varied image responses* mode. +- Set *Destination folder* to "Mingled" +- Set *File extension* to "Set to .webp" +- I suggest you enable *Alter HTML* and select *Replace image URLs*. And also enable *Reference webps that hasn't been converted yet* and *Convert non-existing webp-files upon request*. + +*2. Setup Cache Enabler* +- Open the options +- Enable of the *Create an additional cached version for WebP image support* option + +*3. If you did not enable Alter HTML and Reference webps that hasn't been converted yet: Let rise in a warm place until doubled* +*WebP Express* creates *webp* images on need basis. It needs page visits in order to do the conversions . Bulk conversion is on the roadmap, but until then, you need to visit all pages of relevance. You can either do it manually, let your visitors do it (that is: wait a bit), or, if you are on linux, you can use `wget` to grab your website: + +``` +wget -e robots=off -r -np -w 2 http://www.example.com +``` + +**flags:** +`-e robots=off` makes wget ignore rules in robots.txt +`-np` (no-parent) makes wget stay within the boundaries (doesn't go into parent folders) +`w 2` Waits two seconds between each request, in order not to stress the server + +*4. Clear the Cache Enabler cache.* +Click the "Clear Cache" button in the top right corner in order to clear the Cache Enabler cache. + +*5. Inspect the HTML* +When visiting a page with images on, different HTML will be served to browsers, depending on whether they support webp or not. + +In a webp-enabled browser, the HTML may look like this: ``, while in a non-webp enabled browser, it looks like this: `` + + +*6. Optionally add Cache Enabler rewrite rules in your .htaccess* +*Cache Enabler* provides some rewrite rules that redirects to the cached file directly in the `.htaccess`, bypassing PHP entirely. Their plugin doesn't do that for you, so you will have to do it manually in order to get the best performance. The rules are in the "Advanced configuration" section on [this page](https://www.keycdn.com/support/wordpress-cache-enabler-plugin). + + +### Does it work with lazy loaded images? +No plugins/frameworks has yet been discovered, which does not work with *WebP Express*. + +The most common way of lazy-loading is by setting a *data-src* attribute on the image and let javascript use that value for setting the *src* attribute. That method works, as the image request, seen from the server side, is indistinguishable from any other image request. It could however be that some obscure lazy load implementation would load the image with an XHR request. In that case, the *Accept* header will not contain 'image/webp', but '*/*', and a jpeg will be served, even though the browser supports webp. + +The following lazy load plugins/frameworks has been tested and works with *WebP Express*: +- [BJ Lazy Load](https://da.wordpress.org/plugins/bj-lazy-load/) +- [Owl Carousel 2](https://owlcarousel2.github.io/OwlCarousel2/) +- [Lazy Load by WP Rocket](https://wordpress.org/plugins/rocket-lazy-load/) + +I have only tested the above in *Varied image responses* mode, but it should also work in *CDN friendly* mode. Both *Alter HTML* options have been designed to work with standard lazy load attributes. + +### Can I make an exceptions for some images? +There can be instances where you actually need to serve a jpeg or png. For example if you are demonstrating how a jpeg looks using some compression settings. + +If you want an image to be served in the original format (jpeg og png), do one of the following things: +- Add "?original" to the image url. +- Place an empty file in the same folder as the jpeg/png. The file name must be the same as the jpeg/png with ".do-not-convert" appended + +Doing this will bypass redirection to webp and also prevent Alter HTML to use the webp instead of the original. + +*Bypassing for an entire folder* +To bypass redirection for an entire folder, you can put something like this into your index `.htaccess`: +``` +RewriteRule ^wp-content/uploads/2021/06/ - [L] +``` +PS: If *WebP Express* has placed rules in that .htaccess, you need to place the rule *above* the rules inserted by *WebP Express* + +If you got any further questions, look at, or comment on [this topic](https://wordpress.org/support/topic/can-i-make-an-exception-for-specific-post-image/) + +### Alter HTML only replaces some of the images + +If you are wondering why Alter HTML are missing some images, it can be due to one of the following reasons: + +- WebP Express doesn't convert external images, only images on your server. Alter HTML will therefore not alter URLS that unless they point to your server or a CDN that you have added in the *CDN hostnames* section +- WebP Express has a "Scope" option, which for example can be set to "Uploads and themes". Only images that resides within the selected scope are replaced with webp. +- If you have selected `` tags syntax, only images inserted with ``-tags will be replaced (CSS images will not be replaced). Additionally, the ``-tag must have a "src" attribute or a commonly used data attribute for lazyloading (such as “data-src” or “data-lazy-src”) +- If you have set the "How to replace" option to "Use content filtering hooks", images inserted with some third party plugins/themes might not be replaced. To overcome that, change that setting to "The complete page". +- The image might have been inserted with javascript. WebP Express doesn't changSome plugins might insert the + +### Update failed and cannot reinstall +The 0.17.0 release contained binaries with dots in their filenames, which caused the unpacking during update to fail on a few systems. This failure could leave an incomplete installation. With important files missing - such as the main plugin file - Wordpress no longer registers that the plugin is there (it is missing from the list). However, the folder is there in the file system and trying to install WebP Express again fails because Wordpress complains about just that. The solution is to remove the "webp-express" folder in "plugins" manually (via ftp or a plugin, such as File Manager) and then install WebP Express anew. The setting will be intact. The filenames that caused the trouble where fixed in 0.17.2. + +### When is feature X coming? / Roadmap +No schedule. I move forward as time allows. I currently spend a lot of time answering questions in the support forum. If someone would be nice and help out answering questions here, it would allow me to spend that time developing. Also, donations would allow me to turn down some of the more boring requests from my customers, and speed things up here. + +Right now I am focusing on the File Manager. I would like to add possibility for converting, bulk converting, viewing conversion logs, viewing stats, etc. + +Here are other things in pipeline: +- Excluding certain files and folders. +- Supporting Save-Data header in Varied Image Responses mode (send extra compressed images to clients who wants to use as little bandwidth as possible). +- Displaying rules for NGINX. +- Allow webp for all browsers using [this javascript library](http://libwebpjs.hohenlimburg.org/v0.6.0/). Unfortunately, the javascript library does not (currently) support srcset attributes, which is why I moved this item down the priority list. We need srcset to be supported for the feature to be useful. + +The current milestones, their subtasks and their progress can be viewed here: https://github.com/rosell-dk/webp-express/milestones + +If you wish to affect priorities, it is certainly possible. You can try to argue your case in the forum or you can simply let the money do the talking. By donating as little as a cup of coffee on [ko-fi.com/rosell](https://ko-fi.com/rosell), you can leave a wish. I shall take these wishes into account when prioritizing between new features. + +### Beta testing +I generally create a pre-release before publishing. If you [follow me on ko-fi](https://ko-fi.com/rosell), you will get notified when a pre-release is available. I generally create a pre-release on fridays and mark it as stable on mondays. In order to download a pre-release, go to [the advanced page](https://wordpress.org/plugins/webp-express/advanced/) and scroll down to "Please select a specific version to download". I don't name the pre-releases different. You will just see the next version here before it is available the usual way. + +## Changes in 0.21.1 +*(released: 27 Oct 2021)* +* Bugfix: File manager could not handle many images. It now loads tree branches on need basis instead of the complete tree in one go +* Bugfix: For mulisite, the redirect after settings save was not working (bug introduced in 0.21.0) + +For more info, see the closed issues on the [webp-express 0.21.1 milestone](https://github.com/rosell-dk/webp-express/milestone/42?closed=1) + +## Changes in 0.21.0 +*(released: 25 Oct 2021)* +* Added image browser (in Media tab) +* Updated webp convert library to 2.7.0. +* ImageMagick now supports the "near-lossless" option (provided Imagick >= 7.0.10-54) +* Added "try-common-system-paths" option for ImageMagick (default: true). Thanks to Henrik Alves for adding this option. +* Bugfix: Handling Uncaught Fatal Exception during .htaccess read failure. Thanks to Manuel D'Orso from Italy for the fix. +* Bugfix: File names which were not UTF8 caused trouble in the Bulk Convert. Thank to "mills4078" for the fix +* Bugfix: Redirection back to settings after saving settings failed on some systems. Thanks to Martin Rehberger (@kingkero) from Germany for the fix. +* Bugfix: Webp urls did not contain port number (only relevant when the website was not on default port number). Thanks to Nicolas LIENART (@nicolnt) from France for providing the fix. + +For more info, see the closed issues on the [webp-express 0.21.0 milestone](https://github.com/rosell-dk/webp-express/milestone/41?closed=1) and the +[webp-convert 2.7.0 milestone](https://github.com/rosell-dk/webp-convert/milestone/24?closed=1) + + +## Changes in 0.20.1 +*(released: 20 Jun 2021)* +* Bugfix: Removed composer.lock. It was locked on PHP 7.2, which caused server error on some sites. + +## Changes in 0.20.0 +*(released: 17 Jun 2021)* +* Added WP CLI support. Add "wp webp-express convert" to crontab for nightly conversions of new images! Thanks to Isuru Sampath Ratnayake from Sri Lanka for initializing this. +* Added "sharp-yuv" (not as option, but as always on). Better YUV->RGB color conversion at almost no price. [Read more here](https://www.ctrl.blog/entry/webp-sharp-yuv.html). Supported by cwebp, vips, gmagick, graphicsmagick, imagick and imagemagick +* Bumped cwebp binaries to 1.2.0 +* cwebp now only validates hash of supplied precompiled binaries when necessary. This cuts down conversion time. +* Convert on upload now defaults to false, as it may impact upload experience in themes with many formats. +* bugfix: Alpha quality was saved incorrectly for PNG. Thanks to Chriss Gibbs from the UK for finding and fixing this. +* bugfix: wp-debug log could be flooded with "Undefined index: HTTP_ACCEPT". Thanks to @markusreis for finding and fixing this. + +## Changes in 0.19.0 +*(released: 13 Nov 2020)* +* New convertion method: ffmpeg +* Fixed problem in Bulk Convert when files had special characters in their filename +* Prevented problems if the plugin gets included twice (can anybody enlighten me on how this might happen?) + +For more info, see the closed issues on the [0.19.0 milestone on the github repository](https://github.com/rosell-dk/webp-express/milestone/36?closed=1) + +## Changes in 0.18.3 +*(released: 5 Nov 2020)* +* Bugfix: WebP Express uses live tests to determine the capabilities of the server in respect to .htaccess files (using the htaccess-capability-tester library). The results are used for warnings and also for optimizing the rules in the .htaccess files. However, HTTP Requests can fail due to other reasons than the feature not working (ie timeout). Such failures should lead to an indeterminate result, but it was interpreted as if the feature was not working. +* The Live test now displays a bit more information if the HTTP request failed. +* Changed default value for "destination structure" to "Image roots", as "Document root" doesn't work on hosts that have defined DOCUMENT_ROOT in an unusual way. +* Added possibility to change "%{DOCUMENT_ROOT}" part of RewriteCond by adding a line to wp-config.php. THIS IS A BETA FEATURE AND MIGHT BE REVOKED IF NOBODY ACTUALLY NEEDS IT. +* Got rid of PHP notice Constant WEBPEXPRESS_MIGRATION_VERSION already defined +* Fixed donation link. It now points to https://ko-fi.com/rosell again + +For more info, see the closed issues on the 0.18.3 milestone on the github repository: https://github.com/rosell-dk/webp-express/milestone/34?closed=1 + +## Supporting WebP Express +Bread on the table don't come for free, even though this plugin does, and always will. I enjoy developing this, and supporting you guys, but I kind of need the bread too. Please make it possible for me to continue putting effort into this plugin: + +- [Buy me a coffee](https://ko-fi.com/rosell) +- [Buy me coffee on a regular basis](https://github.com/sponsors/rosell-dk) and help ensuring my coffee supplies doesn't run dry. + + +**Persons / Companies currently backing the project via patreon - Thanks!** + +- Max Kreminsky +- Nodeflame +- [Mathieu Gollain-Dupont](https://www.linkedin.com/in/mathieu-gollain-dupont-9938a4a/) +- Ruben Solvang + +**Persons who contributed with coffee within the last 30 days:** + +| Name | Date | Message | +| ---------------------- | -------------- | ------------ | +| Anon | 2020-08-18 | - | +| Eder Ribeiro | 2020-08-08 | Hello Bjørn I believe that it is a fantastic solution and that it deserves maximum support to keep getting better! If you can, check out my configuration tip, https://github.com/rosell-dk/webp-express/issues/433 | +| Christian | 2020-08-05 | Merci pour votre plugin. Exceptionnel et gratuit. | + + +**Persons who contributed with extra generously amounts of coffee / lifetime backing (>50$) - thanks!:** + +| Name | Amount | Message | +| ---------------------- | -----------| --------- | +| Justin - BigScoots | $105 | Such an amazing plugin for so many reasons, thank you! | +| Sebastian | $99 | WebP for Wordpress – what a great plugin! Thank you! | +| Tammy Lee | $90 | | +| Max Kreminsky | $65 | | +| Steven Sullivan | $51 | Thank you for such a wonderful plugin. | diff --git a/README.txt b/README.txt new file mode 100755 index 0000000..c0b2652 --- /dev/null +++ b/README.txt @@ -0,0 +1,1081 @@ +=== WebP Express === +Contributors: rosell.dk +Donate link: https://ko-fi.com/rosell +Tags: webp, images, performance +Requires at least: 4.0 +Tested up to: 6.5 +Stable tag: 0.25.9 +Requires PHP: 5.6 +License: GPLv3 +License URI: https://www.gnu.org/licenses/gpl-3.0.html + +Serve autogenerated WebP images instead of jpeg/png to browsers that supports WebP. + +== Description == + +More than 9 out of 10 users are using a browser that is able to display webp images. Yet, on most websites, they are served jpeg images, which are typically double the size of webp images for a given quality. What a waste of bandwidth! This plugin was created to help remedy that situation. With little effort, Wordpress admins can have their site serving autogenerated webp images to browsers that supports it, while still serving jpeg and png files to browsers that does not support webp. + +### The image converter +The plugin uses the [WebP Convert](https://github.com/rosell-dk/webp-convert) library to convert images to webp. *WebP Convert* is able to convert images using multiple methods. There are the "local" conversion methods: `imagick`, `cwebp`, `vips`, `gd`. If none of these works on your host, there are the cloud alternatives: `ewww` (paid) or connecting to a Wordpress site where you got WebP Express installed and you enabled the "web service" functionality. + +### The "Serving webp to browsers that supports it" part. + +The plugin supports different ways of delivering webps to browsers that supports it: + +1. By routing jpeg/png images to the corresponding webp - or to the image converter if the image hasn't been converted yet. +2. By altering the HTML, replacing image tags with *picture* tags. Missing webps are auto generated upon visit. +3. By altering the HTML, replacing image URLs so all points to webp. The replacements only being made for browsers that supports webp. Again, missing webps are auto generated upon visit. +4. In combination with *Cache Enabler*, the same as above can be achieved, but with page caching. +5. You can also deliver webp to *all* browsers and add the [webpjs](http://webpjs.appspot.com) javascript, which provides webp support for browsers that doesn't support webp natively. However, beware that the javascript doesn't support srcset attributes, which is why I haven't added that method to the plugin (yet). + +The plugin implements the "WebP On Demand" solution described [here](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/webp-on-demand/webp-on-demand.md) and builds on a bunch of open source libraries (all maintained by me): +- [WebP Convert](https://github.com/rosell-dk/webp-convert): For converting images to webp +- [WebP Convert Cloud Service](https://github.com/rosell-dk/webp-convert-cloud-service): For the Web Service functionality +- [DOM Util for WebP](https://github.com/rosell-dk/dom-util-for-webp): For the Alter HTML functionality +- [Image MimeType Guesser](https://github.com/rosell-dk/image-mime-type-guesser): For detecting mime types of images. +- [HTAccess Capability Tester](https://github.com/rosell-dk/htaccess-capability-tester): For testing .htaccess capabilities in a given directory, using live tests +- [WebP Convert File Manager](https://github.com/rosell-dk/webp-convert-filemanager): For browsing conversions and triggering conversions. +- [Exec With Fallback](https://github.com/rosell-dk/exec-with-fallback): For emulating exec() on systems where it is disabled (using proc_open(), passthru() or similar alternatives). + +### Benefits +- Much faster load time for images in browsers that supports webp. The converted images are typically *less than half the size* (for jpeg), while maintaining the same quality. Bear in mind that for most web sites, images are responsible for the largest part of the waiting time. +- Better user experience (whether performance goes from terrible to bad, or from good to impressive, it is a benefit). +- Better ranking in Google searches (performance is taken into account by Google). +- Less bandwidth consumption - makes a huge difference in the parts of the world where the internet is slow and costly (you know, ~80% of the world population lives under these circumstances). +- Currently ~97% of all traffic are done with browsers supporting webp. +- It's great for the environment too! Reducing network traffic reduces electricity consumption which reduces CO2 emissions. + +== Installation == + +1. Upload the plugin files to the `/wp-content/plugins/webp-express` directory, or install the plugin through the WordPress plugins screen directly. +2. Activate the plugin through the 'Plugins' screen in WordPress +3. Configure it (the plugin doesn't do anything until configured) +4. Verify that it works +5. (Optional) Bulk convert all images, either in the admin ui or using WP CLI (command: "webp-express") + +### Configuring +You configure the plugin in *Settings > WebP Express*. + +#### Operation modes +As sort of a main switch, you can choose between the following modes of operation: + +*Varied image responses*: +WebP Express creates redirection rules for images, such that a request for a jpeg will result in a webp – but only if the request comes from a webp-enabled browser. If a webp already exists, it is served immediately. Otherwise it is converted and then served. Note that not all CDN's handles varied responses well. + +*CDN friendly*: +In "CDN friendly" mode, a jpeg is always served as a jpeg. Instead of varying the image response, WebP Express alters the HTML for webp usage. + +*Just redirect*: +In "just redirect" mode, WebP Express is used just for redirecting jpeg and pngs to existing webp images in the same folder. So in this mode, WebP express will not do any converting. It may be that you use another plugin for that, or that you converted the images off-line and uploaded them manually. + +*Tweaked*: +Here you have all options available. + +#### Conversion methods +WebP Express has a bunch of methods available for converting images: Executing cwebp binary, Gd extension, Imagick extension, ewww cloud converter and remote WebP express. Each requires *something*. In many cases, one of the conversion methods will be available. You can quickly identify which converters are working - there is a green icon next to them. Hovering conversion methods that are not working will show you what is wrong. + +In case no conversion methods are working out of the box, you have several options: +- You can install this plugin on another website, which supports a local conversion method and connect to that using the "Remote WebP Express" conversion method +- You can [purchase a key](https://ewww.io/plans/) for the ewww cloud converter. They do not charge credits for webp conversions, so all you ever have to pay is the one dollar start-up fee :) +- You can set up [webp-convert-cloud-service](https://github.com/rosell-dk/webp-convert-cloud-service) on another server and connect to that. Its open source. +- You can try to meet the server requirements of cwebp, gd, imagick or gmagick. Check out [this wiki page](https://github.com/rosell-dk/webp-convert/wiki/Meeting-the-requirements-of-the-converters) on how to do that + +### Quality detection of jpegs +If your server has Imagick extension or is able to execute imagemagick binary, the plugin will be able to detect the quality of a jpeg, and use that quality for the converted webp. You can tell if the quality detection is available by hovering the help icon in Conversion > Jpeg options > Quality for lossy. The last line in that help text tells you. + +This auto quality has benefits over fixed quality as it ensures that each conversion are converted with an appropriate quality. Encoding low quality jpegs to high quality webps does not magically increase the visual quality so that your webp looks better than the original. But it does result in a much larger filesize than if the jpeg where converting to a webp with the same quality setting as the original. + +If you do not have quality detection working, you can try one of the following: +- Install Imagick on the server (for this purpose, it is not required that it is compiled with WebP support) +- Install imagemagick on the server and grant permission for PHP to use the "exec" function. +- Use "Remote WebP Express" converter to connect to a site, that *does* have quality detection working +- If you have cwebp converter available, you can configure it to aim for a certain reduction, rather than using the quality parameter. Set this to for example 50%, or even 45%. + +### Verifying that it works (in "Varied image responses" mode) +1. Make sure at least one of the conversion methods are working. It should have a green checkmark next to it. +2. If you haven't saved yet, click "Save settings". This will put redirection rules into .htaccess files in the relevant directories (typically in uploads, themes and wp-content/webp-express/webp-images, depending on the "Scope" setting) +3. I assume that you checked at least one of the two first checkboxes in the .htaccess rules section. Otherwise you aren't using "varied responses", and then the "CDN friendly" mode will be more appropriate. +4. Click the "Live test" buttons to see that the enabled rules actually are working. If they are not, it *could* be that the server needs a little time to recognize the changed rules. + +The live tests are quite thorough and I recommend them over a manual test. However, it doesn't hurt to do a manual inspection too. + +*Doing a manual inspection* + +Note that when WebP Express is serving varied image responses, the image URLs *still points to the jpg/png*. If the URL is visited using a browser that supports webp, however, the response will be a webp image. So there is a mismatch between the file extension (the filename ends with "jpg" or "png") and the file type. But luckily, the browser does not rely on the extension to determine the file type, it only looks at the Content-Type response header. + +To verify that the plugin is working (without clicking the test button), do the following: + +- Open the page in a browser that supports webp, ie Google Chrome +- Right-click the page and choose "Inspect" +- Click the "Network" tab +- Reload the page +- Find a jpeg or png image in the list. In the "type" column, it should say "webp" + +You can also look at the headers. When WebP Express has redirected to an existing webp, there will be a "X-WebP-Express" header with the following value: "Redirected directly to existing webp". If there isn't (and you have checked "Enable redirection to converter"), you should see a "WebP-Convert-Log" header (WebP-Express uses the [WebP Convert](https://github.com/rosell-dk/webp-convert) for conversions). + +### Notes + +*Note:* +The redirect rules created in *.htaccess* are pointing to a PHP script. If you happen to change the url path of your plugins, the rules will have to be updated. The *.htaccess* also passes the path to wp-content (relative to document root) to the script, so the script knows where to find its configuration and where to store converted images. So again, if you move the wp-content folder, or perhaps moves Wordpress to a subfolder, the rules will have to be updated. As moving these things around is a rare situation, WebP Express are not using any resources monitoring this. However, it will do the check when you visit the settings page. + +*Note:* +Do not simply remove the plugin without deactivating it first. Deactivation takes care of removing the rules in the *.htaccess* file. With the rules there, but converter gone, your Google Chrome visitors will not see any jpeg images. + +### Bulk convert +You can start a bulk conversion two ways: +1. In the admin UI. On the settings screen, there is a "Bulk Convert" button +2. By using WP CLI (command: "webp-express"). + +I'm currently working on a file manager interface, which will become a third way. + +### Making sure new images becomes converted +There are several ways: +1. Enable redirection to converter in the *.htaccess rules* section. +2. Enable "Convert on upload". Note that this may impact upload experience in themes which defines many formats. +3. Set up a cron job, which executes `wp webp-express convert` regularily + +### WP CLI command +WebP Express currently supports commands for converting and flushing webp images throug the CLI. You can use the --help option to learn about the options: +*wp webp-express --help*. Displays the available commands +*wp webp-express convert --help*. Displays the available options for the "convert" command. + +A few examples: +*wp webp-express convert*: Creates webp images for all unconverted images +*wp webp-express convert --reconvert*: Also convert images that are already converted +*wp webp-express convert themes*: Only images in the themes folder +*wp webp-express convert uploads/2021*: Only images in the "2021" folder inside the uploads folder +*wp webp-express convert --only-png*: Only the PNG images +*wp webp-express convert --quality=50*: Use quality 50 (instead of what was entered in settings screen) +*wp webp-express convert --converter=cwebp*: Specifically use cwebp converter. + +*wp webp-express flushwebp*: Remove all webp images +*wp webp-express flushwebp --only-png*: Remove all webp images that are conversions of PNG images + +Synopsises: +` +wp webp-express convert [] [--reconvert] [--only-png] [--only-jpeg] [--quality=] [--near-lossless=] [--alpha-quality=] [--encoding=] [--converter=] +wp webp-express flushwebp [--only-png] +` + +I'm considering adding commands for viewing status, viewing conversion stats, generating the .htaccess files and modifying the settings. Please let me know if you need any of these or perhaps something else. + +== Limitations == + +* The plugin [should now work on Microsoft IIS server](https://github.com/rosell-dk/webp-express/pull/213), but it has not been tested thoroughly. + +== Supporting WebP Express == +Bread on the table don't come for free, even though this plugin does, and always will. I enjoy developing this, and supporting you guys, but I kind of need the bread too. Please make it possible for me to continue wasting time on this plugin: + +* [Buy me a Coffee](https://ko-fi.com/rosell) +* [Buy me coffee on a regular basis](https://github.com/sponsors/rosell-dk) and help ensuring my coffee supplies doesn't run dry. + +== Supporters of WebP Express == + +**Persons who recently contributed with [ko-fi](https://ko-fi.com/rosell) - Thanks!** + +* 3 Nov: Tobi +* 5 Nov: Anon +* 18 Nov: Oleksii +* 20 Feb: Assen Kovatchev +* 22 Feb: Peter +* 29 Feb: Luis Méndez Alejo +* 5 Mar: tomottoe +* 9 Mar: La Braud + +**Persons who contributed with extra generously amounts of coffee / lifetime backing (>30$) - thanks!:** + +* Max Kreminsky ($115) +* Justin - BigScoots ($105) +* Bill Vallance ($102) +* Label Vier ($100) +* Sebastian ($99) +* Tammy Lee ($90) +* Steven Sullivan ($51) +* Mathieu Gollain-Dupont ($50) +* Erica Dreisbach ($50) +* Brian Laursen ($50) +* Dimitris Vayenas ($50) + +**Persons currently backing the project via GitHub Sponsors or patreon - Thanks!** + +* [Mathieu Gollain-Dupont](https://www.linkedin.com/in/mathieu-gollain-dupont-9938a4a/) + +== Frequently Asked Questions == + += How do I verify that the plugin is working? = + +**Verifying that the plugin works in "Varied image responses" mode** +1. Make sure at least one of the conversion methods are working. It should have a green checkmark next to it. +2. If you haven't saved yet, click "Save settings". This will put redirection rules into .htaccess files in the relevant directories (typically in uploads, themes and wp-content/webp-express/webp-images, depending on the "Scope" setting) +3. I assume that you checked at least one of the checkboxes in the .htaccess rules section - otherwise you might as well change to "CDN friendly" mode. The first +4. Click the "Live test" buttons to see that the enabled rules actually are working. If they are not, it *could* be that the server needs a little time to recognize the changed rules. + +The live tests are quite thorough and I recommend them over a manual test. However, it doesn't hurt to do a manual inspection too. + +*Doing a manual inspection* + +Note that when WebP Express is serving varied image responses, the image URLs *still points to the jpg/png*. If the URL is visited using a browser that supports webp, however, the response will be a webp image. So there is a mismatch between the file extension (the filename ends with "jpg" or "png") and the file type. But luckily, the browser does not rely on the extension to determine the file type, it only looks at the Content-Type response header. + +To verify that the plugin is working (without clicking the test button), do the following: + +- Open the page in a browser that supports webp, ie Google Chrome +- Right-click the page and choose "Inspect" +- Click the "Network" tab +- Reload the page +- Find a jpeg or png image in the list. In the "type" column, it should say "webp" + +You can also look at the headers. When WebP Express has redirected to an existing webp, there will be a "X-WebP-Express" header with the following value: "Redirected directly to existing webp". If there isn't (and you have checked "Enable redirection to converter"), you should see a "WebP-Convert-Log" header (WebP-Express uses the [WebP Convert](https://github.com/rosell-dk/webp-convert) for conversions). + += No conversions methods are working out of the box = +Don't fret - you have options! + +- If you a controlling another WordPress site (where the local conversion methods DO work), you can set up WebP Express there, and then connect to it by configuring the “Remote WebP Express” conversion method. +- You can also setup the ewww conversion method. To use it, you need to purchase an api key. They do not charge credits for webp conversions, so all you ever have to pay is the one dollar start-up fee 🙂 (unless they change their pricing – I have no control over that). You can buy an api key here: https://ewww.io/plans/ +- I have written a [template letter](https://github.com/rosell-dk/webp-convert/wiki/A-template-letter-for-shared-hosts) which you can send to your webhost +- You can try to get one of the local converters working. Check out [this page](https://github.com/rosell-dk/webp-convert/wiki/Meeting-the-requirements-of-the-converters) on the webp-convert wiki. There is also this [test/troubleshooting script](https://github.com/rosell-dk/webp-convert/wiki/A-PHP-script-for-the-webhost) which is handy when messing around with this. +- Finally, if you have access to another server and are comfortable with installing projects with composer, you can install [webp-convert-cloud-service](https://github.com/rosell-dk/webp-convert-cloud-service). It's open source. + +Of course, there is also the option of using another plugin altogether. I can recommend Optimole. If you want to try that out and want to support me in the process, [follow this link](https://optimole.pxf.io/20b0M). It is an affiliate link and will give me a reward in case you decide to sign up. + += It doesn't work - Although test conversions work, it still serves jpeg images = +Actually, you might be mistaking, so first, make sure that you didn't make the very common mistake of thinking that something with the URL *example.com/image.jpg* must be a jpeg image. The plugin serves webp images on same URL as the original (unconverted) images, so do not let appearances fool you! Confused? See next FAQ item. + +Assuming that you have inspected the *content type* header, and it doesn't show "image/webp", please make sure that: +1) You tested with a browser that supports webp (such as Chrome) +2) The image URL you are looking at are not pointing to another server (such as gravatar.com) + +Assuming that all above is in place, please look at the response headers to see if there is a *X-WebP-Convert-Status* header. If there isn't, well, then it seems that the problem is that the image request isn't handed over to WebP Express. Reasons for that can be: + +- You are on NGINX (or an Apache/Nginx combination). NGINX requires special attention, please look at that FAQ item +- You are on WAMP. Please look at that FAQ item + +I shall write more on this FAQ item... Stay tuned. + + += How can a webp image be served on an URL ending with "jpg"? = +Easy enough. Browsers looks at the *content type* header rather than the URL to determine what it is that it gets. So, although it can be confusing that the resource at *example.com/image.jpg* is a webp image, rest assured that the browsers are not confused. To determine if the plugin is working, you must therefore examine the *content type* response header rather than the URL. See the "How do I verify that the plugin is working?" Faq item. + +I am btw considering making an option to have the plugin redirect to the webp instead of serving immediately. That would remove the apparent mismatch between file extension and content type header. However, the cost of doing that will be an extra request for each image, which means extra time and worse performance. I believe you'd be ill advised to use that option, so I guess I will not implement it. But perhaps you have good reasons to use it? If you do, please let me know! + += WP CLI, but how do I use it to bulk convert? = +Well, first, if you don't know WP CLI, here is a [quick start](https://make.wordpress.org/cli/handbook/guides/quick-start/) + +WebP Express currently supports commands for converting and flushing webp images throug the CLI. You can use the --help option to learn about the options: +*wp webp-express --help*. Displays the available commands +*wp webp-express convert --help*. Displays the available options for the "convert" command. + +A few examples: +*wp webp-express convert*: Creates webp images for all unconverted images +*wp webp-express convert --reconvert*: Also convert images that are already converted +*wp webp-express convert themes*: Only images in the themes folder +*wp webp-express convert uploads/2021*: Only images in the "2021" folder inside the uploads folder +*wp webp-express convert --only-png*: Only the PNG images +*wp webp-express convert --quality=50*: Use quality 50 (instead of what was entered in settings screen) +*wp webp-express convert --quality=50 --near-lossless=50 --alpha-quality=50 --encoding=lossy*: More conversion options. encoding can be "lossy", "lossless" or "auto" +*wp webp-express convert --converter=cwebp*: Specifically use cwebp converter. Other options: "vips", "imagemagick", "ffmpeg". PS: For "ewww" and "wpc" (remote webp express) does not work here. + +*wp webp-express flushwebp*: Remove all webp images +*wp webp-express flushwebp --only-png*: Remove all webp images that are conversions of PNG images + +Synopsises: +` +wp webp-express convert [] [--reconvert] [--only-png] [--only-jpeg] [--quality=] [--near-lossless=] [--alpha-quality=] [--encoding=] [--converter=] +wp webp-express flushwebp [--only-png] +` + +I'm considering adding commands for viewing status, viewing conversion stats, generating the .htaccess files and modifying the settings. Please let me know if you need any of these or perhaps something else. + + += Blank images in Safari? = +WebP Express has three ways of distributing webp to webp-enabled browsers while still sending the originals to webp-disabled browsers. While method 1 can be combined with any of the other methods, you would usually just pick method 1 or one of the others if method 1 cannot be used for you. + +Can some of these go wrong? +Yes. All! + +**Method 1: Varied image responses** +The "Varied image responses" method adds rules to the `.htaccess` which redirects jpegs and pngs to the corresponding webps (if they exist). The rules have a condition that makes sure they only trigger for browsers supports webp images (this is established by examining the "accept" header). + +I the method "varied image responses" because the response on a given image URL *varies* (the webp is served on the same URL as the jpeg/png). + +In the cases where method 1 fails, it is due to systems that cache images by the URL alone. To prevent this from happening, the `.htaccess` rules adds a `Vary:Accept` response header. However, most CDNs does not respect that header unless they are configured to do so. Fortunately proxy servers respects it nicely (however often by throwing out the cached image if the accept header doesn't match) + +Method 1 can go wrong if: + +1. You are using a CDN and it hasn't been set up to handle varied image responses. If this has happened, it is critical that you purge the CDN cache! For information regarding CDN setups, check out the CDN section in this FAQ +2. Your server doesn't support adding response headers in `.htaccess`. On Apache, the "mod_headers" module needs to be enabled. Otherwise the all important `Vary:Accept` response header will not be set on the response. +3. Your server doesn't support SetEnv. However, that module is fortunately very common. I have posted a possible solution to make the rules work without SetEnv [here](https://wordpress.org/support/topic/setenv/). +4. You are on Nginx and you haven't created rules that adds the `Vary:Accept` header. + +I do not believe it can go wrong in other ways. To be certain, please check out [this test page](http://toste.dk/rh.php). When visiting the test-page with Safari, you should see two images with the “JPG” label over them. When visiting the test-page with a browser that supports webp, you should see two images with the “WEBP” label over them. If you do not see one of these things, please report! (no-one has yet experienced that) + +Since WebP Express 0.15.0 you can use the "Live test" button to check that browsers not supporting webp gets the original files and that the Vary:Accept header is returned. Note however that it may not detect CDN caching problems if the CDN doesn't cache a new image immediately - and across all its nodes. + +**Method 2: Altering HTML to use picture tags** +IMG tags are replaced with PICTURE tags which has two sources. One of them points to the webp and has the "content-type" set to "image/webp". The other points to the original. The browser will select the webp source if it supports webp and the other source if it doesn't. + +Method 2 can go wrong on old browser that doesn't support the picture tag syntax. However, simply enable the "Dynamically load picturefill.js on older browsers" option, and it will take care of that issue. + +**Method 3: Altering HTML to point directly to webps in webp enabled browsers** +In this solution, the URLs in the HTML are modified for browsers that supports webp. Again, this is determined by examining the "accept" header. So, actually the complete page HTML varies with this method. + +Method 3 can go wrong if you are using a page caching plugin if that plugin does not create a separate webp cache for webp-enabled browsers. The *Cache Enabler* plugin handles this. I don't believe there are other page caching plugins that does. There is a FAQ section in this FAQ describing how to set *Cache Enabler* up to work in tandem with WebP Express. + +Note that Firefox 66+ unfortunately stopped including "image/webp" in the "accept" header it sends when requesting *the page*. While Firefox 66+ fortunately still includes "image/webp" in its accept header *for images*. That will however not get it webp images when using method 3. + += I am on NGINX or OpenResty = + +WebP Express works well on NGINX, however the UI is not streamlined NGINX yet. And of course, NGINX does not process the .htaccess files that WebP Express generates. WebP Express can be used without redirection, as it can alter HTML to use picture tags which links to the webp alternative. See "The simple way" below. Or, you can get your hands dirty and set up redirection in NGINX guided by the "The advanced way" section below. + +**The simple way (no redirecting rules)** +The easy solution is simply to use the plugin in "CDN friendly" mode, do a bulk conversion (takes care of converting existing images), activate the "Convert on upload" option (takes care of converting new images in the media library) and enable Alter HTML (takes care of delivering webp to webp enabled browsers while still delivering the original jpeg/png to browsers not supporting webp). + +*PRO*: Very easy to set up. +*CON*: Images in external CSS and images being dynamically added with javascript will not be served as webp. +*CON*: New new theme images will not be converted until you run a new Bulk conversion + +**The advanced way (creating NGINX redirecting rules)** +Creating NGINX rules requires manually inserting redirection rules in the NGINX configuration file (nginx.conf or the configuration file for the site, found in `/etc/nginx/sites-available`). If you do not have access to do that, you will have to settle with the "simple way" described above. + +There are two different approaches to achieve the redirections. The one that I recommend is based on a *try_files* directive. If that doesn't work for you, you can try the alternative rules that are based on the *rewrite* directive. The rules are described in the next couple of sections. + +For multisite on NGINX, read [here](https://github.com/rosell-dk/webp-express/issues/8) + +**Recommended rules (using "try_files")** + +__Preparational step:__ +The rules looks for existing webp files by appending ".webp" to the URL. So for this to work, you must configure *WebP Express* to store the converted files like that by setting *General > File extension* to *Append ".webp"* + +__The rules:__ +Insert the following in the `server` context of your configuration file (usually found in `/etc/nginx/sites-available`). "The `server` context" refers to the part of the configuration that starts with "server {" and ends with the matching "}". + +` +# WebP Express rules +# -------------------- +location ~* ^/?wp-content/.*\.(png|jpe?g)$ { + add_header Vary Accept; + expires 365d; + if ($http_accept !~* "webp"){ + break; + } + try_files + /wp-content/webp-express/webp-images/doc-root/$uri.webp + $uri.webp + /wp-content/plugins/webp-express/wod/webp-on-demand.php?xsource=x$request_filename&wp-content=wp-content + ; +} + +# Route requests for non-existing webps to the converter +location ~* ^/?wp-content/.*\.(png|jpe?g)\.webp$ { + try_files + $uri + /wp-content/plugins/webp-express/wod/webp-realizer.php?xdestination=x$request_filename&wp-content=wp-content + ; +} +# ------------------- (WebP Express rules ends here) +` + +__BEWARE:__ +- Beware that when copy/pasting you might get html-encoded characters. Verify that the ampersand before "wp-content" isn't encoded (in the last line in the try_files block) + +- Beware that the rules looks for existing webp files by appending ".webp" to the URL. So for this to work, you __must__ configure *WebP Express* to store the converted files like that. + +- Beware that if you haven't enabled *png* conversion, you should replace "(png|jpe?g)" with "jpe?g". + +- Beware that if you have moved wp-content to a non-standard place, you must change accordingly. Note that you must then also change the "wp-content" parameter to the script. It expects a relative path to wp-content (from document root) and is needed so the script can find the configuration file. + +- Beware that there is a hack out there for permalinks which is based on "rewrite" (rather than the usual solution which is based on try_files). If you are using that hack to redirect missing files to index.php, you need to modify it as specified [here](https://wordpress.org/support/topic/nginx-server-404-not-found-when-convert-test-images/page/2/#post-11952444) + +- I have put in an expires statement for caching. You might want to modify or disable that. + +- The rules contains all redirections (as if you enabled all three redirection options in settings). If you do not wish to redirect to converter, remove the last line in the try_files block. If you do not wish to create webp files upon request, remove the last location block. + +- If you have configured WebP Express to store images in separate folder, you do not need the "$uri.webp" line in the first "try_files" block. But it doesn't hurt to have it. And beware that the reverse is not true. If configured to store images in the same folder ("mingled"), you still need the line that looks for a webp in the separate folder. The reason for this is that the "mingled" only applies to the images in the upload folder - other images - such as theme images are always stored in a separate folder. + +If you cannot get this to work then perhaps you need to add the following to your *mime.types* configuration file: + `image/webp webp;` + +If you still cannot get it to work, you can instead try the alternative rules below. + +Credits: These rules are builds upon [Eugene Lazutkins solution](http://www.lazutkin.com/blog/2014/02/23/serve-files-with-nginx-conditionally/). + +**Alternative rules (using "rewrite")** + +In case the recommended rules does not work for you, you can try these alternative rules. + +The reason I recommend the *try_files* approach above over these alternative rules is that it is a bit simpler and it is supposed to perform marginally better. These alternative rules are in no way inferior to the other. Choose whatever works! + +__Preparational step:__ +The rules looks for existing webp files by appending ".webp" to the URL. So for this to work, you must configure *WebP Express* to store the converted files like that by setting *General > File extension* to *Append ".webp"*. Also make sure that WebP Express is configured with "Destination" set to "Mingled". + +__The rules:__ +Insert the following in the `server` context of your configuration file (usually found in `/etc/nginx/sites-available`). "The `server` context" refers to the part of the configuration that starts with "server {" and ends with the matching "}". + +` +# WebP Express rules +# -------------------- +location ~* ^/wp-content/.*\.(png|jpe?g)$ { + add_header Vary Accept; + expires 365d; +} +location ~* ^/wp-content/.*\.webp$ { + expires 365d; + if ($whattodo = AB) { + add_header Vary Accept; + } +} +if ($http_accept ~* "webp"){ + set $whattodo A; +} +if (-f $request_filename.webp) { + set $whattodo "${whattodo}B"; +} +if ($whattodo = AB) { + rewrite ^(.*) $1.webp last; +} +if ($whattodo = A) { + rewrite ^/wp-content/.*\.(jpe?g|png)$ /wp-content/plugins/webp-express/wod/webp-on-demand.php?xsource=x$request_filename&wp-content=wp-content break; +} +# ------------------- (WebP Express rules ends here) +``` + +__BEWARE:__ + +- Beware that when copy/pasting you might get html-encoded characters. Verify that the ampersand before "wp-content" isn't encoded (in the last line in the try_files block) + +- Beware that the rules looks for existing webp files by appending ".webp" to the URL. So for this to work, you __must__ configure *WebP Express* to store the converted files like that. + +- Beware that if you haven't enabled *png* conversion, you should replace "(png|jpe?g)" with "jpe?g". + +- Beware that if you have moved wp-content to a non-standard place, you must change accordingly. Note that you must then also change the "wp-content" parameter to the script. It expects a relative path to wp-content (from document root) and is needed so the script can find the configuration file. + +- Beware that there is a hack out there for permalinks which is based on "rewrite" (rather than the usual solution which is based on try_files). If you are using that hack to redirect missing files to index.php, you need to modify it as specified [here](https://wordpress.org/support/topic/nginx-server-404-not-found-when-convert-test-images/page/2/#post-11952444) + +- I have put in an expires statement for caching. You might want to modify or disable that. + +- I have not set any expire on the webp-on-demand.php request. This is not needed, as the script sets this according to what you set up in WebP Express settings. Also, trying to do it would require a new location block matching webp-on-demand.php, but that would override the location block handling php files, and thus break the functionality. + +- There is no longer any reason to add "&$args" to the line begining with "/wp-content". It was there to enable debugging a single image by appending "?debug" to the url. I however removed that functionality from `webp-on-demand.php`. + +It is possible to put this stuff inside a `location` directive. However, having `if` directives inside `location` directives [is considered evil](https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/). But it seems that in our case, it works. If you wish to do that, use the following rules instead: + +` +# WebP Express rules +# -------------------- +location ~* ^/wp-content/.*\.(png|jpe?g)$ { + add_header Vary Accept; + expires 365d; + + if ($http_accept ~* "webp"){ + set $whattodo A; + } + if (-f $request_filename.webp) { + set $whattodo "${whattodo}B"; + } + if ($whattodo = AB) { + rewrite ^(.*) $1.webp last; + } + if ($whattodo = A) { + rewrite ^/wp-content/.*\.(jpe?g|png)$ /wp-content/plugins/webp-express/wod/webp-on-demand.php?xsource=x$request_filename&wp-content=wp-content last; + } +} + +location ~* ^/wp-content/.*\.webp$ { + expires 365d; + if ($whattodo = AB) { + add_header Vary Accept; + } +} +# ------------------- (WebP Express rules ends here) +` + +PS: In case you only want to redirect images to the script (and not to existing), the rules becomes much simpler: + +` +# WebP Express rules +# -------------------- +if ($http_accept ~* "webp"){ + rewrite ^/(.*).(jpe?g|png)$ /wp-content/plugins/webp-express/wod/webp-on-demand.php?xsource=x$request_filename&wp-content=wp-content break; +} +# ------------------- (WebP Express rules ends here) +` + +Discussion on this topic [here](https://wordpress.org/support/topic/nginx-rewrite-rules-4/) +And here: https://github.com/rosell-dk/webp-express/issues/166 + +Here are rules if you need to *replace* the file extension with ".webp" rather than appending ".webp" to it: https://www.keycdn.com/support/optimus/configuration-to-deliver-webp + += I am on a Windows server = +Good news! It should work now, thanks to a guy that calls himself lwxbr. At least on XAMPP 7.3.1, Windows 10. https://github.com/rosell-dk/webp-express/pull/213. + += I am on a Litespeed server = +You do not have to do anything special for it to work on a Litespeed server. You should be able to use WebP Express in any operation mode. For best performance, I however recommend that use the *LiteSpeed Cache* plugin for page caching. + +LiteSpeed Cache can be set up to maintain separate page caches for browsers that supports webp and browsers that don't. Through this functionality it is possible to use "Alter HTML" with the option "Replace image URLs" and "Only do the replacements in webp enabled browsers" mode. + +The setup was kindly shared and explained in detail by [@ribeiroeder](https://github.com/ribeiroeder) [here](https://github.com/rosell-dk/webp-express/issues/433) + += I am using Jetpack = +If you install Jetpack and enable the "Speed up image load times" then Jetpack will alter the HTML such that images are pointed to their CDN. + +Ie: +`` + +becomes: +`` + +Jetpack automatically serves webp files to browsers that supports it using same mechanism as the standard WebP Express configuration: If the "Accept" header contains "image/webp", a webp is served (keeping original file extension, but setting the "content-type" header to "image/webp"), otherwise a jpg is served. + +As images are no longer pointed to your original server, the .htaccess rules created by WebP Express will not have any effect. + +So if you are using Jetpack you don't really need WebP Express? +Well, there is no point in having the "Speed up image load times" enabled together with WebP Express. + +But there is a case for using WebP Express rather than Jetpacks "Speed up image load times" feature: + +Jetpack has the same drawback as the *standard* WebP Express configuration: If a user downloads the file, there will be a mismatch between the file extension and the image type (the file is ie called "logo.jpg", but it is really a webp image). I don't think that is a big issue, but for those who do, WebP Express might still be for you, even though you have Jetpack. And that is because WebP Express can be set up just to generate webp's, without doing the internal redirection to webp (will be possible from version 0.10.0). You can then for example use the [Cache Enabler](https://wordpress.org/plugins/cache-enabler/) plugin, which is able to generate and cache two versions of each page. One for browsers that accepts webp and one for those that don't. In the HTML for webp-enabled browsers, the images points directly to the webp files. + +Pro Jetpack: +- It is a free CDN which serves webp out of the box. +- It optimizes jpegs and pngs as well (but note that only about 1 out of 5 users gets these, as webp is widely supported now) + +Con Jetpack: +- It is a big plugin to install if you are only after the CDN +- It requires that you create an account on Wordpress.com + +Pro WebP Express: +- You have control over quality and metadata +- It is a small plugin and care has been taken to add only very little overhead +- Plays well together with Cache Enabler. By not redirecting jpg to webp, there is no need to do any special configuration on the CDN and no issue with misleading file extension, if user downloads a file. + +Con WebP Express: +- If you are using a CDN and you are redirecting jpg to webp, you must configure the CDN to forward the Accept header. It is not possible on all CDNs. + += Why do I not see the option to set WebP quality to auto? = +The option will only display, if your system is able to detect jpeg qualities. To make your server capable to do that, install *Imagick extension* (PECL >= 2.2.2) or enable exec() calls and install either *Imagick* or *Gmagick*. + +If you have the *Imagick*, the *Imagick binary* or the *Remote WebP Express* conversion method working, but don't have the global "auto" option, you will have the auto option available in options of the individual converter. + +Note: If you experience that the general auto option doesn't show, even though the above-mentioned requirements should be in order, check out [this support-thread](https://wordpress.org/support/topic/still-no-auto-option/). + += How do I configure my CDN in "Varied image responses" operation mode? = +In *Varied image responses* operation mode, the image responses *varies* depending on whether the browser supports webp or not (which browsers signals in the *Accept* header). Some CDN's support this out of the box, others requires some configuration and others doesn't support it at all. + +For a CDN to cooperate, it needs to +1) forward the *Accept* header and +2) Honour the Vary:Accept response header. + +You can also make it "work" on some CDN's by bypassing cache for images. But I rather suggest that you try out the *CDN friendly* mode (see next FAQ item) + +*Status of some CDN's*: + +- *KeyCDN*: Does not support varied image responses. I have added a feature request [here](https://community.keycdn.com/t/support-vary-accept-header-for-conditional-webp/1864). You can give it a +1 if you like! +- *Cloudflare*: See the "I am on Cloudflare" section +- *Cloudfront*: Works, but needs to be configured to forward the accept header. Go to *Distribution settings*, find the *Behavior tab*, select the Behavior and click the Edit button. Choose *Whitelist* from *Forward Headers* and then add the "Accept" header to the whitelist. + +I shall add more to the list. You are welcome to help out [here](https://wordpress.org/support/topic/which-cdns-works-in-standard-mode/). + +### How do I make it work with CDN? ("CDN friendly" mode) +In *CDN friendly* mode, there is no trickery with varied image responses, so no special attention is required *on the CDN*. + +However, there are other pitfalls. + +The thing is that, unless you have the whole site on a CDN, you are probably using a plugin that *alters the HTML* in order to point your static assets to the CDN. If you have enabled the "Alter HTML" in WebP Express, it means that you now have *two alterations* on the image URLs! + +How will that play out? + +Well, if *WebP Express* gets to alter the HTML *after* the image URLs have been altered to point to a CDN, we have trouble. WebP Express does not alter external images but the URLs are now external. + +However, if *WebP Express* gets to alter the HTML *before* the other plugin, things will work fine. + +So it is important that *WebP Express* gets there first. + +*The good news is that WebP Express does get there first on all the plugins I have tested.* + +But what can you do if it doesn't? + +Firstly, you have an option in WebP Express to select between: +1. Use content filtering hooks (the_content, the_excerpt, etc) +2. The complete page (using output buffering) + +The content filtering hooks gets to process the content before output buffering does. So in case output buffering isn't early enough for you, choose the content filtering hooks. + +There is a risk that you CDN plugin also uses content filtering hooks. I haven't encountered any though. But if there is any out there that does, chances are that they get to process the content before WebP Express, because I have set the priority of these hooks quite high (10000). The reasoning behind this is to that we want to replace images that might be inserted using the same hook (for example, a theme might use *the_content* filter to insert the featured image). If you do encounter a plugin for changing URLs for CDN which uses the content filtering hooks, you are currently out of luck. Let me know, so I can fix that (ie. by making the priority configurable) + +Here are a list of some plugins for CDN and when they process the HTML: + +| Plugin | Method | Hook(s) | Priority +| ----------------- | ------------------ | ------------------------------------------------ | --------------- +| BunnyCDN | Output buffering | template_redirect | default (10) +| CDN enabler | Output buffering | template_redirect | default (10) +| Jetpack | content filtering | the_content, etc | the_content: 10 +| W3 Total Cache | Output buffering | no hooks. Buffering is started before hooks | +| WP Fastest Cache | Output buffering | no hooks. Buffering is started before hooks | +| WP Super Cache | Output buffering | init | default (10) + +With output buffering the plugin that starts the output buffering first gets to process the output last. So WebP Express starts as late as possible, which is on the `template_redirect` hook, with priority 10000 (higher means later). This is later than the `init` hook, which is again later than the `no hooks`. + += I am on Cloudflare = +Without configuration, Cloudflare will not maintain separate caches for jpegs and webp; all browsers will get jpeg. To make Cloudflare cache not only by URL, but also by header, you need to use the [Custom Cache Key](https://support.cloudflare.com/hc/en-us/articles/115004290387) page rule, and add *Header content* to make separate caches depending on the *Accept* request header. + +However, the *Custom Cache Key* rule currently requires an *Enterprise* account. And if you already have that, you may as well go with the *Polish* feature, which starts at the “Pro” level plan. With the *Polish* feature, you will not need WebP Express. + +To make *WebP Express* work on a free Cloudflare account, you have the following choices: + +1. You can configure the CDN not to cache jpeg images by adding the following page rule: If rule matches: `example.com/*.jpg`, set: *Cache level* to: *Bypass* + +2. You can set up another CDN (on another provider), which you just use for handling the images. You need to configure that CDN to forward the *Accept header*. You also need to install a Wordpress plugin that points images to that CDN. + +3. You can switch operation mode to "CDN friendly" and use HTML altering. + += I am on WP Engine = +From version 0.17.1 on WebP Express works with WP engine and this combination will be tested before each release. + +You can use the plugin both in "Varied image responses" mode and in "CDN friendly mode". + +To make the redirection work, you must: +1) Grab the nginx configuration found in the "I am on Nginx/OpenResty" section in this FAQ. Use the "try_files" variant. +2) Contact help center and ask them to insert that configuration. +3) Make sure the settings match this configuration. Follow the "beware" statements in the "I am on Nginx/OpenResty" section. + +WebP Express tweaks the workings of "Redirect to converter" a bit for WP engine. That PHP script usually serves the webp directly, along with a Vary:Accept header. This header is however overwritten by the caching machinery on WP engine. As a work-around, I modified the response of the script for WP engine. Instead of serving the image, it serves a redirect to itself. As there now IS a corresponding webp, this repeated request will not be redirected to the PHP script, but directly to the webp. And headers are OK for those redirects. You can hit the "Live test" button next to "Enable redirection to converter?" to verify that this works as just described. + +If you (contrary to this headline!) are in fact not on WP Engine, but might want to be, I have an affiliate link for you. It will give you 3 months free and it will give me a reward too, if you should decide to stay there. Here you go: [Get 3 months free when you sign up for WP Engine.](https://shareasale.com/r.cfm?b=1343154&u=2194916&m=41388&urllink=&afftrack=) + += WebP Express / ShortPixel setup = +Here is a recipe for using WebP Express together with ShortPixel, such that WebP Express generates the webp's, and ShortPixel only is used to create `` tags, when it detects a webp image in the same folder as an original. + +**There is really no need to do this anymore, because WebP Express is now capable of replacing img tags with picture tags (check out the Alter HTML option)** + +You need: +1 x WebP Express +1 x ShortPixel + +*1. Setup WebP Express* +If you do not want to use serve varied images: +- Open WebP Express options +- Switch to *CDN friendly* mode. +- Set *File extension* to "Set to .webp" +- Make sure the *Convert non-existing webp-files upon request to original image* option is enabled + +If you want to *ShortPixel* to create tags but still want the magic to work on other images (such as images are referenced from CSS or javascript): +- Open WebP Express options +- Switch to *Varied image responses* mode. +- Set *Destination folder* to "Mingled" +- Set *File extension* to "Set to .webp" + +*2. Setup ShortPixel* +- Install [ShortPixel](https://wordpress.org/plugins/shortpixel-image-optimiser/) the usual way +- Get an API key and enter it on the options page. +- In *Advanced*, enable the following options: + - *Also create WebP versions of the images, for free.* + - *Deliver the WebP versions of the images in the front-end* + - *Altering the page code, using the tag syntax* +- As there is a limit to how many images you can convert freely with *ShortPixel*, you should disable the following options (also on the *Advanced* screen): + - *Automatically optimize images added by users in front end.* + - *Automatically optimize Media Library items after they are uploaded (recommended).* + +*3. Visit a page* +As there are presumably no webps generated yet, ShortPixel will not generate `` tags on the first visit. However, the images that are referenced causes the WebP Express *Auto convert* feature to kick in and generate webp images for each image on that page. + +*4. Visit the page again* +As *WebP Express* have generated webps in the same folder as the originals, *ShortPixel* detects these, and you should see `` tags which references the webp's. + +*ShortPixel or Cache Enabler ?* +Cache Enabler has the advantage over ShortPixel that the HTML structure remains the same. With ShortPixel, image tags are wrapped in a `` tag structure, and by doing that, there is a risk of breaking styles. + +Further, Cache Enabler *caches* the HTML. This is good for performance. However, this also locks you to using that plugin for caching. With ShortPixel, you can keep using your favourite caching plugin. + +Cache Enabler will not work if you are caching HTML on a CDN, because the HTML varies depending on the *Accept* header and it doesn't signal this with a Vary:Accept header. You could however add that manually. ShortPixel does not have that issue, as the HTML is the same for all. + +### WebP Express / Cache Enabler setup +The WebP Express / Cache Enabler setup is quite potent and very CDN-friendly. *Cache Enabler* is used for generating *and caching* two versions of the HTML (one for webp-enabled browsers and one for webp-disabled browsers) + +The reason for doing this could be: +1. You are using a CDN which cannot be configured to work in the "Varied image responses" mode. +2. You could tweak your CDN to work in the "Varied image responses" mode, but you would have to do it by using the entire Accept header as key. Doing that would increase the risk of cache MISS, and you therefore decided that do not want to do that. +3. You think it is problematic that when a user saves an image, it has the jpg extension, even though it is a webp image. + +You need: +1 x WebP Express +1 x Cache Enabler + +*1. Setup WebP Express* +If you do not want to use serve varied images: +- Open WebP Express options +- Switch to *CDN friendly* mode. +- Set *File extension* to "Set to .webp" +- Enable *Alter HTML* and select *Replace image URLs*. It is not absolutely necessary, as Cache Enabler also alters HTML - but there are several reasons to do it. Firstly, *Cache Enabler* doesn't get as many URLs replaced as we do. WebP Express for example also replaces background urls in inline styles. Secondly, *Cache enabler* has [problems in edge cases](https://regexr.com/46isf). Thirdly, WebP Express can be configured to alter HTML to point to corresponding webp images, *before they even exists* which can be used in conjunction with the the *Convert non-existing webp-files upon request* option. And this is smart, because then you don't have trouble with *Cache Enabler* caching HTML which references the original images due to that some images hasn't been converted yet. +- If you enabled *Alter HTML*, also enable *Reference webps that hasn't been converted yet* and *Convert non-existing webp-files upon request* +- If you did not enable *Alter HTML*, enable *Convert non-existing webp-files upon request to original image* + +If you want to *Cache Enabler* to create tags but still want the magic to work on other images (such as images are referenced from CSS or javascript): +- Open WebP Express options +- Switch to *Varied image responses* mode. +- Set *Destination folder* to "Mingled" +- Set *File extension* to "Set to .webp" +- I suggest you enable *Alter HTML* and select *Replace image URLs*. And also enable *Reference webps that hasn't been converted yet* and *Convert non-existing webp-files upon request*. + +*2. Setup Cache Enabler* +- Open the options +- Enable of the *Create an additional cached version for WebP image support* option + +*3. If you did not enable Alter HTML and Reference webps that hasn't been converted yet: Let rise in a warm place until doubled* +*WebP Express* creates *webp* images on need basis. It needs page visits in order to do the conversions . Bulk conversion is on the roadmap, but until then, you need to visit all pages of relevance. You can either do it manually, let your visitors do it (that is: wait a bit), or, if you are on linux, you can use `wget` to grab your website: + +``` +wget -e robots=off -r -np -w 2 http://www.example.com +``` + +**flags:** +`-e robots=off` makes wget ignore rules in robots.txt +`-np` (no-parent) makes wget stay within the boundaries (doesn't go into parent folders) +`w 2` Waits two seconds between each request, in order not to stress the server + +*4. Clear the Cache Enabler cache.* +Click the "Clear Cache" button in the top right corner in order to clear the Cache Enabler cache. + +*5. Inspect the HTML* +When visiting a page with images on, different HTML will be served to browsers, depending on whether they support webp or not. + +In a webp-enabled browser, the HTML may look like this: ``, while in a non-webp enabled browser, it looks like this: `` + + +*6. Optionally add Cache Enabler rewrite rules in your .htaccess* +*Cache Enabler* provides some rewrite rules that redirects to the cached file directly in the `.htaccess`, bypassing PHP entirely. Their plugin doesn't do that for you, so you will have to do it manually in order to get the best performance. The rules are in the "Advanced configuration" section on [this page](https://www.keycdn.com/support/wordpress-cache-enabler-plugin). + += Does it work with lazy loaded images? = +No plugins/frameworks has yet been discovered, which does not work with *WebP Express*. + +The most common way of lazy-loading is by setting a *data-src* attribute on the image and let javascript use that value for setting the *src* attribute. That method works, as the image request, seen from the server side, is indistinguishable from any other image request. It could however be that some obscure lazy load implementation would load the image with an XHR request. In that case, the *Accept* header will not contain 'image/webp', but '*/*', and a jpeg will be served, even though the browser supports webp. + +The following lazy load plugins/frameworks has been tested and works with *WebP Express*: +- [BJ Lazy Load](https://da.wordpress.org/plugins/bj-lazy-load/) +- [Owl Carousel 2](https://owlcarousel2.github.io/OwlCarousel2/) +- [Lazy Load by WP Rocket](https://wordpress.org/plugins/rocket-lazy-load/) + +I have only tested the above in *Varied image responses* mode, but it should also work in *CDN friendly* mode. Both *Alter HTML* options have been designed to work with standard lazy load attributes. + += Can I make an exceptions for some images? = +There can be instances where you actually need to serve a jpeg or png. For example if you are demonstrating how a jpeg looks using some compression settings. + +If you want an image to be served in the original format (jpeg og png), do one of the following things: +- Add "?dontreplace" to the image url. +- Place an empty file in the same folder as the jpeg/png. The file name must be the same as the jpeg/png with ".dontreplace" appended + +Doing this will bypass redirection to webp and also prevent Alter HTML to use the webp instead of the original. + +NOTE: You may have to regenerate .htaccess rules (by clicking the button) in order for this to work. The feature was added in 0.23.0 and if you started using WebP Express before that, the necessary rules will not be there, unless you regenerate, that is. + +*Bypassing for an entire folder* +To bypass redirection for an entire folder, you can put something like this into your root .htaccess: +` +RewriteRule ^uploads/2021/06/ - [L] +` +PS: If *WebP Express* has placed rules in that .htaccess, you need to place the rule *above* the rules inserted by *WebP Express* + +If you got any further questions, look at, or comment on [this topic](https://wordpress.org/support/topic/can-i-make-an-exception-for-specific-post-image/) + += Update failed and cannot reinstall = +The 0.17.0 release contained binaries with dots in their filenames, which caused the unpacking during update to fail on a few systems. This failure could leave an incomplete installation. With important files missing - such as the main plugin file - Wordpress no longer registers that the plugin is there (it is missing from the list). However, the folder is there in the file system and trying to install WebP Express again fails because Wordpress complains about just that. The solution is to remove the "webp-express" folder in "plugins" manually (via ftp or a plugin, such as File Manager) and then install WebP Express anew. The setting will be intact. The filenames that caused the trouble where fixed in 0.17.2. + += Alter HTML only replaces some of the images = + +If you are wondering why Alter HTML are missing some images, it can be due to one of the following reasons: + +- WebP Express doesn't convert external images, only images on your server. Alter HTML will therefore not alter URLS that unless they point to your server or a CDN that you have added in the *CDN hostnames* section +- WebP Express has a "Scope" option, which for example can be set to "Uploads and themes". Only images that resides within the selected scope are replaced with webp. +- If you have selected `` tags syntax, only images inserted with ``-tags will be replaced (CSS images will not be replaced). Additionally, the ``-tag must have a "src" attribute or a commonly used data attribute for lazyloading (such as “data-src” or “data-lazy-src”) +- If you have set the "How to replace" option to "Use content filtering hooks", images inserted with some third party plugins/themes might not be replaced. To overcome that, change that setting to "The complete page". +- The image might have been inserted with javascript. WebP Express doesn't changSome plugins might insert the + += When is feature X coming? / Roadmap = +No schedule. I move forward as time allows. I currently spend a lot of time answering questions in the support forum. If someone would be nice and help out answering questions here, it would allow me to spend that time developing. Also, donations would allow me to turn down some of the more boring requests from my customers, and speed things up here. + +Right now I am focusing on the File Manager. I would like to add possibility for converting, bulk converting, viewing conversion logs, viewing stats, etc. + +Here are other things in pipeline: +- Excluding certain files and folders. +- Supporting Save-Data header in Varied Image Responses mode (send extra compressed images to clients who wants to use as little bandwidth as possible). +- Displaying rules for NGINX. +- Allow webp for all browsers using [this javascript library](http://libwebpjs.hohenlimburg.org/v0.6.0/). Unfortunately, the javascript library does not (currently) support srcset attributes, which is why I moved this item down the priority list. We need srcset to be supported for the feature to be useful. + +The current milestones, their subtasks and their progress can be viewed here: https://github.com/rosell-dk/webp-express/milestones + +If you wish to affect priorities, it is certainly possible. You can try to argue your case in the forum or you can simply let the money do the talking. By donating as little as a cup of coffee on [ko-fi.com/rosell](https://ko-fi.com/rosell), you can leave a wish. I shall take these wishes into account when prioritizing between new features. + += Beta testing = +I generally create a pre-release before publishing. If you [follow me on ko-fi](https://ko-fi.com/rosell), you will get notified when a pre-release is available. I generally create a pre-release on fridays and mark it as stable on mondays. In order to download a pre-release, go to [the advanced page](https://wordpress.org/plugins/webp-express/advanced/) and scroll down to "Please select a specific version to download". I don't name the pre-releases different. You will just see the next version here before it is available the usual way. + += Can I buy you a cup of coffee? = +You sure can! To do so, [go here!](https://ko-fi.com/rosell). If payment doesn't work for your country, [try here instead](https://buymeacoff.ee/rosell). + +If you want to make sure that my coffee supplies don't run dry, you can even buy me a cup of coffee on a regular basis [by going here](https://github.com/sponsors/rosell-dk) + + +== Screenshots == + +1. WebP Express settings + +== Changelog == + += 0.25.9 = +(released 7 April 2024) +* Bugfix: Fixed ewww conversion method after ewww API change + += 0.25.8 = +(released 20 October 2023) +* Bugfix: Depreciation warning on PHP 8.2 with Alter HTML. Thanks to @igamingsolustions for reporting the bug + += 0.25.7 = +(released 19 October 2023) +* Bugfix: Removed depreciation warning on settings screen, which was showing in PHP 8.2. Thanks to Rob Meijerink from the Netherlands for providing the fix in a pull request +* Bugfix: Removed depreciation warning when converting, happening in PHP 8.2. Thanks to Sophie B for reporting and Rob Meijerink from the Netherlands for providing the fix in a pull request +* Bugfix: One of the Mime fallback methods did not work. Thanks to gerasart from Ukraine for providing the fix in a pull request + += 0.25.6 = +(released 15 April 2023) +* Bugfix: A bug in another plugin can cause delete file hook to be called with an empty string, which WebP Express was not prepared for (fatal exception). Thanks to Colin Frick from Liechtenstein for providing the fix in a pull request on [github](https://github.com/rosell-dk/webp-express/) +* Bugfix: Bug when testing imagick converter from settings page (only PHP 8). Thanks to Sisir from Bangladesh for reporting and providing the fix in a pull request + += 0.25.5 = +(released 23 May 2022) +* When using the "Prevent using webps larger than original" with Alter HTML (picture tags) on images with srcset, those webps that where larger than the originals was picked out from the srcset for the webp. This could lead to bad quality images. In the fix, the image will only have a webp source alternative when ALL the webps are smaller than their originals (when the "prevent..." option is set). +* Bugfix: In rare cases, WebP Express could fail detecting mime type + += 0.25.4 = +(released 6 May 2022) +* AlterHTML (when using picture tags): Fixed charset problems for special characters in alt and title attributes. The bug was introduced in 0.25.2. Thanks to Cruglk for first pointing this out. + += 0.25.3 = +(released 4 May 2022) +* AlterHTML: Fixed BIG BUG introduced in 0.25.2 (the webp urls was wrong when using picture tags). So sorry! Thankfully, I was quickly made aware of this and quickly patched it. + += 0.25.2 = +(released 4 May 2022) +* AlterHTML did not skip existing picture tags when they contained newlines, resulting in picture tags inside picture tags, which is invalid markup. Thanks to Jonas for being very helpful in solving this. + += 0.25.1 = +(released 7 dec 2021) +* An innocent text file triggered Windows Defender. It has been removed. Thanks to Javad Naroogheh from Iran for notifying + += 0.25.0 = +(released 7 dec 2021, on my daughters 10 year birthday!) +* No exec()? - We don't give up easily, but now emulates it if possible, using proc_open(), passthru() or other alternatives. The result is that the cwebp converter now is available on more systems. Quality detection of jpegs also works on more systems now. The fallback feature with the emulations is btw implemented in a new library, [exec-with-fallback](https://github.com/rosell-dk/exec-with-fallback) +* Bugfix: Our WP CLI command "webp-express" has a quality option, but it was not working + +For older releases, check out changelog.txt + +== Upgrade Notice == + += 0.25.9 = +* Fixed ewww conversion method after ewww API change + += 0.25.8 = +* PHP 8.2 bugfix + += 0.25.7 = +* PHP 8.2 bugfixes + += 0.25.6 = +* Two bugfixes - thanks for the pull requests on github :) + += 0.25.5 = +* Two bugfixes, one of them in Alter HTML. If you are using Alter HTML with picture tags and have enabled "Prevent using webps larger than original" and are using page caching, you should flush your page cache. + += 0.25.4 = +* AlterHTML (when using picture tags): Fixed charset problems for special characters in alt and title attributes. + += 0.25.3 = +* AlterHTML: Fixed BIG BUG introduced in 0.25.2 + += 0.25.2 = +* Fixed bug in Alter HTML functionality. It did not skip existing picture tags when they contained newlines, resulting in picture tags inside picture tags + += 0.25.1 = +* An innocent text file was triggering Windows Defender. + += 0.25.0 = +* No exec()? - We don't give up so easily anymore. + += 0.24.2 = +* Minor bugfix + += 0.24.1 = +* Improved file manager and fixed rare PHP error displayed on settings page + += 0.23.0 = +* Various improvements and bug fixes + += 0.22.1 = +* Two bug fixes related to .htaccess files and redirecting to converter + += 0.22.0 = +* Various improvements and bug fixes + += 0.21.1 = +* Two bug fixes + += 0.21.0 = +* Image browser and various bug fixes + += 0.20.1 = +* Bugfix for PHP 7.1 and below + += 0.20.0 = +* Added WP CLI support. Add "wp webp-express convert" to crontab for nightly conversions of new images. + += 0.19.1 = +* Bugfix for PHP 8 + += 0.19.0 = +* Added new conversion method (ffmpeg) + += 0.18.3 = +* Minor fixes (see changelog) + += 0.18.2 = +* Fixed bug on settings page + += 0.18.1 = +* Minor bug fix for bulk convert + += 0.18.0 = +* General maintenance and improvements + += 0.17.5 = +* Various fixes, mainly by community. + += 0.17.4 = +* Various fixes by community. Maintainer is partly back. Will be fully back mid august. + += 0.17.3 = +* Fixed two critical bugs. One occurred on PHP 7.4, the other when updating old WebP Express on Wordpress 5.3 + += 0.17.2 = +* Fixed bug: Updating plugin failed on a few hosts (in the unzip phase). The problem was introduced in 0.17.0 where the binaries contains dots in their filename. + += 0.17.1 = +* Miscellaneous bug fixes. Including the NGINX rules in the FAQ (added "xdestination" argument to webp-realizer.php). Now works with WP Engine. + += 0.17.0 = +* Improved Cwebp availability and Ewww performance. And updated cwebp binaries to version 1.0.3 + += 0.16.0 = +* Various improvements and fixes + += 0.15.3 = +* Fixed fatal error upon activation for systems which cannot use document root for structuring (rare) + += 0.15.2 = +* Fixed the bug when File extension was set to "Set to .webp". It was buggy when file extension contained uppercase letters. + += 0.15.1 = +* Multiple bug fixes + += 0.15.0 = +* New "Scope" and "destination structure" options, nice test buttons and a lot of work behind the surface + += 0.14.22 = +* A bundle of bug fixes and a security fix for Windows + += 0.14.21 = +* Hopefully fixed WebP Express Error: "png" option is Object + += 0.14.20 = +* Ewww api-key was forgotten upon saving options + += 0.14.19 = +* Bug fix + += 0.14.18 = +* Multiple bug fixes + += 0.14.17 = +* Relaxed abspath sanity check on Windows and fixed updating password for Remote WebP Express + += 0.14.16 = +* Fixed more errors on systems with symlinked folders + += 0.14.15 = +* Fixed errors with "redirect to conversion script" + += 0.14.14 = +Fixed errors on systems with symlinked folders + += 0.14.13 = +Fixed errors in conversion scripts + += 0.14.12 = +Fixed critical bug + += 0.14.11 = +Important security fixes. Upgrade immediately! + += 0.14.10 = +Security related + += 0.14.9 = +Security related + += 0.14.8 = +Security related + += 0.14.7 = +Security related: Removed unneccesary files from webp-convert library + += 0.14.6 = +Security related + += 0.14.5 = +Security related + += 0.14.4 = +Now bundles with multiple cwebp binaries for linux for systems where 1.0.2 fails. + += 0.14.3 = +Fixed supplied binary for cwebp (linux) + += 0.14.2 = +A couple of bugfixes + += 0.14.1 = +Security related + += 0.14.0 = +New awesome conversion options that gets you even smaller webp files without compromising quality. + += 0.13.2 = +Fixed critical error on upload (can occur in combination with some plugins), and it seems we finally nailed the blank settings page bug. + += 0.13.1 = +Fixed several bugs. You should update... + += 0.13.0 = +Bulk Conversion, fixed problem with Gd converter and PNGs and more... + += 0.12.2 = +Fixed bug: On some nginx configurations, the newly added protection against directly calling the converter scripts were triggering also when it should not. + += 0.12.1 = +Fixed bug: Alter HTML crashed when HTML was larger than 600kb and "image urls" where selected + += 0.12.0 = +Multisite support and a new operation mode + += 0.11.3 = +Fixed several bugs. You should update :) + += 0.11.0 = +WebP Express can now alter HTML to either point to webp images directly or by using the picture tag syntax. Also, non-existing webp-files can be converted upon request (means you can reference the converted webp files before they are actually converted!) + += 0.10.0 = +WebP Express can now be used in conjunction with Cache Enabler and ShortPixel. Also introduced "Operation modes" in order to keep setting screens simple but still allow tweaking. + += 0.9.1 = +Fixed critical bug causing options page to go blank + += 0.9.0 = +Option to redirect to existing webp images directly in .htaccess (improves performance) + += 0.8.0 = +New converter and miscellaneous improvements diff --git a/assets/banner-772x250.jpg b/assets/banner-772x250.jpg new file mode 100644 index 0000000000000000000000000000000000000000..af8b74ccbef99d3110746e2322ca2d96254537c4 GIT binary patch literal 121473 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

eL%;Hu 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 k20#b^@c%WrjjIF*Iy<|M9Fk~7{5J;Fl + + +image/svg+xml \ No newline at end of file diff --git a/assets/screenshot-1.png b/assets/screenshot-1.png new file mode 100644 index 0000000000000000000000000000000000000000..7ca21c175c174d50fb7c24c169e4f1653465fbed GIT binary patch literal 79254 zcmce-1ymeg*X~(Z@BqPsTW|>O4k18r_XL8wyN2KvG`J-=G;WOrcXt|hcemU5zw^$# zbMJiXersl}X%>fGyQ)r|t}4!X_HREOrlcT^j`9`-004BEkCG|?06PN!aB4`f&3(ZD*UqqO**=6yiD}BKB)$pZK59 zuE+ryNij9|g~KHeCAFPEo@YDTW1*+s^zu|go)hNOMhUllGl@je{RyqkbL=t9_x_9w zZ``87i5f&XF{5nMB9Xi3Y|U}FJ}Nm#eZ?fyHr2A7zf?=D3%4ykG(8@h!dW^=+vo7R z^f-ua6VZadk&JKf7Lii_bhB42xU+ZRJ?fO{bYDj1_lPA4eYKUZR3e2!UsyrVhCuA^ z+kdtH1pXW0|L%VO(JO(N0p+%S{|5Sx2ssjnHgp)Em=%%P0-J{kymYtFe#COWw|NA2 z!S$|7HO<_Tb}f$fRgP0cJm+ufxd$ocXBB=SHjgn|+&zA)ou1g8g%?xX3+ym9{lzLX z!ASO^y%LpvTXuB5v8*94pmTB7z>_QN4z^85G_b6n^VExD3Vm)GJEga{FzdF$ZG*7; zYaAB)xnPu$yv%oRHNdp_8jJLCW^n|OC{Z_qom_WesCE3r*+t7l6#MO) zQ&gMSY?YGW7fG?>YL}^WNE7wZ#-N*z;!|ni%&isa87+#Pxc=$oxG=7 zywT;`-**_>-rmM|p~ftbc_ZgvbU zc?E!iEhpU23NG=F3>n!RCOvGl)sBugWFtn7e0!itSoG6pE<3I3aeMkMy*hE7obmg>^#gyiVEusja7wzbK z!2z=Zy?f`KD&e=^o3;9^e6GhD(lNSt2@A2v7IGZY-mahH$XH0}8|Q2tu4OQ}7Gi&9 z@eTK_Up3GG0bekpQk37mp$xhwiksf->dMa1T$&j$re#*El{n)Q!Lhnp_CJwt=Z8%Wm7d%$7 zba4gsIz7_{FcQ^Mnn*~sl@NlZx<>TtoO2caoQPnJmn@>f!b`nE_CHWa_G)lo=b3}C zxlO3~NHR8UdHuR&a?!#K&Pb`@_<3ikUfA$_EuV^~)MxA#5h*S&FW=l}yC(u8lI2Xz zreDviuyv5JP)h9Fx!!<5tHyE#V>zp){QSbYaAot*`WsS_$4liJ3`_X{>xcj_92vtQ zeSI*eP^Wf8){?{*H8r6#(-=>joz4~~8Sq|@{al-P3jx&ItOYfgeB9HEy_EBaj+X2}*$VxD~ z7JK$0Q!-dL-NW}Y`5cX6oqrA>=a)l)a!1@Cf^6kKOH1~3pU0&QYu#Z)NwD}R7}&)b zVf`&XIje03?PAul+w19lb)M4V0z&yEzB;Q}|J;@_wrvnM?u97W4SCbXWZ{pxX zWxsK&Qj84UO^hZVh;`5rtxL0R%}L6er<5=bMV#Ka=nYH{<<9P%Z*KF&j8RX2F7BGW!Z4zn7qTdtipE%s<9 zs`qfTDtbBE6%|k>I@RORGdHy{c>Av1#lkQQ9k{nUWUU-fqS-7*CYyF4ml>Gquu6_0 zg)}}LbZ$+Dm?9$EtKs9+N84TqsqQigs>=h6HuHzm2>9+G-OfyV-C7XAi1H`mK}&q`{mYAU*i-77zf;-XTp zM=RYlS*#ADVsy)*+yIvx~M>{J0(@^(Jr|XZfE2 zG#_sh8s~~^9>*J|w++GpSmTVuV%cFx>M(J?^UU6xaq5_aZmjnL#ztriAGec60_efa zu7pUD$Rw?iUs!}}k}!GQ%$}$I7&u6YdfWOIi);#PC4X=Y3dQC=86j=r&qGUXeX54% zS}pY2KYe|&)YD1SR+tR9JYN2!b!!n&WJ(ogyb5SjH_`9J-krGM=t0_LS@CL|wPN9$ zViHTQpzvCUV=np`3#PEVjSFEnTDm3-@ZXu~-c~+p^W8gET!%dd#of$QvZpZxmCZj} zJgz1&IMXlkbcM(2uyA2?1sbF+p%>;&nxX4UpGIgU#&dtb`FwErhePqAVwDSAPx}hR zaWqOOMF!bv*4y{ivgBf|eZtTucL0Hn0c4ee93-LsWL^J*I;67!2MqzM23!Jej^VFxtm&1wz*NIS2tw5UAEmc(#%fpUqz5*% zMu8vFFEn<3bEDUbRm~p#_zGZ3Y#%LSJ|{%HRBgJ-V6^O$5;_JZ`v{h>RX1;ZT;@;g zMi{SP=h@3C#}&i;z)J7#SbBU&C|%HqdV;Y+Mr{teLO89JY9&BT?k$_^dYRM{j67V% zUER&&lM5?bt0P(uzSy?0yKTss@NgUM&*!2NK1Z7rWS3W1(0-fb`Fm4KyR5dL_WqOA z6=5FlJBzi6!^pM5OzO{HvY=4Tz{LU|m-r#A=#b59XU1&-2?s@j=Fg~4*QC$SZFv&0f9Rs_a}2(t`x5v&BbATe@q-c!H;7HyACk+vpd6fWF!dp z)PSv`$mv|B=XdUpaPIe>`t6}eu>BnDQb@V71sGu|>8-rXkdq?a+|kzSsWeAiij=g1 zB^{q`qeSw^VTj;UN^rg9Qn`EAM#4OATC4xI{2Yw@_RSNOVf!p33R**LQeN z_?k$`dTBu?;M+bViA^v}9_48Dr;CPR)KG;L(u~X%D;x7BZLhM{?q#11C0_}b^W)Ae z1vR+cYCaRu3gV$5UT(`Qj|@Pt#g^<;4*i0d&(O{;3*TP>yo0l%p|m~&Y2UgY=F7N*G1b%>n`h0!qMg^I%R4X{ zywpnD%><(djOc!~kfUVCq0joMvT_js%<~ivPY%%&KIz5cD-yE##9a@_(870K7B!hY z42jluX76YJaasH@>YW7^SxItJZ2}TSj zt4RnZ-pvmL)^j}7vyt|i-n%ZI^yfA0=tyjg$c1$W$SFN*sgQakgTlAUTUpYoe~+U? zreOXijE3K!g#A)m=ghk`BHS**q;??+X6hm&YV&+4$l{eVAOn!X4IgF(o{4A~G63@W z#6;(Jo5F(}3Savf;V^?}mtZOBPOUM%X`0iP&kH$3fmR%Pr)Jc=%JOqI!q2Em9^Cum z$h#GPnmW6l4pfoTkiYstn=I)Z?W0`LrzYFOXiEdSjrjpmm2 z_lNM^qQ`Z~+3NHgs9*yaUi@j(tF3C@!ko^5GjExkvu2l0NIx2)i0I$`igYnd&Y+i7 z6FX)pdoS|9iE+EO+I)KxkL^|?WMl<)o(QDCChguYuUE!hRRcunr zgPnV}!qD?&_2CevSX~xt!=`uPXy?Xt^s|~vH4&yn=eI0p12SPYHQ-pUutGQ%?p~lw zLd4#f+2B1hy@|n}!UwPEjocdGD`?Ye#)9-xK{6ESV}5Edx- zfV#fx@26!X6~n|KeFs}E%+nQmTbW0c5gV2Rt0z<*;+LU7n3jTv?qgMZUGs2T6J1w2`g3%!4EG7D|`1l)UW+T4>@=BXR%%8@9=xPrl#?s|APFI`7 zaP^c3Zv7KP|JYx=e(kDW_Ah12Ir+0TEG^~;*{CG$s{_!fNa;B&t4)%%T{WxTa&~&; zfd|HegvZT>-{=R$u4+C!jHrFj1Ty8%DRFulsNTVF#NeXujC)`=2nnVl>8P0Cb!{tncHpg^ ze&X|O zomDJRLwH4JQeQI4EHS1qNbF))?Dw+{Wjz=M#i# zWa9fN96{q1~=7cox#c!}0vHO@ZoG&xb`OnAi2}d@kUw4eb4}9$gxL z&B(`LIkh0l^~=C43m$2~qS4|T%{9}cAf%+?t6_jwMkU0+__45uu8`AG?Aj9ocf)CA zbm2Ba+WcMX>G_(q%!((YehSxnuE8+xO0(gCm=sw_$}W>Ehj{s*@{2gQ-@{Z{HcExC z@#(yP4ZtKYJ{8$Jw3_2*b^sOXsyQ-km=yrs;6V|6$Pbr^WxWb=kG26r3NJ_;lcieV zW@DE46n&PhtwBv*qsvKLm+>=y6df4xI+Ve4cp$8qEf^}>PY?pwhcJ;uk)op9cPoy~ zLeZcX)0mVEQ$^saWa6ZLBolKTqo{ZL@|M#$9`9}2%kjp~A2ExVmrgf{#9A4cr3p)a z^Rf@4eIhRI(_q#*RLLYrx$rF2vtU(Zc{pew!~Y#0k1_8JoiTT5BpyXA3B5V71D1H? zl5g{SMq$ij(hx*wHkdHnT_7Ch8GNmPm9G5wbOuRLHs(C#JruJ2BSOjb(C*efpVMjM z_3`?Q15txk+FUBDdU5-buI8UHi9Da3Qi*0-q=ppbIS!8awHa>>HB`P;b_L%zR~3#+ zD`@N6uYs3Tr5|&{q4IM41jR{(%7qegeQcnO6rP&oS9rO4`i%!DEU;VQV-}|xs6tbw zd2O@Wz_sYK-cyJ$&xk5}c2_z3FWa!t_*Xy z?l7^~0x_T16LLxvG9tj9#fReYwS)8MVz@ZCd^E;;&F0Hia*~%QtM+P?l+Z!jGa+GN z{XGQQQvH4Vul8So|9$wsyWfBI`u}{I@wB&M-Xja&JREb~rjT(~ysn{Ru9a9^{vml#C>gS?9v{t)V_~uI{Gk_h<4TCgGAQ z*8ob_ zS%pJTVLh)<)he0wN3G^BdtCs6zpn1`U{U<{2FOy*5e@DUx6D}3yG`3CcO(CY{igXI zZteG*h63s9h}jnay~BIVMvz)UQn0n6dI@i)r>KfSY`Cka-~%66#2 zf2}w--Yz;Z*9q4%nTu2HuncD6=kz?eM5L#MC-GNHyy472PD!gS*jIXRa5RV)6hs_O zkK2!0wn1)Gf78L8E|Qo7vwC2|DB_%7JiWjuQG}clTgfiKEQ#E%oW!;hif)W|^6|!i zUwLt!Hp18l&!<_4N)j1pEw_@s&MN2O6ROId_}(+3U+t(-7PFFHdmINq z*A3(2wnByH`};eqn%fzd=Jk6aeJ$wM1ZDN#okJ+^X&hAs6%iB75-N(XDgYh9(0T^JDhAi@qpR;A_Vu&G|t> zqKTrhmF`fnfDIL6q+-o+_<*FyZ=P!`+xp| z5j^@Hvf$krUt;8DSZrZcF2YBP=?@vVKKsMUPazI`b&@N&hNH={adEE{gY zaw3CSr|Y?94d*afMC;NStQtk6Da?b~^&5vMW-btyOx~hq z9O13N**uQwjUv9$-dlc)PyV*5&F((AE}!F0nR3j{*lr%%1HwjnS^D=3;KWAP1UDLB`fWe zjE1!4gda=?Bh~(p5*7eZt5%!1Q|f<}^oLbyCd$DF`l>WvP5p?6u8&K$hrx@G&xosJ z9DMNhbv4%jCVbJ>Vuwu@ekP{}0zm3D`xMOOeVlr;GHtP<%$PHN01vngxOuC4mKCkU z`kgtNTs6iRCj(uI=chrZ*~;Xm4E@a4=+0PP1!z;A^EjP@n>uZw>M zYctl@Hxj3l8KDc2)=60CF3*j3r*dNMn^^LznAVE53l9^*K$X43W_Cas_dVyMXpuUc zP^@4~8@fsM7tLPkU{sr<^631%6M;CQwN( zOAm(Tf2Wg0!A&VsmRP6l2Z@=@t|#U587K3#ZRHO=dmTgtFn>h&soWk?=|nWVq4%nm zDwLaAi}xgsMmL0HT;j&IffXcA&k2K-2J$XbLGfpjv1QGM2SAhPK&~vZ;GL;f8&rYj zv`o+IsjU47DenM`{E+}7qc2M%blq@S4nmH?CDHl@3yT)5OY?fV%9EBL*12COP0hAn zIZC~wubLIr7!l+8>{so<5Fi^x2@WG4K@p_;ikwag;j{}USECpP*c0G0`es$c;k=!M zX94M(2vBtP@k@ypIg{R#AZYAzH+@X^2QL1D0c7H0d91LAkj$@zLIZyF*dZ$Ov2n&p zOVm9G&wodwdkZYwzSE>Le)uXIIc5x9ag)=f0mfZEx#SQC`rIk%j17poiWD1C*^3vV z_~(_E*5Uhu4kqsK2-|-WbjHU^F4palNz!~;GF6F^OU-O?dUzuQ%zwG4Scc9wjLWF7 zQbWOyDH(V8>23<;Sw2v_Alj80V}<~H97%ET=$^M7%%KS@kop*hCK@?pKyrh|6I@LD zE*QV2b`apKl*#!9Y6a7I$*TG$mOOOaTd1$E3L;1kL$LQSR;Y0>%$tHq;fil#TR(eJdv{%SuSP^t7PhwFxaDtklOh zlJXzLXLx=acEuc)5D1uj2~>zR5t|~&KYD)0V@kdOKRjwkQiP3wWaKYBiKK^_fBJz0b)@F>7 zV@T=uIQsI4F&OaAmSx|Eqxu!!G^`-sFr@SPwx_5q(JmzMOD(&WE4;tjmI`Sclp#WT zi#UD(b~_W!WYAq5$b6Uo2lO><1w z1vmBOrX<}AKd%o@CXh%ma%X`bbgWZAEUf&$Pp`kRmUNg7Wm+jY#3zaj{o4yLiamZn zD#qU?8%a!7dwiP09D!}T5|Nr&+crJ#_@0()t)(!i(TbrIjyAn@IlsdNy1AFgsKC@) z&vjAA9jV33cs6(QCKnp9zO&r8gvx50`d13N0<3nr{ERHuErpxiL>P5Zo}{ry_BygT z(DL2mdS7?%h+RYkv7MG@IX(bMELrA|^sP7xzSu853+~gl5+0S2b7y7vGII_JF*0Mnai-?SckJca9cl0T_UNucN8lnLm>8!BC`7Vdud5KN!Cz^>-H}R!fdRs$#sxMF0s7yjIVDC*{KU@|GbXznP}NfNKyVD zE>7_BAki$YhBl3ePv~nQ^nwc~g{WP1jgZk#=$detz0|`p$bPI8AK(v%>zDjzy-QSs z354l&;DO5&{>|*$k0ke>%q}j<8(}Va@Ji=AyEpN^p$Eundfm;WuPcyQhs@dv*`2>q zNabJ_AD^_RqdmzzaF9b&^|Cwa8xUArj%fc}wt8I1RzA?SPpVGdf`6fTSXSTv{DtCK z*vo1B-G(~PD&3%V+e@&z%6>0=-x73yn%*`fuUYdarJ2U|)$A9oTuCg?>8WT5;rnKi z6v$TVYOa9dpIx!ExX}eI#pSwql3OpHpTDoHrXE|hjR*fEM83%?E|d9b5oJ_16BhOR$&$j-wz5F1^5;CBQjbJ4u#iq z(L<4gOk`TwC5`r1?FlIeE(&D8$yKdG_3Uw9+MRF5P>Y)-4^5Z#ljgh=4F>}s$YoQu zkx`R&NYEVd^Spq7r^}bMdyg}R%5i$CS;j&u7oRObEP9+&e3T3n7Gee#J637SNuYsnXr1F(x0NA69$}mjQ!Lxiqo(wB z>I{Mm>)$@qxz($Xu=xR5G^TqGuIuOYT@PynhWFLpHLGS1Qmnv&jsKQW3*6W+eXIMr zd3PfuG$+MHJ)J`aNk_|hm2o4$N3N4xw;1HMgaNO7F+;rz&~@(Eej+P)o%I!yLYL^1 zfuVG@RPg7Ip@f2PYVr#@kKJZq#iLJ365Q%MH=XKU#mnd~DFP7@u6QI+??pD~dVwS> z4~(xQJv6#>dp2vh&63H%{3+qAfo9-pD~h!6jcb;GNAcm%Y|(SKoTu*2oLTO(HBVIhkZp(VC6Y6NR0UHPqL>Z@2{ zp%$f{t`O8%rn03|Z6a%SwIA3LS$e$2cw``p5|O6O(LBvtd0{fwtE&!?k0k_WaViDM z{VY45t4}Og?1CHneiNkOur8!7JixS@S+EZV2O(3TPWntXG<9L6 z`dnJi2TU)F2sKKP2m@}6j+*JNv^GKxYt~=#2dp7VQ|xn=gBi)??y47xnJhz7u4Hpg zTP&BV>yOMlhQdAmq30RxDcjpT|Lhp4VLgCOVVRk9lwy`NUjZg95M9Mnd{B$X~(bVUU!(MQ6_8BjmB16fO(n4=uNe?iJtw^E+@j3-4VX%D;JhPrLsWu!k^@=mr{uE2LbPz_}4Y zZC~6vg!wFx!FP9gTDiR8`&iX0{~A9n)s81c7rq+)i@4FRH{W2yl>mQAI=v9j`oU#X zzX)v6mG4*8tY}$%O_0!!(aN!u4G6)F6tQI%#!aVj$nZuBiI|kyt15d6?wzr!WN&!k1 zk3jNaB8-W%_0KWe{r7GzO-ygPrh<^Z*Iy%lr!w!HdwTwi%G8gE6Rr$G4g|DGhu(IG znAph2D8q_fll#5g^2idtOX5}8+?#5CkNkucQEH_;+?_1FFbiINb z+uU^7&aAomvwHXJ>^4{BB$qo4z}9W+m~JNVnDqRD{mYRru&;n+Z36A8Wy^~%Dn{1r z^Y3~1)1vqr_fWXNrO|?-GZJSXPlxCOvaGpwX5iUuX=4ww9iHyqPPW;VcHst>9G?fk< ze-_%3T0XN?tIgvBd|2b$OBh-BQXUk~hl6_wn+?hi^fkZZ_b&o~#BEluRL}*g^Lbg;aclmfBsV$fq+|Yfeg&`y844W)r!yMnQEw(xtR{;roW&2KK7gR zF@tt6IbE4rZ*2d|loo?m4;-JoDxFhc+dxLo;!Powa z5SqIfqUeh7N~|37-9xcr5kQPj4;{W+)CNuVXLjSn*mj1Ixm`s}N{uR9`}jQb;){>1 z-rnik4WH4csWT1>XR+MHtxGX`UYcYfH}R_{*s#++7B7szxNo=;P-df6gnq&P%`wU; zNli*x!Ck~dW#IwjFfy!H$LS=`4HgYGSVKBjst=fd2NS#(5#RCDT%IFQ2uNagaD z@QXFwX|e>zpk_85znUvPV#l#mUXkqjvnDFeeP!vEoNaMJ;eK*%PM1Xh>WN@L1LhB| zVNmP}?pS~*VPN4iv+bN0aUAzM8Mp@Ba&4FKeK5~;hIKp@yRcBht0$iGi8^kcDQzDa zhj3{YW&B)h%TIONi_15(3_kTlWp6NXirYjg#Y-ju1+Qc{HhyRI%}sshkTUwrH@^aF zPh3l|#dI!Hb<_XWYJ};(b#=u^g)|nw#WbcR#PMHe`@Z3+N86reFv;}{1Fcv4jG>>9 zfjYOaI8)7tD6rXS6l&`#=htmdxQ3!NL@*h?_tH!$gIDPV#IkS-bd+a(HEoY4XbHGF zTkun8>wA_#gMlc8e#mi{i)hg@zipXvl!*8Hr{~}3NPIysRt^hjw zKxZ==B&;wh@W5{(-R(wk^`{Is>d^6`N?#>KZ35la+W8tYnSHL7tgrRFY+^_&5TqcG6rN=ous2-*w43zo0Ex6qw!fbr-YV?>O24y6UNg| zuZka&2n}e^ZFx!M6<{pAeYd}DZg7##dq?1si&q<1mvH#kW}LQI3^nnyWJXUW^%Bn zzEV&*)4~sEP~WwHlAqnY>(Y>HB+KG7WYF4yl9$cvCiPSqJ(!Z+Nuu;a0z@HM^A%v? z+`$Wj*+JVKbI5{4wSFb6TQ7GSV_*&u)<$njH={pWF1)K&w|CO4z0H;LHwcqf@yq~te0599c$`Ccj<_IEEf{A&W?h~5N_ z8Sn3^?qWM$6BfJzn(m}lN&%l&<-NBfkaLi+6WsVSj0+@%=Iq|Y@(T3${Qhj` zlsq`CuKBaAe!fHvW{r%6*qC2SX<`4+MZ2VxsZ_jUy^Hp`5uWCg(#!80Dtnd3&txA( zA0Lqmd2EGd*X^%dmzlJcb_2S@!D8eq`)ozjnD14M~LZl|KP#$6CsCV zg2@oydguUXF*z3=xS3X;j3g;e=>_?|9awt$uzPay((Cuw3(b`u*2o60Tn$(Rom+FN z74e=r*UffHGf4$wMRlUibSoe7*`>L;p zo#0!-WLLkpsAVi@GLL%$G`$|tg|HoUoL&0o@I4UEMzk`O9UaDZw}(zyZ=`+{feQVi z-%S!bP9Y^VqdH9#FXjd5H9P0myKCh8<$4*tuB*Vsx_U1MyKXV7Me^PtS5pW zkHWsqH`P1%{mFS7uZmY=T);FqEIY*j*hy&?@J-#~07Ec-I$IL{scS!ccp8fBn*PFXk!|w@{tLW^GDQ7gN3%PTMBYn16U5FR z$eRqgeYaJBL4KuCmSs>n?`Q0UR05?G;}KVmg4sgk6jt&b0=sNn+7F4w@!U8hxBYi; zKr@RM=vVkp;~~X$=AuhV5B=*!d{jC97eS6^+&bzWF;=qjqFN zBPU4dRkfcdN+#geo8qcVr^>QSKRxbQl}74=4;999ASntmW04s(weK0#{>X|-vO)&g z@q%`x?z&mJ#?qqkjY18Wo>f@>UA`PoTf1R`^?ZUtoRa`Q%$CyyslVjPnl|(*{4cnn zIh}2LPW%paCB$uzu>B=kME@#+8o^UQxz-0vX!Ec3UxEL9_`kc~fA;$S?KHWwV}DD) zse30UW)x%CxBq5XzD3;IW%bKOd}DCX2dOtm_AeMhz3(J@J_WBlMbih7_L03@h`e+< zl0yf5kI?dTy=d3=#7%`HgemrXUEfwo{1=7!YX2_`t7YwPhxekFRcrE%e=pjARP3@- z1Z6K#@N@2)ZI+kEbBtx4!dvg?gTQz0yxVkdGw0HcG+lu(o!71$v(Wu9eJa?3jDZt|)wDLVm6tMib^?_I zHTN}H0nF;23mgF1o`AA6?BxjApQ)8wkX3}YpsS&z?y$-Y^h&Z{<@fu=ortT)Mcu)D zXM|)YI>Jz#HHR@F%tF+Zx^dKWJ$-(Ok!!RPAva3jymkrWe=sxwWn`KheN^Wb&07E?qXBLPV z{x>1v`v26{?+hT9xQpfJL%lk@W(%7)PK--=c=K0ROcI;7Qn)OicRe-k*H{`r2z8r>+Leb#h%aiXv5e~rQ<-=l>pW)YS7b~NKO-6a1C zrZXH%6>@R-%aau+ty+&@S3#3NMRNHdefdGaJ8CWF#rAT+_?{u)!4L;HKu>6#5sEZ+u1DAH7 zZRIUvF6i3%X$(e_Yb%Wd3Zw+;U6*aS5T;k8XnaOP|ZBkvTQwvRrv1rSbTvcH!mpH=3&)A*LOVD*a(pLz9_QT zBE*cj3%0rUa1aZfv(K-*+yB$QUmouC4p|H};LQhPFjjm6h=?b#NxV8bbJ^}wf-UEj5Ieg@#!>XLlGd}0 z!q0=|GhIetZorXy7L^bIzj%~HE8B8T+5XSfrxN=5XfA4*6BuoY?`)O~ zpKT0+9tMsjj)VbWr}5yWcf)acW~_#{?AqnrZ%dYd=CW?>kS4e!+)P26bb8L&1;jQ@ zD36`Zyc&jhtat1^_D|fU3-K79MArdp+@s?%=_=PFo^}6=Z2NVRbjok6GvgL1Be-$;jCE@uH|33Ox;}Z_PP)GDn3c+^{_i@xM zZ*1^r{aaBF%nS*1FhGMxaIY3AR}}RIHtzPW!igSGU*qu#ev|gozW3Oo-xo(rm9qgkJW3Yr}+f4 z>sY0e=0wU?s+Cy7XO|=fvy0#6m;cA|L^?F<-Je^VK$eer0PMs>9>!Lxb!%g)S!hP!zuxE9HuEqw3tL@-~{Ea;wJTY%Q zI)~a)2(dHIk_y+Zlo@a_rLM-RzOSKVUrIQgnHo!&Woaf+3=XIPJ}oS}D!pja4q|NY z8y9VzWb(@<^G@u}e){rcduO=0Y*+nGv8tL!5WhV4axfMPoWPexj!jk600Z#5HI(Fh z83SX5KX(g}qNYH^7q5rRgjt#Pko20XZ`ZFTx^?=l)0m@kMv&__;l`}E8v(JrNlTZv zqE&Uy_kg>=0<(YiB=qLM)(wKM)6Fp_+veP=j;&n@RxO(?0=TtMvwJBljC1pharEWS`Ku;iwJI?2kY$GlvAe0+5EvR zM+)nFdxT^Xk-86!e+v$V5zflDlA^;T-DA6Oftemny ztPdQhr1tIjh9fFW>YoTvVY20*fzD@)T%J;VcqPdpLt@HyRYR4dSC?|vxI5I*>Vrc0 zBf=D%e11ERpUroC)DseSPi%$!suoau9G_buYX?-Rg&(69k7MEW^<2k;>1Z(D&%0YY zIsVky-`_p6=rB8`8;SAt#`ovnituSvy*xjXGaFbGgmGZRIQ(=5_ZSx`?KTuWgJN6K zICvoPC5Mg2V(OFcf(mKd)dsiqXgEqx^;4ayki~}v;1aACV^xiF##lc}P!im0 z9Z1SE>%aFuA(FS-FZ`vt($)k zK^mxQNj1WZbpN%EPrlKQBydoss-NG>-&D30nrL9WXqDGrIIiVfplA40?ULb%2$)@6 zt_HwQSVpddFY!dN0jnkdN6ybj>|dY9WH*|kLCdM!&sJFU=0ex4=UU_INNF_li|w<( z@`07AoJ+{aPB3iy(xlur_d7%0Oj5vY(7^c5Z$CHWfN>5}#Klp!Zd~Gz7i4@p6gT*0 z^8#Mqo%*BfR1$!(bZkvJUndhV3~TZ^N=)Ho`S{?z8 z3Q4u8B1rwYI_uC4K7@>`ajn|&>1*ko;iplXUsgihkNNN|Vaeyv?8xn8xdocJ+r6BT zy{zr*^n#F~762-wr(&$lJNJ?qMb??awo1ND6INJ5Cj(2EI)i1BQ5KF5n7PULe)P$^ z$VD5bBk854(g#Z464!q86MM za!J-P)FXtmr7}kP`?}@L1o+`OnGz?bH?^!*oyV4Z4Q&ngtlTl#$YJ`;d{!EmCmBsK z(1W1qFh~QQ0-A%~|2|E7-BA8-FMv4Of00F33S&YozLk{}8pxDJ@Hw;NhA;P;c(7RG za?x&GP|xtVoqwk?)W`r$u>a5R@c+km?*HqsfruN_uK&7E>r{Cp8oU1Z`Npn_^hNz) zKJc&#+;(mB;>ku!wX<$$c<<2~XHBbN>g{^aco5&{F4mKIZD@VXx@(nCy;{BS;z{q; z>9=yvWNZ?4q<$ycI)ym6;?2qtICiju@e&h#X?YLM4zmyg z&2yhI-n}_C`GD!!f}B$%^J0In@_as9Bl3jY?zdQ;n4ot*==Xd>_DsNT+`UjE^1Mh} zB6|cJHgddhx!nBtEry|pegD$#YP4lGlO5()vFx}zLuz<#n6>pxWBx1xX}PwGez^z{ z2_Dr3OU`OLUdCOCyqv7;J-6KvRlR#gz6DujeO*CfhKRfs`sv(c}e5^EkEOy zLJhvooze^gV|=2d%HVs$4>~4w+*dMPBYBHD2VE}lI!ZQTAe#74bH@&zuK}4TvHk82 z4&idIt_e>`n!I1KspZ&<$f7fNj}HPKv08ji3_-I8%O^W6D<=Z{k3vW3qyVrvfA*1y zMnlwWbMa9a902d{Xv@x?)F=0LynR8C#2_s|?^wa&TnG6(nQR#DSjv*V{)09OFr~uJ&HV$9Cd&Nt(Xyylb?f`(c_C()_PN48VEKwL zExh{UUV57oaNm#6wpwX(b>J$~PZgZ;$*kkXVfV-|yVRZyzh*UP$;OJD-@Ed{(|`5d znKR`hE+hP~2P{m1#W&LVWLaDVQZ)Uum+S(Ms|_CtKK3{GU!&M{JY-AG9t~xssCL*d zP_sE4nGIfi<@5(ZkrN;vT!UL4{gnu5W&n`ocjm2YIhaz8hV+?K5W}@}C#k|IRQX<( zSE*lGzhh;D_ZimMf~Yb556aFuDvoVi_eF3B5;VBG26qeY?he7--4Y&&6#e?s1yfrmWI?Q@Oqnyg>^LK;#*K`7{R04o2DilWOMGWP zRttROA)xV7WJp@3F)HLnf`S>75ZDw3HRM~GiJoAkO;--xMxvHp3sb;p6tCv!8YO)GPkab_2y2}Muk|9E4M z2AExH_?sWWLVNyf)-HWrP*1-^{MHRdW1L-uy?n1|(Y!G82^sNWB;jB{xSZ>|=p#w? zBsU{A-=yfcSVmYFkeS;r1RHS%mjQR%_}yx|DI_77@jZsB+7DC}K>l)ZC4uJNN*59k zK?~)o>6N!O=gQ-34(MuU%Xec-p#}3x2}2OMKqv#xVFtJ_%3?Z z&`zLTC8y8Ekr@-$&<9`VowOa=vWWX)ec$$Q6`mV$%?>z6mN7xhXn&EUkq@BH}0 zabrqXHI9X?mGrRQ;x2^_F`P^o&@O-?2@mC|tt?ooT_6aDIxccqOW}5?JTW|knl(^b zoxYQlbLAg=Y(kb%bfYl(#nCu!RN7|0dTAzf4V}4!gO3H|5Pr8gX*aJ+hmI{V1UNVt zQ)VrIZz?fBwDlR%D%ZKD{h{v(4yxh04f$hM`8DBpmi^|^8dcL=0+2~(JgzsNdkxow zC^KY|l}GdO*pgWyBx`K*{pu1b;%%dIlbe$$CzEOnvOGhRlB&IO)od72~ ztPI_cW#|BKpO)BBdRljjuS@7e56YzL6E22MEDHvS;|QWtlC1AAl)8OnIp_Hsk7FcV zsE0isei`s1C7W5PLZ&oeeGx3wl}{f=GheeP<~Qy_uYxHTVBBY&kJ#JsWJN!W!YsKh z?pF@K4xZYPaVH>y39za}6tbZZm<0r+N#QV}*3CanK#gtHDcxWF6pBRy14sp9bhc9p=h!%JhRHDs>rAI&8f+`F;K_8zy`#rOrG|+U$|5rZm*tY5gZj zL2B^$8dR#O16y0RNzfuo*5Sok82Q96GUJ+@;MSFmPhfgs$=VvxF{TpsvJifTY;x?Z zAMUL_0B}MvOpc<3^`i&3i|Stoe><>RC+&t&1LhV7u>4y!$!5IP_MGaJp(sE!n&3M? zFlO5#wM?c$#e^yW`Nv%6i-l4fz0oJuh704=pUFc(aqY3V+g&JXv67{2heR z*~0or%D&2YDY`@uRHP|Ft^cKyv-`Rj6ZPJC3oSXS zrKN2HE3zd+(ZCmdw=v;q@>F)@KkC<&={pr7RK1I%q0ty6hOBxjgg8y5h^*PJA`};c z@m9LcUv3_?IP28q1^V5;cF`yGX3nQt(WjTA!G9@4T|2ZQ)~Is6J1+HSWA)_aF;;SS z-l1!qN>C94^}Yx=4e+R^QNZeOEW8{jx~~1z>X6!_zhx4TAn%h z-nnOZZ_$B>we$~O9r`h2#>Ewv9P&{qfW?+sGMN(#h;OPDXV;K9daog17xi$A5ho}& z7qOQ&J9{9kd-lGTJ=JW<`0`%o!HZaB>D#S636vlk_TDTFb~J=Q6Cs`Upco?FcUnGZ z^OP!WD0R;3nWms3#BVfWm6oq!(EW8IZ{ zK9~xW{z=)~B}rSbBW}OeiA#x6ha_G!t*tVup+w%4d+cRP!e*6~;3^+`MRnP3f(osw zln^8f5Igmmjqggko{$FHFJHm4!KC+SSUJyKVz@sN_dA`dfN3#6TR|oc3Hy<~rWHg4 zOCfj7JJIhUC=1SYTC&O3GRCB7YMeSIvIbh5aGCPUPJjTQu_xH>l6V3L zSXJ2b9+_=bPmYbz&i81Ys21oT0q}qTeNzglxI8ODrnWYy_Yg}lP0U#yUH4ok{aCee zp>$|u?!9cat{64X0cNzVhr5d@^EkM1KzBkvl_|%GZT2{vmeTagim0^&LzyWv_?j8m zrNv9DIv<@cOb%Y;6e=tT0@KA03-UCrQkT6n=;r%XGY&N@jv`+@+DlhS-NOR4;n4#~ zLr%C>t5?7p-vI)L*P5VvT2oV=XHdNv0N)GANPkKN7A*jn0XXyYn3#T>5mLUXHyIxR zKW)i(eO>f}eny8UUEU{3Y6%7jM`ha?O# z*^GsO;akc`M9)lXZh#NTSq_9?bcAZm9$BV)3Ivc~wt%y5$xIgo4D8XysyPM?3}{AeO2Yjv1fKF2dyg;i7o4zdBSS1m5r~ zWoqfJv#8Y zX*EF@uYoBx%dTT`G*{jMpFIU^B^1MNLy;r6-dknvrUcQ+ z*3pmSaCt#qkrvpDvjo-p23v0Vs%!zJt5sSEHa&!{Y@pLNu|zB1BPEM^>YW5b*7U(V zB1&r0F1cVTwg9!V)4avhfrNaovX`Pf!cXsx3W^n@;E}5oR`0f^5UN~qCUj<7h^diO zn&c2=e{-e^S4rs9PU(Qs!5VrmX;zDf6+a_EaQp|Q z1}*)y1Racse)xzewy<~)Gm~Wr_4lRx7BI_sq|_B<9;(=UlY+hd&Py=a^yINwt(l5t zl30idWoL4Q(P1n+lzy4l2cV|1qGIb`>g1w%)k2*^|Azq7Ttseh3u(vhkhBeP!$h7{mk9cVMB$ioPuNRPGw{BYn7k0?aE-~@nVM42%-G0 z@4~6U3k!r+@3%vdAjGPxAI&0I7DONrhKM*XR-K^rA0}&Y0g94e}y@GK(i1!27PMK{=QgODUapOfVK5iP!)O z_&F{n|8A0Jp-bG3W#n8~#9;sc2}ODO?}kOCb8Wk-;>abbU!|RlB`F81e?;E6MCPIs z!cuX62$9~Atky=ZHVm}LoPSRV6_%VbK1^#%4kiviE_v@hG*b3BW&bq>>XSP?U1*aN z58Alp;cpM>yk$iAdoMxyigcKxyo%0kGlO9z5{kH{SnVOQ;1+J9l6>=}L#Cv-$@9ek zTFj}*0k?YT1Avx}F8pJZG#r@*hSFr!`#(0RJ~__F);6-5iiFP6uHJm1glOjUYSc8T zqYfvNx|03Glkw`YUlax;Q_B6zxe4cwV#dsJb1y@jvXlq%vd@nbj<5$f-3EHLgfvtV zgf6sA49cXlYVVC%P^2)8o8pJZBqlm1aI}2#pknrR(Q3Ty7jtAq4emC&;(kRyRWF+7 zU7m|8(hlaWD3iuU3y3KqyswLvA%#>xi|vj{hLo9R0p0M>&gzwfXwfq01{@67_+~h3 zcRe*|bTon^;1>@OB&XFbLIyPy&=e=dvkQxgp)&XIOvuo~@_QADl0y41+L{ec(Dtf@ z$T4KXs@XpFs-`c0+>)N*+H8W(J7V(87c3le-Wfd7M zzELOeVFN2qJ%HF0x}~3GXrS)uE{z2^f#snV`~A61^XuZN%lUMZB&1(C>KewnfZz9@ z5m)3G>$7#!*E;4!do>Yoi`e;d0>QW;QCG6^qH8B>C0|%XASafaJ+?Q+Yj1g2ew^U8 z=&Wgj`h#a5W<(Ek6s=ja=-TvN%>h{E;%hhq8wSB~X?y%K=E5IvQ5IXva5eSi3us;o zB+JzAdkMG2ZAaglohq>1#|2t+UfbW#5xDQAx!Mg0Aw%4gh=62th>-qOVW6;))d#X! zU&^tow*8t+Iy-a-L1c(UVr&D4$KyhNwi2$XtTn7%g^F(BfnSS`*^#%EW(hb@YM?I( zA{)mQ<~i}O#SBVGMvP;UQ2w4s5-yW;Q5>E0^XlCipN*j+tTxRwMKO;mk`M#RNUFT4Cq6L?qk^;rT7 zm;pi5&y(7##h{yyp7!U6#MX?NqwB=zxMyf?+u4dggbi@+7BshU96omfzK21+*a{r( ze+0~K+E~~zdA%s(RoU3(aZ_nW5D7HP<>~VJp}CRpIP5kyHn%7Wgm<7y^)j_QKU3k;$N3eakJJNnBDVHTY`$>^R{&ZbIH1CTInJDJ7c~q3otm`GG-)K~ z@t&PU>kjBnm%BJsSsxcbe;=oA{`U8ZNFqN&MNt@2FRqwcJ zT1qT^uVX~$NR!IhE^IQ!&529+`4SkR#Fk|MAkfXUV!^>;v{ES@k0509J9@zGal|}b zXJTA`2OAp_K;rw@yRc@e%VVJcH+XJFtff5x(|3Hjzhs*Qz-^_q^&W+;VSgdQ56Qcz z7Md_Am_OUR0th1sPRDCph%>0MpBd6C>3Gvawt^qSOrSqy3`t>ssr7gVqGPQu9@&W+ zhu6*RO=zF6@Z?~6L-Uz6LE!9B=xSr)o<^l*}F9Y_IQs($-NMje|he#jzV{A_^u;K$FS!k|0ZF*tKj{KTH_27%lG+na5iYh-6-`T{-*;<2 zvr!Fz4$$>Zt5%paok|rZZSmmjLLK8PKY9d6G}C;Gtn++68Fi}j8r;mO*;DL=6Ld@$ z$h45Fi;omFOwJbi)S57v_^4ij4Ae#AVuiq0i(-}WAt!V4W5{3dPsQk`% zVp^N^nkzC#n#~ft%gjX)8Zh*bHQ=4IEYA3iE2B)wT)=U%GibEBXKwkKC~n?eG~>&d zPKnTm0^%6QfYO6-7ibb$S`JwRj-sm#qfgCeA{mh1Hotx_vkaUOt}m1qEos;*jDP|b zG9PJ98dz-N-%z6llG2YMG!P7|yKn2$cA~9v@+7FknWNI6%gZJ?56_iXLLR0&+P5#EFAYbaw~^d z=f{L|5G947_h$xvvpj#ONBj!PCD77Y4cC`Co*d8rk6B|sSkZ)%y?vE_8KtV#i~d4? znJ)fZ4{;+XoQ~uRlH(#7sp?$ctA0Otm@F2~TlOk=xG~bJg_#@e=6tUDdA}cK`fy`I zk@95PzHX{FTj}gYl$ReSg9pBuIj)@?DZS0MWzUI){c{TUZDV2cC|TkbRkbvRAGH1T zCoFfYMKzF%DX@nelZi*QWB~c^NC>9Rr>L8g`p`7JpSQxwHOI`LII*w>kwFWUK>SZ4 zNc50kc#=AMX2OGbVb4ThP~<-do(chCgz;da00^zssYwNj#$G(6kD7|qG%)ZWZxG>} zobqZ_JwX2w?6{YtM$ro`2_>X#^(ExlVAhj}aN=hbX>R`2#TQld zq()Qz+J))ACP?kOj`3hz7S&EksfAe37#P#r^&5vpjB9!|T_wo46Ta|zZ&qdmOS7

v`i8im9B&YFzwExoPb#I3R9A@0*(3=o98o4ET}4#6kc8-cE#Wzq+xmJizy=aS{x5 z*F5G;Jd(z<@jpsa0g`Mn)Q-9x!-km%7-~sLG!z9G6h!|3vNn^iDf&9rxH7Tvg9pwy zJ5Cn%nJq5fyfs$Y^Cdx#Bg(qAw{`4l-jkaXgCelvp@738J&km8B!iP`1qi-jskbcm zQ9>5AR`vG^XX~HerD1?uZ}8}HC&-Pfs9D3DDrBr0GVM!%P+#82&-cKPwq|Z>DLLTG z7E`*w<>fLbkebqlk6&HX+RR0wOp1lgk+c07Jciig}QNsTGF?7*Xc8~C{IZ;b9&?*E$!;5 zYgUg2m11#V##&s!UKoD^hR=9(eZ^;%nA!h<%1R?ZJ+Gkm_~$#|LR*z-$qoCz5wKJb z!ostoqk@-FZbX4;Wy0^XG88ay0@E15XrBEqCUdoih)RLUr92~U)oaRzSU1`nFnsf%3SYkB}g=w}cxBtV* zM#({#OxNNjR>y43B)p)edYIlsqw0yV5P@|D1nlx(^Re- zkXbxgeTNB~S)3_(8Jie|$BC?E6EUW&5j!5H76$=aoDUXHCNWt>{+#k~g!VVc3TC#1 z2pwD#zAb?4wZW(s$teqi0%GS>$VvK59TY{T0hZCmrnCXB_Ht)7Cz?p`075}6JgZD| z>dshNqWXE{wd(h^-c4uMryoKy%T=z0$M`|iqSPDL+4^(2mF|kNSYg?Zz=cr%dMB5cAa8}?Er>$l?ll3u<+@d;fNU^~ zIWtC3v`qsQKl6#PBK#;O2m6cT=y-;OOejR_rA_LeVOdkdEK0MX9!1D;JU}hc;dIz% zy)(Fhc;=}SQhhfvQ+MR~=h(yx=?z6$w(pWjLd&lx4$RZ%{sbpvvuht5J9=amk{JFj znwkLxP|6?%ntb#fH%|>I#z$T>Rxx3D} z6X)awAyIoaCiVKD>>aydGdXr_O80oMH(YugKQF%Sbls)UNcx^^iU0iIIwISW0FqD*z)pW4e87>PeMV(UoD7D9U=ej9b zy;y!V5KMk|j}-5$I=PU`6%P>9)=E@IY#e>?IEpl~Hj$JrUr13gG2KPWnQe_+%Ed~F zzJUBvL(*MW&3^iVpjcpB?4Ly~Qq_8_bOQAb9F#sO#n(W$r{=V}ruoTzcb- ze^CP_KCS4TUsf!!w5h8u;J_D_N%%!!McRa(t2;LfgA)NmJC5*PVOkA&WfhDunGl=^ z78V)3K?-tH5e0D(l923rr|x;|Pd&z7)D1-poH0L_%g6hO2el>Wck+qt1#Mj&kD@SB zetgdt)}V(yulm-raGXDylBZj7^dqdkd1S}9R#Tb_8*4m~3J;J+15@PFi%u9-_V|jw zr|pvOMvo>FM%F|sjp%YS_X1##F8{bc#_d|h&~wOjYe+*e%yO$EI?o!*I%)?K@r#cX z$)>6z9nOwUV9^1WQSF#zYacV6Gy88e3#gfv+}n!DRlhbMCGwAK{V;BW5SJ53Cl~Rv zbS@hA^fbJT8BYt>g^T}FI(<>GqNLmiEq-;W4$R=c0VKha9Kj1mQ2j!GV762KEo#Uj zM4Y(E8WVG;0gR;~$Z>n$go+4BN%j&WcRzYjWz!v`^eEPcFqT#YJmt&O#A?GnB3YPpGAm)NXRu{v1Qnwa0Jy%onPW zRH}2TTZga%BvotuhQi>M%s3%WP+Q)XnV%zj#5^b{NzPo(W|>kHZCe@kOk(#q*S;+F zq`s0auVMP_FUsHk5dCE0nK|BV!zlM5Tp=j}H%t;FR35-EE`#A>>tAGo6m;ejTNP zd$*fit=PFr=X|VL-D^+)I_qyC2_0N2J*9KPwWXrgB^dH-25Yq1RXk+PCiinzi z0h^9~p>oVFz1sva!&P4#qK`FctSD0+fp+bD;{yYKV$36$9C(%mDC<~Zn8|3Vkby9Y zKZUpRX=ivpztI{gV@&v^+=?R(8y%c$$Ral@!&3fo zWG<5Vxjkp=4XEm++dKINQC3ZNsn7Ot%-2Dy9R`O9+SlehGrWRW?j^||@}B+^#s`2 z!H;COo{yrF2!1%9K+uKVpgxNusssJHpTHY;J3Fl(Bd%^?qKBf!hZ~Jfq^?MaO}q&JC~1 zEtkDMQ$ciWb<4H=wSz10ug$>(cmW%t@BiXG|8Ssx(Vqa6zj@C;^oQ`jtR^4~blbyJ zb0lPW)P+Y6Lc;%;-i9@*H2oDCQN(e-AT=cRkl&7jdX1>1n|Z)@6? z@66N?bYg}?mvIGDHLt*VYWeJh?&{d&DBQNsYsupCGg-sx-pV|A9GvAzL%b!NRln|I zzlQ=}0&Ds@p`~5ETx*VIxjvp9{6?}AU(ZFqiy0QRmLk`_*;P1rm3`~!#+O38ip{P% z+licyy=^&1;#xc7G9^1T@NgMN09ZgILY_&F=| z_6ioqc{KsbAx?%m1+T}8n!!AN@(@;;IrI&Aw2CFk8IuJXy2)iaKaQx=lF@@`U`s>) zd}+lZ4>)WdNNj7=j1#9x?b2j2x3o0hpzd+C6p#TQAD1vq@t@}|AJrt;{+;cB{2-Ya zij(_I(p5tH>Mir@Ytj{;HfmO8&Xad22GGaj!geI!1XEY>UY;?YofFj@<6^3Wbv2)y zH7iEt_kvmf3v5wSS9x>7zzR+kiUxNSq)meZAT(%2n~p()-D_u)v0m30E>EEYQtB~| z$@h>GSWOrez8!ff+BsV^F@bI@i9=H8W@fb>PkhU<9i+igW9d@=vi(R%fEuRj>L=Gw z3?L7(3-KCl3_6+QkrW>hADA&!5tG3F8vL3?^FnaNGEbkZZS^7!vaF$Xw~Qv-X)C#1 zrRcp;4%&`wJo_g*-!;~U)-Whg7=%#+a*>-Y^BH9DkH(h7GMp!Y%Iu4jFpf78mB`v* zr<#s;ibv((EtnBlj1D*#Zf|APNC?h3fnq*9FAd&lwJ%`oIo^T419IUg^+%1U2Y5H{ z$1LywBd)%mR#A9X+Y9}-i?&LpmJj>-u60X5^}r*W4->zd3P$t8Dce64$)`BInQri* zbA{x3Md}{pG!;r0p1LZdw3-HVWRZf&F|hw}SYXzDF+e$q0(o3I&F+M8cnQ4e>0|%# zr|g6zw+=0F7SAQ5+Qh0usI7;YjzMT0H0?X02KLfw^3kNUd2jjyNQ-8!XN#e;{2+7A zsG4F-W9FRAn&I62OuXS49Mt2|kKc=C)y5V`8L;(?!a28E>qzBO1ev>ipY20VVA8o* zH?n%wDHD~zh7Mw~?%tijM+JdwD=x-v#kF&OqH8AckXb3Ze_J`;MaN}zQH_3pi3)0Q zqx%q=J7h^$@t>|Au07bHgUznS5f5llG3ccq_xtjt7OvJrp6&m|KZFPfe8~PBSCY=2V0O_qF$!);qP#oW%LVgb)w{ zj~>lIALpy`!noe31Tu|LikFa;Y?Us0B^qP-CD_6P+bRHME@WVxqw)!j8sA98Jn7tQ-z2F- z@=x6c1=#`&W)zc#d5~^jFo_y|uWx?MO-BxH>3H^~)=)6WnVRfl!Mdpt0(sc*Y^_cT zL$QD&1S|j{Qm$b;fq{WGG~h^{Vsg?>-_a8CjxrDB-84uXW0`&J-EqbWE8=u712YG8 z113i%fYQ-C#-=(2==o13KxN64_(fy7X3k>s#jm1Q+Y>=}OnT?qMbFc*4pc2EO***}wB>zoLRGCz(HGxi}*=`R! zy_73l{|&$Ou_=!fXRZ_&Dh4`9iR{UfTb3Fq)cHe0K@>88*Y*oK5y?A0rX;FEHyo|* z53Ee(`E!s>#-eNuAP_+QwaC4rqgT9i(0FNhIt;Fg6vjcte>)#lq<)DvrS9=rl`d`| zaeeyPRYWpKNNMBnplTX8f)GG}8QXGo07cRAmh-@nTIbb_MnVw+hz5l%Lzjl_(5*~2 z83vT@9x%~x#!bjI1Bxik!;S7~33^kotVLs!`b&>O)hQ+QYf}ey;4y+7|0Qr)v@8V@ z{I{+C!=Z60$w)7kf5djM^kiaOI22I&uBEtW#s%g3$&AAz%tZN&HNF*y#6C`i3rCYG z3xF504=H+;7*V5fIfQSO@z~aIX`sw3{>vzWS;nkV&P+JE9DeSk>ay5dD$edM4eSKe z+K~}(IuTaKD=;Sxca{#BRW@jjM_u8M+2h*QEtko9pY%!m03i0m*0MN|nY$LQPfNP*^GI^t zC}Yv{LZLFvI~f@Gqi3>xB?2H+rZbBL4MS>S!hh23bI6^To?v6KH^0$3t{ijs;IE|R z!^R6ONy$SagO5dCs)nVlV42#ql(m66Ln75bKNx01+~DwO7Y`Oysf+BipX%1yr2&3R z6qc}tgACFn;1cFw8@w5Z#UF5v+!5YJC}-vaMeexHC(&MCay|tUT9h(^|1Es8nE;U2 zDnMM7mIda^oXYBCk@rqwYOa5CVETNy@>=#YrPSkPfet58>Zr5``EOCZKi^cq7seg~ z*3gGh;}J6(RhiD&jCg3`u`f7_%`+cV{UuqB5w7Zz6CF{|5G?VJuSdni^qk-qt=PCf zaeUYV@5&iv9fL=%>@84opgG7jf9<47{efP+C|%24e);}=;}0&pj-ZbOq1#OYdTKTP z0iZ#{XSF%&nDmC4Dq$|u@@>k~o>8rniXl$JRm=CS3CZH7BE(w8r9Ph{-m@Fvw`a5c zkwGWI7}xL$Gc~t>?1Ub{XbV~)wj+VMH}ZSa$_(^nFVu#*#6LvyeS)A!lq{cd%#^^z z14AQqg$t$+B-CdJg^1H*G_;&wDqMFkQkALESarZ_218tPB-zEN@S+NaYkPMs&fH&; zRdx7c=wsf0wv5f4qzNr=QkS`8Ga_1FCA!#Tjz_-@4^hQD7V9;P-HQB;d&V#nh!3Hjd{op)KjOY zS&^?&LPM}VupPTmB~>u0y4Lu=15_s2l*1p$xoJ$dLU?)?ZGXFi|D}cP2d#>WXD11J zXYQo4nxWohno7shVk9}De+apK)^_v;R+SxHW zBvKj{WM!D1TpNoyoX-}oDU(P|jY~umf2LudB$bgCR?4PL`gW%-WN39W>XW0NBRM~& z*?dwNJ&w73QT*YDa!=8|x>r5nFc}dy1uB9E?+;u$D|d@+bNeNKfg=E1u7c2 zI9N5St9>28JP?6=r*Cd<(Bo#T_+~K)jr`*0x_kw2<;28}QA`E?WrwIq)gvXWM-|X$ z)j?!J4H=`pekUM-fX~6x&W2KJc2 z)o6asnH@RAko$J{AG%V{D<+9$In^mS)Y=^>_a@=cBt@7}REN!hmild&j%2k-BT+{~ zH?KlV??aAqkf7B)UhYN}yL zk+tAI>h~UY{Xe8J8(66i@4$chF2n+gEwDutET0{+Q6eI*WobYn{_@(Lj&w#zL%HS^ zOV5gyZ$ZQRzMet;=HKsYR%&31c%S!YS6Q{ zHzOOy0bdC+Lh0yhcv24FSL?=wn^h%#>kb;rJD8-^&T@~T+U}@H&Pj+{2 z@SoJIOi@3O-KN+&1AbiGUfZ0n3@o|0r%su90+7w%!{X%R(4?k;1xO>~>vlAs25_)DtD4#Iymt9=spo-)CH@a@ zY>jZh8piq2C+4XyMv3GZHFR;>89x0*hE7+E7XKiE&MZF0@%C=M)DMk9=(BmO?Q8Q9 z+ubB7@1BEUI7#D&uW!DH0cfE8?|1+5OMr4lpye?C+b{99owwINc8dSU)&HpPH$D6I z>wl^5fBpLZZT0`4-T(T#|GTvRA6EYlzK=JPNOWq~>sNAtQ$|o>S!jiJWuA0uPS#A8pXHw1cEHYmb$DHaKDCYd z-rtSPBGr2)iKBF%N82OhhS{j)wH{@5wDU4!<*ItNRPL9R6b$&h_-ImwDl3JN)a-OhaJF$Fehth~19)J!h|n zENH&Roydvt^2?_{f|qWR+h^xHH8V<3q>+*!z*NNBTUn4gSUr7S*p7>u9#^9BeQ;l< zPRmTak0>TzUw9u~PmoR_{Mcjpk(KXy)Z{?3qWX-wyjwPI`Cm7O>sSUvHaqeIrCRt6 zR;o0UTbW{70dBtMZKS?_ayjQU@WZ1x{uh~Nr-kSFc5i|XM&1`aTZ6{6MnEDkoriPX z?U?)g4ABSwh1>0Tr1+!vV?mBlg^nmQ5eQo?kPfV7FvcF1@TZlHb=yAwHm&GJtUuqH zCtq#w`QY=rJbk@yX~(4HInR7gE&I4yf6$?`A~&!d~xt^j4Rjl6?8y4X(AH z`+O0djy?Ao&U|yXaMpc_^A>Lo{M9z5#_@dzT+X`J-CzMg_wcb~=On#BFVfZhpO$_~ z=nwX_U-e2uYVcmWUOXkx_1Y;4Zd>;T2YjAemv8QneE!@bEuMPst6!i10-MWvO%03n zXNxLQh@c(ruPDi|Tk9xju0MZ(^wWtz!Uogd(_2}G$$TNdH9@F3_SqlweV$}^*}AW& zEQt+kVy6f9x&X7G06vc19)r1UHFPv7v*Q6TUV4`{Dt8rn+MRA~3O?R13ByP}CqJeg zZYc=mpWpV|Fhd9O>+JB>q#AwqY6kyOwdt3l z4)?6vg9AOiZ#o$(Iq9!m7T>;`IEyrKZm=ye|pS7|Ng z+)OMH3(@G*G)Fy9&zoUvN!z5|?9`NE4|+OWqtk)nsuKVt8cE!ZmbmjEJ9HNg=R+=t z#sZbOOcm`fzxIbC_+Cw2G7~`8E+J!peWx%#@9O~Oa{QdKH{MXDS$kKJ(GL5^ zRFBj7Mz62q@fm1{dC%nOnf^YV>;B}$7aNhM6=!B%ubkcBtcw4%$(9}PS~;(y;OjqK zY#EpSwWId>IIqS4x3>r8!pcoYlX}szt}SnHyV^3JwhmmFzMd~^$>DlFg&66KzZ{n# z(632y0mm!e^N8~6Kl;ml;Or>*I`ZF7HTgb{k~MmqCr$RG_IV>E7rzqswR5(Wko;L} znGgqEe$E)!w^|5`o45Rf&qz#tua&aw&i5)xxNlCkHVUM;kCso{O>d9c+gEX39?mf0 z#ILD=PT9S!^aYv%5%ht+o1Dm5rKeeVTOEjn$-B%iIG~*nw5b&LnWqS^c;et;e3-#W zn$1(yfn2B$+4VhUMp|bkZhcr{(DlA{tthE{1Z`F$L8dMD-7(=jO(=w4SVcS2qeIO`AqY69oSbDEH6GhH7N*eeY)rfEVwv~G}(I&qXq{yeve1`dOPh#=w#sV+^=GC zpJ{!X)NS)RkA(v=b=%zz4igJ@RAk)TX9yk!kN!-=z2ZKfh6LBUY&+e4mW7AKG|4fA zKzHG7jMBX${?ILw}C%GFf`b2(jr{d2IXm#>#9)4`VNWBq%8Vm|r3>nY$j zT-;n}OAppLmd1UQU!hLaRRhO9(!0IEuD5n+b{BYl7J<9$$$7KgaH$5imX0XKz<*)9 zylO`HcFiq-P*6d4;a@N$XMkteo5e%odD>vRKD?+GQb1+^uhhFM{`v^2&R+UMgyCtv z!Pg1Y4;O2|>7VwuuMWHMK}~4M-nsK6)l>r5nVXwHq11!Jb1mF=yg25RNL$DCHK6KAaFkRyz`giZ*^rXd3g~Nyb;UJwfYXVxYdy^EBx4vg4^Q zD~Er@e)GCjQwOK9^?Y;50ee;H&^_e zE)1u=g@5&AtyI&b!A5YO2a|JZF9#f8PyFP@EtV87rM%HL>iCa>Q(ZMVaU7u{dWEe>VHZfmSp@UI*8^jh?-+`mEkt&ex;bd-%% zc;C14ZtuX`wyNmBF0LM1LHOa#pZx-?TK0Twf>T~+r!@RqZus%;gM-!GE@$U`I;Nin zrq>UB?DkaO*F_w?>=kslos~;n3Vq;v=`>pRdg)v}$^+LP8hwFsS-DM?GiY5SbmhP5 zJk|4c-R9TrusbjNeLQ&f^i;f)_2^==++;egBlU{q8YghNV7*e~h_eJQh#aPAoBA&= z!1nV7$yw{=*va}(33ET6qhX>I>}&Sxi-zt3*hk!fa01pqMXI@8y~>TDoAj{o!pzLroi*md3ecGY$L`K_28Kd6Wu!Q;o%FyaAE zNM9;U`sdq*=Jw|}HHJ?7Tg=lE&VJjL^;8#MIXKV_)ck%`+n9L4qmXKX^_^o#zjKKXk=~Mk|7ehbQLv~SVpl@Yi2gL?6(j)gb^ca4KPiyIY5p4F zH-P0S!bbRk5KHP;+n&E<&jpxYzru(AKVSKqzW5((=PSvSh8M=Ko1i=+E{EIQot>hx z%_CF+6cTP^XbCDPVf6V~;vPepg5nX$r4OEfItetv9*>Wz5V)8*2Twak0sQPPHvA`O z`yS!O>2V5>`Cb0Z36CSx*vC0cb_WX`l&zNso{xb)4?3LqqWzV*r1Cw^Qb^%(*;v@N zz8yxV6pZFZPzk@i?I+7Gqxgt;Q=6Q7x3Mwv=(V>ai_e8DS#nl}oMLNk|HHW<(;eg; zx7j>Eic_PS)@>LzA+?o~TaBD(9X`=_1^^2#r;Ulhm6MrVE=JWPPX;OP%F$K{B8Bg+ z_#C=&7;U!MXJ2i*p*F^6h+IGJD0@lnJ{5Sg!2z8c@bSH;Jx*ggHC_WALRDkW$8=Q5 zEQ>d(d{#;pHN%0EZ3+8T>)HEFfihEC6NA3SAlYq4t60G260 zN18J!T(1t676Y2ME{h7Anfri5_VH|O$Vn)grFe4l%zW=E@VvV%`*Eg5_WAdgunXvF-t+Q!U-5yXm)Hg|i39FxHdE6^055RfH( zr8Px-l9!=eRHa7%F*>J zjLuO9Zo9F>(9xSg7`eDQoixaYolj3J)iTDR&}15=`S3IJ=AbTS3o&YI5pERUI8}-N zZ|Az+>S^)(1$w}z<K3^mnDlnPh8`Q3S={_gBKa6bq3Y5qTNPHX_2%2Zdf@%( za}q)EPdNxY%S$IWYbcaV7WdLe9&2k#;|s3wLQuhUI{JFu|L)I*+T8A%Kz~K@ND;*4 zUt!?%a=Otjerrlpn9{8P1cl!1KQm6Mij;kauy@B$D3Wpg%{4b7N}YzIul$~_1Z$$s zzV(!#g-_Bl7CYW=q?Y|rU}$^Q?s!^&kkI&t$N53NOK}tsGF|)rPQm#y_h1u zY;DybI#bmUsY%RG-z-YCK!;&{;$pp@1F;>T60S{bA7~#IpJ_hSDEvFLKQP(l@rB5D zpniJ+mH_N@T`yCed2eNJg!A?%bF8FLQ?u}m!n@saM7?M^Dm0^7&Y!B0q1Va;A5nnJ zqhZ(Z zaZ%Z9mK;LUBMDI2n;RMxH02u&6D*RpC=A}?MR!7!P?l9zQx}TrRi19=(W@$P9+2Of zRk!AH)PB15aN;iPUvnGd0S`$i!V_Vmt2Q~aI9C~$e#Nt~1xEO3{}5(?+*CMhh%|%f7|!5 zb4&Ge9^5#$Esur(p;#;3X<>eA(Ph?HGYX7TBlzHzN8B$#oe-N+KUS|9_uF+8%3jy7bM`~L4D0#{&oPUX!ppm7qFqaSNGdIz zz0aV1nn68{j5+p<$ZaZbnAZ>}te?MinSUWM{iJX%&yk#-_*p^3mQ4YZuax8X^dp%S zf;Ce8Y)UTZ5pz?$&prB%IeOGC_0BGA$I5}Lbm^5WnAkp&dXqDnmV+yITi!^G6ou8z zbM+4nI%X^>W%WZn@_yB`^>uRzyCSV?0w}5$0q9h5S%qV|>vNiFqB3sy1J56oPw8X4 zDsGX|y*Y$~?gnfEl3Cu?a_*LC|%NP~u!kMKbhka!e znJCZo1*nRwt;^O2d!pQfP-(PR7q_CQV1zI~S{VbxQ3E$}Aw<5)I+{uvJyR@}E*xct zh5U@%b|s-SKl7kehc&EBL_=XEfH}kL`-yK5z;9vkiDVg;0i&1y#o1Rz#T9Jn9-QC~ zAy}~B8X!Q>;1V2yySoKT(EBp(Q{7@Cl1rrh7liQ=wx?y`f{lYGfcb8w98xmC@;$(i_{x zANR^e2lZUWe9=5A`fRT@XX1D^gGoCd#*}!rGR|J(8Zt7QZBws`!-wBHBV#bNORXO8)HcKWbe9>9_#Nd;G}Q zM4F%geO13+Io%=2@wLToc~a8i@nZ%Crm3}G%rfeIg|}PgeyQ|QI=?4Dj}#Pu=nf)f z;iu!Gwx_g0cGBjQNFY-VQ>V$rHKeEib82N~S@ogLtZvSv?jX*$V}}*jBU}1<6THr}rMxDT}<;E^`0*QSuYZmM+63bKs(= zh_P}}*PzZ~62&-$LESRMJ&l#H&yE%jlCf+7n)dz@?A@b~l^RBHr?Y3^AzX02eO5^t zV3r{F1Ibk>a?%ksAZs%*p-DG9fwbW76GNa!^rNtQQ$(oi-R z$Ygq-WUsZ#xfKOghiU3meT0ZFyMML&N{R9K^r()ZoF#+w9f73?!keVHY4~##SoI0$ z(8DSe3|QdC+*kiSkE=$Zx<1Z=jywr+cBwilaft zOdR484Ui~n3ybvglTbqP&yj`siXH5gifcS{UQ>Xj_EcE5rRqr`J?tQRCY2I zH4rLj2?r#H%J^7$+9pCn@=nGd_c{= z;m!!Jq`*qxjKN*hdsQQn8Ida?D36wmoGO_eGLhMa!nF7Kg*-Hc2PqIMlqtc+_X;%v z@Uu`#jS8*0{cPIL&5@*p*ek7XoMi}?NB14yMY$A|fRM#vqAqpI{i7$(7D$;VuW3Ts zyNEDT%1NhEGmUqps1g(oQ=lhlTWomQ5__2}^r5i{SA47pijmP<7zPm1s?V1Y&)45a z6~y4IfiOE;7mvn<0R~p2t4Qs5F@HJIC141>cfe(Br29Z2qy-chw6jhV6iW2!i*L-} zdi9onl^?zTUFUchpf~QQAm#(fZ`9Fj-$Sf`=C52}^f?*}<~`0&;oghRV3R6UZz&n^ z(mpk*qrOQS!S}n4MxT%Y1R$HXw6dDvg;M>ZtrA$2M;U(~;K zMU==R>>Evli<+yvp=0?iY-+q;MN1oPzBH#Gv`*1i_Zj?mL4vt{XP#MOlS@@5rdrlJ z27Kc|p9*daT@&XN$g)ioLz!Wej@ZU9%}oMUSl#QVD4{Bh_;f4HOZR6C>>k$K0_UcT z{EghCU&jJo9ekVPqsTAYahzs;C}^wa<&)SM$PAB|tq-b(m+a{T%LEFKHIqXPy8I~5 zuc#%#L5TT16Zj&j0*pIl%oxWMgWKnWd&{xyn5^ zG>6{AGoq5tR{VvDvu=z!((Nsra|)>=5`FLEBmMOc2l?#ll!QLi`h~p6aA&CM8+#Jq z_jjl*MG5I~`C`Q*L}dQYr4Szz<>@_9yC*_Pf=h|&rbFNWpeGm749UVKcO%X*7pM;mfPdB>rJ!Zm79CXh=!U%jU8h zF|XYlTjUwacg%pqhwb>|3dewgtKU!QfKl*NtsbIqGks)2bv36h1lJflhw+1LRF+yd zG!J*IQtNkPH`=}z340#8k%mVX;czK-=8Tz|ybsVJ5!W6q@i@)%_-Cvb_ zbE%dl8HYjXah8&2{``(S1zqMn!xf9}7g)W?hnGngUSa0&r&xwYrAS5d<0FX2S+sIqjmVNzG-An1{ zKsq#HM8cn9fkSiR_&C*nQK#5o@LEC>1UkNAN#22wA9OQe?WA0#9i*;@zkCtJP%5TB(Xtq};Q&Dzhzl)hDN(!Gg z^MJsN>U$rT2?=OcYo(;e--xW@r}4QJ+x3{)N*YQ*s!B>u-$>2yG4)+@9IQC<~%vRsLo2#-OJLcXYXH_!#~ zi{e?7{@Wft@Z(X)4$j|LhY%2V$%WBknb5__^A`zvgnB;-S1jad)dwcuRzbrHR4!v~ zVCD=2ee=zkqI2E&fMjThbZ(EDCwYE|4-+@q+V$0fs(Qk`!;OSMvZoKWIdH2qVjCvS z`+Tvb)MYWrl`8J_46NoR#UZkJ-u}JV1ySU{CGq79wGZQOcOZ@$v^X=atdAH{Qf%CT z4`={4WTm~aq6~ZtBY*Lg4h?=p$RL|n@4%5riH0VZJwdb0dpnKQb38DLaLz%l;!>nF zyL-!yl20O&%et6GBUyKNl2T5vY>~_ipM_g6@hDN^LZBSO#jT! zGho8T$SolN_6NIiH3hrJz_A$?VFogeqnvbG_x7PV--Z&Eh_&sPQ7pi)ycC_g74G~F z1AnY+9(@uUQP741f{iWzGte0-diI4$kjXWSH!HcZC$Hl7&|pj3nI9Rn z{_jPJ9dvOC$fDW3C9dk#Q%+}s&#qxH!FL$A6eyZK2;rWoGX{;8E5d1}N(yo3 zw6x(gwXC;;g-jeM)28bm(o}!XE#YUc9zPwC{5crq3QYE>iLO&R-CJ$<(^gXCQmBpz zDFlF`92#Tsp!w^FZU2H!J$h<9^t=&KNt|hQ%k~NLM z$;zEaw>+hyN{ynWx+@50cnr=DnK;eNe^W_%?qm)Dcj|sN!gi?4Mb)mhJvp~~1=MT& zm|~f+@^cw@PVg&Z`Kr|i31IyQzXTmIX?eXmrvFB3MxuYxLc{$+Tu!g{EPw&^j2e#9K&W+0(x@!|%Qh-Xb@u*es~^L- ze0jmjsN2&)L2hKj{%~)sPu~&0t+7J!Q%w>5`QkFw0+BmyDCTjoRXPoynsI^6i?cO2 zXNEt+H#?R;qL1W7O6J6py_QToRA2#I1UMI5(Cu_9Y~*jlPiWEo-mx5{L)uS(FuiqB2~>YAzavUBEDg*&{yx z2?DP2W*zxMY}~q9#x7g|e(2o1SFJ!;4rK7rUh?^HWWq*{!*i<*SJ?w$H2B z?6eR!4c96rNt=DRWcw*(8dkgZQc>YuWr$6omG%6~Ng~@pL_OoWTs8WmYFI+AvM1vo zaC6WE;oA_wIAq*825*oKNJP?&6lkD?WW><7x$pw6VgI zv3_2B9*fd!{3u9@x>)T+TAHS!2ljJB+fMA&p!Ym00tK5iPNH0g%G1lTKXv^Qc_bvj zdCDBbODus8r(hF8_s*{B(TXtxbj!kte$n_W@~MzNpEXhGixVEsTbB?E%f{K2XuzVj zM>&(ZIN)utuz!alDjc9EwTmK3-EW-vbhc`DUM&tJf3*>Dc^rl;jndho{l(f5`Zx7L zO}FgOYaX``x#^+(>2s5JG%+^U!@6pEHGh?!ZN- zh5_s>RP}G8_A30EO0fX{Gsyxk*0D-z#$^0=7u2%0`QHo0FI=9$nsJ?QZgaCTx_2Qy zuW*9t86n7#eG1T)D~Dwh!e2E(MZaX5_1s3=7m4Y$alB|e+za&F%m#>YuxL7ZR8>+y zi<;Cd6KlIsp})51L}YascvVp#fx+rblymqjQj=T?uR|O^0*BRAba|ller1z&_GT_c zJo-VPp`RFsT(E9J_^&-6C=6q(o-7(#bLmJJL$X*gP!WFpD3F5}*H-Y=I&b*HMj@6` zdw$NCTplbCjVWNA#5hpskpeA-!m+k(Y~*#% zFjsr8<8JH}7chhlAtvlmq2H}5CwEAPT^C8p^;}HteG${5!8*3K71P2^i3L3L5oT{a z1(`UBep2t={VR8(^1bzfNugn90T?u z{}b#})p{_KwLW<>;6!`%uAr>0q1NtjJdYaln~$OJ)_+7CO3GxVOFGMvzIpigBiWH| z5GOQl-Dn^d7h}PL{M>$?E;-Jx^^F7xB{uW-LICk!vRJs)mkC4=dURE;f62>F&n+sO z%fB!z@kCq-Qs4qZxRdlcu=Z@Y>J)2_*9H@LaBWq(b!XRN@SUE@v z&9KbKK;~A`bqf1LH#(b`HKp>oZcZ(cE{SRqt+KMn+Ps8WV8DK3qgt`L7L2$RXT59m z(0vc&RTcIU&YoBtoLJc7=ePIm{Sk^(9-i8tZiM8ykmGH$z}ZSDHlYGr0+B~7{V9c} z?xKs2)xUCz%Lh$;b-)msb2iGi)MHn-3>beiHxFy4a&#c}PbAKI{NN87?%=E)}H&__xTo`a5(&K{;zR?#&Lr4LWYMVYxG*_O;wQ;$wG%qDj6vFr)9 zAhF>9lAC&((wNyPtP4-C5gn&Q{NKOlOe(8nCpY||w+?g^Eo~KXs=+7+ff5A`GXuZg z0I^ci+&?a*5`Hh`Izr9c$Nx|8Nlc_)-H2Gv<6@x!ctC@(o0S}af`$G{fc*6Ol9TML#FqU`+w82zGK4#l}sBc`gk zvrP&z28X)j02-EfEP(d@s=w(fl~9CU+8(E{_Q(6%px9dMYQ6< zOpzp@`yG%Hh45QWrV>r0%LLpls`SPc4)If${7klgP`<_XX#4;G3<$4eaZzFOD&-%J zm!1B}uV#Y8CSc!S?M-~}1c@Jmd|)WHMXhbLudeZ48BU5a&ZVWvVGz~BoPM{rpBv>Y z0xzOKFD$=NQ5uq*a+{Spd-tq!MY=raKmh@8xfGT_P>7Z2AB#HL34ISw4!i6IU6JEi zm?;tX+{#1f_b9DQ4^+(s7A&^8R3wI;TOtJtm9j0fBH|W3tn{#?h4lItrxdx3Gu*pJ zI1ZA-Geh8gVCCCYTC)bC{di*r=Eto+RzJDx*~mu=AcO5sG<+=i)n0wLSS*m7l9i3S zXXXoS#_$7Lz#EdqJDCgw@JHKI6UT@GR0#Yu4Blg1vDa)8#Jv3i1_I;`PXYag!HAy? zS%~6Hpg87j$c<-wl5jh7AOTWFW7K4Nlmt=87||F+U0EZ5e*;EGV$W+UY43{?4Ur^d zLy2;lHr@0G$4uGJ)G`4%_}AI72Lf;YCbPmW^4fiSCw(Q0jfKpQNJxkZz*1eh8V)54 z#ZjAak`riwh8{WjQ2#s?EH_zM?P}JnS5+NjZt@wAmXcjPPc|xF$h4tROsIM75%s-ij;`} zS(h1xUt6d*={wE8po$I>Dtx`gS_X^(C6!&ho!~bKbrtC9AwU2538Y0ktljeC6q~_v zkz-_sb)nBCp(Rr6c!y24P+>n!Dt@4S1AIEo0$_IYz{Za%B5=+G+{3hVu zTlM@x{cLN|iHoL~-ZZ1NJfrrf@0BcN9rEKf#5(_vIedCDXRYr>34g-o%5p(GcbdoXiilKge@nb3 zLHb7POH#p=FUv|tadr6^7#6VId5|nw_OY88Ev#?9T?CBvWA!o}vDM=P=2HqUI@<9w zAL{rIM=j_k_-JY(wbOc)p@qq>2EQ@rC3&&Rxy+X|F zd*;Pf?{ShcQpu^s`kZ-VG)8gh^s2d(-Sm6zI2?+P&5ur5$8#8tnG zz`kszB%tKbQ&0B3u5Bih59>8rM|a4g=uy$?J7>9(q@xM*Zu}#FcQ1CjGqX<-f7i$( zpaqU$AQ~7eXlYq@tm@J|w2ZfQ2F1m#jUf2TIhf-nouA}p61s}~-3hxlfA|s~UqTC2 z^pt4|2YsaWjrI=_Bc+C@qA=93tJ)s0A^%88+IZa%<=eX;W(*%U!? z?a-C9c#58%rQ+iHz~Iawk&=N-9o6r2kx4x;ZDUkeYcSb4v~+B4FwQ&=agJ2vAmXN( z9|H~eqjC(dSy`}_>D5%d+nh)xm-V`OiRjD5ovIf^wG!{$WgY}IVvJGu$7J2wAmqGD3lOUTG7pB_TiMWfLCHVH#X z$gDpxO&cHw$fignM>kB;Qqg#bOFyaZBRb)=&Gt1tsiTClt=0)Um}>fMUEVaf*C8r$ z<&K(iL<6do1?0qdL{kUlBKTQIj~kB@$CVwCa{LEZU2b>m2=#Dj61KSnA8$Z^pVrpI z(QAL5J?)P1r|Ro-b@jzivFLh%`gqeGSV{t~Nfa9+P-)FXkR@MKrac|{e*5pn+S>iX}a*Z)^t{|s~gXOZyVx~Qf_ z*L?L`U0ff)S}%9fm#Ib59}S!M6$g=w=c}3z%t#D>qi4H+^tq=v+!tc27UMV6PrJR9 z6|`m7FY}Sd3_cU0tTa72u#*3sMPev5zYUj|N#pM>6WA&+$Eg{jo0`TU4y*4SIgou?cs;I=7|jmwJX%`e=^=Y;%o(K41Z;OQT2Me?+uf=trBChY>d^fgoy6MW>o(M*F%+t!D{S|r{dCpY zQh9^>pUZ?RuTD)$a8DgfX)M)}#BC-OKK*Y^#-*a z72p?K6Y;CQaBz6J`P|91D*Z^%fCA`65j`YDUfc-AL~?zSX(0wZeVQL9^1dhDg#apG zuH1huR=qpa9aygij&w~iiSyeh_{j@_n z=VdX?HgC~v#?yK3W&1F>KE!Y5)ZE-4%M1>8!JDS@3TDYLE{|mC9`i~##x|U z)Sp_2z}2-(mOEy}(oge`??{5Yuw5`WPVBdh0iEejRvqXY`D|Ij0Bh$}ki@dlc`LwT;(vxF{rdC+p3EQjWpN z%jpGsa6_}5Y_WHIsfVs*I>@1-`@GuKJBWaarqRp8%$POS>1~-Tf0+?bOhwT}DlM?l z*X3Sw9!@vBZ|EI`ov|=;kt(eMDN^G5}7??1L=G+ zkOTXa+FXcP={z}?SsixE5y!Q-hok#ho>ut4+@Hn%TxzJp^&ri1pZ9t&&C1eirJcwP8N9}C7z#;_=gZ-e0}uH!wZD#bxM(H% zXmo&HZd~32bC!)pR%=>btNp-O=S|Z!Cu*vv!?ylmTXO9<-|fmnN*v4I$%PN-Jl#(N zz|~=O7QI?%^!RWoO^rKf1&lH`#mT&E?c@j!TN^!IAJY&$?42KT6Ng2J}z3B>v5T`lNyLl^C)g>>kn{LWzXWzfw$z|UwK}a zT)b}qU|*&K7O1l7cJ;odC3-HgY`}@tA5ZsOx<24^vBHJbS!Fv0fH{xm(~v|Nx>2Yx zt;T_!Cr5H`B(H|?H@{Hx#25Yg*93E`XV-Dd*~#s>;y2rS+9>Kx>+rwjZn zw`q4+$pP<|rAJf1n-(dHh8$v}fzN>lH01(#^F=}x^w7W~k{6B}%0EW4!nYGUtE}lfa=;@h!}AjE4b)3a*V-Z zK6$!bJ!5B`X~IoUvjAnZC7=$D%B_&lC{wLM~@TLHkt-dM_m(OauycqO@P5AO%{xa7- zN|mn1VtG(Ky&J33vvK9~0>d#ZWVS_I&1MhOY6Skg%$uR8msTbR95#9E0=+3ust<9+glB4>G0TR11t6E z!Q&4AX;$s0eE}mod*4vq&T6p_*`u$1OIba%Sk^;nu2YY@h>dx!ztK;Xllf>kjRw+! ztJIy3dGpk{|M@z5Easv{$#KYcTZu~0WUW3|$X&7gsyWVKDNNvU2DYk<8S7QIfA4r+ zyFpeA-oGaDn|2sYME~w&YTPgR8h?F<|L79@%WL@G?#}-LwEsWr`Y&9StNLK)yOT?y zwIMQzKYNdchBgtG&LwP#u@z35-3m8G;i~eExqJnk2=%(WKqmRf@H9`$u6GR>S#rd4j zwB_!^>9NatbvFqK6*X`@p_G#v9wS$uVK1ZGesVzeG|XoJ(LXYq@&4@Md*6#CkIINj zTOkh}6nr&xLS!~z@R4$gD{$nbrIo+UJc9H)I9K;RS5rFPvF+)){d@)Y&(pU?pxH?y9KgUFd@Bp zmq-LEZ%T^E@mu?KQ2{Ny0Ru+wa4)|*9==BL+%4!w@hIkhx}qg=KdJ5~ayzO1&Q1GH z@JOCA%4vZ6{dL2=hEzyxK|0xVEyFP2-6?~Pl*Y*|<{Ld_)9hr5PR>An_(Bj@fpzEJ zJ!(FLN_gr~_8g5JsB!iSnxv<|L_}26y<;p91zuG?yXn9O@9J4lVHhC=%xPU-jgfTsBBi8~diMtvNNvSb5iG!bVEgRr9%>L8 zmNF=pQ{tAT%aD!Qh;rkvfN%kKo^X7={)gPRy#fR<`i6paM^oqH8o`5Kk`jWE92}7Y zgjIVJxQ7;ISp=`) zqWOvv3GHo=n8UC#D+Jrz`b}3LCwVJ0)x(G$7^wE{pT-R>^agVQW=?oifbGTF>&Gz> z;h-+xk@y*QCH!KWXCh*cgDOo#;4jFZJ2wU!=K6TZBBf}3bZQRL3?(dJl^;NPB;@r)Oc5OU{jui1@xTSMl@KW!ABP@4KZ=sLsrkDcab zR2ReV$zkt^)9KDDXh|XR_^&f{_4i+uUEA~F`wX(mJw>D(5=*#5WAK4k-K=G90&E}l z)X&Vn8upVN^#8nD{9(vA6$|{Ti%$xIOCS&SCE2act$3wJM9v_4P#p&6ieSzk(crj> z=%bKODT9k)r~e+%mFY)RQ^C9T<8)gA0e*JWPW{{A3EQWry7g)1RC#zAM>g$gRa3Ef zUQ#jse=$MK>l~@QtE(dH^P~f@D6;st@-#{}18teI8uDobL1v>zCo1cLNr>#{@AHOJ z76xF}Fs_w2uIk4q!_*Ln7uOknysZ*Ec_zAE^0=+a&Z`dkj@&D9d|z}s9w8?W`cMj0 z(0#8CGv0e&l)!L3dtYQR8U0|9t6VRfC9IkHj=1<|)b~+-E&Dq8`9r3G5buUkFz>;i z-@rM&jUagLqlBg!97aRPorPY6m%u_H0SHO~0c%>*j~B1XNWpO!`?{`Cgbmv=ir01FJIt!bSAZNgHBA(LIXgG%j;JQjoq?Fotsk=U$8ig>71OXl z!!RTE{MUu$RYl5P=uq8RFu~Bz@g$$`C`x>4#q^E`ERN*If`)-_Ypdfn20=t)>0xvq z;j9u;D`2QhK5h81x&Apv9R}>`bBxNK>%q6)EpLnZIsbk;vjY4}q(-$TBRWS>H#gLz zxNn4+Rq+*|a3JC0*( z$zjBn@zf5Pn`+140$1TXaqOmw(^lt%oR z*lwTXt634#dNsEu+VbJXwvt2hNjJ&dpUV2w^Y`+gNK30e3qP3kAR-@5ZFYI&cVS36Nh6Al7D^ER|6F5fnQnm~Ikv(4z4;QJ;-f6)oQ#mL6{Y ztor-hq?|gcnw@9mkZFXdrSZU@~br z>^Bw=T~-dV$wD##{H~9+_RI;l!fJnEoL2rV~d1^C|qc zV3N{MW3<5f@~AUBxOhehp1$Xy=rG?8Ue{359un)lnajfU}l%x_6!TCMlp>-vJd zO5HFACBw~iM%V`~(Tmzdd#fv{z?aMIxhdes+U|YG# zIF96Bcth+#{@rZ=NvR&t6!@)G%mpkMUz>BuOe>p2h0H zAwo}yiMx2r0}7!GR3T$YSl%+^ zH{(_(D*E>i!VQT_Dzgn$1#F5R#pw+5bsnK?xwq@qQ1KZ$MVT})Jd zD?y* zmI}U)lxQOs+j-CelX#|Lu(daxy|u4q>w+vW>0dF}h*Os#2h|v3mF46m@UDqfR8dp( zC)#X%e+uhWKwtx#6~TaV6d2Q*_Y0>i zc$l6oynGtS{j99x=LDQTGko~MV%(+}Zbo@@kLcR+tvp6aLHA8Z%G!R)Mx00DU71Zv zWwYpEts*^Vy5-CeOQON?RElacBw+v8mL7hIVL7o?OLEXwg--U4ImXC=+&g{8FH=M% zxY$8$hQye`K|+uYEOkYQt!B<*OwLh-_1?szCcu*%ip4v-43NkFIcSqEB@Qh-1n?h9 zr=#KRPdl=7l$2N9GUGJSD?ntf$$ibxD%!j)_k4wCu30rSc>5v7Dv zDwXO6?p(HiGXKcwG3#Ua7Z&o=A!EOUU5BgK+k=2)?V__3yFe$Gg!zefQoj5=J$DTL zt)S($ujWnACS(I?mJF&qA>c$DI{!)fam9+$2>Yo zao&mTeI*!5F1K&S$ewd{V`D8?K&f$0ND(}M!FLb^)V_#EJ51y$N(?H{r-ifdNz+|H z7U3aNQ;>EO@nw0=7_^__Zgpsg7^ivtps+XQP)F5ZS*|>y4-rTbWtwH1jhFk=K1se) zkt87;l8qL>fJ9zoDs6Z35I(3Z^e6we2%GU*3GAyY4TDXJw#@KSzJg#V>6F*^-4Bj} zBxrH`INj}ui6HTnK!NiZB{UygEZwbAr-yff!uBu}Y?jJ?_<%gKu5Px1<(evnO~VSN z?4guVV*c(|Nblc@ivDUV_a`46MR?xPj80kFju}8ZZ3`$JZxUJ*PorBqoi0+y{a%xw zHQ+GPG7TGpiEk=NJikypmw@QSvqTBQ2apo#3{THqo!r2hwMG0Ri8|mVr^RM$kLqVr z>HWhDzH)x&Ng@2=6@JU|s$GK&smYkURqw6Cgz7Mk+5u?l+i<7Xh|G=U8 zJqZCmY%Z5ozg=$?7uA-tyQOTX)LL5=O(<}bnA2HB6v4#9)KdCzDkXfXNTB=lvmC%b z`I{!{_n49TbsRn4J#k7Y2r(juB?Wukr)3+X7fWSM2~Q08RbGdZy;^6SBvj;3kb$CU ztdTJKu1=-GBfYY&f^759ANFG<1)y4eJqeO*uZflH9}4f z^CZBZq_`w_`h5Q|i?5v98EaktBbZ3`vDY=r$%|}qG5&I)fX=`@bwQ{01JMCzRbkeg zI)Vez=+5Z(F=ei8S+K}e=GViYkz4}@Z+QW+QATI_ywYK}jVaA^#17$5XQBfr^|*{& zv5180=O8^C?zA>7{t^=j;l-&8vF-`~_MV}x{5vYU{9*T7u!H@%CPGbB5bW%*Qj+c3 zDY+d*aUm^X7*niI))Z>z;OVPMX9!1Jle+f1AKcD^AElR!##`{x$uYQm@aNsoYd;dH z-r7_n3*2gx!8fnu6`6NhQVoX*#LsT9BHP0-O77be(1HXXGjniNC}n6g!Y8{YC@+=g zhy=U!O2T{s&U!j*Bi|!S_Isq|Hv-Fjok6ICO6&AyRTViq%#fH|=?8GM9Cr6zW*=aynm3B|6(5h8<_DwvY2Zsympi4FUQ(% zp_|{}J$~s}g-!jN7Qp@B8*vv2$zMX?@a0=9c{qvS0hDh}Q3vR@GCANM*RNHBMRNa; zkovFP7ZemQz40HEJ6?|af|F!}zX}7##<$z~+wUD(|4YI_tV23^iu({yF}pt)w{lOQ7_qKis=|iLYEdfl>cxDun9;$}U6%|?|g0Io1Mg-T%xgF-&|=T?ubv8!6&V--k-N?+x=X9 zF;K#twK-YyB7xY!U7|hnc)TWZdNcyQ++Q~%oc9Il*S+!U6}pT|wu3M%Oj=C4aUi|h z=_KO0?Dx1CwbbkOuy*v;Zhsb#=*=@4v*VaIpaE&u9@@T9nCH7kB>rng=e@=P1zF~m zEpP9S?zo(iTCz2G*48#$1On-otu*eh9uUDa&VDA(WesUWma_mX;Jh17fPy=f|eFt3*q64jii%EnYT|-BTUrwz%_Su`&3#^Js0?Gef)M z`VY)+jdZP-&kHK;d^x5M#eI%?Z`Nal+AmOES7Ub{rMqRWx%;`al>uO5WnS}}6G~=l zt%u3$pDe4>O|M+wV%++*l9loGvFvsEo115D`cu{?Hq=jGDrVRHEei4xIm)~pdtv)+ zcFvlI4;IjyS-B_$@#T+S^uMm-KOvw-?^v5VUyTJprF{@-_k;!Ft2dG#)%i5nFA%TE zsuhOR%&(KOzOvjzbq~(#f4c6tJ$Y84)PQ3Z=&0Uc86(4U&jPe^fksNz`Q)e~vL03~BJE!5yO67NkLyPV z;}}~Zfx_?M-!?y4Iq#-`2hUq!yp7x%Jm9R+!+)~I&hFbEjv|EPMh!n%5)-<;G<`?k zxS!-Ec5&1{9IgDE^jd}oI^L{aTRc>F&wb7D3_5*FYd$Bo&g8ni-CqaJ3gX^=o+7JQ zU9GCjW!T1fn%y*2wzgJSYA@2(ajdeuy1MVOygZI_uv@=6ZkMk&*bm~KcJI&q$U^l3Ng8%AD0#)Nay{O9y?l*%_11s8esKcz@J>Di6d%!l&<5vA zX{_1NG_Naa_sWzM5LyEbEFPC7s^Ckmq>XVBKWY+eL?n6H@4vOGda4X6URzoo@p6;>DW@4~n2l?V?rsfDh{%JKIyw^lvHN@l0aHw<5W|+*_ z`WW@l+zyy)29HZiCO3?FoJ_{l+7u{2<;;=CQKDVW^=#KMpydq*U|(rGb-n*PI7gS5 zA@tgf_w|@#`ZBL5GmH@(HJ5!|5kOT_mCbhbW!(PIu5n*N7fnA-(^1H!z4n6Z(audG zBUVK>$c~fFr`5gSjcnxCV=fJ_akKs`c!CP(>vtUY+`b3*O?LlX! zzy$}#dd+U)EivpbJ||>?56p*p4fod?;tL7LwTJ7G!@Qb+_B*#amHZ714%)=I z`(tAO@Wc%5aCluuwR2H{T(MhT_k8hTmC1NpZsD+eRAs9cmIl!BIjp?@%xlAZ;mLGP zpeH^uIBw(CbJRxkt0y$xSV*{-!BuC=)p7~w z(f*T68yAD+s>gQUG3Gp2(eTWT=jC_}UT6!N3~}wL43!=S#G+kb(N4B5(u%k z-VO0qZU;$>p8eccyL|hr8jZW^4SSkuSP9c!PZy3iu}@&Z#2|;3$0g_JpZ&dYk82li z^Nrc+{fzMrPzmJ4KF8X_K%I4`Hi)UbXhvlO#{G4F^w>T#w6P@JkMV6Us+cd!<><}g zX^{=!?_9*ae|eb29eZQ+#(1;W8eAq)@}GcXgK-J7T&qFv({@Q%YBc<*B$&7hPNyB% zWjXJbdBY1j>gx&}<+o~WlC^z15Y2P%KBKQyr%FR?u=;xEuku~v?GS8!DlQV~&-B-_ zBn>uk_e2_w8&<`BzO1A<-v)*XVhmU|XqPORw;|NrRz{xfAHGim`3OfN*c)m-k$p;tXuWApE@4X zZ&~?359+r~sbz34!^E-Ryc_pKGc!&1$Zv4E0m;$t2K{^DqcKK*3t~Lze7~bW9eCPkWiOhU?N$qb|B@wR7v@eIFEhvV2XZ`DR`P34(B)k)ctin96=~Um=vzvRjL=YQY?-!2M=*k9&4j8I zsxNga=ciT2M4NP~gQJqimeUpimNX>^QI*x|FX;2jy3eKRHB{DuPA0m&4%NG&%aMSn zUp8#mThBnh{N^ilcHfvyx?zi_9lJ1t1yv@irp4lNd-pK9ZQIY~QhKP%(go_kt(bwz zlU|$wcFXm9Ywl-PRSbCf1Zpfk&VpH?kbNm!brc6x*bu7rN-{KZ(wtVoguL3?G4wD@ z7%%&_y3tultnezKuMiZz5O1!>odYG;2ug=2{jRu@JwH{;-tHofRY)boab#C8H~T71 zd8OH=RpzOSh&ZWi*@c99db>CKpeiEcR>YKBHP8Vdmju(P?USAXg(RO=z3r>Ytz^f$ zXosO2Q8RNdC)ZeVbJuL&KtxH0!fhed?1SjSot307NE9NIs2+wCWcv7~0kudYa&jS? z7EhZWB#}mBJ|8=f84i|FzhNiK`!a7V_mR&QUh6YjTMsS`B1{<|?xkp}pvBl8x85yT z-x&!t-XQH}?WN(0eX3XN9(kYjgwj!e^o;M5=NiiNdZXj>M(_OGQ=KT~sZa#B;jn*|yfEqXmt zI#vt$fo9=e;%-aez^NL%KTyL^03QMGsShb+ zrzUC#3Y3kTaCCfpyh7o3utwWvUQ$9r5E`P9o>WX;{{K+-)R zWd}!6cfttYBAysg@m_f46{Q6Ax)N?J$t)-5&`a{%kMtlv9mIJ?Kp&ld z@yFSXf*@A)Y{w?K(?(k7y>Xrs=f$Li)wx+o8+qx5rY?+k9dSoaOlhBKHI_&WiQStX z7K`=y*D+lM${o$oPb*d_G)J{4$nwgHGqQ8VcT`rWP#S+7cYD2kdKGZr%bIa9uzg8! zn5r8YSQ2D5lMAk?QX{XTl28Hi+e59)7AmBrWWeC3_EJ=;Z0r*N8N5n^t5e#E&7p(2 zIj+i-hdSHa=KOp+$%i!uIDnutB|t<>{G}nZEh>inG}W(7EU0PWd9^ zL+nYEiLj_1UD(Wx*Blq~vH;DoZ~GV6VjJ`3P`faodYxmHUlr6YvH4|Zw}Fa3@IyU8 z{G4&~{aVVec!3)e#D}>dyu44F@!Nho0SlS+t@|9Lfa(+yrTu5C;i->m@cP=wti2Uy zvGx`1X);U^2Tdt!=r3dQ&&VNcay($r4!U>LoPo5jxELspH{IQ7vP;5GpN9qfi0XZ; zJI2gh`HiOprDyZBV1INx2!c4Ay3*5JdDiJh`!_b1uL~W2Enu`OKi^Zh#-v#OfL11?O zoAAj*=Y!ML1buxzu3b(R^?b+f-hRQ$91x_cJFb8)39+BtSI~C_+kbhRsXkm1xD+Rw zlycd!7!ndM{Rn*8)bn(?((&lxy^zT%2+8|avqiTo%`IyMWb{F&0&q@5=fSL z*Os~xnkaqBZUz;@5Bi=dK<2`ck4OsCCM(CHZ;8E-2!=ZS@-8gE<3&bc-wJ&4sQ!A%K zzuI+hp^c4AtCxxs8vtlH`6(r#Yes$K=VOMSoJ>_&dVdn;Z3tQPtkTs&M`;{d`b@+V z&=->kf8J$nygsIPKY&A*x1O+X!-j|8HR?TT2**m58anYaTUCwcMQ&Z&uP2tJ>Rxt8 zc398xh{)y}`^;{K6NQcyI+pqTjs8>-_v4WmHEq{7fuiCfmi=)ae87&W<2pe<&s<8< za8^NrJKf}NApFNUdVJ61<6%;!M3X6Md@lFNn3O>I2_}~}7@T00Og`8xPS=xpwqR<% zquUX*$)(n*c5i+(hhnE@EeT@9pA=BhiCSK5FaiRp%G)~L4=fauVu1&#C$Nk-7OrGsML96)o>5bGS9DtwR?4Bb6%{wT%V^utg#l7b)@Qv?!IV_~Fk)zI)PZ z|E}+5ia(r=?uyxlNOQM@yW*r zF23Oj+}PJx-*eD+?)bL*94DG;ONq95&s*}4oS}gKl9jNlaveBzwY&kd3NGoDcIN7x|;h&2Y0-Ncb^* zY>$B*u8&?`;;DB8lgd&!W(KD+6-VPuxW;LZs-91Mw(;JdXR6Itsu}@7GytBhWB+bFC?S2+UC7HnyLnG0vh+R7gMl1R< zWb>rM-RE1`Yen}A!`BkZZ`SqphC$~IZ`9axGMO+#!#0Y-gf}`uMG9Ci3BxykTX}3V z9Du8Ut=MQzbb-a8#fmOxnI(OY9Z==TOjOI8W8kZJUCWhomYyfa4T6xiIGy%gLN5dx z=GnFCMj$Tp=&Fga?@PCi0rR`Ivn8GsA`XZ17)40?jdR*JIZvft6}k;&6|+$e@40F{ z-xT{Z868W(QjHGx^4PoNLX9T?#H@(ZU1u=qPH~6{Cijoa9kNB=98AjRy?-9woK^(bN#0t07~G7_3EVJkyPJ#V7jCua(S_6{ z^eJ&;yD2>{wBE^dpVR;ClJ~u|bms(e@^rvn=QS0`qi+Lf;(Mq-CW9KrjYM^~;58P?BF_1AY$sDyu z0^oSGcs!-W7vPZXOm;nAy}jUf8JHej{8k)`f0Usbpw0TKjMi_LmDP zT<+h`4?zuUJZt{Ke4m}U7eMnR(aN@~j5cxrr-bxv(d`b#WH`qRw_9&fdsk&Syj}KH zaW>t1seqdq8m*>X*iuGy*1#0oG0n+51vTpeaYK8vr|yV?F+{QcUSUAHwU9@@vizaC z?V@YGaBf63s`kxFuvJ9hZSOr8V za!g?7(kx5xmMMVXP>;+INubZQ)k@-f;}{psWJ>x+Bzqu6Vs;cxd?Fr{IjX;x|2v@mFL3#Pz0(V5I+Uf$goFen@^_5HOh`&{ zKbU@LkQsrjg)_&WXfovAMvRI;Vsxj^D4lIj^E*%)B!i>2Vy*cj zu+Tas-Z2nHw>}BeKv^2ZbY%)LH#h&Myz^WCej1dyxtR{^;BYdO#-g>nXU;AEGq3tT zd?k9otMW(-aAJuejJKDoHhSA{8WuIRUYX#qXHNH|AGEMcp23hrNh==$~PINr6q zXgjRJJeqgi1HW6?I9(iSJ*XXHgx2(}rFMB`Hx@^|1XwUOG&exz{>AS-J28ElIz7?3 zJbOC_1dQA0yYzAyNZ+qrpYDzBf~eQ5{i(+E%C|G~@%B4I09tudzSLz|hw2whG?WIW zU?UU4fgD46~iKRHGsCXS)-q zydCQjrB*y7w7-or@u_@YO|O8qVTfiE-L)%3Jti;2}N<^00dbPyQ2^0#+YX{0x1A zcrG&pUTJwBktKQc;0InCMj|D2qa2HdnJy;6+_si`pIDM&pG}UqOR>ep76c%MImvv7 zc)RpzFC^Xi;Q7hCO~9S?&ua!QRi~Q^8xXZL@HC*Mywc#lBXdjQdk%bGd^sG}VzOs0 z%`B}c^(W}TSN#h0u=UO8>Fw|47h%4otnGsq;QpF&8{?&UL=@|>Yiny;(Lf*8B*Toh z44a!-WknS~IuWgv+pJSBWVbGHL9NTvZh69)TSiaa5`ICeFY3$JDRE@k8Z(Gw5jFhT zMvR*wt*9|9$1wbG?FjRroE-XyI|R>X4MveRGpx3^ueSiIWuts0-VZD7-0H2BEnJ*q z9FuH}rqxDfy!#CHue$RnmelncE4 z?D1Dmo$bWr?WdoiwjR~UWm0R-cnI5ZVYhPZd51{XQEf-75D*CBNJ&@u^-PJi%PEEP zqn|Cr&q_*jLC}zO;$_P!tM7(=z4?q!@m+bgFkqt73$G$ijh5_Rfc>-NA!>xXt?TLV z+2)uHGZD1$@&ib(_Htx!lgl;?PjAK5d2bYj45O*K;=JXRc$4Db!ZNA~^&$3iKC68? zj@dvdW~(-UY&IJOqSWoByt&O5ZV%Wy*ZJMg`0S>HL06@riD$=kv;?t3LqqHn3S@M$ zX&F}Tc!1k1g!CC@zQHg(q1{=bXom0IZvzPcvnh;aq<|yXZ<&`R+l};o% zUAUlOVA`vA_KCeMG-^N(-1pg}`-_)6vGSJV1AZ+_wE6E{FYkmN&=P&RtE=UsUPKD; z4Q?Z2kh=mkR_RbntXgJ3e2u}7{qt_{UdwD;)6uU54eEIFUrYBlJWPS{J3dS^2)jE9 z{Y#?bhu*}qWCQB>`!7-{$~u@Bzy*;sA2Gk6Xaa>X8@{~+O;(o$(-31bu60_?2#WJsjqkm14trjn2up2`I zcXQm8C5g#PysJ@=@AW~paDvDsK-xu?g>obXEjNxd7Dw4UE;hQFfuPV>#uVI5gbF=| zK_>7u1m*@A^n6AU#v1hdphu+ysNT<4&YF>40x5QJu@CmgJs7@(07hl@x ze&JH*QRRG|KBJ#&x4+=Lgr}HLg)$1)7 z{8cf8djp@F-YM|UmaPxnTC9@RS*w1%q$0|F&lL8&7|%p0ZwkYg|~KYN_Y z;O8K5n6G`n#~3r|QRBz`KXk-cKD5{UMVv)J)|q0}vh;Yui+8^%0i>SLGKnX;r@_d5 z>D0w}yFvD!%laUd1#MitfLI0PZP2`JUgP*HIiP!j#JX#_7asDBFu1)p9YVcp=LS%$ zR3p?zKC5NQcdCgK-d+X^r)Cuec23l0YqO6N0Ms*a0RWd%=f=}drJD>w2`HN6XJ5@+ z=m<+mtm*AMMa7EyiXL-eFyA)rVN{nX3gZux)-JXN%J!g=?iL_Qq#4NM+ zKJdq!&f~l>{GuUz316}g%tm!~o@g0~ozKOR57Lc2OpEVXShP=xywe~!iE(X%^2n&CUk9WC) z5g*zw%%s@{*2#yU;x(qz2Dg7{5$!2>AxmzHn(r^U`a>ZxAu%yAAwdoI(;-?1;K>_c z_f({T5(S~;b)U}G#q@=HyXwHVfxF0`$AjSIiO!QBF9N_PmC=J5FYe>Nf z<&)iKCuGe~Iysy#^Iv&mqoW)PkRdP{F9GHZLBM=OB0$C)IwdejB|bqJgON`|dG$|9_cwfi@=yA28UHE!zhwN2 z`}&n%c=9_v<}Sy6!9fD{JB9zR@vttCVD_X6;^jw2$)#MOe!3)aG##N){$Xn$QeG=X zu&GhNTK}mFHPog19Zv$e054B$_XgB7a{g0kU-^VGf3T5uX}BE0oQaq~B$fqQ-m~G` z9DKz|=J9kfxaJuAjvv|N&USSD9zmfV_4=aHla#dz162>lcz*1(6ith`^LW&rr~J1C zqR&G>S22N#DVt=g0gZ7}iz_hP9+A($EHmEX9w4zBbP>iG6lL~R5!9~0WrdX3mBW6s z;x>UE$e80OER*&ukbpF$Jr|hgaLs99970>?WYVv8`#rrc1T@>_=>@i6V70LAr015f zObCOGjLjZdrmA$%d+r@B=86%x7 z7oIlMKGo)w2BtVDpKtUYTWPtZZf?UQlqGfIn2IY{4bmw5JVFX+zhR1G)CTfFB#N?z zLtjD+4`V{Vy#5`*J5_`~FgcNddi+sn(?jdwMN5sGo^EWaRxe$f22=cN4?fqVBCp`{ z>qa^o(L@FMRQWAivp~<6cz-}(4?}!UyyB4;Zmeu)47o8~G2H6?f#U}NGE42zV)hx$ z%ASp==on%ghC@Y~@-mi$-e&s>WGpdO;wON&Bu|cQ#CB&`qYD%UePYvlHaIBv-c^ve z6!(Xkd12D5IKLo>C^t<?k^G1t za2nEq;_0-zF68H^D061(2i}Xo%uOj8sLQbJAnp1-_cE|?)FDH>C{45v#9L2WMu~XV z)uJZ(Yxq*T_;gou<)bXjT?f*K zZ1n~^X*cPcs2EjPdReAgg&>*R*Eu~|R9perv9&M>HlSxy+C>6To&7djL)<{7*jAku zpzgeMg{55Y{z&pH19pM4e?Efqzly<8(LU=$%=8|1kh!dd&`j@px_AO)=+sfkRNtUx zj>goZ=tS76*Hv@sD-H!-k9;8fY}oYMHD~w#20`*v|5pU5_ZLArCZ!KDu(_&saJWh! zMO{u3g?=2%`uN6gEt?-Q80%Y&f;gNeYs4UivzYH!xwI|#%onb@T7>~Tg9=7=-xy7i z(ZMbf8ZK40>N5IlGetu)nC%}%)|~+i;DzCCE9?ojG5g(tWa#hFl8%cQ2OfoQ*>s?$ zI{SFyHZl}hSzO1~!+TCJw$p zi+84!r{AWvRzk{|BCv5?fk5U3BF;JAUA0WGJkmY@p{zQ=CaPYig5u9mV)lfiZ#MuFcbs^UujoI9CTRhWdCuG;pqU=4ClFS9ss zH0*Ao8BP}Yfb&T`j^fZ)wHuRPW~Z^fZD?}%baLxu6T^bQ*`L8gavFXYhb)d3hotmE zErlt}N5KP-Fizf}P+u7pM~gkX>Z>9CG&e?|x&QdIBW(T-l!;tdvKGoR*4~Hc3DHZr z&(GesCC9T^pmiREzNUAT^>>vHQVH6 z*hq^RAM5ae3rYV}dIX)0-#I6nT$G*#|9-cbp|U-(tQsg8+PGkQA6KItajQQ$aC-Bc zu1k7njDB`ed-XEg{LJ;jFh{F^DBjo*Vdl3VR;^r_Q9A}bA3k8e3%}#{p??(VQIl$e z)z`NiYD}^Y59*T#s%C<{Ei9wY4yE-Eqa7mMXU4vLF?2YWtjm)Bcy<9#wtTuwzGj^A zUu98_dE1yx@75_&xZYTMA!qLG1l@}jw9kjr9dNwk1L9O2GK+s9kw%K03YpHgclli# zW<5Bwlbi{c#e7mLP=6KFDR5N+32><3mg|PMG$YWO6od0nP?qGqHl&n$f4aJ&L5?9i zF|?*%Yu;;ytB0eb&CQo4iPl3st-bs*&AM6hMesW)o}f6JEvF$>R1_gnq>Nh8VxIID zT>U4f!+$HF{wEv6T1w;WD){u)A1Ra~tI_&bBJJKiCkjVL+C97^?nQArVK3flXlSTp ziH7_&d~hLMDFE~}2>7q+iiDQGMV_Nv8xXt&pg6jq|0@0q^5)Q1|C&uc8Ll13z5PRi ziLzwJ4aasqr0Ya+YTTh|C&X%w8rm6cgt1%wP-SPLdP5kr;pDWqVvnDX zjjXp(bW%nYo@7o?NRWuQxIab^-=vS{ZG za1A7%t0B*#YvBoIH)+3b=8TQExvZgR9YHHliCuANi1>f4#gYQ!Pz|R7RV{Dnn+TCu zOqfh(J3?CGSS`wof%VI4-J|OIYi~Mfh9ui4nOoNN{+{Nd<=IW#9zDh6pBFy2qU3z8 zFRsPPV9VK#V)c1iY=7!tqQrdS28Bcf08-Zl`jKy$ckT&b|xID!|bV+~k4|^n-GqBJDVi%IHnigs2UM*;4sh-!L!h8oZ9qv9>j%Ex&N{ zj9b8}7Pnql3J@KN%$urOzh{);WjfxkbJw#^z)H-NbJ5Kx;A0r!$wDV>E9eVeb{DC!42H_*5Ki%8bcper1VrQsU}+C@o#bVW16y8yVQGLvdLwuj1=N>M})YLC2GLGyar=WUGCO?}j zX`rvp5bd#Gt|b$zlRjf5ZPs|`?DuL!&shH!tS}mCn)?WMtI;B-Lz4M^2d8%I;cSHf;8Cwme4|;^8*2IB3gJ%Sb?< zcVZyDQ}fJ9$p@H#;dZ{+_w7a|eK$?(x)SDOKBL-rN1-~XYwH6%!++2d)TbpTu-G-< zfgK?P-Z4lNjeNg5$r_Ffn%-8XOaF1_7L#8{|HfCOnIDC&Ka1jR#hd;WQgkaaI{JT+);@R>;TGhv#cCmR{%(70+B41ufyKriY`aZKXe<=Z&6C$?y`g?-? zH362-Y5zFz>Fqq3657WDZH7>JaX=Mp#gPK>O1sN=>H*2ld{n}VYo}a(4h%luw*A7h zYam_uDKy>Fi=Q$w1iW#Y+n5d38+)btnz}qstNtf4fFFVhJBwo0=;5ZxTlwcu)R z>VBikuB3DYmo7WL7_rOz_|Z6B>%GKhRFzW7a9Uccmc*iwIRZd|#V#p%|3uAL;5=tm zGFz9fm=hY4b|*zdPq=K1rTwc;tnFYeR0@g>6|0{A1>j!=R}By_a*7zn{f_`YH}!v3 zh)Jo|h{kvd%BwvEZU|qTZ=un_%iUJ4K4WmD1bEO$f3%u0@$G|rV|cF`Utwebk&iIO zK>>R8w7k-FR#zn|ta)&3@q5L;=BXzN4+RrGrz5nq-QI%KUwXgGjVs!9E@F0BPu9uT zWPsc26iHEH-(@*C2woG=Q&LeVFl7Tx%#-?j@u2tY@G0fa{|ge3--opH{&yT;10!t9 zJkQ%4+%k`*AM0SEP}up_o$}-=;3J)2 ztJz&{`jcGY_)ods3}=TJZDL}oh)?-e5~X_IY3cHWDd^}nHrAatQFZ$i92rmxdAFV; z{UQuH9OJUHNA8|D$gPnBqzrUvx?Vr|%K?s3RJ;Eq-O7j{7aE5 z-$d3C!!CAysRe191p@-E3N?qpyB|LH8V!2Xc^RgQ31_Pn4hC_ndOQ$Ss@d`0W^g#b z2G8hPlYKYC-fG?Me5=p5NR8fpZl1>ER`C@ag`d7yPUY7T>4prX@@04sWfl`0T5g5m zLDM@LcnJ&a-BrShMtPb!AZ@GAZ~RTBOW`l8+s&Wa)x%aTw!P89ZEf4PSf6&GE0))^ zwE1}1%ZBJnAU)j%n(E!QPAeAMV5E}Dl;v$-8Xo-{v!v9OLdYy z+`IqYr`w?ApyeOyLEvFx3DVbQR~lr`h2Vzld$?Ws_+{&7SxC9$Fga~W75!I>1QDQe zxJ*4TfW@w@+9AFIPVVMh>aY?9A$t8j+qh#g>Ld43uUYqJR5MhFgMM}FJ0>*qPh{EgZt7E>B%*;>aE#*ySV6t5HDJ@eiy3b1)ptr@EEe>Ix z4SgcAWxVO`_X#Sri=Km~6H+0uO$VMSmUH}pz zukFYnMPvraoE&)uH6Tjk;riT16vs+Tq|w3^>90U4(#O6?dVnmULFTV6E$VSVJgR`w zI2Vi=f#MuVkEtSqtVScr4bn*Rf1(fn3pw~N(fAM9IEsGl5-%T1xEC4sqoUI1do1w4 zqs-3u)p8zPv}zNRM<6ueY;vA1l+t>k0Wzae;iRK;4LqHkVq+&dX&qr(F|oooo^f#4 zrBLmdAZx5iC{KU9Z_YVsJU^DTzbYqyEkiaCPFW2uSmchye()KmJI8tQyZdvyz;-I% zX$O+Gd2%`uj~8OmE44IwIucTkOZaQeSzlvzoHDY!#uGcMYJEn;xJH8Dt2u%2TU}lI znx)($jFJ^{TN;z~_IT5?N9$cUSi(|;-HLS$p zcY48ggqyA3mqYJpwlf3IV{zB>u@F1S#sH30K~cxOkrC+4=s9VN7Z{6#bM)afFe59V#HY3@N>8G6Gz%;!*nXI_npxO2dj^H zCMGSeU30h;D_I$VTZz{l&P(Ca#lL1jhJi;NoaI1_v33o^cm*J_yRU*+tyXE=nN!9? zeq~vJMa2Vhw_SD}ne^Ob^6c>Q~kx<|+f} z`B(crGm-LglsznDI)Q1Wl(q;j4Q_giDK-_(PA*_u3ANQ07oTjhdem)~I{Zpg+O-V_ z=Sc+_cDT&NKd|KYXSG#93g0MB?0986u^S^?l7qqUcFJSW&Zm&}NB{PMxQm%mSaCvA$8oPU7{w)Tp~)V6~&6om1T8Wr08>NlHn6wA20R zo7=@}OC_6|U4bAYfZ@$TjRpYMP)EFj@jO3S0L#iO0Fg5Lz!F|#H9O78se2wC+QyM_ zAS56boRlr*=i;FJ+5ITu!isBab$0GVbdtt`_hK8{E{Bh;zxueKd2fR&m4pDGuOp7o z-jOS{tCO1@HrZtxm-Wl~g_2?1`X^Z`xx}Wi!0{JJdK#ea+BZK6;=j%|mR11dUz)3N zK@=jrt1m1_b-60C%D0J-{k@|-$iVUsnEw3sQLgUxr2K6P40P7o<_Xi_^INyf43PJ; zflkNjNER@;IvSkO#0=J-jHD1ET90p4_mXiVi=u05>7N*696;*l+mOGY#nWblA0X19 zcNRn6^2wK~9fd7j2QkQ2Tdy0~ap8F_Tc4afMNCJhkvcnlG27t#<%h1?T7MxtI@*(q zZIJz^kMn}om;F1WIx-h2V8)7Lh^|QsZTQ484*x92f^}^55%akT ztI5^EhK-eA_GGP+T?!|sHt#M8?BBfrQS29q9?^I-L*_f9#(IW{%c78(J&XJP-s=Wy zDT6HN#bk*k%}^k3P@v<;!FFxIuDDLLz9h`wOyN*Ih$1TpYWikpvt%=wFYPSo*~cuzMe0T5Q|W-9c0j_LFgg(s!0` zdlPg5>igRd<(;&W4AonXn$Q617rRcnGfuV7UQ0BH3oUL@ESJSAA}!OJa$LgNx9-P+ zTz9U=3y$Nh8&ZO}x*aY45MO%->>sdk5{jt+Kpq+;K#V%p@9ds0Cl2bqC# zDca{kg&o%mZU%H%{O#v`I~(;hx(Wl(8q1m9CvEELo-UjKn=!+9>w8x9&a)n7ij1ST zOts&|TROr6JkNR0_h*DV0yeWa+IUh{a!$$uPc}8^A|6^DdPaB7Q@M}qjT;=NKCiIk zv)puj6nIvU8*IwN_+LbQMd;*=0Z*;tw*4~e%s(7 zWEZb?yWqu$WJ-O9;A;DyB|Ja#nR^fWV#)EvMJkJ$$Q(l>>YKh^hpRvx{`x%61viYz zc%tgSWk}9lep29-fq=0|-jhR}x}-{kH^lw^3nK^T*n_E6X+y~YZ*1e{s`bs5gMNdp zrjiqKNRSMJj0_7%vtCEGBS#P9;^623^sG5Q?R=c+J2y=X2KPf7O=XC*+uA<5bq!j*C@)=kywadgwf~gW?CsHYusf>i_13BE z!|6e2r7qXtvGMi5XnS=KW&LzVM~`mvMeRt2?%YAv;mYdGc@=+y@YKRF8B^ZMlPi`x zx5Ke6;Fh%)*fnxyx}i2O-)jd-A?RdmSPC=NUEN}Q>1pp9ltmx3e=vf}W(VXbJupNg zU1seo)P{z#tvcLvAaf@TrS!c9Y#Kgj@m#mfRh#0dr_Yx>0BZE@(%^g4 z$eGEMJLYc_N?~T}b?)%*oxH5^=hi_mYhQJU>Dl0g1wY{T60I`san?bQsZkm3e zlPPvq@=ZS*<{+{5W6H2HN1r|GgUL&YCmFgkupdn)g7%JjxAwk6CGZ`T_=eL&?ed6_ zAZTmTdRthYp`=Kgbl2OkFFWlmxgQ-I93KmnM?SluU7c}BmGqlOnB1P#_Dua%G{+8UPt-~+|?b{mQ;Ae8|BvhJmkT&((Nb34!baB?6tdME`XK7x&v=`Z}b zJlN%>GV!+{7nAG}J!%h2DR;2;cCxIq(KMq`pRPGhbg=I^tH-5~EZMlej`1OGy6_QN zDOTKdIJfqt#M9GhJD2%>2D7N0*_^&=g}E>K$rvC)uJcdvtXD^0lp4Iiew?lH>1s)? zbLeTNPo+vYyz`W416BWV!o@8mC9P_o7bdO1E&+btQ9Z!tc9XKTzB#%Kn_oH4S*TYH zxZkcx+uz@I3c5YjT#P@j5E8X~uC9c*%Lv|fazgfu{K0hxwsx|X0{gg}$3ZJx8r|2u zusGeS1*=N?&pX;4VrhR{WTnc!@H$czBHSnD#Y-9l$ra2QsCa;W!&3sbKdh%I%{e;W z7%Ic#;a-ApR%HJg|D(<*P!8RBm1nIKu&iQvEJqly!VUHVwcr7 z)(EC&`@N}srJ@{Rf4+k|goT!&gD^YVE}L61RBLI@UC&pX_DEPBv&2TA)BfRir1=~<<*8?Vh3!$nP1^>5oJ#IF zt%Ey5Xxr|iOP$9YK>ao^{*!olVPTR%`ccLOh&YgqZ!I-k&ojdSvd z%WJ=U1`fTOiA9TTte21z`7fjeAzcTQwhWz=bj?xQ+O@j{GH_%Wveq{&hK0rQ*H;w?2w-SPhSzChvrQRh z7~~hRakpr3G;`yc*z(CbKD}b`ff_EHmDnISVCq=Hl&AsK*~p_t&6aK9%~ z(%0+b=Ol0?2d56O4MA^VSvXRj#;>OYJw16hba5j~$C$m@J9Z>fU;R75X&=X? z9(hmD&&kLee;JoRro+qVfULv`U9F54{_XN6C92m~aO-)OHOv>fQU$4Jx0AtxlSe$b zoIWejK_!zuQ1P?-#r9JKd}V4*rTM)>z9e#Cr=>gzcKwA4yQ?nNmGau6)Eq$G5cH&GBv4yDNAB;McNS7x zPe)j6HO67NXh>xS73Vi(+c5djWi()!(^TVw&%apjMw(J`%P^@sl0{-IF)sAbYq;gy`6vo*#he`*5u^N@a3 z<)ZJ`tVO0b-P~Qj9v(WY8WTr_Pcy(Cf4=9{Ssx8DIiZE9p=s>=#h!j=G3COFwg{6xeMC zdBA6yzjO7Qv&de@RrV8L1kCY9vnZ3Q4^uFTUdPbv;cLiVrMVf!r;S2<^;ua)EDoSq zDmB60s z3@x={mQhGL-lS~avwMQnA$ogmc;;CurSOKuVitS@4FDP#Ewn1Ez1rh0$nkn8EIQBs z9NrfGTQ?)ez&0=3Ig*8iX+=@SJV)q(6 zo(*^8+-}+jB;8<)wIPP# zle+u-_n#1p*6D&Y>zm)qLp-YJ_S}PoyM-oss>U0w=P^MgJH~(Xtek8uzg^g04Nr{} z*bNbOb-Z5_3wZNof+aCq(tuv(mpujm1ZTj0njiJfCGCrd=jDWT+$10K8RybGBRbk! zND%b#?gTn|^w!1Qdy5669`+p1N*vKX!2d zMh)20Fp9Te;z23Hm z6HJY{XZmA1OpR6}jpcQsd4m5V3bBCJ^n)|>`uucn^JAF#*?^T~qR)t5AT0rk5kv_O zHvMMJtrOOq!E4Yl9{YSkNShK(y6$Jgf-Ff=x#3!!0UkGljM$ZqR%3h;MTsVZ4L;=x zn>&T3DKQGXfO3x zqy43r$--IlRYOSHnqxR%@4a-OoDJeWRqTrWgqLp>7>uyb1;M_1gZD5sZ(wS~&Yz4$-azwjDAdahY- zv{bUrS5o0HizpXc0tIG40yQzhfGqPP zN!KMR8XByXw!?uSa{CTfW(9?;dZxZTsimt1r|p+;{50ns=YYrYTlp8Tr{{m- z8;*(O%J7hlT7TV0FZ$Xb7i^OG^#F7iR&-oOyuo)*xwbBh@l`3Im3W}Ikk^l@b384&|)I{lanQV3z!#bMd0CjmyTn}+h?1mZXXcspph-Uky zc}m~&UcN=oEqTB0KkH8(&7Lz?#b*BpKcqrsh|Dd7h?$xuhE(BXTI|S{5 zoJB&7w>>W!X4->VDUMGO5zfw-f}|+YiV5)U3ecq>>6*2ek%ns{_Fg5Y?`bk<1o7!z zxenWsv|mvEia=oCF8V3^;}D}&WxdF^)r_#RI znxgHZ=%xGbG~3dy?E|L@aq$agkWs`h1DDPNlyQ@wya1zo87ixoV<{uw#-Vjrj~Sht zri&2GPR1yZ6%}=}Sm`3=r_v6epwkD-zAT(?Vz!>v8WF#mD}OW(VKgFpRYd8cQQ#ca zC(X_Yj-}HVK6qhI)KFp`D&Qqu4vvjR4OB{IoMp#?8KPTP0!)t->BmnD1@*2PL{L=9 zwb_gu=h|X#q2)V)y4peci>t`){z97qkeAWGG-nUBSXkY8X~u}G)9OVeV$K+&sem;h zJ7agg+N>*)B+P&`tCiEq0v%nb=|lPH(10zS(!VkUt# z^QiTl57ne8Y&Ccw2#OxpHvA&F`reNV(yo>p{qJyj>X|7Id|X891Zd+X4yQdL{M3s+G;_WtxdFHwkwPGz&eP4@1Z!CQ5h7WShtWvSn z!n)13wwGNo8bn_+ri*R1d!l`Ltp4#WHOuW@h;i&ex_Xg-rhJOk(OBc~3@*1qoD-j# zL5Mj#G%E6O;d0}^)I%OQRO%qh6H?jHa~peuS!2k<;wDdKySG~FDt`R*MkEGjOuf|9 zDaXc$OKUlvjQLxtqlz7OrC}iXju^A5-|}Hc;`8t7p~c{@}Z~I z<-&Py)93K(KR?jXLw=IT$8M)sjtcx-*(7;Et?qO{(b3jj<7>>}vrps0q_cHx?c4h^ znFbVuJpWI3Ul|nF)~r1P879Gly95pHa&S*V@ZcUiz(8<^pdpYz0t{|RV9?;1-~4-PS?b6lvAFQDW)`aat6f z%)M!zQ}-}#h*QLWx!!8pCK3e585k_AXZQYK%g^uZ`u-vB|WZ?X5mj<4g!I zD|vIP>YbMhdd;uWm!|K(K`UnB)76x*ytiDoM@i|;FFem^*ooNokwT=F-!6ojm7TAi zii&5sp-OH~lE9xWgLe*wTEpFj3=WT@)l0uw>YUfT9&fpQZg*Xpsf*=AH3yrU`o1+y z;Hniw?Y$gmQ;Jw{4BoDzTr1w|QyIG6D}`Il?YZggp+C@`8NK56p9uG^Sfe68^Ofoe zc4vxCY@BmH72lI`|Mf#6_OLs6;=SvYIAm7Nj80D5m9}MeE3?3~$-c_f^6AZuXSb}O z=j&T&i5M8=SSsP(PM&1@b@-eup3QLbHrv7eo5|_PTEf-q{?Mz+G0@8GG)us1CGylJ z_5>f%-9|{ffCJS#-=-gfYbdYb$Nu$aT{4N}4-?kQd8WO8aLXRu7V&V!Q>Sg8g zoCK*yFwKk9HTN8wj0(o$Jit8EV?l*j%SPY)^s$@4L`Qb3kr7qI^#;`9fxg$L!DWL zMShyx=*-k_bQ{pNFP4?cW?CXAS$p3{Cx)}>I03`}e8^yAlJ7{5#)p#zDQV7oB zxe~}tkVzM$4x7?-E79E!;rmOcLDyv3Y-+o*reth|D$ckxYX6YddtBkoz_$<$kNAzT z={cXC0fcvS(dIVz7!Wp}#L=(sli6LWbI1+l@$c?f#u{^mPBZ0xTf z_fF5c0oi8Tn@7&yHKnJkYZ3&zaU@L}w*YN=!E4p*T&xdwBUWL!;KI~zmbSrMW5%YI zLz_T=pKDnN(ld_h_b6_zsbe~`RvHEZeY^(B=O%iEoM5x7!d-8|6|slTo@%D^^4wTE z&!6%BJe{pRCCuB^PEx@y?^oY5BQ17wnjd1avoBy!A9rm3LNl4r%P4NgYppHm3(=eZ z{7zzTHyAeml7)K`Cd!LfJzl#_0l7sUJad|{;m8h^#@V@YY@feF!^ z1}o8i&bKcP$__t*77KH7Hi4YR5^Pm^?ng7keS`f_&Q(AAE-=VWt;7$SNlGh{G+g$C zaHJ-FK2HmkZ2$~=#Jikdeas}&1_r~N3N(KbR8>@~OJ4Ja7p^LEomZKvU^X&nau%6R zSZ4MhVRVquEF0US*~fNkprqZ;a6JqRga`dt0ZK`7lMH6n^E{awZ~DeV^~du#fG*Sb zg=-5GU@xgsv|IU_Arn=d?Tn8clMqt5K>*gi4$S!+Hx(RrIIyNjLR5cZjc13tN`~Gr zTIgW4IextjZ+<@>40&kLIG1@}-s?;;fAmACzW5pbT|{`e5_eBStctgLM#h+BJ=|+- z%r*y4_WXsmQP^1?>RvK{iNNO_Zg5-LcwA?FK#1F!=0P=|*zo-@LnkVY#lf}p*~*)i zQFT`;4FD~LbBQTQXTmc;bFhV;N01$tay~q-_I3GXe(%Lu(@pB-p}Oe~9tn{>c%Q10 z3|&tMs2J<8BMn0#zhim^-@h2BhhqyiL0swJttItk-4FZ%8fV1meEZ`kwjL%vl&aeB zQ;$Lcv${)UzfEYG9)Jd`@pE#zPF)~6_FeY?@?m^P)RcjDzRta`KW)Y}kTY}emZv@K zVodKsaD_FIVc@XG1~8{82!x&tm=C*J?cF7O0_JwEDdF>Zg7PoOiQ)tT1{LYtz29QExj~e0!OV7Ik|CrG23TZNt&Rb>(a0-U&p}NIZ15 zHz6L|!Y<5SrQtDM?|zx&#Nm;XX~vF~hK>&O0th(%NMuVdmw7s4)#G`P?yHk+8&Nl&FmDMxNdFwTw;hec)i>O`!@V-0)hRn-R+%PZ->lMPHCS^U>j>hTE*h zc(RUOlre4MVy@mhGXf2}lze8Oiwm}ysp@SY2~K8H=sT-z9bXc4aJ=@Jk&`<_sXrDj z?!I$#m^S$eH|wc4<%{o7@A|AVE>ZvQUDD|gMODn~Kj{Y{9-Wh{ju{faW5QVSe*XQt zNO$LvjRl5OWUavq&uF&uz)OEmc`R%?O4NU@u9)0gh}06gJ&PTwfGEue1gtr0c-vg- zxX4l)NFNR@H!@l9ampBT4Uu3v03Wg`6R%foj#9hPjksRwP0@-Dj@`FJo_5W+@Vd6H zQu|{EvyD^kdC@-hGOrZ5FK$Rblli8D)NbUgXXeXnbQ!oM%2$Kne$`hGX!9BI z%fF?^giD&z&1x#p_&w!f|p^qgsNTA|Ru3{BtUpzTvqg%77jdjjH9jSal8 zig`K(MV4pPk4UB~{9}J`6M9?a2=16(l1aI)#M7O&ui1;22V&%<_jRK+8n$0yADJol8h(z86E&*iTT=e3eK$$XQEGCUWONo z$n4ur$6N$$_r&)AI-vo2DKF1Fhhs-lMtaxU?PId_3)s7j`b~HvQx}R#330uAPMkm4 zJckYJeh=K)3C)=%)z7{OV*m^V9!A#pLXP|uGiwNMOkMv0Q6;@K6s||AS$qZbUTu+MY~fqp7au zgMM5JUv=TXQZ=xz>t4M`-Gvz16_iG!E7lbu{?2w4c$={auwP`qG?sw!C8g#3lK$mH z5w&03B7T($Q}lmlb@b1dS=K@{hzb z<<5QlbhRuls+GpqwZf-YSER}E@~X8Z`?VTxALP^4Va}4i!mh!>D!bDXOMZXd1J$FB z>{5b7`EE>U9!+MBzf4kl*H;>_4732-?9|4+bTPw# zjR?G{-orw{!DkNx#gok|2j9bQUs|t9uLUKX%~-Z8i8kc4u$x?9&hsP0T+;t3sDQht zaCBboyY;4BSH*Z>O>>hLGJDu2AgEF#8+I6$sG%i98EM= z_LDGm<~L~+sA`X1&D9OsSu;}A638=3HZIkllLK$Bv|V;Mle{j96#^(`tdtb!4E=lA znc7^mbEGGU?vwBII*^QwJ6tN8Nlt@~%3{AzPW%)m9F?}QC+`OU%joWoJU+K! z`||M-7z1j|D(I&hYDxs=$0bn;ZM=+p{%x8A?>dDY`0#XnMJu@Ra4zYZ8M@c#^)oA6 zV;%kv0nTKgDoyiqERX7?E~VTRvfR3(jCjzmaH^DC5U2k(wkFJgRP6AVfJ}lx3_+T( z`j@h8=WPXyMClxE0o?{1&!stEc`B)OOBCxR$ABLH8XutL^afTEoy+a1l4TD7%5zTS zTmTh%pYOEh44tX-CYShTV?ctX+p`(}N&v%Z}Q?LfkM}(7VV}*%KJ?1(F+8FSY ztZ!a4&q;OlHB2e904xfuMWW;~b3HpwuQd7VtVMt>MrFm2r$a@;QZoMyjsl*|#OYdw zIF$w~EdAUDFknNMN~N`cE*4!(zF(Xjv8_zF|n?wyKPd|R)v za^}CC5_HoGe>n-bXWOai^4GjBvmao+swAPbZqy+1gG?@J!`NkTjl$00IRJ3)Pc^{@ zSA9wv6?qzIgSpz@mUWi@JM0!xkK_aLC=X9hjpz)ole6)k=kY|;to#9${&{%C2dyO( zAGa)3J#pOsTItOD@m`;lcRdGKSs(*o(bFzD;qf+z30o$!6a*x`;v*CJp|`$S3z+Jm z&W~u+3Kaf=4bb1@`L^wa?l)EthuUKaeIEC&W1NfyWOWbRKjn9UO*tbcq?w*UoIASN zurn|(2qyYefhSgdL!EeFdO+9^N@R*>JeSUOL6Fb4VPCpLDo z7IHwV9q^vX1^oiPw|}X6I2(SCJZ%5sfbByeLF<)OIVX{DWK&(g#CqJC@M9JOS4nXh z8$V=GfA2eZD%`epVx+IS5b?JC-tI(#*kJZIZYeu5d-}j$_Ztcth0z*ARmHPAk z#wjU>|2dX?&&RW&doc=b#8kDyX$mG}!gx@iO>69+40Zq9UrR z&@=p(RfZBbBL{L+xsbRHE?F()j@`^$nu8B z75|!Dt`nbQc9ZC*tF0aDt6woZd$m>yF5lklINttvk(Nf=3*2o`nm7`y!Hd{Ol`;k$ zEsrN-I&5ef}Qs)_e zw7%5;irXHwo=`(8RJh^fjzP;R{{qw(m%~XOcMOfC z$qG&S9Hezi9BtkcqZXiY78z?*lp%zZka~5!sVIYv$=cI%xYKxYR?q7kWtLzn`sT2k z(s~6l9!WHCqOEl|XK8Zp;JnF7a|CPP<(06Cl!}3@rS-<9eKS3|q!smplg!R{3^D9?D`~J*HO6=G{`MEI^-pkJ8TwI{D0)CR zcPqXSDB|^*A{U;u$0OnHv6n}r4GbH^%Ow($T=75%>SkF*J97Y5g#3UNlr8xtAZ+^= zpsw|ZN$kC%bODcZ_hsd_tu7`wk1LI4HZm|Ordya_%>H&<*sZo6=siEWp+3kE^oNC` zY1{pMCm69}MU7b5_g_3{TaR+%Ttc)70Z<3+>(u@QbVAXpBpBg5LY0Ygi5Q5cirvsQ zF7Cf$j(me=zNA$jyZv^P%jUdBqF*Qw_7K2nEf}e&lclL7&G-=1oGJTQ0}2RRD@i}r zALjdhOsPuw_XZ>EfRaHLPe^dUi#|WMKppbMk%|w_nclfKqD~a}z%Y!OuvZ%$I!pDx znq^3&pU=)BV2x#NS>$d0XV;TzqBlN2jPXMV0Tzrw0B&#vPPc-G5?v%K0j0F9M-jU` z&fBGZWmUoutmJ2i$9JS9^O*{HCpf`S((W0Z!$QE1quD&HZ`^y=`Leq`0!mCn>p5pb zO*>APoa8LWJ`~9e{I@*k<=2eV^W0a9PDDXZ(2a~cI^(qSyJX&r$lSN)G$rjR>Vc<> zp=(Klg3JJX)PW*h{QCstt&-9#>)U3TDD=fJ6hb6@KbQLEbXj;!>t*D(@7KYifn=V} z6I9FWmFF9Boxx*MWWwuvu*#s$giA&V(bhjpBL!;(=p#!adi~~;xa6XmT&vTFBbb29 z`rz%?uLnh)N=uVGTHI;EDpKdufez+J4n)^0lXdzQC)M38mglX7G!j~Yfm=s`G`;3R zoa8U??p*5EUa1sV4+r|%Vmk!x>v5ZOl=}{q!tmgw@9y5KcOX?sse?H+@<5+!g_FV6 zi(HUf@Jst2HS0Sy-!5L~)1;E-JneHpBxG&Y*66{}HZRSPYOm2v4%P)*!`myOCHc5u zREogQ%A420eY-+CM_15Xee$xm=BS4$mqorcFnB6D^|b3o>r8VlgPwfcfUSimnH^zYRb*&h8?*Gar)A9=|%yC2d~}dUmh8B z$lsQ>PC{fuY(KG2tw*Shq^&`6m%02-&aR`+;5KVW^$yWbw6ds6NtU}1dQtf3?Q@)Z zy6y20stnPJZ(k6V>d}7$V%C#{86id3>%I<{*I9P zt!9gRTVu}#zZMjRL2Wn|s(x-XHQY?8D2R~=8|f#9JmNofy-AQ8;75-QRC`%)-5o-; zmR@W@C0aWtRQ9_~-`hW<7wOYVgo)V-l%?VC&k%u22^lpqOQW#9 zzod-5!>`(-(S5(sCt0i3e*{@uZ<+_8l=OV9+dAyL6hgps{Mm8%AmLd5dvp(36CAC_ z(TDq4{d{|S#4^}xj63Ez;dxtWo&mN8g7x_r4bER7hkFiL9;@L|cndeM7$o|nBd3&a zaRvNSN-A4vJ*F3s(`vCp)$rW@?Id`bH%8O;3*YX^k3gJ`5C-C zi%oE2v4b(}!+2TNm5a#7(qIdA?7o~*S2w`{ivjQExlQbDWO**#qCJ_tEb)MXq@=`N zcTZnoyzRpTwc>=?qazSVI5{y(CW7H%*ht`tGE5+FccR{eAWvMmxz59fhdZX94FGd!40P)5ncfQXNG`32)W

uV8Q~LIDn#YLyU-?rA5uSdNF|YbLw31k&SB82pTf3#w&{>bcKZB zU_M`0R+<98CJ){V9)qG^m<%SPV*|gCQvKx+1yr5$Rc4RK0>LD1ZgX>UueBHBpj29X zY-m(B0X4QHb`JrRKBUT%Se9CX5c2RdsRe%%gT}~GPW&4+JS}?tA`}$$5rs{4L2wpG zukMg}Bi`p!dq?tG29w`oG0eu~QvD8bs+5EmG(mJmY(evjo{Dxt0{#1>w$XSb9+(6k zoLHJi4A)g@d+q>*?^Z83o=@*Gp~I#mXW$uo%LCEZE&|&ai|K zcKZh%9HD;x2)hn7e=2)UxsBzdh0M8=qy=<=3WG_dV}|>!<)s-V=w4;*ge+ zjwOkp)x(hxA9eJ;-_>@)W8s9z&UU|N@#G{&3cenAut(17C~4SdsmY68hG~Y77*PmO z%dkY>IcWk5;t!pB-N$-1$ptBML}2)I4-%iMoMO3cCOY zx67S6Ivji#EnC$RTXj8faM`FRWckAumzBLJeePn=BZML3+-Pv9RiS_gGhR_gj<$Ad zh_If)WuxWUBcA)5T_}0z`P}8tmj3(gLPE!uw}f941DWcF13%iWjFH|s6~W^ri}|eE zW~USf*)G`JapF`}4^v0GalqysJ-(tI#{}&rVdv5ewc4p7csrI7vVoNx7ymO;=w&fC zMutS%)8W0<-J-i-!t5I}pz}Ai{yzoz{|;{d?N{Re_6^}|4UG-<5*IIQi4(zm= 7.0.10-54) +* Added "try-common-system-paths" option for ImageMagick (default: true). Thanks to Henrik Alves for adding this option. +* Bugfix: Handling Uncaught Fatal Exception during .htaccess read failure. Thanks to Manuel D'Orso from Italy for the fix. +* Bugfix: File names which were not UTF8 caused trouble in the Bulk Convert. Thank to "mills4078" for the fix +* Bugfix: Redirection back to settings after saving settings failed on some systems. Thanks to Martin Rehberger (@kingkero) from Germany for the fix. +* Bugfix: Webp urls did not contain port number (only relevant when the website was not on default port number). Thanks to Nicolas LIENART (@nicolnt) from France for providing the fix. + +For more info, see the closed issues on the [webp-express 0.21.0 milestone](https://github.com/rosell-dk/webp-express/milestone/41?closed=1) and the +[webp-convert 2.7.0 milestone](https://github.com/rosell-dk/webp-convert/milestone/24?closed=1) + += 0.20.1 = +*(released: 20 Jun 2021)* +* Bugfix: Removed composer.lock. It was locked on PHP 7.2, which caused server error on some sites (only some sites with old php were affected). Also deleted the library which required PHP 7.2 (onnov/detect-encoding/). The functionality is not really needed. + += 0.20.0 = +*(released: 17 Jun 2021)* +* Added WP CLI support. Add "wp webp-express convert" to crontab for nightly conversions of new images! Thanks to Isuru Sampath Ratnayake from Sri Lanka for initializing this. +* Added "sharp-yuv" (not as option, but as always on). Better YUV->RGB color conversion at almost no price. [Read more here](https://www.ctrl.blog/entry/webp-sharp-yuv.html). Supported by cwebp, vips, gmagick, graphicsmagick, imagick and imagemagick +* Bumped cwebp binaries to 1.2.0 +* cwebp now only validates hash of supplied precompiled binaries when necessary. This cuts down conversion time. +* Convert on upload now defaults to false, as it may impact upload experience in themes with many formats. +* bugfix: Alpha quality was saved incorrectly for PNG. Thanks to Chriss Gibbs from the UK for finding and fixing this. +* bugfix: wp-debug log could be flooded with "Undefined index: HTTP_ACCEPT". Thanks to @markusreis for finding and fixing this. + += 0.19.1 = +*(released: 03 May 2021)* +* Bugfix for PHP 8.0 - fread() does not permit second argument to be 0. Thanks to @trition for reporting and fixing this bug. + += 0.19.0 = +*(released: 13 Nov 2020)* +* New convertion method: ffmpeg +* Fixed problem in Bulk Convert when files had special characters in their filename +* Prevented problems if the plugin gets included twice (can anybody enlighten me on how this might happen?) + +For more info, see the closed issues on the [0.19.0 milestone on the github repository](https://github.com/rosell-dk/webp-express/milestone/36?closed=1) + + += 0.18.3 = +*(released: 5 Nov 2020)* +* Bugfix: WebP Express uses live tests to determine the capabilities of the server in respect to .htaccess files (using the htaccess-capability-tester library). The results are used for warnings and also for optimizing the rules in the .htaccess files. However, HTTP Requests can fail due to other reasons than the feature not working (ie timeout). Such failures should lead to an indeterminate result, but it was interpreted as if the feature was not working. +* The Live test now displays a bit more information if the HTTP request failed. +* Changed default value for "destination structure" to "Image roots", as "Document root" doesn't work on hosts that have defined DOCUMENT_ROOT in an unusual way. +* Added possibility to change "%{DOCUMENT_ROOT}" part of RewriteCond by adding a line to wp-config.php. THIS IS A BETA FEATURE AND MIGHT BE REVOKED IF NOBODY ACTUALLY NEEDS IT. +* Got rid of PHP notice Constant WEBPEXPRESS_MIGRATION_VERSION already defined +* Fixed donation link. It now points to https://ko-fi.com/rosell again + +For more info, see the closed issues on the [0.18.3 milestone on the github repository](https://github.com/rosell-dk/webp-express/milestone/34?closed=1) + += 0.18.2 = +*(released: 28 Sep 2020)* +* Bugfix: Fixed error on the settings page on a handful of setups. + += 0.18.1 = +*(released: 24 Sep 2020)* +* Bugfix: Bulk Convert failed to show list on systems that did not have the [utf8-encode()](https://www.php.net/manual/en/function.utf8-encode.php) function. + += 0.18.0 = +*(released: 24 Sep 2020)* +* You can now set cache control header in CDN friendly mode too +* The code for testing what actually works in .htaccess files on the server setup has been moved to a new library: [htaccess-capability-tester](https://github.com/rosell-dk/htaccess-capability-tester). It has been strengthened in the process. +* Improved diagnosing in the "Live test" buttons +* Simplified the logic for adding "Vary header" in the .htaccess residing in the cache dir. The logic no longer depends on the Apache module "mod_envif" being installed. mod_envif has Apache "Base" status, which means it is very rarely missing, so I decided not to trigger automatically updating of the .htaccess rules. To apply the change, you must click the button that forces .htaccess regeneration +* The plugin has a folder called "wod" which contains php scripts for converting an image to webp. This is used for the function that rely on redirect magic to trigger conversion ("Enable redirection to converter?" and "Create webp files upon request?"). The .htaccess file in the "wod" folder in the plugin dir contains directives for modifying access (in order to counterfight rules placed by security plugins for disallows scripts to be run directly). However if these directives has been disallowed in the server setup, any request to a file in the folder will result in a 500 internal server error. To circumvent this, a "wod2" folder has been added, which contains the same scripts, but without the .htaccess. Upon saving, WebP Express now automatically checks which one works, and points to that in the .htaccess rules. +* Bugfix: webp mime type was not registred in .htaccess in "CDN friendly" mode. This is a minor fix so I decided not to update the .htaccess automatically. To apply it, you must click the button that forces .htaccess regeneration. +* Bugfix: Bulk convert failed to load the list when there were filenames containing non-unicode characters +* Added a new way to support me. I'm on [GitHub Sponsors](https://github.com/sponsors/rosell-dk)! + +For more info, see the closed issues on the 0.18.0 milestone on the github repository: https://github.com/rosell-dk/webp-express/milestone/33?closed=1 + += 0.17.5 = +*(released: 11 Aug 2020)* +* Fixed "Path is outside resolved document root" in a certain symlinked configuration. Thanks to @spiderPan on github for providing the fix. +* Added content filtering hooks for several third party plugins including ACF and WooCommerce Product Images. With this change, the "Use content filtering hooks" in Alter HTML works in more scenarios, which means there are fewer scenarios where you have to resort to the slower "The complete page" option. Thanks to alextuan for providing the contribution +* Fixed problems with Alter HTML when migrating: Absolute paths were cached in the database and the cache was only updated upon saving settings. The paths are not cached anymore (recalculating these on each page load is not a performance problem) + +For more info, see the closed issues on the 0.17.5 milestone on the github repository: https://github.com/rosell-dk/webp-express/milestone/30?closed=1 + += 0.17.4 = +*(released: 26 Jun 2020)* +* Hopefully fixed that configuration was resetting for some users +* Fixed "Path is outside resolved document root" on file conversion attempts in Windows. Thanks to @Ruzgfpegk from Japan for providing the fix. +* Fix errors not caught in the selftest. Thanks to Benji Bilheimer from Germany providing the fix. +* Fix errors not caught in the selftest with unverified certificates. Thanks to Rikesh Ramlochund from Mauritius for providing the fix. +* Fixed errors with filenames containing encoded symbols. Thanks to Eddie Zhou from Canada for the fix. + + += 0.17.3 = +*(released: 3 Feb 2020)* + +* Fixed critical bug: Fatal error after updating plugin (if one had been postponing updating WebP Express for a while and then updated Wordpress and THEN updated WebP Express) +* A critical bug was fixed in the webp-convert library (PHP 7.4 related) +* A critical bug was fixed in dom-util-for-webp library (PHP 7.4 related) +* Alter HTML now processes the "poster" attribute in Video tags. Thanks to @MikhailRoot from Russia for the PR on github. +* On some Litespeed hosts, WebP Express reported that mod_headers was not available even though it was. Thanks to @lubieowoce from Poland for the PR on github) + + += 0.17.2 = +*(released: 5 Oct 2019)* + +* Fixed bug: Updating plugin failed on a few hosts (in the unzip phase). Problem was introduced in 0.17.0 with the updated binaries. +* Fixed bug: Alter HTML used the protocol (http/https) for the site for generated links (rather than keeping the protocol for the link). Thanks to Jacob Gullberg from Sweden for discovering this bug. + +If you experienced update problems due to the update bug, you will probably be left with an incomplete installation. Some of the plugin files are there, but not all. Especially, the main plugin file (webp-express.php) is missing, which means that Wordpress don't "see" the plugin (it is missing from the list). Trying to install WebP Express again will probably not work, because the "webp-express" folder is already there. You will then have to remove the "webp-express" folder in "plugins" manually (via ftp or a plugin, such as File Manager). + +For more info, see the closed issues on the 0.17.2 milestone on the github repository: https://github.com/rosell-dk/webp-express/milestone/29?closed=1 + += 0.17.1 = +*(released: 3 Oct 2019)* + +* Fixed NGINX rules in FAQ (added xdestination for the create webp upon request functionality) +* Fixed issue with Alter HTML. Thanks to @jonathanernst for discovering issue and supplying the patch. +* WebP Express now works on WP Engine. Check out the new "I am on WP Engine" section in the FAQ +* Miscellaneous bug fixes + +For more info, see the closed issues on the 0.17.1 milestone on the github repository: https://github.com/rosell-dk/webp-express/milestone/27?closed=1 + += 0.17.0 = +*(released: 27 sep 2019)* + +* Cwebp conversion method runs on more systems (not subject to open_basedir restrictions and also tries "pure" cwebp command). Thanks to cng11 for reaching out so I spotted this. +* Ewww conversion method no longer does a remote api-key check for each conversion - so it is faster. If an ewww conversions fails due to a non-functional key, the key will not be tried again (until next time the options are saved) +* Updated cwebp binaries to version 1.0.3 + += 0.16.0 = +*(released: 24 sep 2019)* + +* Added option to specify CDN urls in Alter HTML. Thanks to Gunnar Peipman from Estonia for suggesting this. +* Direct Nginx users to Nginx FAQ section on welcome page +* Fixed Bulk Conversion halting due to nonce expiry +* Fixed unexpected output upon reactivation +* Added affiliate link to [Optimole](https://optimole.pxf.io/20b0M) in the "Don't despair - You have options!" message + += 0.15.3 = +*(released: 19 sep 2019)* + +* Fixed fatal error upon activation for systems which cannot use document root for structuring (rare) + += 0.15.2 = + +* Fixed the bug when File extension was set to "Set to .webp". It was buggy when file extension contained uppercase letters. + += 0.15.1 = +*(released: 17 sep 2019)* + +* Bug alert: Added alert about a bug when destination folder is set to "mingled" and File extension is set to "Set to .webp" +* Bugfix: Plugin URL pointed to webpexpress - it should point to parent. This gave trouble with images located in plugins. Thanks to Guillaume Meyer from Switzerland for discovering and reporting. +* Bugfix: Images with uppercase chars in extension did not get Vary:Accept +* Bugfix: There were issues with "All content" and destination:document-root when webp-realizer is activated + += 0.15.0 = +*(released: 17 sep 2019)* + +* Provided test-buttons for checking if the redirects works. +* You can now choose which folders WebP Express is active in. Ie "Uploads and Themes". +* You can now choose an alternative file structure for the webps which does not rely on DOCUMENT_ROOT being available. +* WebP Express can now handle when wp-content is symlinked. +* The .htaccess rules are now divided across folders. Some rules are needed where the source files are located, some where the webp files are located. +* Added option to convert only PNG files +* And a couple of bugfixes. + +For more info, see the closed issues on the 0.15.0 milestone on the github repository: https://github.com/rosell-dk/webp-express/milestone/22?closed=1 + += 0.14.22 = +*(released: 4 aug 2019)* + +* Fixed bug in Nginx rules in the FAQ (they did not take into account that the webp images outside upload folder are never stored "mingled") +* Fixed bug: The extension setting was not respected - it was always appending ".webp", never setting. Thanks to Florian from Germany and Derrick Hammer from USA for reporting. +* Fixed bug: It turns out that Imagick gives up quality detection for some images and returns 0. This resulted in very poor quality webps when the quality was set to same as jpeg. The 0 is now treated as a failure and the max-quality will be used for such images. Thanks to @sanjayojha303 from India for reporting that there were quality problems on some images. +* Fixed bug-like behavior: The conversion scripts no longer requires that the respective setting is on for Nginx. Thanks to Mike from Russia for reporting this. +* Fixed bug: The error handler in webp-convert for handling warnings could in some cases result in endless recursion. For some the result was that they could no longer upload images. Thanks to Tobias Keller from Germany for reporting this bug. +* Fixed minor bug: Attempt to call private method in a rare scenario (when accessing one of the php scripts in the "wod" folder directly - which is not allowed). Thanks to Giacomo Lawrance from the U.K. for providing input that led to this discovery. +* Fixed minor bug: It was not tested whether a corresponding webp existed before trying to deleting it when an image was deleted. This produced warnings in debug.log. +* Security related: Added sanitizing of paths to avoid false positives on coderisk.com (there where no risk because already test the paths for sanity - but this is not detected by coderisk, as the variable is not modified). This was simply done in order get rid of the warnings at coderisk. +* Security fix: Paths were not sanitized on Windows. + += 0.14.21 = +*(released: 30 jun 2019)* + +* Hopefully fixed WebP Express Error: "png" option is Object + += 0.14.20 = +*(released: 29 jun 2019)* + +* Fixed bug: Ewww api-key was forgot upon saving options + += 0.14.19 = +*(released: 28 jun 2019)* + +* Removed a line that might course Sanity Check to fail ("path not within document root") + += 0.14.18 = +*(released: 28 jun 2019)* + +* Fixed Sanity Error: Path is outside allowed path on systems using symlinked folders +* Updated cache breaking token for javascript in order for the last fix for changing password with Remote WebP Express to take effect +* Fixed undefined variable error in image_make_intermediate_size hook, which prevented webps thumbnails to be generated upon upload +* Minor bug fix in cwebp converter (updated to webp-convert v.2.1.4) + += 0.14.17 = +*(released: 28 jun 2019)* + +* Relaxed abspath sanity check on Windows +* Fixed updating password for Remote WebP Express + += 0.14.16 = +*(released: 26 jun 2019)* + +* Fixed conversion errors using Bulk convert or Test convert on systems with symlinked folders + += 0.14.15 = +*(released: 26 jun 2019)* + +* Fixed errors with "redirect to conversion script" on systems with symlinked folders +* Fixed errors with "redirect to conversion script" on systems where the filename cannot be passed through an environment variable + += 0.14.14 = +*(released: 26 jun 2019)* + +* Fixed errors on systems with symlinked folders + += 0.14.13 = +*(released: 26 jun 2019)* + +* Fixed errors in conversion scripts + += 0.14.12 = +*(released: 26 jun 2019)* + +* Fixed critical bug + += 0.14.11 = +*(released: 24 jun 2019)* + +The following security fixes has been applied in 0.14.0 - 0.14.11: +It is urged that you upgrade all of you WebP Express installations! + +– Security fix: Closed a security hole that could be used to view the content of any file on the server (provided that the full path is known or guessed). This is a very serious flaw, which unfortunately has been around for quite a while. +– Security fix: Added capability checks to options page. +– Security fix: Sanitized user input. +– Security fix: Added checks for file paths and directories. +– Security fix: Nonces and capability checks for AJAX calls. + += 0.14.10 = +*(released: 24 jun 2019)* + +* Security related + += 0.14.9 = +*(released: 22 jun 2019)* + +* Security related + += 0.14.8 = +*(released: 21 jun 2019)* + +* Security related + += 0.14.7 = +*(released: 20 jun 2019)* + +* Security related: Removed unneccesary files from webp-convert library + += 0.14.6 = +*(released: 20 jun 2019)* + +* Security related + += 0.14.5 = +*(released: 20 jun 2019)* + +* Security related + += 0.14.4 = +*(released: 18 jun 2019)* + +* Now bundles with multiple cwebp binaries for linux for systems where 1.0.2 fails. + += 0.14.3 = +*(released: 18 jun 2019)* + +* Fixed filename of supplied cwebp for linux (bug was introduced in 0.14.2) + += 0.14.2 = +*(released: 17 jun 2019)* + +* Fixed problem with older versions of cwebp +* Fixed that images was not deleted +* Fixed cache problem on options page on systems that disables cache busting (it resulted in "SyntaxError: JSON.parse") + += 0.14.1 = +*(released: 15 jun 2019)* + +* Security related + += 0.14.0 = +*(released: 15 jun 2019)* + +* Security fix: Closed a security hole that could be used to view the content of any file on the server (provided that the full path is known or guessed). This is a very serious flaw, which has been around for quite a while. I urge you to upgrade to 0.14.0. +* Added new "encoding" option, which can be set to auto. This can in some cases dramatically reduce the size of the webp. It is supported by all converters except ewww and gd. +* Added new "near-lossless" option (only for cwebp and vips). Using this is a good idea for reducing size of lossless webps with an acceptable loss of quality +* Added new "alpha-quality" option (all converters, except ewww and gd). Using this is a good idea when images with transparency are converted to lossy webp - it has the potential to reduce the size up to 50% (depending on the source material) while keeping an acceptable level of quality +* Added new conversion methods: Vips and GraphicsMagick +* Imagick conversion method now supports webp options (finally cracked it!) +* Using MimeType detection instead of relying on file extensions +* In "test" converter you now change options and also test PNG files +* Added conversion logs +* PNGs are now enabled by default (with the new conversion features especially PNGs are compressed much better) + +For more info, see the closed issues on the 0.14.0 milestone on the github repository: https://github.com/rosell-dk/webp-express/milestone/9?closed=1 + += 0.13.0 = +*(released: 21 mar 2019)* +* Bulk Conversion +* Fixed problems with Gd converter and PNG +* Optinally auto convert upon media upload +* Windows fix (thanks, lwxbr!) + += 0.12.2 = +*(released 8 mar 2019)* +* Fixed bug: On some nginx configurations, the newly added protection against directly calling the converter scripts were triggering also when it should not. + += 0.12.1 = +*(released 7 mar 2019)* +* Fixed bug: Alter HTML crashed when HTML was larger than 600kb and "image urls" where selected + += 0.12.0 = +*(released 5 mar 2019)* +* Multisite support (!) +* A new operation mode: "No conversion", if you do not want to use WebP Express for converting. Replaces the old "Just redirect" mode +* Added capability testing of .htaccess. The .htaccess rules are now tailored to the capabilities on the system. For example, on some platforms the filename of a requested image is passed to the converter script through the query string, but on platforms that supports passing it through an environment variable, that method is used instead +* Picturefill.js is now optional (alter html, picture tag) +* A great bunch more! + +For more info, see the closed issues on the 0.12.0 milestone on the github repository: https://github.com/rosell-dk/webp-express/milestone/12?closed=1 + += 0.11.3 = +*(released 18 feb 2019)* +* Fixed bug: Alter HTML caused media library not to display images on some systems. Alter HTML is now disabled in admin mode. +* Alter HTML (picture tags) could produce the source tags with "src" attribute. But source tags inside picture tags must use "srcset" attribute. Fixed. +* Alter HTML (image urls): srcsets containing "x" descriptors wasn't handled (ie, srcset="image.jpg 1x") +* Fixed rewrite rules when placed in root so they are confined to wp-content and uploads. In particular, they no longer apply in wp-admin area, which might have caused problems, ie with media library. +* Added warning when rules are placed in root and "Convert non-existing webp-files upon request" feature is enabled and WebP Express rules are to be placed below Wordpress rules +* Fixed bug: The code that determined if WebP Express had placed rules in a .htaccess failed in "CDN friendly" mode. The effect was that these rules was not cleaned up upon plugin deactivation + += 0.11.2 = +*(released 14 feb 2019)* +* Fixed bug which caused Alter HTML to fail miserably on some setups +* AlterHTML now also looks for lazy load attributes in DIV and LI tags. + += 0.11.1 = +*(released 6 feb 2019)* +* Fixed bug which caused the new "Convert non-existing webp-files upon request" not to work on all setups + += 0.11.0 = +*(released 6 feb 2019)* +* Alter HTML to point to webp files (choose between picture tags or simply altering all image urls) +* Convert non-existing webp-files upon request (means you can reference the converted webp files before they are actually converted!) + +For more info, see the closed issues on the 0.11.0 milestone on the github repository: https://github.com/rosell-dk/webp-express/milestone/14?closed=1 + += 0.10.0 = +*(released 7 jan 2019)* +* Introduced "Operation modes" in order to keep setting screens simple but still allow tweaking +* WebP Express can now be used in conjunction with Cache Enabler and ShortPixel +* Cache-Control header is now added in *.htaccess*, when redirecting directly to existing webp + +For more info, see the closed issues on the 0.10.0 milestone on the github repository: https://github.com/rosell-dk/webp-express/milestone/7?closed=1 + += 0.9.1 = +*(released 28 dec 2018)* +* Fixed critical bug causing blank page on options page + += 0.9.0 = +*(released 27 dec 2018)* +* Optionally make .htaccess redirect directly to existing webp (improves performance) +* Optionally do not send filename from *.htaccess* to the PHP in Querystring, but use other means (improves security and reduces risks of problems due to firewall rules) +* Fixed some bugs + +For more info, see the closed issues on the 0.9.0 milestone on the github repository: https://github.com/rosell-dk/webp-express/issues?q=is%3Aclosed+milestone%3A0.9.0 + += 0.8.1 = +*(released 11 dec 2018)* +* Fixed javascript bug + += 0.8.0 = +*(released 11 dec 2018)* +* New conversion method, which calls imagick binary directly. This will make WebP express work out of the box on more systems +* Made sure not to trigger LFI warning i Wordfence (to activate, click the force .htaccess button) +* Imagick can now be configured to set quality to auto on systems where the auto option isn't generally available +* Added Last-Modified header to images. This makes image caching work better +* Added condition in .htaccess that checks that source file exists before handing over to converter +* On some systems, converted files where stored in ie *..doc-rootwp-content..* rather than *..doc-root/wp-content..*. This is fixed, a clean-up script corrects the file structure upon upgrade. + +For more info, see the closed issues on the 0.8.0 milestone on the github repository: https://github.com/rosell-dk/webp-express/issues?q=is%3Aclosed+milestone%3A0.8.0 + += 0.7.2 = +*(released 21 nov 2018)* +Fixed a critical bug which generated an error message which caused corrupt images. It was not the bug itself, but the error message it generated, that caused the images to be corrupted. It only happened when debugging was enabled in php.ini + += 0.7.1 = +*(released 9 nov 2018)* +Fixed minor "bug". The Api version combobox in Remote WebP Express converter was showing on new sites, but I only want it to show when old api is being used. + += 0.7.0 = +*(released 9 nov 2018)* +This version added option to provide conversion service to other sites! + +For more info, see the closed issues on the 0.7.0 milestone on the github repository: https://github.com/rosell-dk/webp-express/issues?q=is%3Aclosed+milestone%3A0.7.0 + += 0.6.0 = +*(released 4 okt 2018)* +This version added option for setting caching header, fixed a serious issue with *Imagick*, added a new converter, *Gmagick*, added a great deal of options to *Cwebp* and generally improved the interface. + +* Added option for caching +* Fixed long standing and serious issue with Imagick converter. It no longer generates webp images in poor quality +* Added gmagick as a new conversion method +* WebPExpress now runs on newly released WebP-Convert 1.2.0 +* Added many new options for *cwebp* +* You can now quickly see converter status by hovering over a converter +* You can now choose between having quality auto-detected or not (if the server supports detecting quality). +* If the server does not support detecting quality, the WPC converter will display a quality "auto" option +* Added special intro message for those who has no working conversion methods +* Added help texts for options +* Settings are now saved, when changing converter options. Too many times, I found myself forgetting to save... + +For more info, see the closed issues on the 0.6.0 milestone on our github repository: https://github.com/rosell-dk/webp-express/issues?q=is%3Aclosed+milestone%3A0.6.0 + += 0.5.0 = +This version works on many more setups than the previous. Also uses less resources and handles when images are changed. + +* Configuration is now stored in a separate configuration file instead of storing directly in the *.htaccess* file and passing it on via query string. When updating, these settings are migrated automatically. +* Handles setups where Wordpress has been given its own directory (both methods mentioned [here](https://codex.wordpress.org/Giving_WordPress_Its_Own_Directory)) +* Handles setups where *wp-content* has been moved, even out of Wordpress root. +* Handles setups where Uploads folder has been moved, even out of *wp-content*. +* Handles setups where Plugins folder has been moved, even out of *wp-content* or out of Wordpress root +* Is not as likely to be subject to firewalls blocking requests (in 0.4.0, we passed all options in a querystring, and that could trigger firewalls under some circumstances) +* Is not as likely to be subject to rewrite rules from other plugins interfering. WebP Express now stores the .htaccess in the wp-content folder (if you allow it). As this is deeper than the root folder, the rules in here takes precedence over rules in the main *.htaccess* +* The *.htaccess* now passes the complete absulute path to the source file instead of a relative path. This is a less error-prone method. +* Reconverts the webp, if source image has changed +* Now runs on version 1.0.0 of [WebP On Demand](https://github.com/rosell-dk/webp-on-demand). Previously ran on 0.3.0 +* Now takes care of only loading the PHP classes when needed in order not to slow down your Wordpress. The frontend will only need to process four lines of code. The backend footprint is also quite small now (80 lines of code of hooks) +* Now works in Wordpress 4.0 - 4.6. +* Added cache-breaking tokens to image test links +* Denies deactivation if rewrite rules could not be removed +* Refactored thoroughly +* More helpful texts. +* Extensive testing. Tested on Wordpress 4.0, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8 and 4.9. Tested with PHP 5.6, PHP 7.0 and PHP 7.1. Tested on Apache and LiteSpeed. Tested when missing various write permissions. Tested migration. Tested when installed in root, in subfolder, when Wordpress has its own directory (both methods), when wp-content is moved out of Wordpress directory, when plugins is moved out of Wordpress directory, when both of them are moved and when uploads have been moved. + +For more info, see the closed issues on the 0.5.0 milestone on our github repository: https://github.com/rosell-dk/webp-express/milestone/2?closed=1 + += 0.4.0 = +This version fixes some misbehaviours and provides new http headers with info about the conversion process. + +* Fixed bug: .htaccess was not updated every time the settings was saved. +* Fixed bug: The plugin generated error upon activation. +* Now produces X-WebP-Convert-And-Serve headers with info about the conversion - useful for validating that converter receives the expected arguments and executes correctly. +* WebPExpress options are now removed when plugin is uninstalled. +* No longer generates .htaccess rules on install. The user now has to actively go to Web Express setting and save first +* Added a "first time" message on options page and a reactivation message + +For more info, see the closed issues on the github repository: https://github.com/rosell-dk/webp-express/milestone/1?closed=1 + += 0.3.1 = +* The "Only jpeg" setting wasn't respected in 0.3.0. It now works again + += 0.3 = +* Now works on LiteSpeed webservers +* Now sends X-WebP-On-Demand headers for easier debugging diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4b2e7a2 --- /dev/null +++ b/composer.json @@ -0,0 +1,34 @@ +{ + "name": "rosell-dk/webp-express", + "description": "WebP for the masses", + "type": "wordpress-plugin", + "license": "MIT", + "require": { + "composer/installers": "^1.12.0", + "rosell-dk/webp-convert": "^2.9.2", + "rosell-dk/webp-convert-cloud-service": "^2.0.0", + "rosell-dk/dom-util-for-webp": "^0.7.1", + "rosell-dk/htaccess-capability-tester": "^0.9.0", + "rosell-dk/image-mime-type-guesser": "^1.1.1" + }, + "require-dev": { + }, + "scripts": { + "ci": [ + "@composer validate --no-check-all --strict" + ], + "copylocalwebpconvert": "rsync -varz /home/rosell/github/webp-convert/src/ /home/rosell/github/webp-express/vendor/rosell-dk/webp-convert/src/" + }, + "authors": [ + { + "name": "Bjørn Rosell", + "homepage": "https://www.bitwise-it.dk/contact", + "role": "Project Author" + } + ], + "config": { + "allow-plugins": { + "composer/installers": true + } + } +} diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..7ccc01e --- /dev/null +++ b/docs/development.md @@ -0,0 +1,111 @@ +## Updating the vendor dir: + +1. Run `composer update` in the root (plugin root). +2. Run `composer dump-autoload -o` +(for some reason, `vendor/composer/autoload_classmap.php` looses all its mappings on composer update). It also looses them on a `composer dump-autoload` (without the -o option). +It actually seems that the mappings are not needed. It seems to work fine when I alter autoload_real to not use the static loader. But well, I'm reluctant to change anything that works. + +3. Remove unneeded files: + +- Open bash +- cd into the folder + +```console +rm -r vendor/rosell-dk/webp-convert/docs +rm -r vendor/rosell-dk/webp-convert/src/Helpers/*.txt +rm composer.lock +rmdir vendor/bin +``` + +3. Commit on git + + +## Copying WCFM +I created the following script for building WCFM, copying it to webp-express, etc +``` +#!/bin/bash + +WCFM_PATH=/home/rosell/github/webp-convert-filemanager +WE_PATH=/home/rosell/github/webp-express +WE_PATH_WCFM=$WE_PATH/lib/wcfm +WCFMPage_PATH=/home/rosell/github/webp-express/lib/classes/WCFMPage.php +WC_PATH=/home/rosell/github/webp-convert + +copyassets() { + # remove assets in WebP Express + rm -f $WE_PATH_WCFM/index*.css + rm -f $WE_PATH_WCFM/index*.js + rm -f $WE_PATH_WCFM/vendor*.js + + # copy assets from WCFM + cp $WCFM_PATH/dist/assets/index*.css $WE_PATH_WCFM/ + cp $WCFM_PATH/dist/assets/index*.js $WE_PATH_WCFM/ + cp $WCFM_PATH/dist/assets/vendor*.js $WE_PATH_WCFM/ + + + #CSS_FILE = $(ls /home/rosell/github/webp-express/lib/wcfm | grep 'index.*css' | tr '\n' ' ' | sed 's/\s//') + CSS_FILE=$(ls $WE_PATH_WCFM | grep 'index.*css' | tr '\n' ' ' | sed 's/\s//') + JS_FILE=$(ls $WE_PATH_WCFM | grep 'index.*js' | tr '\n' ' ' | sed 's/\s//') + + + if [ ! $CSS_FILE ]; then + echo "No CSS file! - aborting" + exit + fi + if [ ! $JS_FILE ]; then + echo "No JS file! - aborting" + exit + fi + + echo "CSS file: $CSS_FILE" + echo "JS file: $JS_FILE" + + # Update WCFMPage.PHP references + sed -i "s/index\..*\.css/$CSS_FILE/g" $WCFMPage_PATH + sed -i "s/index\..*\.js/$JS_FILE/g" $WCFMPage_PATH +} + +if [ ! $1 ]; then + echo "Missing argument. Must be buildfm, copyfm, build-copyfm or rsync-wc" + exit +fi + +buildwcfm() { + npm run build --prefix $WCFM_PATH +} + +if [ $1 = "copyfm" ]; then + echo "copyfm" + copyassets +fi + +if [ $1 = "buildfm" ]; then + echo "buildfm" + buildwcfm +fi + +if [ $1 = "build-copyfm" ]; then + echo "build-copyfm" + buildwcfm + copyassets +fi + +rsyncwc() { + rsync -avh --size-only --exclude '.git' --exclude '.github' --exclude='composer.lock' --exclude='scripts' --exclude='vendor/rosell-dk/webp-convert/.git' --exclude='vendor/rosell-dk/webp-convert/.git' --exclude='.gitignore' "$WC_PATH/src/" "$WE_PATH/vendor/rosell-dk/webp-convert/src" --delete +} + +if [ $1 = "rsync-wc" ]; then + echo "rsync-wc" + rsyncwc +fi +``` + +# Instruction for installing development version, for non-developers :) + +To install the development version: +1) Go to https://wordpress.org/plugins/webp-express/advanced/ +2) Find the place where it says “Please select a specific version to download” +3) Click “Download” +4) Browse to /wp-admin/plugin-install.php (ie by going to the the Plugins page and clicking “Add new” button in the top) +5) Click “Upload plugin” (button found in the top) +6) The rest is easy diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100755 index 0000000..35b8601 --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,151 @@ +*These instructions are actually just notes for myself*. Some commands work only in my environment. + +If it is only the README.txt that has been changed: + +1. Validate the readme: https://wordpress.org/plugins/developers/readme-validator/ +2. Update the tag below to current +3. Update the commit message below +4. Run the below +``` +cd /var/www/we/svn/ +cp ~/github/webp-express/README.txt trunk +cp ~/github/webp-express/README.txt tags/0.20.1 +svn status +svn ci -m 'minor change in README (now tested with Wordpress 5.8 RC2)' +``` + +After that, check out if it is applied, on http://plugins.svn.wordpress.org/webp-express/ + +and here: +https://wordpress.org/plugins/webp-express/ + +'changelog.txt' changed too? +WELL - DON'T PUBLISH THAT, without publishing a new release. Wordfence will complain! + +------------------- + + + + + +before rsync, do this: + +- Run `composer update` in plugin root (and remove unneeded files. Check development.md !) + 1. `composer update` + 2. `composer dump-autoload -o` + 3. + rm -r vendor/rosell-dk/webp-convert/docs + rm -r vendor/rosell-dk/webp-convert/src/Helpers/*.txt + rm vendor/rosell-dk/dom-util-for-webp/phpstan.neon + rm composer.lock + rmdir vendor/bin + +- Make sure you remembered to update version in: + 1. the *webp-express.php* file + 2. in `lib/options/enqueue_scripts.php` + 3. in `lib/classes/ConvertHelperIndependent.php` + 4. in `README.txt` (Stable tag) - UNLESS IT IS A PRE-RELEASE :) +- Perhaps make some final improvements of the readme. + Inspiration: https://www.smashingmagazine.com/2011/11/improve-wordpress-plugins-readme-txt/ +https://pippinsplugins.com/how-to-properly-format-and-enhance-your-plugins-readme-txt-file-for-the-wordpress-org-repository/ +- Make sure you upgraded the *Upgrade Notice* section. +- Skim: https://codex.wordpress.org/Writing_a_Plugin +- https://developer.wordpress.org/plugins/wordpress-org/ +- Validate the readme: https://wordpress.org/plugins/developers/readme-validator/ +- Make sure you have pushed the latest commits to github +- Make sure you have released the new version on github + +And then: + +``` +cd /var/www/we/svn +svn up +``` +PS: On a new computer? - checkout first: `svn co http://plugins.svn.wordpress.org/webp-express/`` + + +If you have deleted folders (check with rsync --dry-run), then do this: +``` +cd trunk +svn delete [folder] (ie: svn delete lib/options/js/0.14.5). It is ok that the folder contains files +svn ci -m 'deleted folder' +``` +(workflow cycle: http://svnbook.red-bean.com/en/1.7/svn.tour.cycle.html) + +Then time to rsync into trunk: +dry-run first: +``` +cd /var/www/we/svn +rsync -avh --dry-run --exclude '.git' --exclude '.github' --exclude='composer.lock' --exclude='scripts' --exclude='vendor/rosell-dk/webp-convert/.git' --exclude='vendor/rosell-dk/webp-convert/.git' --exclude='.gitignore' ~/github/webp-express/ /var/www/we/svn/trunk/ --delete +``` + +``` +cd /var/www/we/svn +rsync -avh --exclude '.git' --exclude '.github' --exclude='composer.lock' --exclude='scripts' --exclude='vendor/rosell-dk/webp-convert/.git' --exclude='.gitignore' ~/github/webp-express/ /var/www/we/svn/trunk/ --delete +``` + +**It should NOT contain a long list of files! (unless you have run phpreplace)** + +*- and then WITHOUT "--dry-run" (remove "--dry-run" from above, and run)* + + +## TESTING + +1. Create a zip + - Select all the files in trunk (not the trunk dir itself) + - Save it to /var/www/we/pre-releases/version-number/webp-express.zip +2. Upload the zip to the LiteSpeed test site and test + - Login to https://betasite.com.br/rosell/wp-admin/plugins.php + - Go to Plugins | Add new and click the "Upload Plugin button" +3. Upload the zip to other sites and test + - https://lutzenmanagement.dk/wp-admin/plugin-install.php + - http://mystress.dk/wp-admin/plugin-install.php + ... etc + + +### Committing +Add new and remove deleted (no need to do anything with the modified): +``` +cd svn +svn stat (to see what has changed) +svn add --force . (this will add all new files - https://stackoverflow.com/questions/2120844/how-do-i-add-all-new-files-to-svn) +svn status | grep '^!' (to see if any files have been deleted) +svn status | grep '^!' | awk '{print $2}' | xargs svn delete --force (this will delete locally deleted files in the repository as well - see https://stackoverflow.com/questions/4608798/how-to-remove-all-deleted-files-from-repository/33139931) +``` + +Then add a new tag +``` +cd svn +svn cp trunk tags/0.25.8 (this will copy trunk into a new tag) +``` + +And commit! +``` +svn ci -m '0.25.8' +``` + + +After that, check out if it is applied, on http://plugins.svn.wordpress.org/webp-express/ +And then, of course, test the update +... And THEN. Grab a beer and celebrate! + +And lastly, check if there are any new issues on https://coderisk.com + + +# New: + +# svn co https://plugins.svn.wordpress.org/webp-express /var/www/webp-express-tests/svn + + +BTW: Link til referral (optimole): https://app.impact.com/ + + + +# On brand new system: + +1. Install svn +`sudo apt-get install subversion` + +2. create dir for plugin, and `cd` into it +3. Check out +`svn co https://plugins.svn.wordpress.org/webp-express my-local-dir` (if in dir, replace "my-lockal-dir" with ".") diff --git a/docs/regex.md b/docs/regex.md new file mode 100644 index 0000000..0ca3049 --- /dev/null +++ b/docs/regex.md @@ -0,0 +1,154 @@ +Pattern used for image urls: + in attributes: https://regexr.com/46jat + in css: https://regexr.com/46jcg + +In case reqexr.com should be down, here are the content: + + +# in attributes + +*pattern:* +(?<=(?:<(img|source|input|iframe)[^>]*\s+(src|srcset|data-[^=]*)\s*=\s*[\"\']?))(?:[^\"\'>]+)(\.png|\.jp[e]?g)(\s\d+w)?(?=\/?[\"\'\s\>]) + +*text:* +Notice: The pattern is meant for PHP and contains syntax which only works in some browsers. It works in Chrome. Not in Firefox. + +The following should produce matches: + + + + + + + + +hello + + + + + + + + +In srcset, the whole attribute must be matched + + + + +Common lazy load attributes are matched: + + + + + + + + + + + + + + +The following should NOT produce matches: +----------------------------------------- + +Ignore URLs with query string: + + + + + +nice-jpg + +src="http://example.com/header.jpeg" +

+'; +} + + +if (get_option('webp-express-alter-html-replacement') == 'picture') { + add_action( 'wp_head', 'webpExpressAddPictureJs'); +} + +if (get_option('webp-express-alter-html-hooks', 'ob') == 'ob') { + /* TODO: + Which hook should we use, and should we make it optional? + - Cache enabler uses 'template_redirect' + - ShortPixes uses 'init' + + We go with template_redirect now, because it is the "innermost". + This lowers the risk of problems with plugins used rewriting URLs to point to CDN. + (We need to process the output *before* the other plugin has rewritten the URLs, + if the "Only for webps that exists" feature is enabled) + */ + add_action( 'init', 'webpExpressOutputBuffer', 1 ); + //add_action( 'template_redirect', 'webpExpressOutputBuffer', 1 ); + +} else { + add_filter( 'the_content', 'webPExpressAlterHtml', 10000 ); // priority big, so it will be executed last + add_filter( 'the_excerpt', 'webPExpressAlterHtml', 10000 ); + add_filter( 'post_thumbnail_html', 'webPExpressAlterHtml'); +} + +//echo wp_doing_ajax() ? 'ajax' : 'no ajax'; exit; +//echo is_feed() ? 'feed' : 'no feed'; exit; diff --git a/lib/classes/Actions.php b/lib/classes/Actions.php new file mode 100644 index 0000000..aeb38b2 --- /dev/null +++ b/lib/classes/Actions.php @@ -0,0 +1,46 @@ +donate?', + ]; + } else { + $mylinks = array( + 'Settings', + 'Provide coffee for the developer', + ); + + } + return array_merge($links, $mylinks); + } + + // Add settings link in multisite + // The hook was registred in AdminInit + public static function networkPluginActionLinksFilter($links) + { + $mylinks = array( + 'Settings', + 'donate?', + ); + return array_merge($links, $mylinks); + } + + + // callback for 'network_admin_menu' (registred in AdminInit) + public static function networAdminMenuHook() + { + add_submenu_page( + 'settings.php', // Parent element + 'WebP Express settings (for network)', // Text in browser title bar + 'WebP Express', // Text to be displayed in the menu. + 'manage_network_options', // Capability + 'webp_express_settings_page', // slug + array('\WebPExpress\OptionsPage', 'display') // Callback function which displays the page + ); + + add_submenu_page( + 'settings.php', // Parent element + 'WebP Express File Manager', //Page Title + 'WebP Express File Manager', //Menu Title + 'manage_network_options', //capability + 'webp_express_conversion_page', // slug + array('\WebPExpress\WCFMPage', 'display') //The function to be called to output the content for this page. + ); + + } + + public static function adminMenuHookMultisite() + { + // Add Media page + /* + not ready - it should not display images for the other blogs! + + add_submenu_page( + 'upload.php', // Parent element + 'WebP Express', //Page Title + 'WebP Express', //Menu Title + 'manage_network_options', //capability + 'webp_express_conversion_page', // slug + array('\WebPExpress\WCFMPage', 'display') //The function to be called to output the content for this page. + ); + */ + + } + + public static function adminMenuHook() + { + //Add Settings Page + add_options_page( + 'WebP Express Settings', //Page Title + 'WebP Express', //Menu Title + 'manage_options', //capability + 'webp_express_settings_page', // slug + array('\WebPExpress\OptionsPage', 'display') //The function to be called to output the content for this page. + ); + + // Add Media page + add_media_page( + 'WebP Express', //Page Title + 'WebP Express', //Menu Title + 'manage_options', //capability + 'webp_express_conversion_page', // slug + array('\WebPExpress\WCFMPage', 'display') //The function to be called to output the content for this page. + ); + + } +} diff --git a/lib/classes/AlterHtmlHelper.php b/lib/classes/AlterHtmlHelper.php new file mode 100644 index 0000000..039ce4b --- /dev/null +++ b/lib/classes/AlterHtmlHelper.php @@ -0,0 +1,377 @@ + http + [host] => we0 + [path] => /wordpress/uploads-moved + )*/ + + $imageUrlComponents = parse_url($imageUrl); + /* ie: + ( + [scheme] => http + [host] => we0 + [path] => /wordpress/uploads-moved/logo.jpg + )*/ + if ($baseUrlComponents['host'] != $imageUrlComponents['host']) { + return false; + } + + // Check if path begins with base path + if (strpos($imageUrlComponents['path'], $baseUrlComponents['path']) !== 0) { + return false; + } + + // Remove base path from path (we know it begins with basepath, from previous check) + return substr($imageUrlComponents['path'], strlen($baseUrlComponents['path'])); + + } + + /** + * Looks if $imageUrl is rooted in $baseUrl and if the file is there + * PS: NOT USED ANYMORE! + * + * @param $imageUrl (ie http://example.com/wp-content/image.jpg) + * @param $baseUrl (ie http://example.com/wp-content) + * @param $baseDir (ie /var/www/example.com/wp-content) + */ + public static function isImageUrlHere($imageUrl, $baseUrl, $baseDir) + { + + $srcPathRel = self::getRelUrlPath($imageUrl, $baseUrl); + + if ($srcPathRel === false) { + return false; + } + + // Calculate file path to src + $srcPathAbs = $baseDir . $srcPathRel; + //return 'dyt:' . $srcPathAbs; + + // Check that src file exists + if (!@file_exists($srcPathAbs)) { + return false; + } + + return true; + + } + + // NOT USED ANYMORE + public static function isSourceInUpload($src) + { + /* $src is ie http://we0/wp-content-moved/themes/twentyseventeen/assets/images/header.jpg */ + + $uploadDir = wp_upload_dir(); + /* ie: + + [path] => /var/www/webp-express-tests/we0/wordpress/uploads-moved + [url] => http://we0/wordpress/uploads-moved + [subdir] => + [basedir] => /var/www/webp-express-tests/we0/wordpress/uploads-moved + [baseurl] => http://we0/wordpress/uploads-moved + [error] => + */ + + return self::isImageUrlHere($src, $uploadDir['baseurl'], $uploadDir['basedir']); + } + + + /** + * Get url for webp from source url, (if ), given a certain baseUrl / baseDir. + * Base can for example be uploads or wp-content. + * + * returns false: + * - if no source file found in that base + * - if source file is found but webp file isn't there and the `only-for-webps-that-exists` option is set + * - if webp is marked as bigger than source + * + * @param string $sourceUrl Url of source image (ie http://example.com/wp-content/image.jpg) + * @param string $rootId Id (created in Config::updateAutoloadedOptions). Ie "uploads", "content" or any image root id + * @param string $baseUrl Base url of source image (ie http://example.com/wp-content) + * @param string $baseDir Base dir of source image (ie /var/www/example.com/wp-content) + */ + public static function getWebPUrlInImageRoot($sourceUrl, $rootId, $baseUrl, $baseDir) + { + + + $srcPathRel = self::getRelUrlPath($sourceUrl, $baseUrl); + + if ($srcPathRel === false) { + return false; + } + + // Calculate file path to source + $srcPathAbs = $baseDir . $srcPathRel; + + // Check that source file exists + if (!@file_exists($srcPathAbs)) { + return false; + } + + if (file_exists($srcPathAbs . '.do-not-convert')) { + return false; + } + if (file_exists($srcPathAbs . '.dontreplace')) { + return false; + } + + // Calculate destination of webp (both path and url) + // ---------------------------------------- + + // We are calculating: $destPathAbs and $destUrl. + + // Make sure the options are loaded (and fixed) + self::getOptions(); + $destinationOptions = new DestinationOptions( + self::$options['destination-folder'] == 'mingled', + self::$options['destination-structure'] == 'doc-root', + self::$options['destination-extension'] == 'set', + self::$options['scope'] + ); + + if (!isset(self::$options['scope']) || !in_array($rootId, self::$options['scope'])) { + return false; + } + + $destinationRoot = Paths::destinationRoot($rootId, $destinationOptions); + + $relPathFromImageRootToSource = PathHelper::getRelDir( + realpath(Paths::getAbsDirById($rootId)), // note: In multisite (subfolders), it contains ie "/site/2/" + realpath($srcPathAbs) + ); + $relPathFromImageRootToDest = ConvertHelperIndependent::appendOrSetExtension( + $relPathFromImageRootToSource, + self::$options['destination-folder'], + self::$options['destination-extension'], + ($rootId == 'uploads') + ); + $destPathAbs = $destinationRoot['abs-path'] . '/' . $relPathFromImageRootToDest; + $webpMustExist = self::$options['only-for-webps-that-exists']; + if ($webpMustExist && (!@file_exists($destPathAbs))) { + return false; + } + + // check if webp is marked as bigger than source + /* + $biggerThanSourcePath = Paths::getBiggerThanSourceDirAbs() . '/' . $rootId . '/' . $relPathFromImageRootToDest; + if (@file_exists($biggerThanSourcePath)) { + return false; + }*/ + + // check if webp is larger than original + if (self::$options['prevent-using-webps-larger-than-original']) { + if (BiggerThanSource::bigger($srcPathAbs, $destPathAbs)) { + return false; + } + } + + $destUrl = $destinationRoot['url'] . '/' . $relPathFromImageRootToDest; + + // Fix scheme (use same as source) + $sourceUrlComponents = parse_url($sourceUrl); + $destUrlComponents = parse_url($destUrl); + $port = isset($sourceUrlComponents['port']) ? ":" . $sourceUrlComponents['port'] : ""; + $result = $sourceUrlComponents['scheme'] . '://' . $sourceUrlComponents['host'] . $port . $destUrlComponents['path']; + + /* + error_log( + "getWebPUrlInImageRoot:\n" . + "- url: " . $sourceUrl . "\n" . + "- baseUrl: " . $baseUrl . "\n" . + "- baseDir: " . $baseDir . "\n" . + "- root id: " . $rootId . "\n" . + "- root abs: " . Paths::getAbsDirById($rootId) . "\n" . + "- destination root (abs): " . $destinationRoot['abs-path'] . "\n" . + "- destination root (url): " . $destinationRoot['url'] . "\n" . + "- rel: " . $srcPathRel . "\n" . + "- srcPathAbs: " . $srcPathAbs . "\n" . + '- relPathFromImageRootToSource: ' . $relPathFromImageRootToSource . "\n" . + '- get_blog_details()->path: ' . get_blog_details()->path . "\n" . + "- result: " . $result . "\n" + );*/ + return $result; + } + + + /** + * Get url for webp + * returns second argument if no webp + * + * @param $sourceUrl + * @param $returnValueOnFail + */ + public static function getWebPUrl($sourceUrl, $returnValueOnFail) + { + // Get the options + self::getOptions(); + + // Fail for webp-disabled browsers (when "only-for-webp-enabled-browsers" is set) + if (self::$options['only-for-webp-enabled-browsers']) { + if (!isset($_SERVER['HTTP_ACCEPT']) || (strpos($_SERVER['HTTP_ACCEPT'], 'image/webp') === false)) { + return $returnValueOnFail; + } + } + + // Fail for relative urls. Wordpress doesn't use such very much anyway + if (!preg_match('#^https?://#', $sourceUrl)) { + return $returnValueOnFail; + } + + // Fail if the image type isn't enabled + switch (self::$options['image-types']) { + case 0: + return $returnValueOnFail; + case 1: + if (!preg_match('#(jpe?g)$#', $sourceUrl)) { + return $returnValueOnFail; + } + break; + case 2: + if (!preg_match('#(png)$#', $sourceUrl)) { + return $returnValueOnFail; + } + break; + case 3: + if (!preg_match('#(jpe?g|png)$#', $sourceUrl)) { + return $returnValueOnFail; + } + break; + } + + + //error_log('source url:' . $sourceUrl); + + // Try all image roots + foreach (self::$options['scope'] as $rootId) { + $baseDir = Paths::getAbsDirById($rootId); + $baseUrl = Paths::getUrlById($rootId); + + if (Multisite::isMultisite() && ($rootId == 'uploads')) { + $baseUrl = Paths::getUploadUrl(); + $baseDir = Paths::getUploadDirAbs(); + } + + $result = self::getWebPUrlInImageRoot($sourceUrl, $rootId, $baseUrl, $baseDir); + if ($result !== false) { + return $result; + } + + // Try the hostname aliases. + if (!isset(self::$options['hostname-aliases'])) { + continue; + } + $hostnameAliases = self::$options['hostname-aliases']; + + $hostname = Paths::getHostNameOfUrl($baseUrl); + $baseUrlComponents = parse_url($baseUrl); + $sourceUrlComponents = parse_url($sourceUrl); + // ie: [scheme] => http, [host] => we0, [path] => /wordpress/uploads-moved + + if ((!isset($baseUrlComponents['host'])) || (!isset($sourceUrlComponents['host']))) { + continue; + } + + foreach ($hostnameAliases as $hostnameAlias) { + + if ($sourceUrlComponents['host'] != $hostnameAlias) { + continue; + } + //error_log('hostname alias:' . $hostnameAlias); + + $baseUrlOnAlias = $baseUrlComponents['scheme'] . '://' . $hostnameAlias . $baseUrlComponents['path']; + //error_log('baseurl (alias):' . $baseUrlOnAlias); + + $result = self::getWebPUrlInImageRoot($sourceUrl, $rootId, $baseUrlOnAlias, $baseDir); + if ($result !== false) { + $resultUrlComponents = parse_url($result); + return $sourceUrlComponents['scheme'] . '://' . $hostnameAlias . $resultUrlComponents['path']; + } + } + } + + return $returnValueOnFail; + } + +/* + public static function getWebPUrlOrSame($sourceUrl, $returnValueOnFail) + { + return self::getWebPUrl($sourceUrl, $sourceUrl); + }*/ + +} diff --git a/lib/classes/AlterHtmlImageUrls.php b/lib/classes/AlterHtmlImageUrls.php new file mode 100644 index 0000000..67ec742 --- /dev/null +++ b/lib/classes/AlterHtmlImageUrls.php @@ -0,0 +1,33 @@ + tag is not allowed + return $content; + } + } + + if (!isset(self::$options)) { + self::$options = json_decode(Option::getOption('webp-express-alter-html-options', null), true); + //AlterHtmlHelper::$options = self::$options; + } + + if (self::$options == null) { + return $content; + } + + if (Option::getOption('webp-express-alter-html-replacement') == 'picture') { + require_once __DIR__ . "/../../vendor/autoload.php"; + require_once __DIR__ . '/AlterHtmlHelper.php'; + require_once __DIR__ . '/AlterHtmlPicture.php'; + return \WebPExpress\AlterHtmlPicture::replace($content); + } else { + require_once __DIR__ . "/../../vendor/autoload.php"; + require_once __DIR__ . '/AlterHtmlHelper.php'; + require_once __DIR__ . '/AlterHtmlImageUrls.php'; + + return \WebPExpress\AlterHtmlImageUrls::replace($content); + } + } + + public static function addPictureFillJs() + { + // Don't do anything with the RSS feed. + // - and no need for PictureJs in the admin + if ( is_feed() || is_admin() ) { return; } + + echo ''; + } + + public static function sidebarBeforeAlterHtml() + { + ob_start(); + } + + public static function sidebarAfterAlterHtml() + { + $content = ob_get_clean(); + + echo self::alterHtml($content); + + unset($content); + } + + public static function setHooks() { + + if (Option::getOption('webp-express-alter-html-add-picturefill-js')) { + add_action( 'wp_head', '\\WebPExpress\\AlterHtmlInit::addPictureFillJs'); + } + + if (Option::getOption('webp-express-alter-html-hooks', 'ob') == 'ob') { + /* TODO: + Which hook should we use, and should we make it optional? + - Cache enabler uses 'template_redirect' + - ShortPixes uses 'init' + + We go with template_redirect now, because it is the "innermost". + This lowers the risk of problems with plugins used rewriting URLs to point to CDN. + (We need to process the output *before* the other plugin has rewritten the URLs, + if the "Only for webps that exists" feature is enabled) + */ + add_action( 'init', '\\WebPExpress\\AlterHtmlInit::startOutputBuffer', 1 ); + add_action( 'template_redirect', '\\WebPExpress\\AlterHtmlInit::startOutputBuffer', 10000 ); + + } else { + add_filter( 'the_content', '\\WebPExpress\\AlterHtmlInit::alterHtml', 99999 ); // priority big, so it will be executed last + add_filter( 'the_excerpt', '\\WebPExpress\\AlterHtmlInit::alterHtml', 99999 ); + add_filter( 'post_thumbnail_html', '\\WebPExpress\\AlterHtmlInit::alterHtml', 99999); + add_filter( 'woocommerce_product_get_image', '\\WebPExpress\\AlterHtmlInit::alterHtml', 99999 ); + add_filter( 'get_avatar', '\\WebPExpress\\AlterHtmlInit::alterHtml', 99999 ); + add_filter( 'acf_the_content', '\\WebPExpress\\AlterHtmlInit::alterHtml', 99999 ); + add_action( 'dynamic_sidebar_before', '\\WebPExpress\\AlterHtmlInit::sidebarBeforeAlterHtml', 0 ); + add_action( 'dynamic_sidebar_after', '\\WebPExpress\\AlterHtmlInit::sidebarAfterAlterHtml', 1000 ); + + + /* + TODO: + check out these hooks (used by Jetpack, in class.photon.php) + + // Images in post content and galleries + add_filter( 'the_content', array( __CLASS__, 'filter_the_content' ), 999999 ); + add_filter( 'get_post_galleries', array( __CLASS__, 'filter_the_galleries' ), 999999 ); + add_filter( 'widget_media_image_instance', array( __CLASS__, 'filter_the_image_widget' ), 999999 ); + + // Core image retrieval + add_filter( 'image_downsize', array( $this, 'filter_image_downsize' ), 10, 3 ); + add_filter( 'rest_request_before_callbacks', array( $this, 'should_rest_photon_image_downsize' ), 10, 3 ); + add_filter( 'rest_request_after_callbacks', array( $this, 'cleanup_rest_photon_image_downsize' ) ); + + // Responsive image srcset substitution + add_filter( 'wp_calculate_image_srcset', array( $this, 'filter_srcset_array' ), 10, 5 ); + add_filter( 'wp_calculate_image_sizes', array( $this, 'filter_sizes' ), 1, 2 ); // Early so themes can still easily filter. + + // Helpers for maniuplated images + add_action( 'wp_enqueue_scripts', array( $this, 'action_wp_enqueue_scripts' ), 9 ); + */ + } + } + +} diff --git a/lib/classes/AlterHtmlPicture.php b/lib/classes/AlterHtmlPicture.php new file mode 100644 index 0000000..594177e --- /dev/null +++ b/lib/classes/AlterHtmlPicture.php @@ -0,0 +1,18 @@ + tag to a tag and add the webp versions of the images + * Based this code on code from the ShortPixel plugin, which used code from Responsify WP plugin + */ + +use \WebPExpress\AlterHtmlHelper; +use DOMUtilForWebP\PictureTags; + +class AlterHtmlPicture extends PictureTags +{ + public function replaceUrl($url) { + return AlterHtmlHelper::getWebPUrl($url, null); + } +} diff --git a/lib/classes/BiggerThanSource.php b/lib/classes/BiggerThanSource.php new file mode 100644 index 0000000..570c1c1 --- /dev/null +++ b/lib/classes/BiggerThanSource.php @@ -0,0 +1,32 @@ + $filesizeSource); + } +} diff --git a/lib/classes/BiggerThanSourceDummyFiles.php b/lib/classes/BiggerThanSourceDummyFiles.php new file mode 100644 index 0000000..b291c2d --- /dev/null +++ b/lib/classes/BiggerThanSourceDummyFiles.php @@ -0,0 +1,135 @@ + +Require all denied + + +Order deny,allow +Deny from all + +APACHE + ); + @chmod($dir . '/.htaccess', 0664); + } + return is_dir($dir); + } + + public static function pathToDummyFile($source, $basedir, $imageRoots, $destinationFolder, $destinationExt) + { + $sourceResolved = realpath($source); + + // Check roots until we (hopefully) get a match. + // (that is: find a root which the source is inside) + foreach ($imageRoots->getArray() as $i => $imageRoot) { + $rootPath = $imageRoot->getAbsPath(); + + // We can assume that $rootPath is resolvable using realpath (it ought to exist and be within open_basedir for WP to function) + // We can also assume that $source is resolvable (it ought to exist and within open_basedir) + // So: Resolve both! and test if the resolved source begins with the resolved rootPath. + if (strpos($sourceResolved, realpath($rootPath)) !== false) { + $relPath = substr($sourceResolved, strlen(realpath($rootPath)) + 1); + $relPath = ConvertHelperIndependent::appendOrSetExtension($relPath, $destinationFolder, $destinationExt, false); + + return $basedir . '/' . $imageRoot->id . '/' . $relPath; + break; + } + } + return false; + } + + public static function pathToDummyFileRootAndRelKnown($source, $basedir, $rootId, $destinationFolder, $destinationExt) + { + } + + /** + * Check if webp is bigger than original. + * + * @return boolean|null True if it is bigger than original, false if not. NULL if it cannot be determined + */ + public static function bigger($source, $destination) + { + /* + if ((!@file_exists($source)) || (!@file_exists($destination) { + return null; + }*/ + $filesizeDestination = @filesize($destination); + $filesizeSource = @filesize($source); + + // sizes are FALSE on failure (ie if file does not exists) + if (($filesizeDestination === false) || ($filesizeDestination === false)) { + return null; + } + + return ($filesizeDestination > $filesizeSource); + } + + /** + * Update the status for a single image (when rootId is unknown) + * + * Checks if webp is bigger than original. If it is, a dummy file is placed. Otherwise, it is + * removed (if exists) + * + * @param string $source Path to the source file that was converted + * + * + */ + public static function updateStatus($source, $destination, $webExpressContentDirAbs, $imageRoots, $destinationFolder, $destinationExt) + { + $basedir = $webExpressContentDirAbs . '/webp-images-bigger-than-source'; + if (!file_exists($basedir)) { + self::createBiggerThanSourceBaseDir($basedir); + } + $bigWebP = BiggerThanSource::bigger($source, $destination); + + $file = self::pathToDummyFile($source, $basedir, $imageRoots, $destinationFolder, $destinationExt); + if ($file === false) { + return; + } + + if ($bigWebP === true) { + // place dummy file, which marks that webp is bigger than source + + $folder = @dirname($file); + if (!@file_exists($folder)) { + mkdir($folder, 0777, true); + } + if (@file_exists($folder)) { + file_put_contents($file, ''); + } + + } else { + // remove dummy file (if exists) + if (@file_exists($file)) { + @unlink($file); + } + } + + } + +} diff --git a/lib/classes/BiggerThanSourceDummyFilesBulk.php b/lib/classes/BiggerThanSourceDummyFilesBulk.php new file mode 100644 index 0000000..7abcb42 --- /dev/null +++ b/lib/classes/BiggerThanSourceDummyFilesBulk.php @@ -0,0 +1,120 @@ + $config['destination-extension'], + 'destination-folder' => $config['destination-folder'], /* hm, "destination-folder" is a bad name... */ + 'webExpressContentDirAbs' => Paths::getWebPExpressContentDirAbs(), + 'uploadDirAbs' => Paths::getUploadDirAbs(), + 'useDocRootForStructuringCacheDir' => (($config['destination-structure'] == 'doc-root') && (Paths::canUseDocRootForStructuringCacheDir())), + //'imageRoots' => new ImageRoots(Paths::getImageRootsDefForSelectedIds($config['scope'])), // (Paths::getImageRootsDef() + 'imageRoots' => new ImageRoots(Paths::getImageRootsDefForSelectedIds(Paths::getImageRootIds())), // (Paths::getImageRootsDef() + 'image-types' => $config['image-types'], + ]; + + + //$rootIds = Paths::filterOutSubRoots($config['scope']); + + // We want to update status on ALL root dirs (so we don't have to re-run when user changes scope) + $rootIds = Paths::filterOutSubRoots(Paths::getImageRootIds()); + //$rootIds = ['uploads']; + //$rootIds = ['uploads', 'themes']; + + foreach ($rootIds as $rootId) { + self::updateStatusForRoot($rootId); + } + } + + /** + * Pre-requirement: self::$settings is set. + * + * Idea for improvement: Traverse destination dirs instead. This will be quicker, as there will not be + * as many images (unless all have been converted), and not as many folders (non-image folders will not be present. + * however, index does not take too long to traverse, even though it has many non-image folders, so it will only + * be a problem if there are plugins or themes with extremely many folders). + */ + private static function updateStatusForRoot($rootId, $dir = '') + { + if ($dir == '') { + $dir = Paths::getAbsDirById($rootId); + } + + // Canonicalize because dir might contain "/./", which causes file_exists to fail (#222) + $dir = PathHelper::canonicalize($dir); + + if (!@file_exists($dir) || !@is_dir($dir)) { + return []; + } + + $fileIterator = new \FilesystemIterator($dir); + + $results = []; + + while ($fileIterator->valid()) { + $filename = $fileIterator->getFilename(); + + if (($filename != ".") && ($filename != "..")) { + if (@is_dir($dir . "/" . $filename)) { + $newDir = $dir . "/" . $filename; + + // The new dir might have its own root id + $newRootId = Paths::findImageRootOfPath($newDir, Paths::getImageRootIds()); + //echo $newRootId . ': ' . $newDir . "\n"; + self::updateStatusForRoot($newRootId, $newDir); + } else { + // its a file - check if its a valid image type (jpeg or png) + $regex = '#\.(jpe?g|png)$#'; + if (preg_match($regex, $filename)) { + + $source = $dir . "/" . $filename; + + $destination = ConvertHelperIndependent::getDestination( + $source, + self::$settings['destination-folder'], + self::$settings['ext'], + self::$settings['webExpressContentDirAbs'], + self::$settings['uploadDirAbs'], + self::$settings['useDocRootForStructuringCacheDir'], + self::$settings['imageRoots'], + //$rootId + + ); + $webpExists = @file_exists($destination); + + //echo ($webpExists ? 'YES' : 'NO') . ' ' . $rootId . ': ' . $source . "\n"; + + BiggerThanSourceDummyFiles::updateStatus( + $source, + $destination, + self::$settings['webExpressContentDirAbs'], + self::$settings['imageRoots'], + self::$settings['destination-folder'], + self::$settings['ext'], + // TODO: send rootId so the function doesn't need to try all + // $rootId, + ); + + } + } + } + $fileIterator->next(); + } + return $results; + } +} diff --git a/lib/classes/BulkConvert.php b/lib/classes/BulkConvert.php new file mode 100644 index 0000000..aa0624f --- /dev/null +++ b/lib/classes/BulkConvert.php @@ -0,0 +1,337 @@ + Paths::getUploadDirAbs(), + 'ext' => $config['destination-extension'], + 'destination-folder' => $config['destination-folder'], /* hm, "destination-folder" is a bad name... */ + 'webExpressContentDirAbs' => Paths::getWebPExpressContentDirAbs(), + 'uploadDirAbs' => Paths::getUploadDirAbs(), + 'useDocRootForStructuringCacheDir' => (($config['destination-structure'] == 'doc-root') && (Paths::canUseDocRootForStructuringCacheDir())), + 'imageRoots' => new ImageRoots(Paths::getImageRootsDefForSelectedIds($config['scope'])), // (Paths::getImageRootsDef() + 'filter' => [ + 'only-converted' => false, + 'only-unconverted' => true, + 'image-types' => $config['image-types'], + 'max-depth' => 100, + ], + 'flattenList' => true, + ]; + } + + /** + * Get grouped list of files. They are grouped by image roots. + * + */ + public static function getList($config, $listOptions = null) + { + + /* + isUploadDirMovedOutOfWPContentDir + isUploadDirMovedOutOfAbsPath + isPluginDirMovedOutOfAbsPath + isPluginDirMovedOutOfWpContent + isWPContentDirMovedOutOfAbsPath */ + + if (is_null($listOptions)) { + $listOptions = self::defaultListOptions($config); + } + + $rootIds = Paths::filterOutSubRoots($config['scope']); + + $groups = []; + foreach ($rootIds as $rootId) { + $groups[] = [ + 'groupName' => $rootId, + 'root' => Paths::getAbsDirById($rootId) + ]; + } + + foreach ($groups as $i => &$group) { + $listOptions['root'] = $group['root']; + /* + No use, because if uploads is in wp-content, the cache root will be different for the files in uploads (if mingled) + $group['image-root'] = ConvertHelperIndependent::getDestinationFolder( + $group['root'], + $listOptions['destination-folder'], + $listOptions['ext'], + $listOptions['webExpressContentDirAbs'], + $listOptions['uploadDirAbs'] + );*/ + $group['files'] = self::getListRecursively('.', $listOptions); + //'image-root' => ConvertHelperIndependent::getDestinationFolder() + } + + return $groups; + //self::moveRecursively($toDir, $fromDir, $srcDir, $fromExt, $toExt); + } + + /** + * $filter: all | converted | not-converted. "not-converted" for example returns paths to images that has not been converted + */ + public static function getListRecursively($relDir, &$listOptions, $depth = 0) + { + $dir = $listOptions['root'] . '/' . $relDir; + + // Canonicalize because dir might contain "/./", which causes file_exists to fail (#222) + $dir = PathHelper::canonicalize($dir); + + if (!@file_exists($dir) || !@is_dir($dir)) { + return []; + } + + $fileIterator = new \FilesystemIterator($dir); + + $results = []; + $filter = &$listOptions['filter']; + + while ($fileIterator->valid()) { + $filename = $fileIterator->getFilename(); + + if (($filename != ".") && ($filename != "..")) { + + if (@is_dir($dir . "/" . $filename)) { + if ($listOptions['flattenList']) { + $results = array_merge($results, self::getListRecursively($relDir . "/" . $filename, $listOptions, $depth+1)); + } else { + $r = [ + 'name' => $filename, + 'isDir' => true, + ]; + if ($depth > $listOptions['max-depth']) { + return $r; // one item is enough to determine that it is not empty + } + if ($depth < $listOptions['max-depth']) { + $r['children'] = self::getListRecursively($relDir . "/" . $filename, $listOptions, $depth+1); + $r['isEmpty'] = (count($r['children']) == 0); + } else if ($depth == $listOptions['max-depth']) { + $c = self::getListRecursively($relDir . "/" . $filename, $listOptions, $depth+1); + $r['isEmpty'] = (count($c) == 0); + //$r['isEmpty'] = !(new \FilesystemIterator($dir))->valid(); + } + $results[] = $r; + } + } else { + // its a file - check if its a jpeg or png + + if (!isset($filter['_regexPattern'])) { + $imageTypes = $filter['image-types']; + $fileExtensions = []; + if ($imageTypes & 1) { + $fileExtensions[] = 'jpe?g'; + } + if ($imageTypes & 2) { + $fileExtensions[] = 'png'; + } + $filter['_regexPattern'] = '#\.(' . implode('|', $fileExtensions) . ')$#'; + } + + if (preg_match($filter['_regexPattern'], $filename)) { + $addThis = true; + + $destination = ConvertHelperIndependent::getDestination( + $dir . "/" . $filename, + $listOptions['destination-folder'], + $listOptions['ext'], + $listOptions['webExpressContentDirAbs'], + $listOptions['uploadDirAbs'], + $listOptions['useDocRootForStructuringCacheDir'], + $listOptions['imageRoots'] + ); + $webpExists = @file_exists($destination); + + if (($filter['only-converted']) || ($filter['only-unconverted'])) { + //$cacheDir = $listOptions['image-root'] . '/' . $relDir; + + // Check if corresponding webp exists + /* + if ($listOptions['ext'] == 'append') { + $webpExists = @file_exists($cacheDir . "/" . $filename . '.webp'); + } else { + $webpExists = @file_exists(preg_replace("/\.(jpe?g|png)\.webp$/", '.webp', $filename)); + }*/ + + if (!$webpExists && ($filter['only-converted'])) { + $addThis = false; + } + if ($webpExists && ($filter['only-unconverted'])) { + $addThis = false; + } + } else { + $addThis = true; + } + + if ($addThis) { + + $path = substr($relDir . "/", 2) . $filename; // (we cut the leading "./" off with substr) + + // Additional safety check: verify the file actually exists before adding to list + $fullPath = $dir . "/" . $filename; + if (!file_exists($fullPath)) { + continue; // Skip this file if it doesn't exist + } + + // Check if the string can be encoded to json (if not: change it to a string that can) + if (json_encode($path, JSON_UNESCAPED_UNICODE) === false) { + /* + json_encode failed. This means that the string was not UTF-8. + Lets see if we can convert it to UTF-8. + This is however tricky business (see #471) + */ + + $encodedToUTF8 = false; + + // First try library that claims to do better than mb_detect_encoding + /* + DISABLED, because Onnov EncodingDetector requires PHP 7.2 + https://wordpress.org/support/topic/get-http-error-500-after-new-update-2/ + + if (!$encodedToUTF8) { + $detector = new EncodingDetector(); + + $dectedEncoding = $detector->getEncoding($path); + + if ($dectedEncoding !== 'utf-8') { + if (function_exists('iconv')) { + $res = iconv($dectedEncoding, 'utf-8//TRANSLIT', $path); + if ($res !== false) { + $path = $res; + $encodedToUTF8 = true; + } + } + } + + + try { + // iconvXtoEncoding should work now hm, issue #5 has been fixed + $path = $detector->iconvXtoEncoding($path); + $encodedToUTF8 = true; + } catch (\Exception $e) { + + } + }*/ + + // Try mb_detect_encoding + if (!$encodedToUTF8) { + if (function_exists('mb_convert_encoding')) { + $encoding = mb_detect_encoding($path, mb_detect_order(), true); + if ($encoding) { + $path = mb_convert_encoding($path, 'UTF-8', $encoding); + $encodedToUTF8 = true; + } + } + } + + if (!$encodedToUTF8) { + /* + We haven't yet succeeded in encoding to UTF-8. + What should we do? + 1. Skip the file? (no, the user will not know about the problem then) + 2. Add it anyway? (no, if this string causes problems to json_encode, then we will have + the same problem when encoding the entire list - result: an empty list) + 3. Try wp_json_encode? (no, it will fall back on "wp_check_invalid_utf8", which has a number of + things we do not want) + 4. Encode it to UTF-8 assuming that the string is encoded in the most common encoding (Windows-1252) ? + (yes, if we are lucky with the guess, it will work. If it is in another encoding, the conversion + will not be correct, and the user will then know about the problem. And either way, we will + have UTF-8 string, which will not break encoding of the list) + */ + + // https://stackoverflow.com/questions/6606713/json-encode-non-utf-8-strings + if (function_exists('mb_convert_encoding')) { + $path = mb_convert_encoding($path, "UTF-8", "Windows-1252"); + } elseif (function_exists('iconv')) { + $path = iconv("CP1252", "UTF-8", $path); + } elseif (function_exists('utf8_encode')) { + // utf8_encode converts from ISO-8859-1 to UTF-8 + $path = utf8_encode($path); + } else { + $path = '[cannot encode this filename to UTF-8]'; + } + + } + + } + if ($listOptions['flattenList']) { + $results[] = $path; + } else { + $results[] = [ + 'name' => basename($path), + 'isConverted' => $webpExists + ]; + if ($depth > $listOptions['max-depth']) { + return $results; // one item is enough to determine that it is not empty + } + + } + } + } + } + } + $fileIterator->next(); + } + return $results; + } + +/* + public static function convertFile($source) + { + $config = Config::loadConfigAndFix(); + $options = Config::generateWodOptionsFromConfigObj($config); + + $destination = ConvertHelperIndependent::getDestination( + $source, + $options['destination-folder'], + $options['destination-extension'], + Paths::getWebPExpressContentDirAbs(), + Paths::getUploadDirAbs() + ); + $result = ConvertHelperIndependent::convert($source, $destination, $options); + + //$result['destination'] = $destination; + if ($result['success']) { + $result['filesize-original'] = @filesize($source); + $result['filesize-webp'] = @filesize($destination); + } + return $result; + } +*/ + + public static function processAjaxListUnconvertedFiles() + { + if (!check_ajax_referer('webpexpress-ajax-list-unconverted-files-nonce', 'nonce', false)) { + wp_send_json_error('The security nonce has expired. You need to reload the settings page (press F5) and try again)'); + wp_die(); + } + + $config = Config::loadConfigAndFix(); + $arr = self::getList($config); + + // We use "wp_json_encode" rather than "json_encode" because it handles problems if there is non UTF-8 characters + // There should be none, as we have taken our measures, but no harm in taking extra precautions + $json = wp_json_encode($arr, JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + if ($json === false) { + // TODO: We can do better error handling than this! + echo ''; + } else { + echo $json; + } + + wp_die(); + } + +} diff --git a/lib/classes/CLI.php b/lib/classes/CLI.php new file mode 100644 index 0000000..be52e83 --- /dev/null +++ b/lib/classes/CLI.php @@ -0,0 +1,272 @@ +] + * : Limit which folders to process to a single location. Ie "uploads/2021". The first part is the + * "image root", which must be "uploads", "themes", "plugins", "wp-content" or "index" + * + * [--reconvert] + * : Even convert images that are already converted (new conversions replaces the old conversions) + * + * [--only-png] + * : Only convert PNG images + * + * [--only-jpeg] + * : Only convert jpeg images + * + * [--quality] + * : Override quality with specified (0-100) + * + * [--near-lossless] + * : Override near-lossless quality with specified (0-100) + * + * [--alpha-quality] + * : Override alpha-quality quality with specified (0-100) + * + * [--encoding] + * : Override encoding quality with specified ("auto", "lossy" or "lossless") + * + * [--converter=] + * : Specify the converter to use (default is to use the stack). Valid options: cwebp | vips | ewww | imagemagick | imagick | gmagick | graphicsmagick | ffmpeg | gd | wpc | ewww + */ + public function convert($args, $assoc_args) + { + $config = Config::loadConfigAndFix(); + $override = []; + + if (isset($assoc_args['quality'])) { + $override['max-quality'] = intval($assoc_args['quality']); + $override['png-quality'] = intval($assoc_args['quality']); + } + if (isset($assoc_args['near-lossless'])) { + $override['png-near-lossless'] = intval($assoc_args['near-lossless']); + $override['jpeg-near-lossless'] = intval($assoc_args['near-lossless']); + } + if (isset($assoc_args['alpha-quality'])) { + $override['alpha-quality'] = intval($assoc_args['alpha-quality']); + } + if (isset($assoc_args['encoding'])) { + if (!in_array($assoc_args['encoding'], ['auto', 'lossy', 'lossless'])) { + \WP_CLI::error('encoding must be auto, lossy or lossless'); + } + $override['png-encoding'] = $assoc_args['encoding']; + $override['jpeg-encoding'] = $assoc_args['encoding']; + } + if (isset($assoc_args['converter'])) { + if (!in_array($assoc_args['converter'], ConvertersHelper::getDefaultConverterNames())) { + \WP_CLI::error( + '"' . $assoc_args['converter'] . '" is not a valid converter id. ' . + 'Valid converters are: ' . implode(', ', ConvertersHelper::getDefaultConverterNames()) + ); + } + } + + $config = array_merge($config, $override); + + \WP_CLI::log('Converting with the following settings:'); + \WP_CLI::log('- Lossless quality: ' . $config['png-quality'] . ' for PNG, ' . $config['max-quality'] . " for jpeg"); + \WP_CLI::log( + '- Near lossless: ' . + ($config['png-enable-near-lossless'] ? $config['png-near-lossless'] : 'disabled') . ' for PNG, ' . + ($config['jpeg-enable-near-lossless'] ? $config['jpeg-near-lossless'] : 'disabled') . ' for jpeg, ' + ); + \WP_CLI::log('- Alpha quality: ' . $config['alpha-quality']); + \WP_CLI::log('- Encoding: ' . $config['png-encoding'] . ' for PNG, ' . $config['jpeg-encoding'] . " for jpeg"); + + if (count($override) == 0) { + \WP_CLI::log('Note that you can override these with --quality=, etc'); + } + \WP_CLI::log(''); + + + $listOptions = BulkConvert::defaultListOptions($config); + if (isset($assoc_args['reconvert'])) { + $listOptions['filter']['only-unconverted'] = false; + } + if (isset($assoc_args['only-png'])) { + $listOptions['filter']['image-types'] = 2; + } + if (isset($assoc_args['only-jpeg'])) { + $listOptions['filter']['image-types'] = 1; + } + + if (!isset($args[0])) { + $groups = BulkConvert::getList($config, $listOptions); + foreach($groups as $group){ + \WP_CLI::log($group['groupName'] . ' contains ' . count($group['files']) . ' ' . + (isset($assoc_args['reconvert']) ? '' : 'unconverted ') . + 'files'); + } + \WP_CLI::log(''); + } else { + $location = $args[0]; + if (strpos($location, '/') === 0) { + $location = substr($location, 1); + } + if (strpos($location, '/') === false) { + $rootId = $location; + $path = '.'; + } else { + list($rootId, $path) = explode('/', $location, 2); + } + + if (!in_array($rootId, Paths::getImageRootIds())) { + \WP_CLI::error( + '"' . $args[0] . '" is not a valid image root. ' . + 'Valid roots are: ' . implode(', ', Paths::getImageRootIds()) + ); + } + + $root = Paths::getAbsDirById($rootId) . '/' . $path; + if (!file_exists($root)) { + \WP_CLI::error( + '"' . $args[0] . '" does not exist. ' + ); + } + $listOptions['root'] = $root; + $groups = [ + [ + 'groupName' => $args[0], + 'root' => $root, + 'files' => BulkConvert::getListRecursively('.', $listOptions) + ] + ]; + if (count($groups[0]['files']) == 0) { + \WP_CLI::log('Nothing to convert in ' . $args[0]); + } + } + + $orgTotalFilesize = 0; + $webpTotalFilesize = 0; + + $converter = null; + $convertOptions = null; + + if (isset($assoc_args['converter'])) { + + $converter = $assoc_args['converter']; + $convertOptions = Config::generateWodOptionsFromConfigObj($config)['webp-convert']['convert']; + + // find the converter + $optionsForThisConverter = null; + foreach ($convertOptions['converters'] as $c) { + if ($c['converter'] == $converter) { + $optionsForThisConverter = (isset($c['options']) ? $c['options'] : []); + break; + } + } + if (!is_array($optionsForThisConverter)) { + \WP_CLI::error('Failed handling options'); + } + + $convertOptions = array_merge($convertOptions, $optionsForThisConverter); + unset($convertOptions['converters']); + } + + foreach($groups as $group){ + if (count($group['files']) == 0) continue; + + \WP_CLI::log('Converting ' . count($group['files']) . ' files in ' . $group['groupName']); + \WP_CLI::log('------------------------------'); + $root = $group['root']; + + $files = array_reverse($group['files']); + //echo count($group["files"]); + foreach($files as $key => $file) + { + $path = trailingslashit($group['root']) . $file; + \WP_CLI::log('Converting: ' . $file); + + $result = Convert::convertFile($path, $config, $convertOptions, $converter); + + if ($result['success']) { + $orgSize = $result['filesize-original']; + $webpSize = $result['filesize-webp']; + + $orgTotalFilesize += $orgSize; + $webpTotalFilesize += $webpSize; + + //$percentage = round(($orgSize - $webpSize)/$orgSize * 100); + $percentage = ($orgSize == 0 ? 100 : round(($webpSize/$orgSize) * 100)); + + \WP_CLI::log( + \WP_CLI::colorize( + "%GOK%n. " . + "Size: " . + ($percentage<90 ? "%G" : ($percentage<100 ? "%Y" : "%R")) . + $percentage . + "% %nof original" . + " (" . self::printableSize($orgSize) . ' => ' . self::printableSize($webpSize) . + ") " + ) + ); + //print_r($result); + } else { + \WP_CLI::log( + \WP_CLI::colorize("%RConversion failed. " . $result['msg'] . "%n") + ); + } + } + } + + if ($orgTotalFilesize > 0) { + $percentage = ($orgTotalFilesize == 0 ? 100 : round(($webpTotalFilesize/$orgTotalFilesize) * 100)); + \WP_CLI::log( + \WP_CLI::colorize( + "Done. " . + "Size of webps: " . + ($percentage<90 ? "%G" : ($percentage<100 ? "%Y" : "%R")) . + $percentage . + "% %nof original" . + " (" . self::printableSize($orgTotalFilesize) . ' => ' . self::printableSize($webpTotalFilesize) . + ") " + ) + ); + } + } + + /** + * Flush webps + * + * ## OPTIONS + * [--only-png] + * : Only flush webps that are conversions of a PNG) + */ + public function flushwebp($args, $assoc_args) + { + $config = Config::loadConfigAndFix(); + + $onlyPng = isset($assoc_args['only-png']); + + if ($onlyPng) { + \WP_CLI::log('Flushing webp files that are conversions of PNG images'); + } else { + \WP_CLI::log('Flushing all webp files'); + } + + $result = CachePurge::purge($config, $onlyPng); + + \WP_CLI::log( + \WP_CLI::colorize("%GFlushed " . $result['delete-count'] . " webp files%n") + ); + if ($result['fail-count'] > 0) { + \WP_CLI::log( + \WP_CLI::colorize("%RFailed deleting " . $result['fail-count'] . " webp files%n") + ); + } + } + + +} diff --git a/lib/classes/CacheMover.php b/lib/classes/CacheMover.php new file mode 100644 index 0000000..2b9b799 --- /dev/null +++ b/lib/classes/CacheMover.php @@ -0,0 +1,235 @@ +'; + echo 'from: ' . $fromDir . '
'; + echo 'to: ' . $toDir . '
'; + echo 'ext:' . $fromExt . ' => ' . $toExt . '
'; + echo '';*/ + + //error_log('move to:' . $toDir . ' ( ' . (file_exists($toDir) ? 'exists' : 'does not exist ') . ')'); + + //self::moveRecursively($toDir, $fromDir, $srcDir, $fromExt, $toExt); + } + + /** + * @return [$numFilesMoved, $numFilesFailedMoving] + */ + public static function moveRecursively($fromDir, $toDir, $srcDir, $fromExt, $toExt) + { + if (!@is_dir($fromDir)) { + return [0, 0]; + } + if (!@file_exists($toDir)) { + // Note: 0777 is default. Default umask is 0022, so the default result is 0755 + if (!@mkdir($toDir, 0777, true)) { + return [0, 0]; + } + } + + $numFilesMoved = 0; + $numFilesFailedMoving = 0; + + //$filenames = @scandir($fromDir); + $fileIterator = new \FilesystemIterator($fromDir); + + //foreach ($filenames as $filename) { + while ($fileIterator->valid()) { + $filename = $fileIterator->getFilename(); + + if (($filename != ".") && ($filename != "..")) { + //$filePerm = FileHelper::filePermWithFallback($filename, 0777); + + if (@is_dir($fromDir . "/" . $filename)) { + list($r1, $r2) = self::moveRecursively($fromDir . "/" . $filename, $toDir . "/" . $filename, $srcDir . "/" . $filename, $fromExt, $toExt); + $numFilesMoved += $r1; + $numFilesFailedMoving += $r2; + + // Remove dir, if its empty. But do not remove dirs in srcDir + if ($fromDir != $srcDir) { + $fileIterator2 = new \FilesystemIterator($fromDir . "/" . $filename); + $dirEmpty = !$fileIterator2->valid(); + if ($dirEmpty) { + @rmdir($fromDir . "/" . $filename); + } + } + } else { + // its a file. + // check if its a webp + if (strpos($filename, '.webp', strlen($filename) - 5) !== false) { + + $filenameWithoutWebp = substr($filename, 0, strlen($filename) - 5); + $srcFilePathWithoutWebp = $srcDir . "/" . $filenameWithoutWebp; + + // check if a corresponding source file exists + $newFilename = null; + if (($fromExt == 'append') && (@file_exists($srcFilePathWithoutWebp))) { + if ($toExt == 'append') { + $newFilename = $filename; + } else { + // remove ".jpg" part of filename (or ".png") + $newFilename = preg_replace("/\.(jpe?g|png)\.webp$/", '.webp', $filename); + } + } elseif ($fromExt == 'set') { + if ($toExt == 'set') { + if ( + @file_exists($srcFilePathWithoutWebp . ".jpg") || + @file_exists($srcFilePathWithoutWebp . ".jpeg") || + @file_exists($srcFilePathWithoutWebp . ".png") + ) { + $newFilename = $filename; + } + } else { + // append + if (@file_exists($srcFilePathWithoutWebp . ".jpg")) { + $newFilename = $filenameWithoutWebp . ".jpg.webp"; + } elseif (@file_exists($srcFilePathWithoutWebp . ".jpeg")) { + $newFilename = $filenameWithoutWebp . ".jpeg.webp"; + } elseif (@file_exists($srcFilePathWithoutWebp . ".png")) { + $newFilename = $filenameWithoutWebp . ".png.webp"; + } + } + } + + if ($newFilename !== null) { + //echo 'moving to: ' . $toDir . '/' .$newFilename . "
"; + $toFilename = $toDir . "/" . $newFilename; + if (@rename($fromDir . "/" . $filename, $toFilename)) { + $numFilesMoved++; + } else { + $numFilesFailedMoving++; + } + } + } + } + } + $fileIterator->next(); + } + return [$numFilesMoved, $numFilesFailedMoving]; + } + +} diff --git a/lib/classes/CachePurge.php b/lib/classes/CachePurge.php new file mode 100644 index 0000000..e7d9697 --- /dev/null +++ b/lib/classes/CachePurge.php @@ -0,0 +1,171 @@ + $onlyPng, + 'only-with-corresponding-original' => false + ]; + + $numDeleted = 0; + $numFailed = 0; + + list($numDeleted, $numFailed) = self::purgeWebPFilesInDir(Paths::getCacheDirAbs(), $filter, $config); + FileHelper::removeEmptySubFolders(Paths::getCacheDirAbs()); + + if ($config['destination-folder'] == 'mingled') { + list($d, $f) = self::purgeWebPFilesInDir(Paths::getUploadDirAbs(), $filter, $config); + + $numDeleted += $d; + $numFailed += $f; + } + + // Now, purge dummy files too + $dir = Paths::getBiggerThanSourceDirAbs(); + self::purgeWebPFilesInDir($dir, $filter, $config); + FileHelper::removeEmptySubFolders($dir); + + return [ + 'delete-count' => $numDeleted, + 'fail-count' => $numFailed + ]; + + //$successInRemovingCacheDir = FileHelper::rrmdir(Paths::getCacheDirAbs()); + + } + + + /** + * Purge webp files in a dir + * Warning: the "only-png" option only works for mingled mode. + * (when not mingled, you can simply delete the whole cache dir instead) + * + * @param $filter. + * only-png: If true, it will only be deleted if extension is .png.webp or a corresponding png exists. + * + * @return [num files deleted, num files failed to delete] + */ + private static function purgeWebPFilesInDir($dir, &$filter, &$config) + { + if (!@file_exists($dir) || !@is_dir($dir)) { + return [0, 0]; + } + + $numFilesDeleted = 0; + $numFilesFailedDeleting = 0; + + $fileIterator = new \FilesystemIterator($dir); + while ($fileIterator->valid()) { + $filename = $fileIterator->getFilename(); + + if (($filename != ".") && ($filename != "..")) { + + if (@is_dir($dir . "/" . $filename)) { + list($r1, $r2) = self::purgeWebPFilesInDir($dir . "/" . $filename, $filter, $config); + $numFilesDeleted += $r1; + $numFilesFailedDeleting += $r2; + } else { + + // its a file + // Run through filters, which each may set "skipThis" to true + + $skipThis = false; + + // filter: It must be a webp + if (!$skipThis && !preg_match('#\.webp$#', $filename)) { + $skipThis = true; + } + + // filter: only with corresponding original + $source = ''; + if (!$skipThis && $filter['only-with-corresponding-original']) { + $source = Convert::findSource($dir . "/" . $filename, $config); + if ($source === false) { + $skipThis = true; + } + } + + // filter: only png + if (!$skipThis && $filter['only-png']) { + + // turn logic around - we skip deletion, unless we deem it a png + $skipThis = true; + + // If extension is "png.webp", its a png + if (preg_match('#\.png\.webp$#', $filename)) { + // its a png + $skipThis = false; + } else { + if (preg_match('#\.jpe?g\.webp$#', $filename)) { + // It is a jpeg, no need to investigate further. + } else { + + if (!$filter['only-with-corresponding-original']) { + $source = Convert::findSource($dir . "/" . $filename, $config); + } + if ($source === false) { + // We could not find corresponding source. + // Should we delete? + // No, I guess we need more evidence, so we skip + // In the future, we could detect mime + } else { + if (preg_match('#\.png$#', $source)) { + // its a png + $skipThis = false; + } + } + } + + } + + } + + if (!$skipThis) { + if (@unlink($dir . "/" . $filename)) { + $numFilesDeleted++; + } else { + $numFilesFailedDeleting++; + } + } + } + } + $fileIterator->next(); + } + return [$numFilesDeleted, $numFilesFailedDeleting]; + } + + public static function processAjaxPurgeCache() + { + + if (!check_ajax_referer('webpexpress-ajax-purge-cache-nonce', 'nonce', false)) { + wp_send_json_error('The security nonce has expired. You need to reload the settings page (press F5) and try again)'); + wp_die(); + } + + $onlyPng = (sanitize_text_field($_POST['only-png']) == 'true'); + + $config = Config::loadConfigAndFix(); + $result = self::purge($config, $onlyPng); + + echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK | JSON_PRETTY_PRINT); + wp_die(); + } +} diff --git a/lib/classes/CapabilityTest.php b/lib/classes/CapabilityTest.php new file mode 100644 index 0000000..c01a689 --- /dev/null +++ b/lib/classes/CapabilityTest.php @@ -0,0 +1,103 @@ +'; + if (!@file_exists(Paths::getWebPExpressPluginDirAbs() . '/htaccess-capability-tests/' . $testDir)) { + // test does not even exist + //echo 'test does not exist: ' . $testDir . '
'; + return null; + } + + if (!@file_exists(Paths::getWebPExpressContentDirAbs() . '/htaccess-capability-tests/' . $testDir)) { + self::copyCapabilityTestsToWpContent(); + } + + // If copy failed, we can use the test in plugin path + if (!@file_exists(Paths::getWebPExpressContentDirAbs() . '/htaccess-capability-tests/' . $testDir)) { + $testUrl = Paths::getContentUrl() . '/' . 'webp-express/htaccess-capability-tests/' . $testDir . '/test.php'; + } else { + $testUrl = Paths::getWebPExpressPluginUrl() . '/' . 'htaccess-capability-tests/' . $testDir . '/test.php'; + } + + //echo 'test url: ' . $testUrl . '
'; + // TODO: Should we test if wp_remote_get exists first? - and if not, include wp-includes/http.php ? + + $response = wp_remote_get($testUrl, ['timeout' => 10]); + //echo '
' . print_r($response, true) . '
'; + if (wp_remote_retrieve_response_code($response) != '200') { + return null; + } + $responseBody = wp_remote_retrieve_body($response); + if ($responseBody == '') { + return null; // Some failure + } + if ($responseBody == '0') { + return false; + } + if ($responseBody == '1') { + return true; + } + return null; + } + + + /** + * Three possible outcomes: true, false or null (null if failed to run test) + */ + public static function modRewriteWorking() + { + return self::runTest('has-mod-rewrite'); + } + + /** + * Three possible outcomes: true, false or null (null if failed to run test) + */ + public static function modHeaderWorking() + { + return self::runTest('has-mod-header'); + } + + /** + * Three possible outcomes: true, false or null (null if failed to run test) + */ + public static function passThroughEnvWorking() + { + return self::runTest('pass-through-environment-var'); + } + + /** + * Three possible outcomes: true, false or null (null if failed to run test) + */ + public static function passThroughHeaderWorking() + { + // pretend it fails because .htaccess rules aren't currently generated correctly + return false; + return self::runTest('pass-server-var-through-header'); + } + +} diff --git a/lib/classes/Config.php b/lib/classes/Config.php new file mode 100644 index 0000000..450fdb5 --- /dev/null +++ b/lib/classes/Config.php @@ -0,0 +1,755 @@ + 'varied-image-responses', + + // general + 'image-types' => 3, + 'destination-folder' => 'separate', + 'destination-extension' => 'append', + 'destination-structure' => (PlatformInfo::isNginx() ? 'doc-root' : 'image-roots'), + 'cache-control' => 'no-header', /* can be "no-header", "set" or "custom" */ + 'cache-control-custom' => 'public, max-age=31536000, stale-while-revalidate=604800, stale-if-error=604800', + 'cache-control-max-age' => 'one-week', + 'cache-control-public' => false, + 'scope' => ['themes', 'uploads'], + 'enable-logging' => false, + 'prevent-using-webps-larger-than-original' => true, + + // redirection rules + 'enable-redirection-to-converter' => true, + 'only-redirect-to-converter-on-cache-miss' => false, + 'only-redirect-to-converter-for-webp-enabled-browsers' => true, + 'do-not-pass-source-in-query-string' => false, // In 0.13 we can remove this. migration7.php depends on it + 'redirect-to-existing-in-htaccess' => true, + 'forward-query-string' => false, + 'enable-redirection-to-webp-realizer' => true, + + // conversion options + 'jpeg-encoding' => 'auto', + 'jpeg-enable-near-lossless' => true, + 'jpeg-near-lossless' => 60, + 'quality-auto' => $qualityAuto, + 'max-quality' => 80, + 'quality-specific' => 70, + + 'png-encoding' => 'auto', + 'png-enable-near-lossless' => true, + 'png-near-lossless' => 60, + 'png-quality' => 85, + 'alpha-quality' => 80, + + 'converters' => [], + 'metadata' => 'none', + //'log-call-arguments' => true, + 'convert-on-upload' => false, + + // serve options + 'fail' => 'original', + 'success-response' => 'converted', + + // alter html options + 'alter-html' => [ + 'enabled' => false, + 'replacement' => 'picture', // "picture" or "url" + 'hooks' => 'ob', // "content-hooks" or "ob" + 'only-for-webp-enabled-browsers' => true, // If true, there will be two HTML versions of each page + 'only-for-webps-that-exists' => false, + 'alter-html-add-picturefill-js' => true, + 'hostname-aliases' => [] + ], + + // web service + 'web-service' => [ + 'enabled' => false, + 'whitelist' => [ + /*[ + 'uid' => '', // for internal purposes + 'label' => '', // ie website name. It is just for display + 'ip' => '', // restrict to these ips. * pattern is allowed. + 'api-key' => '', // Api key for the entry. Not neccessarily unique for the entry + //'quota' => 60 + ] + */ + ] + ], + + 'environment-when-config-was-saved' => [ + 'doc-root-available' => null, // null means unavailable + 'doc-root-resolvable' => null, + 'doc-root-usable-for-structuring' => null, + 'image-roots' => null, + ] + ]; + } + + /** + * Apply operation mode (set the hidden defaults that comes along with the mode) + * @return An altered configuration array + */ + public static function applyOperationMode($config) + { + if (!isset($config['operation-mode'])) { + $config['operation-mode'] = 'varied-image-responses'; + } + + if ($config['operation-mode'] == 'varied-image-responses') { + $config = array_merge($config, [ + //'redirect-to-existing-in-htaccess' => true, // this can now be configured, so do not apply + //'enable-redirection-to-converter' => true, // this can now be configured, so do not apply + 'only-redirect-to-converter-for-webp-enabled-browsers' => true, + 'only-redirect-to-converter-on-cache-miss' => false, + 'do-not-pass-source-in-query-string' => true, // Will be removed in 0.13 + 'fail' => 'original', + 'success-response' => 'converted', + ]); + } elseif ($config['operation-mode'] == 'cdn-friendly') { + $config = array_merge($config, [ + 'redirect-to-existing-in-htaccess' => false, + 'enable-redirection-to-converter' => false, + /* + 'only-redirect-to-converter-for-webp-enabled-browsers' => false, + 'only-redirect-to-converter-on-cache-miss' => true, + */ + 'do-not-pass-source-in-query-string' => true, // Will be removed in 0.13 + 'fail' => 'original', + 'success-response' => 'original', + // cache-control => 'no-header' (we do not need this, as it is not important what it is set to in cdn-friendly mode, and we dont the value to be lost when switching operation mode) + ]); + } elseif ($config['operation-mode'] == 'no-conversion') { + + // TODO: Go through these... + + $config = array_merge($config, [ + 'enable-redirection-to-converter' => false, + 'destination-folder' => 'mingled', + 'enable-redirection-to-webp-realizer' => false, + ]); + $config['alter-html']['only-for-webps-that-exists'] = true; + $config['web-service']['enabled'] = false; + $config['scope'] = ['uploads']; + + } + + return $config; + } + + /** + * Fix config. + * + * Among other things, the config is merged with default config, to ensure all options are present + * + */ + public static function fix($config, $checkQualityDetection = true) + { + if ($config === false) { + $config = self::getDefaultConfig(!$checkQualityDetection); + } else { + if ($checkQualityDetection) { + if (isset($config['quality-auto']) && ($config['quality-auto'])) { + $qualityDetectionWorking = TestRun::isLocalQualityDetectionWorking(); + if (!TestRun::isLocalQualityDetectionWorking()) { + $config['quality-auto'] = false; + } + } + } + $defaultConfig = self::getDefaultConfig(true); + $config = array_merge($defaultConfig, $config); + + // Make sure new defaults below "alter-html" are added into the existing array + // (note that this will not remove old unused properties, if some key should become obsolete) + $config['alter-html'] = array_replace_recursive($defaultConfig['alter-html'], $config['alter-html']); + + // Make sure new defaults below "environment-when-config-was-saved" are added into the existing array + $config['environment-when-config-was-saved'] = array_replace_recursive($defaultConfig['environment-when-config-was-saved'], $config['environment-when-config-was-saved']); + } + + if (!isset($config['base-htaccess-on-these-capability-tests'])) { + self::runAndStoreCapabilityTests($config); + } + + // Apparently, migrate7 did not fix old "operation-mode" values for all. + // So fix here + if ($config['operation-mode'] == 'just-redirect') { + $config['operation-mode'] = 'no-conversion'; + } + if ($config['operation-mode'] == 'no-varied-responses') { + $config['operation-mode'] = 'cdn-friendly'; + } + if ($config['operation-mode'] == 'varied-responses') { + $config['operation-mode'] = 'varied-image-responses'; + } + + // In case doc root no longer can be used, use image-roots + // Or? No, changing here will not fix it for WebPOnDemand.php. + // An invalid setting requires that config is saved again and .htaccess files regenerated. + /* + if (($config['operation-mode'] == 'doc-root') && (!Paths::canUseDocRootForRelPaths())) { + $config['destination-structure'] = 'image-roots'; + }*/ + + $config = self::applyOperationMode($config); + + // Fix scope: Remove invalid and put in correct order + $fixedScope = []; + foreach (Paths::getImageRootIds() as $rootId) { + if (in_array($rootId, $config['scope'])) { + $fixedScope[] = $rootId; + } + } + $config['scope'] = $fixedScope; + + if (!isset($config['web-service'])) { + $config['web-service'] = [ + 'enabled' => false + ]; + } + if (!is_array($config['web-service']['whitelist'])) { + $config['web-service']['whitelist'] = []; + } + // remove whitelist entries without required fields (label, ip) + $config['web-service']['whitelist'] = array_filter($config['web-service']['whitelist'], function($var) { + return (isset($var['label']) && (isset($var['ip']))); + }); + + if (($config['cache-control'] == 'set') && ($config['cache-control-max-age'] == '')) { + $config['cache-control-max-age'] = 'one-week'; + } + + /*if (is_null($config['alter-html']['hostname-aliases'])) { + $config['alter-html']['hostname-aliases'] = []; + }*/ + + if (!is_array($config['converters'])) { + $config['converters'] = []; + } + + if (count($config['converters']) > 0) { + // merge missing converters in + $config['converters'] = ConvertersHelper::mergeConverters( + $config['converters'], + ConvertersHelper::$defaultConverters + ); + } else { + // This is first time visit! + $config['converters'] = ConvertersHelper::$defaultConverters; + } + + + return $config; + } + + + public static function runAndStoreCapabilityTests(&$config) + { + $config['base-htaccess-on-these-capability-tests'] = [ + 'passThroughHeaderWorking' => HTAccessCapabilityTestRunner::passThroughHeaderWorking(), + 'passThroughEnvWorking' => HTAccessCapabilityTestRunner::passThroughEnvWorking(), + 'modHeaderWorking' => HTAccessCapabilityTestRunner::modHeaderWorking(), + //'grantAllAllowed' => HTAccessCapabilityTestRunner::grantAllAllowed(), + 'canRunTestScriptInWOD' => HTAccessCapabilityTestRunner::canRunTestScriptInWOD(), + 'canRunTestScriptInWOD2' => HTAccessCapabilityTestRunner::canRunTestScriptInWOD2(), + ]; + } + + /** + * Loads Config (if available), fills in the rest with defaults + * also applies operation mode. + * If config is not saved yet, the default config will be returned + */ + public static function loadConfigAndFix($checkQualityDetection = true) + { + // PS: Yes, loadConfig may return false. "fix" handles this by returning default config + return self::fix(Config::loadConfig(), $checkQualityDetection); + } + + /** + * Run a fresh test on all converters and update their statuses in the config. + * + * @param object config to be updated + * @return object Updated config + */ + public static function updateConverterStatusWithFreshTest($config) { + // Test converters + $testResult = TestRun::getConverterStatus(); + + // Set "working" and "error" properties + if ($testResult) { + foreach ($config['converters'] as &$converter) { + $converterId = $converter['converter']; + $hasError = isset($testResult['errors'][$converterId]); + $hasWarning = isset($testResult['warnings'][$converterId]); + $working = !$hasError; + + /* + Don't print this stuff here. It can end up in the head tag. + TODO: Move it somewhere + if (isset($converter['working']) && ($converter['working'] != $working)) { + + // TODO: webpexpress_converterName($converterId) + if ($working) { + Messenger::printMessage( + 'info', + 'Hurray! - The ' . $converterId . ' conversion method is working now!' + ); + } else { + Messenger::printMessage( + 'warning', + 'Sad news. The ' . $converterId . ' conversion method is not working anymore. What happened?' + ); + } + } + */ + $converter['working'] = $working; + if ($hasError) { + $error = $testResult['errors'][$converterId]; + if ($converterId == 'wpc') { + if (preg_match('/Missing URL/', $error)) { + $error = 'Not configured'; + } + if ($error == 'No remote host has been set up') { + $error = 'Not configured'; + } + + if (preg_match('/cloud service is not enabled/', $error)) { + $error = 'The server is not enabled. Click the "Enable web service" on WebP Express settings on the site you are trying to connect to.'; + } + } + $converter['error'] = $error; + } else { + unset($converter['error']); + } + if ($hasWarning) { + $converter['warnings'] = $testResult['warnings'][$converterId]; + } + } + } + return $config; + } + + + public static $configForOptionsPage = null; // cache the result (called twice, - also in enqueue_scripts) + public static function getConfigForOptionsPage() + { + if (isset(self::$configForOptionsPage)) { + return self::$configForOptionsPage; + } + + + $config = self::loadConfigAndFix(); + + // Remove keys in whitelist (so they cannot easily be picked up by examining the html) + foreach ($config['web-service']['whitelist'] as &$whitelistEntry) { + unset($whitelistEntry['api-key']); + } + + // Remove keys from WPC converters + foreach ($config['converters'] as &$converter) { + if (isset($converter['converter']) && ($converter['converter'] == 'wpc')) { + if (isset($converter['options']['api-key'])) { + if ($converter['options']['api-key'] != '') { + $converter['options']['_api-key-non-empty'] = true; + } + unset($converter['options']['api-key']); + } + } + } + + if ($config['operation-mode'] != 'no-conversion') { + $config = self::updateConverterStatusWithFreshTest($config); + } + + self::$configForOptionsPage = $config; // cache the result + return $config; + } + + public static function isConfigFileThere() + { + return (FileHelper::fileExists(Paths::getConfigFileName())); + } + + public static function isConfigFileThereAndOk() + { + return (self::loadConfig() !== false); + } + + public static function loadWodOptions() + { + return FileHelper::loadJSONOptions(Paths::getWodOptionsFileName()); + } + + /** + * Some of the options in config needs to be quickly accessible + * These are stored in wordpress autoloaded options + */ + public static function updateAutoloadedOptions($config) + { + $config = self::fix($config, false); + + Option::updateOption('webp-express-alter-html', $config['alter-html']['enabled'], true); + Option::updateOption('webp-express-alter-html-hooks', $config['alter-html']['hooks'], true); + Option::updateOption('webp-express-alter-html-replacement', $config['alter-html']['replacement'], true); + Option::updateOption('webp-express-alter-html-add-picturefill-js', (($config['alter-html']['replacement'] == 'picture') && (isset($config['alter-html']['alter-html-add-picturefill-js']) && $config['alter-html']['alter-html-add-picturefill-js'])), true); + + + //Option::updateOption('webp-express-alter-html', $config['alter-html']['enabled'], true); + + $obj = $config['alter-html']; + unset($obj['enabled']); + $obj['destination-folder'] = $config['destination-folder']; + $obj['destination-extension'] = $config['destination-extension']; + $obj['destination-structure'] = $config['destination-structure']; + $obj['scope'] = $config['scope']; + $obj['image-types'] = $config['image-types']; // 0=none,1=jpg, 2=png, 3=both + $obj['prevent-using-webps-larger-than-original'] = $config['prevent-using-webps-larger-than-original']; + + Option::updateOption( + 'webp-express-alter-html-options', + json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK), + true + ); + } + + /** + * Save configuration file. Also updates autoloaded options (such as alter html options) + */ + public static function saveConfigurationFile($config) + { + $config['paths-used-in-htaccess'] = [ + 'wod-url-path' => Paths::getWodUrlPath(), + ]; + + if (Paths::createConfigDirIfMissing()) { + $success = FileHelper::saveJSONOptions(Paths::getConfigFileName(), $config); + if ($success) { + State::setState('configured', true); + self::updateAutoloadedOptions($config); + } + + return $success; + } + return false; + } + + public static function getCacheControlHeader($config) { + $cacheControl = $config['cache-control']; + switch ($cacheControl) { + case 'custom': + return $config['cache-control-custom']; + case 'no-header': + return ''; + default: + $public = (isset($config['cache-control-public']) ? $config['cache-control-public'] : true); + $maxAge = (isset($config['cache-control-max-age']) ? $config['cache-control-max-age'] : $cacheControl); + $maxAgeOptions = [ + '' => 'max-age=604800', // it has happened, but I don't think it can happen again... + 'one-second' => 'max-age=1', + 'one-minute' => 'max-age=60', + 'one-hour' => 'max-age=3600', + 'one-day' => 'max-age=86400', + 'one-week' => 'max-age=604800', + 'one-month' => 'max-age=2592000', + 'one-year' => 'max-age=31536000', + ]; + return ($public ? 'public, ' : 'private, ') . $maxAgeOptions[$maxAge]; + } + + } + + public static function generateWodOptionsFromConfigObj($config) + { + + // WebP convert options + // -------------------- + $wc = [ + 'converters' => [] + ]; + + // Add active converters + foreach ($config['converters'] as $converter) { + if (isset($converter['deactivated']) && ($converter['deactivated'])) { + continue; + } + $wc['converters'][] = $converter; + } + + // Clean the converter options from junk + foreach ($wc['converters'] as &$c) { + + // In cwebp converter options (here in webp express), we have a checkbox "set size" + // - there is no such option in webp-convert - so remove. + if ($c['converter'] == 'cwebp') { + if (isset($c['options']['set-size']) && $c['options']['set-size']) { + unset($c['options']['set-size']); + } else { + unset($c['options']['set-size']); + unset($c['options']['size-in-percentage']); + } + } + + if ($c['converter'] == 'ewww') { + $c['options']['check-key-status-before-converting'] = false; + } + + // 'id', 'working' and 'error' attributes are used internally in webp-express, + // no need to have it in the wod configuration file. + unset ($c['id']); + unset($c['working']); + unset($c['error']); + + if (isset($c['options']['quality']) && ($c['options']['quality'] == 'inherit')) { + unset ($c['options']['quality']); + } + /* + if (!isset($c['options'])) { + $c = $c['converter']; + }*/ + } + + // Create jpeg options + // https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#png-og-jpeg-specific-options + + $auto = (isset($config['quality-auto']) && $config['quality-auto']); + $wc['jpeg'] = [ + 'encoding' => $config['jpeg-encoding'], + 'quality' => ($auto ? 'auto' : $config['quality-specific']), + ]; + if ($auto) { + $wc['jpeg']['default-quality'] = $config['quality-specific']; + $wc['jpeg']['max-quality'] = $config['max-quality']; + } + if ($config['jpeg-encoding'] != 'lossy') { + if ($config['jpeg-enable-near-lossless']) { + $wc['jpeg']['near-lossless'] = $config['jpeg-near-lossless']; + } else { + $wc['jpeg']['near-lossless'] = 100; + } + } + + // Create png options + // --- + $wc['png'] = [ + 'encoding' => $config['png-encoding'], + 'quality' => $config['png-quality'], + ]; + if ($config['png-encoding'] != 'lossy') { + if ($config['png-enable-near-lossless']) { + $wc['png']['near-lossless'] = $config['png-near-lossless']; + } else { + $wc['png']['near-lossless'] = 100; + } + } + if ($config['png-encoding'] != 'lossless') { + // Only relevant for pngs, and only for "lossy" (and thus also "auto") + $wc['png']['alpha-quality'] = $config['alpha-quality']; + } + + // Other convert options + $wc['metadata'] = $config['metadata']; + $wc['log-call-arguments'] = true; // $config['log-call-arguments']; + + // Serve options + // ------------- + $serve = [ + 'serve-image' => [ + 'headers' => [ + 'cache-control' => false, + 'content-length' => true, + 'content-type' => true, + 'expires' => false, + 'last-modified' => true, + //'vary-accept' => false // This must be different for webp-on-demand and webp-realizer + ] + ] + ]; + if ($config['cache-control'] != 'no-header') { + $serve['serve-image']['cache-control-header'] = self::getCacheControlHeader($config); + $serve['serve-image']['headers']['cache-control'] = true; + $serve['serve-image']['headers']['expires'] = true; + } + $serve['fail'] = $config['fail']; + + + // WOD options + // ------------- + $wod = [ + 'enable-logging' => $config['enable-logging'], + 'enable-redirection-to-converter' => $config['enable-redirection-to-converter'], + 'enable-redirection-to-webp-realizer' => $config['enable-redirection-to-webp-realizer'], + 'base-htaccess-on-these-capability-tests' => $config['base-htaccess-on-these-capability-tests'], + 'destination-extension' => $config['destination-extension'], + 'destination-folder' => $config['destination-folder'], + 'forward-query-string' => $config['forward-query-string'], + //'method-for-passing-source' => $config['method-for-passing-source'], + 'image-roots' => Paths::getImageRootsDef(), + 'success-response' => $config['success-response'], + ]; + + + // Put it all together + // ------------- + + //$options = array_merge($wc, $serve, $wod); + + // I'd like to put the webp-convert options in its own key, + // but it requires some work. Postponing it to another day that I can uncomment the two next lines (and remove the one above) + //$wc = array_merge($wc, $serve); + //$options = array_merge($wod, ['webp-convert' => $wc]); + + //$options = array_merge($wod, array_merge($serve, ['conversion' => $wc])); + + $options = [ + 'wod' => $wod, + 'webp-convert' => array_merge($serve, ['convert' => $wc]) + ]; + + + return $options; + } + + public static function saveWodOptionsFile($options) + { + if (Paths::createConfigDirIfMissing()) { + return FileHelper::saveJSONOptions(Paths::getWodOptionsFileName(), $options); + } + return false; + } + + + /** + * Save both configuration files, but do not update htaccess + * Returns success (boolean) + */ + public static function saveConfigurationFileAndWodOptions($config) + { + if (!isset($config['base-htaccess-on-these-capability-tests'])) { + self::runAndStoreCapabilityTests($config); + } + if (!(self::saveConfigurationFile($config))) { + return false; + } + $options = self::generateWodOptionsFromConfigObj($config); + return (self::saveWodOptionsFile($options)); + } + + /** + * Regenerate config and .htaccess files + * + * It will only happen if configuration file exists. So the method is meant for updating - ie upon migration. + * It updates: + * - config files (both) - and ensures that capability tests have been run + * - autoloaded options (such as alter html options) + * - .htaccess files (all) + */ + public static function regenerateConfigAndHtaccessFiles() { + self::regenerateConfig(true); + } + + /** + * Regenerate config and .htaccess files + * + * It will only happen if configuration file exists. So the method is meant for updating - ie upon migration. + * It updates: + * - config files (both) - and ensures that capability tests have been run + * - autoloaded options (such as alter html options) + * - .htaccess files - but only if needed due to configuration changes + */ + public static function regenerateConfig($forceRuleUpdating = false) { + if (!self::isConfigFileThere()) { + return; + } + $config = self::loadConfig(); + $config = self::fix($config, false); // fix. We do not need examining if quality detection is working + if ($config === false) { + return; + } + self::saveConfigurationAndHTAccess($config, $forceRuleUpdating); + } + + /** + * + * $rewriteRulesNeedsUpdate: + */ + public static function saveConfigurationAndHTAccess($config, $forceRuleUpdating = false) + { + // Important to do this check before saving config, because the method + // compares against existing config. + + if ($forceRuleUpdating) { + $rewriteRulesNeedsUpdate = true; + } else { + $rewriteRulesNeedsUpdate = HTAccessRules::doesRewriteRulesNeedUpdate($config); + } + + if (!isset($config['base-htaccess-on-these-capability-tests']) || $rewriteRulesNeedsUpdate) { + self::runAndStoreCapabilityTests($config); + } + + if (self::saveConfigurationFile($config)) { + $options = self::generateWodOptionsFromConfigObj($config); + if (self::saveWodOptionsFile($options)) { + if ($rewriteRulesNeedsUpdate) { + $rulesResult = HTAccess::saveRules($config, false); + return [ + 'saved-both-config' => true, + 'saved-main-config' => true, + 'rules-needed-update' => true, + 'htaccess-result' => $rulesResult + ]; + } + else { + $rulesResult = HTAccess::saveRules($config, false); + return [ + 'saved-both-config' => true, + 'saved-main-config' => true, + 'rules-needed-update' => false, + 'htaccess-result' => $rulesResult + ]; + } + } else { + return [ + 'saved-both-config' => false, + 'saved-main-config' => true, + ]; + } + } else { + return [ + 'saved-both-config' => false, + 'saved-main-config' => false, + ]; + } + } + + public static function getConverterByName($config, $converterName) + { + foreach ($config['converters'] as $i => $converter) { + if ($converter['converter'] == $converterName) { + return $converter; + } + } + } + +} diff --git a/lib/classes/Convert.php b/lib/classes/Convert.php new file mode 100644 index 0000000..250cd33 --- /dev/null +++ b/lib/classes/Convert.php @@ -0,0 +1,349 @@ + false, + 'msg' => 'Source file does not exist: ' . $source, + 'log' => '', + ]; + } + + $source = SanityCheck::absPathExistsAndIsFile($source); + //$filename = SanityCheck::absPathExistsAndIsFileInDocRoot($source); + // PS: No need to check mime type as the WebPConvert library does that (it only accepts image/jpeg and image/png) + + // Check that source is within a valid image root + $activeRootIds = Paths::getImageRootIds(); // Currently, root ids cannot be selected, so all root ids are active. + $rootId = Paths::findImageRootOfPath($source, $activeRootIds); + if ($rootId === false) { + throw new \Exception('Path of source is not within a valid image root'); + } + + // Check config + // -------------- + $checking = 'configuration file'; + if (is_null($config)) { + $config = Config::loadConfigAndFix(); // ps: if this fails to load, default config is returned. + } + if (!is_array($config)) { + throw new SanityException('configuration file is corrupt'); + } + + // Check convert options + // ------------------------------- + $checking = 'configuration file (options)'; + if (is_null($convertOptions)) { + $wodOptions = Config::generateWodOptionsFromConfigObj($config); + if (!isset($wodOptions['webp-convert']['convert'])) { + throw new SanityException('conversion options are missing'); + } + $convertOptions = $wodOptions['webp-convert']['convert']; + } + if (!is_array($convertOptions)) { + throw new SanityException('conversion options are missing'); + } + + + // Check destination + // ------------------------------- + $checking = 'destination'; + $destination = self::getDestination($source, $config); + + $destination = SanityCheck::absPath($destination); + + // Check log dir + // ------------------------------- + $checking = 'conversion log dir'; + if (isset($config['enable-logging']) && $config['enable-logging']) { + $logDir = SanityCheck::absPath(Paths::getWebPExpressContentDirAbs() . '/log'); + } else { + $logDir = null; + } + + + } catch (\Exception $e) { + return [ + 'success' => false, + 'msg' => 'Check failed for ' . $checking . ': '. $e->getMessage(), + 'log' => '', + ]; + } + + // Done with sanitizing, lets get to work! + // --------------------------------------- +//return false; + $result = ConvertHelperIndependent::convert($source, $destination, $convertOptions, $logDir, $converter); + +//error_log('looki:' . $source . $converter); + // If we are using stack converter, check if Ewww discovered invalid api key + //if (is_null($converter)) { + if (isset(Ewww::$nonFunctionalApiKeysDiscoveredDuringConversion)) { + // We got an invalid or exceeded api key (at least one). + //error_log('look:' . print_r(Ewww::$nonFunctionalApiKeysDiscoveredDuringConversion, true)); + EwwwTools::markApiKeysAsNonFunctional( + Ewww::$nonFunctionalApiKeysDiscoveredDuringConversion, + Paths::getConfigDirAbs() + ); + } + //} + + self::updateBiggerThanOriginalMark($source, $destination, $config); + + if ($result['success'] === true) { + $result['filesize-original'] = @filesize($source); + $result['filesize-webp'] = @filesize($destination); + $result['destination-path'] = $destination; + + $destinationOptions = DestinationOptions::createFromConfig($config); + + $rootOfDestination = Paths::destinationRoot($rootId, $destinationOptions); + + $relPathFromImageRootToSource = PathHelper::getRelDir( + realpath(Paths::getAbsDirById($rootId)), + realpath($source) + ); + $relPathFromImageRootToDest = ConvertHelperIndependent::appendOrSetExtension( + $relPathFromImageRootToSource, + $config['destination-folder'], + $config['destination-extension'], + ($rootId == 'uploads') + ); + + $result['destination-url'] = $rootOfDestination['url'] . '/' . $relPathFromImageRootToDest; + } + return $result; + } + + /** + * Determine the location of a source from the location of a destination. + * + * If for example Operation mode is set to "mingled" and extension is set to "Append .webp", + * the result of looking passing "/path/to/logo.jpg.webp" will be "/path/to/logo.jpg". + * + * Additionally, it is tested if the source exists. If not, false is returned. + * The destination does not have to exist. + * + * @return string|null The source path corresponding to a destination path + * - or false on failure (if the source does not exist or $destination is not sane) + * + */ + public static function findSource($destination, &$config = null) + { + try { + // Check that destination path is sane and inside document root + $destination = SanityCheck::absPathIsInDocRoot($destination); + } catch (SanityException $e) { + return false; + } + + // Load config if not already loaded + if (is_null($config)) { + $config = Config::loadConfigAndFix(); + } + + return ConvertHelperIndependent::findSource( + $destination, + $config['destination-folder'], + $config['destination-extension'], + $config['destination-structure'], + Paths::getWebPExpressContentDirAbs(), + new ImageRoots(Paths::getImageRootsDef()) + ); + } + + public static function processAjaxConvertFile() + { + + if (!check_ajax_referer('webpexpress-ajax-convert-nonce', 'nonce', false)) { + //if (true) { + //wp_send_json_error('The security nonce has expired. You need to reload the settings page (press F5) and try again)'); + //wp_die(); + + $result = [ + 'success' => false, + 'msg' => 'The security nonce has expired. You need to reload the settings page (press F5) and try again)', + 'stop' => true + ]; + + echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK | JSON_PRETTY_PRINT); + wp_die(); + } + + // Check input + // -------------- + try { + // Check "filename" + $checking = '"filename" argument'; + Validate::postHasKey('filename'); + + $filename = sanitize_text_field(stripslashes($_POST['filename'])); + + // holy moly! Wordpress automatically adds slashes to the global POST vars - https://stackoverflow.com/questions/2496455/why-are-post-variables-getting-escaped-in-php + $filename = wp_unslash($_POST['filename']); + + //$filename = SanityCheck::absPathExistsAndIsFileInDocRoot($filename); + // PS: No need to check mime version as webp-convert does that. + + + // Check converter id + // --------------------- + $checking = '"converter" argument'; + if (isset($_POST['converter'])) { + $converterId = sanitize_text_field($_POST['converter']); + Validate::isConverterId($converterId); + } + + + // Check "config-overrides" + // --------------------------- + $checking = '"config-overrides" argument'; + if (isset($_POST['config-overrides'])) { + $configOverridesJSON = SanityCheck::noControlChars($_POST['config-overrides']); + $configOverridesJSON = preg_replace('/\\\\"/', '"', $configOverridesJSON); // We got crazy encoding, perhaps by jQuery. This cleans it up + + $configOverridesJSON = SanityCheck::isJSONObject($configOverridesJSON); + $configOverrides = json_decode($configOverridesJSON, true); + + // PS: We do not need to validate the overrides. + // webp-convert checks all options. Nothing can be passed to webp-convert which causes harm. + } + + } catch (SanityException $e) { + wp_send_json_error('Sanitation check failed for ' . $checking . ': '. $e->getMessage()); + wp_die(); + } catch (ValidateException $e) { + wp_send_json_error('Validation failed for ' . $checking . ': '. $e->getMessage()); + wp_die(); + } + + + // Input has been processed, now lets get to work! + // ----------------------------------------------- + if (isset($configOverrides)) { + $config = Config::loadConfigAndFix(); + + + // convert using specific converter + if (!is_null($converterId)) { + + // Merge in the config-overrides (config-overrides only have effect when using a specific converter) + $config = array_merge($config, $configOverrides); + + $converter = ConvertersHelper::getConverterById($config, $converterId); + if ($converter === false) { + wp_send_json_error('Converter could not be loaded'); + wp_die(); + } + + // the converter options stored in config.json is not precisely the same as the ones + // we send to webp-convert. + // We need to "regenerate" webp-convert options in order to use the ones specified in the config-overrides + // And we need to merge the general options (such as quality etc) into the option for the specific converter + + $generalWebpConvertOptions = Config::generateWodOptionsFromConfigObj($config)['webp-convert']['convert']; + $converterSpecificWebpConvertOptions = isset($converter['options']) ? $converter['options'] : []; + + $webpConvertOptions = array_merge($generalWebpConvertOptions, $converterSpecificWebpConvertOptions); + unset($webpConvertOptions['converters']); + + // what is this? - I forgot why! + //$config = array_merge($config, $converter['options']); + $result = self::convertFile($filename, $config, $webpConvertOptions, $converterId); + + } else { + $result = self::convertFile($filename, $config); + } + } else { + $result = self::convertFile($filename); + } + + $nonceTick = wp_verify_nonce($_REQUEST['nonce'], 'webpexpress-ajax-convert-nonce'); + if ($nonceTick == 2) { + $result['new-convert-nonce'] = wp_create_nonce('webpexpress-ajax-convert-nonce'); + // wp_create_nonce('webpexpress-ajax-convert-nonce') + } + + $result['nonce-tick'] = $nonceTick; + + + $result = self::utf8ize($result); + + echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK | JSON_PRETTY_PRINT); + + wp_die(); + } + + private static function utf8ize($d) { + if (is_array($d)) { + foreach ($d as $k => $v) { + $d[$k] = self::utf8ize($v); + } + } else if (is_string ($d)) { + return utf8_encode($d); + } + return $d; + } +} diff --git a/lib/classes/ConvertHelperIndependent.php b/lib/classes/ConvertHelperIndependent.php new file mode 100644 index 0000000..2074f68 --- /dev/null +++ b/lib/classes/ConvertHelperIndependent.php @@ -0,0 +1,739 @@ +getArray() as $i => $imageRoot) { + // in $obj, "rel-path" is only set when document root can be used for relative paths. + // So, if it is set, we can use it (beware: we cannot neccessarily use realpath on document root, + // but we do not need to - see the long comment in Paths::canUseDocRootForRelPaths()) + + $rootPath = $imageRoot->getAbsPath(); + /* + if (isset($obj['rel-path'])) { + $docRoot = rtrim($_SERVER["DOCUMENT_ROOT"], '/'); + $rootPath = $docRoot . '/' . $obj['rel-path']; + } else { + // If "rel-path" isn't set, then abs-path is, and we can use that. + $rootPath = $obj['abs-path']; + }*/ + + // $source may be resolved or not. Same goes for $rootPath. + // We can assume that $rootPath is resolvable using realpath (it ought to exist and be within open_basedir for WP to function) + // We can also assume that $source is resolvable (it ought to exist and within open_basedir) + // So: Resolve both! and test if the resolved source begins with the resolved rootPath. + if (strpos($sourceResolved, realpath($rootPath)) !== false) { + $relPath = substr($sourceResolved, strlen(realpath($rootPath)) + 1); + $relPath = self::appendOrSetExtension($relPath, $destinationFolder, $destinationExt, false); + + $destination = $webExpressContentDirAbs . '/webp-images/' . $imageRoot->id . '/' . $relPath; + break; + } + } + if ($destination == '') { + return false; + } + } + } + + } catch (SanityException $e) { + return false; + } + + return $destination; + } + + + /** + * Find source corresponding to destination, separate. + * + * We can rely on destinationExt being "append" for separate. + * Returns false if source file is not found or if a path is not sane. Otherwise returns path to source + * destination does not have to exist. + * + * @param string $destination Path to destination file (does not have to exist) + * @param string $destinationStructure "doc-root" or "image-roots" + * @param string $webExpressContentDirAbs + * @param ImageRoots $imageRoots An image roots object + * + * @return string|false Returns path to source, if found. If not - or a path is not sane, false is returned + */ + private static function findSourceSeparate($destination, $destinationStructure, $webExpressContentDirAbs, $imageRoots) + { + try { + + if ($destinationStructure == 'doc-root') { + + // Check that destination path is sane and inside document root + // -------------------------- + $destination = SanityCheck::absPathIsInDocRoot($destination); + + + // Check that calculated image root is sane and inside document root + // -------------------------- + $imageRoot = SanityCheck::absPathIsInDocRoot($webExpressContentDirAbs . '/webp-images/doc-root'); + + + // Calculate source and check that it is sane and exists + // ----------------------------------------------------- + + // TODO: This does not work on Windows yet. + if (strpos($destination, $imageRoot . '/') === 0) { + + // "Eat" the left part off the $destination parameter. $destination is for example: + // "/var/www/webp-express-tests/we0/wp-content-moved/webp-express/webp-images/doc-root/wordpress/uploads-moved/2018/12/tegning5-300x265.jpg.webp" + // We also eat the slash (+1) + $sourceRel = substr($destination, strlen($imageRoot) + 1); + + $docRoot = rtrim(realpath($_SERVER["DOCUMENT_ROOT"]), '/'); + $source = $docRoot . '/' . $sourceRel; + $source = preg_replace('/\\.(webp)$/', '', $source); + } else { + // Try with symlinks resolved + // This is not trivial as this must also work when the destination path doesn't exist, and + // realpath can only be used to resolve symlinks for files that exists. + // But here is how we achieve it anyway: + // + // 1. We make sure imageRoot exists (if not, create it) - this ensures that we can resolve it. + // 2. Find closest folder existing folder (resolved) of destination - using PathHelper::findClosestExistingFolderSymLinksExpanded() + // 3. Test that resolved closest existing folder starts with resolved imageRoot + // 4. If it does, we could create a dummy file at the destination to get its real path, but we want to avoid that, so instead + // we can create the containing directory. + // 5. We can now use realpath to get the resolved path of the containing directory. The rest is simple enough. + if (!file_exists($imageRoot)) { + mkdir($imageRoot, 0777, true); + } + $closestExistingResolved = PathHelper::findClosestExistingFolderSymLinksExpanded($destination); + if ($closestExistingResolved == '') { + return false; + } else { + $imageRootResolved = realpath($imageRoot); + if (strpos($closestExistingResolved . '/', $imageRootResolved . '/') === 0) { +// echo $destination . '
' . $closestExistingResolved . '
' . $imageRootResolved . '/'; exit; + // Create containing dir for destination + $containingDir = PathHelper::dirname($destination); + if (!file_exists($containingDir)) { + mkdir($containingDir, 0777, true); + } + $containingDirResolved = realpath($containingDir); + + $filename = PathHelper::basename($destination); + $destinationResolved = $containingDirResolved . '/' . $filename; + + $sourceRel = substr($destinationResolved, strlen($imageRootResolved) + 1); + + $docRoot = rtrim(realpath($_SERVER["DOCUMENT_ROOT"]), '/'); + $source = $docRoot . '/' . $sourceRel; + $source = preg_replace('/\\.(webp)$/', '', $source); + return $source; + } else { + return false; + } + } + } + + return SanityCheck::absPathExistsAndIsFileInDocRoot($source); + } else { + + // Mission: To find source corresponding to destination (separate) - using the "image-roots" structure. + + // How can we do that? + // We got the destination (unresolved) - ie '/website-symlinked/wp-content/webp-express/webp-images/uploads/2018/07/hello.jpg.webp' + // If we were lazy and unprecise, we could simply: + // - search for "webp-express/webp-images/" + // - strip anything before that - result: 'uploads/2018/07/hello.jpg.webp' + // - the first path component is the root id. + // - the rest of the path is the relative path to the source - if we strip the ".webp" ending + + // So, are we lazy? - what is the alternative? + // - Get closest existing resolved folder of destination (ie "/var/www/website/wp-content-moved/webp-express/webp-images/wp-content") + // - Check if that folder is below the cache root (resolved) (cache root is the "wp-content" image root + 'webp-express/webp-images') + // - Create dir for destination (if missing) + // - We can now resolve destination. With cache root also being resolved, we can get the relative dir. + // ie 'uploads/2018/07/hello.jpg.webp'. + // The first path component is the root id, the rest is the relative path to the source. + + $closestExistingResolved = PathHelper::findClosestExistingFolderSymLinksExpanded($destination); + $cacheRoot = $webExpressContentDirAbs . '/webp-images'; + if ($closestExistingResolved == '') { + return false; + } else { + $cacheRootResolved = realpath($cacheRoot); + if (strpos($closestExistingResolved . '/', $cacheRootResolved . '/') === 0) { + + // Create containing dir for destination + $containingDir = PathHelper::dirname($destination); + if (!file_exists($containingDir)) { + mkdir($containingDir, 0777, true); + } + $containingDirResolved = realpath($containingDir); + + $filename = PathHelper::basename($destination); + $destinationResolved = $containingDirResolved . '/' . $filename; + $destinationRelToCacheRoot = substr($destinationResolved, strlen($cacheRootResolved) + 1); + + $parts = explode('/', $destinationRelToCacheRoot); + $imageRoot = array_shift($parts); + $sourceRel = implode('/', $parts); + + $source = $imageRoots->byId($imageRoot)->getAbsPath() . '/' . $sourceRel; + $source = preg_replace('/\\.(webp)$/', '', $source); + return $source; + } else { + return false; + } + } + return false; + } + } catch (SanityException $e) { + return false; + } + + return $source; + } + + /** + * Find source corresponding to destination (mingled) + * Returns false if not found. Otherwise returns path to source + * + * @param string $destination Path to destination file (does not have to exist) + * @param string $destinationExt Extension ('append' or 'set') + * @param string $destinationStructure "doc-root" or "image-roots" + * + * @return string|false Returns path to source, if found. If not - or a path is not sane, false is returned + */ + private static function findSourceMingled($destination, $destinationExt, $destinationStructure) + { + try { + + if ($destinationStructure == 'doc-root') { + // Check that destination path is sane and inside document root + // -------------------------- + $destination = SanityCheck::absPathIsInDocRoot($destination); + } else { + // The following will fail if path contains directory traversal. TODO: Is that ok? + $destination = SanityCheck::absPath($destination); + } + + // Calculate source and check that it is sane and exists + // ----------------------------------------------------- + if ($destinationExt == 'append') { + $source = preg_replace('/\\.(webp)$/', '', $destination); + } else { + $source = preg_replace('#\\.webp$#', '.jpg', $destination); + // TODO! + // Also check for "Jpeg", "JpEg" etc. + if (!@file_exists($source)) { + $source = preg_replace('/\\.webp$/', '.jpeg', $destination); + } + if (!@file_exists($source)) { + $source = preg_replace('/\\.webp$/', '.JPG', $destination); + } + if (!@file_exists($source)) { + $source = preg_replace('/\\.webp$/', '.JPEG', $destination); + } + if (!@file_exists($source)) { + $source = preg_replace('/\\.webp$/', '.png', $destination); + } + if (!@file_exists($source)) { + $source = preg_replace('/\\.webp$/', '.PNG', $destination); + } + } + if ($destinationStructure == 'doc-root') { + $source = SanityCheck::absPathExistsAndIsFileInDocRoot($source); + } else { + $source = SanityCheck::absPathExistsAndIsFile($source); + } + + + } catch (SanityException $e) { + return false; + } + + return $source; + } + + /** + * Get source from destination (and some configurations) + * Returns false if not found. Otherwise returns path to source + * + * @param string $destination Path to destination file (does not have to exist). May not contain directory traversal + * @param string $destinationFolder 'mingled' or 'separate' + * @param string $destinationExt Extension ('append' or 'set') + * @param string $destinationStructure "doc-root" or "image-roots" + * @param string $webExpressContentDirAbs + * @param ImageRoots $imageRoots An image roots object + * + * @return string|false Returns path to source, if found. If not - or a path is not sane, false is returned + */ + public static function findSource($destination, $destinationFolder, $destinationExt, $destinationStructure, $webExpressContentDirAbs, $imageRoots) + { + + try { + + if ($destinationStructure == 'doc-root') { + // Check that destination path is sane and inside document root + // -------------------------- + $destination = SanityCheck::absPathIsInDocRoot($destination); + } else { + // The following will fail if path contains directory traversal. TODO: Is that ok? + $destination = SanityCheck::absPath($destination); + } + + } catch (SanityException $e) { + return false; + } + + if ($destinationFolder == 'mingled') { + $result = self::findSourceMingled($destination, $destinationExt, $destinationStructure); + if ($result === false) { + $result = self::findSourceSeparate($destination, $destinationStructure, $webExpressContentDirAbs, $imageRoots); + } + return $result; + } else { + return self::findSourceSeparate($destination, $destinationStructure, $webExpressContentDirAbs, $imageRoots); + } + } + + /** + * + * @param string $source Path to source file + * @param string $logDir The folder where log files are kept + * + * @return string|false Returns computed filename of log - or false if a path is not sane + * + */ + public static function getLogFilename($source, $logDir) + { + try { + + // Check that source path is sane and inside document root + // ------------------------------------------------------- + $source = SanityCheck::absPathIsInDocRoot($source); + + + // Check that log path is sane and inside document root + // ------------------------------------------------------- + $logDir = SanityCheck::absPathIsInDocRoot($logDir); + + + // Compute and check log path + // -------------------------- + $logDirForConversions = $logDir .= '/conversions'; + + // We store relative to document root. + // "Eat" the left part off the source parameter which contains the document root. + // and also eat the slash (+1) + + $docRoot = rtrim(realpath($_SERVER["DOCUMENT_ROOT"]), '/'); + $sourceRel = substr($source, strlen($docRoot) + 1); + $logFileName = $logDir . '/doc-root/' . $sourceRel . '.md'; + SanityCheck::absPathIsInDocRoot($logFileName); + + } catch (SanityException $e) { + return false; + } + return $logFileName; + + } + + /** + * Create the directory for log files and put a .htaccess file into it, which prevents + * it to be viewed from the outside (not that it contains any sensitive information btw, but for good measure). + * + * @param string $logDir The folder where log files are kept + * + * @return boolean Whether it was created successfully or not. + * + */ + private static function createLogDir($logDir) + { + if (!is_dir($logDir)) { + @mkdir($logDir, 0775, true); + @chmod($logDir, 0775); + @file_put_contents(rtrim($logDir . '/') . '/.htaccess', << +Require all denied + + +Order deny,allow +Deny from all + +APACHE + ); + @chmod($logDir . '/.htaccess', 0664); + } + return is_dir($logDir); + } + + /** + * Saves the log file corresponding to a conversion. + * + * @param string $source Path to the source file that was converted + * @param string $logDir The folder where log files are kept + * @param string $text Content of the log file + * @param string $msgTop A message that is printed before the conversion log (containing version info) + * + * + */ + private static function saveLog($source, $logDir, $text, $msgTop) + { + + if (!file_exists($logDir)) { + self::createLogDir($logDir); + } + + $text = preg_replace('#' . preg_quote($_SERVER["DOCUMENT_ROOT"]) . '#', '[doc-root]', $text); + + // TODO: Put version number somewhere else. Ie \WebPExpress\VersionNumber::version + $text = 'WebP Express 0.25.9. ' . $msgTop . ', ' . date("Y-m-d H:i:s") . "\n\r\n\r" . $text; + + $logFile = self::getLogFilename($source, $logDir); + + if ($logFile === false) { + return; + } + + $logFolder = @dirname($logFile); + if (!@file_exists($logFolder)) { + mkdir($logFolder, 0777, true); + } + if (@file_exists($logFolder)) { + file_put_contents($logFile, $text); + } + } + + /** + * Trigger an actual conversion with webp-convert. + * + * PS: To convert with a specific converter, set it in the $converter param. + * + * @param string $source Full path to the source file that was converted. + * @param string $destination Full path to the destination file (may exist or not). + * @param array $convertOptions Conversion options. + * @param string $logDir The folder where log files are kept or null for no logging + * @param string $converter (optional) Set it to convert with a specific converter. + */ + public static function convert($source, $destination, $convertOptions, $logDir = null, $converter = null) { + include_once __DIR__ . '/../../vendor/autoload.php'; + + // At this point, everything has already been checked for sanity. But for good meassure, lets + // check the most important parts again. This is after all a public method. + // ------------------------------------------------------------------ + try { + + // Check that source path is sane, exists, is a file and is inside document root + // ------------------------------------------------------- + + // First check if file exists before doing any other validations + if (!file_exists($source)) { + return [ + 'success' => false, + 'msg' => 'Source file does not exist: ' . $source, + 'log' => '', + ]; + } + + $source = SanityCheck::absPathExistsAndIsFileInDocRoot($source); + + + // Check that destination path is sane and is inside document root + // ------------------------------------------------------- + $destination = SanityCheck::absPathIsInDocRoot($destination); + $destination = SanityCheck::pregMatch('#\.webp$#', $destination, 'Destination does not end with .webp'); + + + // Check that log path is sane and inside document root + // ------------------------------------------------------- + if (!is_null($logDir)) { + $logDir = SanityCheck::absPathIsInDocRoot($logDir); + } + + + // PS: No need to check $logMsgTop. Log files are markdown and stored as ".md". They can do no harm. + + } catch (SanityException $e) { + return [ + 'success' => false, + 'msg' => $e->getMessage(), + 'log' => '', + ]; + } + + $success = false; + $msg = ''; + $logger = new BufferLogger(); + try { + if (!is_null($converter)) { + //if (isset($convertOptions['converter'])) { + //print_r($convertOptions);exit; + $logger->logLn('Converter set to: ' . $converter); + $logger->logLn(''); + $converter = ConverterFactory::makeConverter($converter, $source, $destination, $convertOptions, $logger); + $converter->doConvert(); + } else { +//error_log('options:' . print_r(json_encode($convertOptions,JSON_PRETTY_PRINT), true)); + WebPConvert::convert($source, $destination, $convertOptions, $logger); + } + $success = true; + } catch (\WebpConvert\Exceptions\WebPConvertException $e) { + $msg = $e->getMessage(); + } catch (\Exception $e) { + //$msg = 'An exception was thrown!'; + $msg = $e->getMessage(); + } catch (\Throwable $e) { + //Executed only in PHP 7 and 8, will not match in PHP 5 + $msg = $e->getMessage(); + } + + if (!is_null($logDir)) { + self::saveLog($source, $logDir, $logger->getMarkDown("\n\r"), 'Conversion triggered using bulk conversion'); + } + + return [ + 'success' => $success, + 'msg' => $msg, + 'log' => $logger->getMarkDown("\n"), + ]; + + } + + /** + * Serve a converted file (if it does not already exist, a conversion is triggered - all handled in webp-convert). + * + */ + public static function serveConverted($source, $destination, $serveOptions, $logDir = null, $logMsgTop = '') + { + include_once __DIR__ . '/../../vendor/autoload.php'; + + // At this point, everything has already been checked for sanity. But for good meassure, lets + // check again. This is after all a public method. + // --------------------------------------------- + try { + + // Check that source path is sane, exists, is a file. + // ------------------------------------------------------- + //$source = SanityCheck::absPathExistsAndIsFileInDocRoot($source); + $source = SanityCheck::absPathExistsAndIsFile($source); + + + // Check that destination path is sane + // ------------------------------------------------------- + //$destination = SanityCheck::absPathIsInDocRoot($destination); + $destination = SanityCheck::absPath($destination); + $destination = SanityCheck::pregMatch('#\.webp$#', $destination, 'Destination does not end with .webp'); + + + // Check that log path is sane + // ------------------------------------------------------- + //$logDir = SanityCheck::absPathIsInDocRoot($logDir); + if ($logDir != null) { + $logDir = SanityCheck::absPath($logDir); + } + + // PS: No need to check $logMsgTop. Log files are markdown and stored as ".md". They can do no harm. + + } catch (SanityException $e) { + $msg = $e->getMessage(); + echo $msg; + header('X-WebP-Express-Error: ' . $msg, true); + // TODO: error_log() ? + exit; + } + + $convertLogger = new BufferLogger(); + WebPConvert::serveConverted($source, $destination, $serveOptions, null, $convertLogger); + if (!is_null($logDir)) { + $convertLog = $convertLogger->getMarkDown("\n\r"); + if ($convertLog != '') { + self::saveLog($source, $logDir, $convertLog, $logMsgTop); + } + } + } +} diff --git a/lib/classes/ConvertLog.php b/lib/classes/ConvertLog.php new file mode 100644 index 0000000..c424561 --- /dev/null +++ b/lib/classes/ConvertLog.php @@ -0,0 +1,48 @@ +' . $logFile . '


'; + + if (!file_exists($logFile)) { + $msg .= 'No log file found on that location'; + + } else { + $log = file_get_contents($logFile); + if ($log === false) { + $msg .= 'Could not read log file'; + } else { + $msg .= nl2br($log); + } + + } + + //$log = $source; + //file_get_contents + + echo json_encode($msg, JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK | JSON_PRETTY_PRINT); + */ + wp_die(); + } + +} diff --git a/lib/classes/ConvertersHelper.php b/lib/classes/ConvertersHelper.php new file mode 100644 index 0000000..f0b28ec --- /dev/null +++ b/lib/classes/ConvertersHelper.php @@ -0,0 +1,287 @@ + 'cwebp', 'options' => [ + 'use-nice' => true, + 'try-common-system-paths' => true, + 'try-supplied-binary-for-os' => true, + 'method' => 6, + 'low-memory' => true, + 'command-line-options' => '', + ]], + ['converter' => 'vips', 'options' => [ + 'smart-subsample' => false, + 'preset' => 'none' + ]], + ['converter' => 'imagemagick', 'options' => [ + 'use-nice' => true, + ]], + ['converter' => 'graphicsmagick', 'options' => [ + 'use-nice' => true, + ]], + ['converter' => 'ffmpeg', 'options' => [ + 'use-nice' => true, + 'method' => 4, + ]], + ['converter' => 'wpc', 'options' => []], // we should not set api-version default - it is handled in the javascript + ['converter' => 'ewww', 'options' => []], + ['converter' => 'imagick', 'options' => []], + ['converter' => 'gmagick', 'options' => []], + ['converter' => 'gd', 'options' => [ + 'skip-pngs' => false, + ]], + ]; + + public static function getDefaultConverterNames() + { + $availableConverterIDs = []; + foreach (self::$defaultConverters as $converter) { + $availableConverterIDs[] = $converter['converter']; + } + return $availableConverterIDs; + + // PS: In a couple of years: + //return array_column(self::$defaultConverters, 'converter'); + } + + public static function getConverterNames($converters) + { + return array_column(self::normalize($converters), 'converter'); + } + + public static function normalize($converters) + { + foreach ($converters as &$converter) { + if (!isset($converter['converter'])) { + $converter = ['converter' => $converter]; + } + if (!isset($converter['options'])) { + $converter['options'] = []; + } + } + return $converters; + } + + /** + * Those converters in second array, but not in first will be appended to first + */ + public static function mergeConverters($first, $second) + { + $namesInFirst = self::getConverterNames($first); + $second = self::normalize($second); + + foreach ($second as $converter) { + // migrate9 and this functionality could create two converters. + // so, for a while, skip graphicsmagick and imagemagick + + if ($converter['converter'] == 'graphicsmagick') { + if (in_array('gmagickbinary', $namesInFirst)) { + continue; + } + } + if ($converter['converter'] == 'imagemagick') { + if (in_array('imagickbinary', $namesInFirst)) { + continue; + } + } + if (!in_array($converter['converter'], $namesInFirst)) { + $first[] = $converter; + } + } + return $first; + } + + /** + * Get converter by id + * + * @param object $config + * @return array|false converter object + */ + public static function getConverterById($config, $id) { + if (!isset($config['converters'])) { + return false; + } + $converters = $config['converters']; + + if (!is_array($converters)) { + return false; + } + + foreach ($converters as $c) { + if (!isset($c['converter'])) { + continue; + } + if ($c['converter'] == $id) { + return $c; + } + } + return false; + } + + /** + * Get working converters. + * + * @param object $config + * @return array + */ + public static function getWorkingConverters($config) { + if (!isset($config['converters'])) { + return []; + } + $converters = $config['converters']; + + if (!is_array($converters)) { + return []; + } + + $result = []; + + foreach ($converters as $c) { + if (isset($c['working']) && !$c['working']) { + continue; + } + $result[] = $c; + } + return $result; + } + + /** + * Get array of working converter ids. Same order as configured. + */ + public static function getWorkingConverterIds($config) + { + $converters = self::getWorkingConverters($config); + $result = []; + foreach ($converters as $converter) { + $result[] = $converter['converter']; + } + return $result; + } + + /** + * Get working and active converters. + * + * @param object $config + * @return array Array of converter objects + */ + public static function getWorkingAndActiveConverters($config) + { + if (!isset($config['converters'])) { + return []; + } + $converters = $config['converters']; + + if (!is_array($converters)) { + return []; + } + + $result = []; + + foreach ($converters as $c) { + if (isset($c['deactivated']) && $c['deactivated']) { + continue; + } + if (isset($c['working']) && !$c['working']) { + continue; + } + $result[] = $c; + } + return $result; + } + + /** + * Get active converters. + * + * @param object $config + * @return array Array of converter objects + */ + public static function getActiveConverters($config) + { + if (!isset($config['converters'])) { + return []; + } + $converters = $config['converters']; + if (!is_array($converters)) { + return []; + } + $result = []; + foreach ($converters as $c) { + if (isset($c['deactivated']) && $c['deactivated']) { + continue; + } + $result[] = $c; + } + return $result; + } + + public static function getWorkingAndActiveConverterIds($config) + { + $converters = self::getWorkingAndActiveConverters($config); + $result = []; + foreach ($converters as $converter) { + $result[] = $converter['converter']; + } + return $result; + } + + public static function getActiveConverterIds($config) + { + $converters = self::getActiveConverters($config); + $result = []; + foreach ($converters as $converter) { + $result[] = $converter['converter']; + } + return $result; + } + + /** + * Get converter id by converter object + * + * @param object $converter + * @return string converter name, or empty string if not set (it should always be set, however) + */ + public static function getConverterId($converter) { + if (!isset($converter['converter'])) { + return ''; + } + return $converter['converter']; + } + + /** + * Get first working and active converter. + * + * @param object $config + * @return object|false + */ + public static function getFirstWorkingAndActiveConverter($config) { + + $workingConverters = self::getWorkingAndActiveConverters($config); + + if (count($workingConverters) == 0) { + return false; + } + return $workingConverters[0]; + } + + /** + * Get first working and active converter (name) + * + * @param object $config + * @return string|false id of converter, or false if no converter is working and active + */ + public static function getFirstWorkingAndActiveConverterId($config) { + $c = self::getFirstWorkingAndActiveConverter($config); + if ($c === false) { + return false; + } + if (!isset($c['converter'])) { + return false; + } + return $c['converter']; + } + +} diff --git a/lib/classes/Destination.php b/lib/classes/Destination.php new file mode 100644 index 0000000..a0134cc --- /dev/null +++ b/lib/classes/Destination.php @@ -0,0 +1,208 @@ +mingled; + $replaceExt = $destinationOptions->replaceExt; + $useDocRoot = $destinationOptions->useDocRoot; + + try { + // Check source + // -------------- + // TODO: make this check work with symlinks + //$source = SanityCheck::absPathExistsAndIsFileInDocRoot($source); + + // Calculate destination and check that the result is sane + // ------------------------------------------------------- + if (self::storeMingledOrNot($source, $mingled, $uploadDirAbs)) { + $destination = self::appendOrSetExtension($source, $mingled, $replaceExt, true); + } else { + + if ($useDocRoot) { + // We must find the relative path from document root to source. + // However, we dont know if document root is resolved or not. + // We also do not know if source begins with a resolved or unresolved document root. + // And we cannot be sure that document root is resolvable. + + // Lets say: + // 1. document root is unresolvable. + // 2. document root is configured to something unresolved ("/my-website") + // 3. source is resolved and within an image root ("/var/www/my-website/wp-content/uploads/test.jpg") + // 4. all image roots are resolvable. + // 5. Paths::canUseDocRootForRelPaths()) returned true + + // Can the relative path then be found? + // Actually, yes. + // We can loop through the image roots. + // When we get to the "uploads" root, it must neccessarily contain the unresolved document root. + // It will in other words be: "my-website/wp-content/uploads" + // It can not be configured to the resolved path because canUseDocRootForRelPaths would have then returned false as + // It would not be possible to establish that "/var/www/my-website/wp-content/uploads/" is within document root, as + // document root is "/my-website" and unresolvable. + // To sum up, we have: + // If document root is unresolvable while canUseDocRootForRelPaths() succeeded, then the image roots will all begin with + // the unresolved path. + // In this method, if $useDocRootForStructuringCacheDir is true, then it is assumed that canUseDocRootForRelPaths() + // succeeded. + // OH! + // I realize that the image root can be passed as well: + // $imageRoot = $webExpressContentDirAbs . '/webp-images'; + // So the question is: Will $webExpressContentDirAbs also be the unresolved path? + // That variable is calculated in WodConfigLoader based on various methods available. + // I'm not digging into it, but would expect it to in some cases be resolved. Which means that relative path can not + // be found. + // So. Lets play it safe and require that document root is resolvable in order to use docRoot for structure + + if (!PathHelper::isDocRootAvailable()) { + throw new \Exception( + 'Can not calculate destination using "doc-root" structure as document root is not available. $_SERVER["DOCUMENT_ROOT"] is empty. ' . + 'This is probably a misconfiguration on the server. ' . + 'However, WebP Express can function without using documument root. If you resave options and regenerate the .htaccess files, it should ' . + 'automatically start to structure the webp files in subfolders that are relative the image root folders rather than document-root.' + ); + } + + if (!PathHelper::isDocRootAvailableAndResolvable()) { + throw new \Exception( + 'Can not calculate destination using "doc-root" structure as document root cannot be resolved for symlinks using "realpath". The ' . + 'reason for that is probably that open_basedir protection has been set up and that document root is outside outside that open_basedir. ' . + 'WebP Express can function in that setting, however you will need to resave options and regenerate the .htaccess files. It should then ' . + 'automatically stop to structure the webp files as relative to document root and instead structure them as relative to image root folders.' + ); + } + $docRoot = rtrim(realpath($_SERVER["DOCUMENT_ROOT"]), '/'); + $imageRoot = $webExpressContentDirAbs . '/webp-images'; + + // TODO: make this check work with symlinks + //SanityCheck::absPathIsInDocRoot($imageRoot); + + $sourceRel = substr(realpath($source), strlen($docRoot) + 1); + $destination = $imageRoot . '/doc-root/' . $sourceRel; + $destination = self::appendOrSetExtension($destination, $mingled, $replaceExt, false); + + + // TODO: make this check work with symlinks + //$destination = SanityCheck::absPathIsInDocRoot($destination); + } else { + $destination = ''; + + $sourceResolved = realpath($source); + + + // Check roots until we (hopefully) get a match. + // (that is: find a root which the source is inside) + foreach ($imageRoots->getArray() as $i => $imageRoot) { + // in $obj, "rel-path" is only set when document root can be used for relative paths. + // So, if it is set, we can use it (beware: we cannot neccessarily use realpath on document root, + // but we do not need to - see the long comment in Paths::canUseDocRootForRelPaths()) + + $rootPath = $imageRoot->getAbsPath(); + /* + if (isset($obj['rel-path'])) { + $docRoot = rtrim($_SERVER["DOCUMENT_ROOT"], '/'); + $rootPath = $docRoot . '/' . $obj['rel-path']; + } else { + // If "rel-path" isn't set, then abs-path is, and we can use that. + $rootPath = $obj['abs-path']; + }*/ + + // $source may be resolved or not. Same goes for $rootPath. + // We can assume that $rootPath is resolvable using realpath (it ought to exist and be within open_basedir for WP to function) + // We can also assume that $source is resolvable (it ought to exist and within open_basedir) + // So: Resolve both! and test if the resolved source begins with the resolved rootPath. + if (strpos($sourceResolved, realpath($rootPath)) !== false) { + $relPath = substr($sourceResolved, strlen(realpath($rootPath)) + 1); + $relPath = self::appendOrSetExtension($relPath, $mingled, $replaceExt, false); + + $destination = $webExpressContentDirAbs . '/webp-images/' . $imageRoot->id . '/' . $relPath; + break; + } + } + if ($destination == '') { + return false; + } + } + } + + } catch (SanityException $e) { + return false; + } + + return $destination; + } + +} diff --git a/lib/classes/DestinationOptions.php b/lib/classes/DestinationOptions.php new file mode 100644 index 0000000..cd18b3d --- /dev/null +++ b/lib/classes/DestinationOptions.php @@ -0,0 +1,42 @@ +mingled = $mingled; + $this->useDocRoot = $useDocRoot; + $this->replaceExt = $replaceExt; + $this->scope = $scope; + } + + /** + * Set properties from config file + * + * @param array $config WebP Express configuration object + */ + public static function createFromConfig(&$config) + { + return new DestinationOptions( + $config['destination-folder'] == 'mingled', // "mingled" or "separate" + $config['destination-structure'] == 'doc-root', // "doc-root" or "image-roots" + $config['destination-extension'] == 'set', // "set" or "append" + $config['scope'] + ); + } + + +} diff --git a/lib/classes/DestinationUrl.php b/lib/classes/DestinationUrl.php new file mode 100644 index 0000000..7d029ee --- /dev/null +++ b/lib/classes/DestinationUrl.php @@ -0,0 +1,229 @@ + http + [host] => we0 + [path] => /wordpress/uploads-moved + )*/ + + $imageUrlComponents = parse_url($imageUrl); + /* ie: + ( + [scheme] => http + [host] => we0 + [path] => /wordpress/uploads-moved/logo.jpg + )*/ + if ($baseUrlComponents['host'] != $imageUrlComponents['host']) { + return false; + } + + // Check if path begins with base path + if (strpos($imageUrlComponents['path'], $baseUrlComponents['path']) !== 0) { + return false; + } + + // Remove base path from path (we know it begins with basepath, from previous check) + return substr($imageUrlComponents['path'], strlen($baseUrlComponents['path'])); + + } + + /** + * Get url for webp from source url, (if ), given a certain baseUrl / baseDir. + * Base can for example be uploads or wp-content. + * + * returns false + * - if no source file found in that base + * - or source file is found but webp file isn't there and the `only-for-webps-that-exists` option is set + * + * @param string $sourceUrl Url of source image (ie http://example.com/wp-content/image.jpg) + * @param string $rootId Id (created in Config::updateAutoloadedOptions). Ie "uploads", "content" or any image root id + * @param string $baseUrl Base url of source image (ie http://example.com/wp-content) + * @param string $baseDir Base dir of source image (ie /var/www/example.com/wp-content) + * @param object $destinationOptions + */ + public static function getWebPUrlInImageRoot($sourceUrl, $rootId, $baseUrl, $baseDir, $destinationOptions) + { + //error_log('getWebPUrlInImageRoot:' . $sourceUrl . ':' . $baseUrl . ':' . $baseDir); + + + $srcPathRel = self::getRelUrlPath($sourceUrl, $baseUrl); + + if ($srcPathRel === false) { + return false; + } + + // Calculate file path to source + $srcPathAbs = $baseDir . $srcPathRel; + + // Check that source file exists + if (!@file_exists($srcPathAbs)) { + return false; + } + + // Calculate destination of webp (both path and url) + // ---------------------------------------- + + // We are calculating: $destPathAbs and $destUrl. + + if (!isset($destinationOptions->scope) || !in_array($rootId, $destinationOptions->scope)) { + return false; + } + + $destinationRoot = Paths::destinationRoot( + $rootId, + $destinationOptions + ); + + $relPathFromImageRootToSource = PathHelper::getRelDir( + realpath(Paths::getAbsDirById($rootId)), + realpath($srcPathAbs) + ); + $relPathFromImageRootToDest = Destination::appendOrSetExtension( + $relPathFromImageRootToSource, + $destinationOptions->mingled, + $destinationOptions->replaceExt, + ($rootId == 'uploads') + ); + $destPathAbs = $destinationRoot['abs-path'] . '/' . $relPathFromImageRootToDest; + $webpMustExist = self::$options['only-for-webps-that-exists']; + if ($webpMustExist && (!@file_exists($destPathAbs))) { + return false; + } + + $destUrl = $destinationRoot['url'] . '/' . $relPathFromImageRootToDest; + + // Fix scheme (use same as source) + $sourceUrlComponents = parse_url($sourceUrl); + $destUrlComponents = parse_url($destUrl); + $port = isset($sourceUrlComponents['port']) ? ":" . $sourceUrlComponents['port'] : ""; + return $sourceUrlComponents['scheme'] . '://' . $sourceUrlComponents['host'] . $port . $destUrlComponents['path']; + } + + + /** + * Get url for webp + * returns second argument if no webp + * + * @param $sourceUrl + * @param $returnValueOnFail + */ + public static function getWebPUrl($sourceUrl, $returnValueOnFail) + { + // Get the options + self::getOptions(); + + // Fail for webp-disabled browsers (when "only-for-webp-enabled-browsers" is set) + if (self::$options['only-for-webp-enabled-browsers']) { + if (!isset($_SERVER['HTTP_ACCEPT']) || (strpos($_SERVER['HTTP_ACCEPT'], 'image/webp') === false)) { + return $returnValueOnFail; + } + } + + // Fail for relative urls. Wordpress doesn't use such very much anyway + if (!preg_match('#^https?://#', $sourceUrl)) { + return $returnValueOnFail; + } + + // Fail if the image type isn't enabled + switch (self::$options['image-types']) { + case 0: + return $returnValueOnFail; + case 1: + if (!preg_match('#(jpe?g)$#', $sourceUrl)) { + return $returnValueOnFail; + } + break; + case 2: + if (!preg_match('#(png)$#', $sourceUrl)) { + return $returnValueOnFail; + } + break; + case 3: + if (!preg_match('#(jpe?g|png)$#', $sourceUrl)) { + return $returnValueOnFail; + } + break; + } + + //error_log('source url:' . $sourceUrl); + + // Try all image roots + foreach (self::$options['scope'] as $rootId) { + $baseDir = Paths::getAbsDirById($rootId); + $baseUrl = Paths::getUrlById($rootId); + + //error_log('baseurl: ' . $baseUrl); + if (Multisite::isMultisite() && ($rootId == 'uploads')) { + $baseUrl = Paths::getUploadUrl(); + $baseDir = Paths::getUploadDirAbs(); + } + + $result = self::getWebPUrlInImageRoot($sourceUrl, $rootId, $baseUrl, $baseDir); + if ($result !== false) { + return $result; + } + + // Try the hostname aliases. + if (!isset(self::$options['hostname-aliases'])) { + continue; + } + $hostnameAliases = self::$options['hostname-aliases']; + + $hostname = Paths::getHostNameOfUrl($baseUrl); + $baseUrlComponents = parse_url($baseUrl); + $sourceUrlComponents = parse_url($sourceUrl); + // ie: [scheme] => http, [host] => we0, [path] => /wordpress/uploads-moved + + if ((!isset($baseUrlComponents['host'])) || (!isset($sourceUrlComponents['host']))) { + continue; + } + + foreach ($hostnameAliases as $hostnameAlias) { + + if ($sourceUrlComponents['host'] != $hostnameAlias) { + continue; + } + //error_log('hostname alias:' . $hostnameAlias); + + $baseUrlOnAlias = $baseUrlComponents['scheme'] . '://' . $hostnameAlias . $baseUrlComponents['path']; + //error_log('baseurl (alias):' . $baseUrlOnAlias); + + $result = self::getWebPUrlInImageRoot($sourceUrl, $rootId, $baseUrlOnAlias, $baseDir); + if ($result !== false) { + $resultUrlComponents = parse_url($result); + return $sourceUrlComponents['scheme'] . '://' . $hostnameAlias . $resultUrlComponents['path']; + } + } + } + + return $returnValueOnFail; + } + +/* + public static function getWebPUrlOrSame($sourceUrl, $returnValueOnFail) + { + return self::getWebPUrl($sourceUrl, $sourceUrl); + }*/ + +} diff --git a/lib/classes/DismissableGlobalMessages.php b/lib/classes/DismissableGlobalMessages.php new file mode 100644 index 0000000..96a81bc --- /dev/null +++ b/lib/classes/DismissableGlobalMessages.php @@ -0,0 +1,100 @@ +
'; + foreach ($buttons as $i => $button) { + $javascript = "jQuery(this).closest('div.notice').slideUp();"; + //$javascript = "console.log(jQuery(this).closest('div.notice'));"; + $javascript .= "jQuery.post(ajaxurl, " . + "{'action': 'webpexpress_dismiss_global_message', " . + "'id': '" . $id . "'})"; + if (isset($button['javascript'])) { + $javascript .= ".done(function() {" . $button['javascript'] . "});"; + } + if (isset($button['redirect-to-settings'])) { + $javascript .= ".done(function() {location.href='" . Paths::getSettingsUrl() . "'});"; + } + + $msg .= ''; + + } + Messenger::printMessage($level, $msg); + } + + public static function printMessages() + { + $ids = State::getState('dismissableGlobalMessageIds', []); + foreach ($ids as $id) { + include_once __DIR__ . '/../dismissable-global-messages/' . $id . '.php'; + } + } + + /** + * Dismiss message + * + * @param string $id An identifier, ie "suggest_enable_pngs" + */ + public static function dismissMessage($id) { + $messages = State::getState('dismissableGlobalMessageIds', []); + $newQueue = []; + foreach ($messages as $mid) { + if ($mid == $id) { + + } else { + $newQueue[] = $mid; + } + } + State::setState('dismissableGlobalMessageIds', $newQueue); + } + + /** + * Dismiss message + * + * @param string $id An identifier, ie "suggest_enable_pngs" + */ + public static function dismissAll() { + State::setState('dismissableGlobalMessageIds', []); + } + + public static function processAjaxDismissGlobalMessage() { + /* + We have no security nonce here + Dismissing a message is not harmful and dismissMessage($id) do anything harmful, no matter what you send in the "id" + */ + $id = sanitize_text_field($_POST['id']); + self::dismissMessage($id); + } + + +} diff --git a/lib/classes/DismissableMessages.php b/lib/classes/DismissableMessages.php new file mode 100644 index 0000000..1b146f6 --- /dev/null +++ b/lib/classes/DismissableMessages.php @@ -0,0 +1,86 @@ +' . $gotItText . ''; + } + Messenger::printMessage($level, $msg); + } + + public static function printMessages() + { + $ids = State::getState('dismissableMessageIds', []); + foreach ($ids as $id) { + include_once __DIR__ . '/../dismissable-messages/' . $id . '.php'; + } + } + + /** + * Dismiss message + * + * @param string $id An identifier, ie "suggest_enable_pngs" + */ + public static function dismissMessage($id) { + $messages = State::getState('dismissableMessageIds', []); + $newQueue = []; + foreach ($messages as $mid) { + if ($mid == $id) { + + } else { + $newQueue[] = $mid; + } + } + State::setState('dismissableMessageIds', $newQueue); + } + + /** + * Dismiss message + * + * @param string $id An identifier, ie "suggest_enable_pngs" + */ + public static function dismissAll() { + State::setState('dismissableMessageIds', []); + } + + public static function processAjaxDismissMessage() { + /* + We have no security nonce here + Dismissing a message is not harmful and dismissMessage($id) do anything harmful, no matter what you send in the "id" + */ + $id = sanitize_text_field($_POST['id']); + self::dismissMessage($id); + } + + +} diff --git a/lib/classes/EwwwTools.php b/lib/classes/EwwwTools.php new file mode 100644 index 0000000..46076c5 --- /dev/null +++ b/lib/classes/EwwwTools.php @@ -0,0 +1,113 @@ + $c) { + if (!isset($c['converter'])) { + continue; + } + if ($c['converter'] == 'ewww') { + //unset($wodOptions['webp-convert']['convert']['converters'][$i]); + array_splice($wodOptions['webp-convert']['convert']['converters'], $i, 1); + + //$successfulWrite = Config::saveConfigurationFileAndWodOptions($config); + $successfulWrite = FileHelper::saveJSONOptions($configDir . '/wod-options.json', $wodOptions); + return $successfulWrite; + } + } + } + + /** + * Mark ewww api keys as non functional. + * + * Current implementation simply removes ewww from wod-options.json. + * It will reappear when options are saved - but be removed again upon next failure + * + * @return boolean If it went well. + */ + public static function markApiKeysAsNonFunctional($apiKeysToMarkAsNonFunctional, $configDir) + { + //self::markApiKeysAsNonFunctionalInConfig($apiKeysToMarkAsNonFunctional, $configDir); + + // TODO: We should update the key to api-key-2 the first time. + // But I am going to change the structure of wod-options so ewww becomes a stack converter, so + // I don't bother implementing this right now. + self::removeEwwwFromWodOptions($apiKeysToMarkAsNonFunctional, $configDir); + + } + +} diff --git a/lib/classes/FileHelper.php b/lib/classes/FileHelper.php new file mode 100644 index 0000000..1b0b3d8 --- /dev/null +++ b/lib/classes/FileHelper.php @@ -0,0 +1,395 @@ +valid()) { + $filename = $fileIterator->getFilename(); + $filepath = $dir . "/" . $filename; + +// echo $filepath . "\n"; + + $isDir = @is_dir($filepath); + + if ((!$isDir && (is_null($regexFileMatchPattern) || preg_match($regexFileMatchPattern, $filename))) || + ($isDir && (is_null($regexDirMatchPattern) || preg_match($regexDirMatchPattern, $filename)))) { + // chmod + if ($isDir) { + if (!is_null($dirPerm)) { + self::chmod($filepath, $dirPerm); + //echo '. chmod dir to:' . self::humanReadableFilePerm($dirPerm) . '. result:' . self::humanReadableFilePermOfFile($filepath) . "\n"; + } + } else { + if (!is_null($filePerm)) { + self::chmod($filepath, $filePerm); + //echo '. chmod file to:' . self::humanReadableFilePerm($filePerm) . '. result:' . self::humanReadableFilePermOfFile($filepath) . "\n"; + } + + } + + // chown + if (!is_null($uid)) { + @chown($filepath, $uid); + } + + // chgrp + if (!is_null($gid)) { + @chgrp($filepath, $gid); + + } + } + + // recurse + if ($isDir) { + self::chmod_r($filepath, $dirPerm, $filePerm, $uid, $gid, $regexFileMatchPattern, $regexDirMatchPattern); + } + + // next! + $fileIterator->next(); + } + } + + + /** + * Create a dir using same permissions as parent. + * If + */ + /* + public static function mkdirSamePermissionsAsParent($pathname) { + + }*/ + + /** + * Get directory part of filename. + * Ie '/var/www/.htaccess' => '/var/www' + * Also works with backslashes + */ + public static function dirName($filename) { + return preg_replace('/[\/\\\\][^\/\\\\]*$/', '', $filename); + } + + /** + * Determines if a file can be created. + * BEWARE: It requires that the containing folder already exists + */ + public static function canCreateFile($filename) { + $dirName = self::dirName($filename); + if (!@file_exists($dirName)) { + return false; + } + if (@is_writable($dirName) && @is_executable($dirName) || self::isWindows() ) { + return true; + } + + $existingPermission = self::filePerm($dirName); + + // we need to make sure we got the existing permission, so we can revert correctly later + if ($existingPermission !== false) { + if (self::chmod($dirName, 0775)) { + // change back + self::chmod($filename, $existingPermission); + return true; + } + } + return false; + } + + /** + * Note: Do not use for directories + */ + public static function canEditFile($filename) { + if (!@file_exists($filename)) { + return false; + } + if (@is_writable($filename) && @is_readable($filename)) { + return true; + } + + // As a last desperate try, lets see if we can give ourself write permissions. + // If possible, then it will also be possible when actually writing + $existingPermission = self::filePerm($filename); + + // we need to make sure we got the existing permission, so we can revert correctly later + if ($existingPermission !== false) { + if (self::chmod($filename, 0664)) { + // change back + self::chmod($filename, $existingPermission); + return true; + } + } + return false; + + // Idea: Perhaps we should also try to actually open the file for writing? + + } + + public static function canEditOrCreateFileHere($filename) { + if (@file_exists($filename)) { + return self::canEditFile($filename); + } else { + return self::canCreateFile($filename); + } + } + + /** + * Try to read from a file. Tries hard. + * Returns content, or false if read error. + */ + public static function loadFile($filename) { + $changedPermission = false; + if (!@is_readable($filename)) { + $existingPermission = self::filePerm($filename); + + // we need to make sure we got the existing permission, so we can revert correctly later + if ($existingPermission !== false) { + $changedPermission = self::chmod($filename, 0664); + } + } + + $return = false; + try { + $handle = @fopen($filename, "r"); + } catch (\ErrorException $exception) { + $handle = false; + error_log($exception->getMessage()); + } + if ($handle !== false) { + // Return value is either file content or false + if (filesize($filename) == 0) { + $return = ''; + } else { + $return = @fread($handle, filesize($filename)); + } + fclose($handle); + } + + if ($changedPermission) { + // change back + self::chmod($filename, $existingPermission); + } + return $return; + } + + + /* Remove dir and files in it recursively. + No warnings + returns $success + */ + public static function rrmdir($dir) { + if (@is_dir($dir)) { + $objects = @scandir($dir); + foreach ($objects as $object) { + if ($object != "." && $object != "..") { + if (@is_dir($dir . "/" . $object)) + self::rrmdir($dir . "/" . $object); + else + @unlink($dir . "/" . $object); + } + } + return @rmdir($dir); + } else { + return false; + } + } + + + /** + * Copy dir and all its files. + * Existing files are overwritten. + * + * @return $success + */ + public static function cpdir($sourceDir, $destinationDir) + { + if (!@is_dir($sourceDir)) { + return false; + } + if (!@file_exists($destinationDir)) { + if (!@mkdir($destinationDir)) { + return false; + } + } + + $fileIterator = new \FilesystemIterator($sourceDir); + $success = true; + + while ($fileIterator->valid()) { + $filename = $fileIterator->getFilename(); + + if (($filename != ".") && ($filename != "..")) { + //$filePerm = FileHelper::filePermWithFallback($filename, 0777); + + if (@is_dir($sourceDir . "/" . $filename)) { + if (!self::cpdir($sourceDir . "/" . $filename, $destinationDir . "/" . $filename)) { + $success = false; + } + } else { + // its a file. + if (!copy($sourceDir . "/" . $filename, $destinationDir . "/" . $filename)) { + $success = false; + } + } + } + $fileIterator->next(); + } + return $success; + } + + /** + * Remove empty subfolders. + * + * Got it here: https://stackoverflow.com/a/1833681/842756 + * + * @return boolean If folder is (was) empty + */ + public static function removeEmptySubFolders($path, $removeEmptySelfToo = false) + { + if (!file_exists($path)) { + return; + } + $empty = true; + foreach (scandir($path) as $file) { + if (($file == '.') || ($file == '..')) { + continue; + } + $file = $path . DIRECTORY_SEPARATOR . $file; + if (is_dir($file)) { + if (!self::removeEmptySubFolders($file, true)) { + $empty=false; + } + } else { + $empty=false; + } + } + if ($empty && $removeEmptySelfToo) { + rmdir($path); + } + return $empty; + } + + /** + * Verify if OS is Windows + * + * + * @return true if windows; false if not. + */ + public static function isWindows(){ + return preg_match('/^win/i', PHP_OS); + } + + + /** + * Normalize separators of directory paths + * + * + * @return $normalized_path + */ + public static function normalizeSeparator($path, $newSeparator = DIRECTORY_SEPARATOR){ + return preg_replace("#[\\\/]+#", $newSeparator, $path); + } + + /** + * @return object|false Returns parsed file the file exists and can be read. Otherwise it returns false + */ + public static function loadJSONOptions($filename) + { + $json = self::loadFile($filename); + if ($json === false) { + return false; + } + + $options = json_decode($json, true); + if ($options === null) { + return false; + } + return $options; + } + + public static function saveJSONOptions($filename, $obj) + { + $result = @file_put_contents( + $filename, + json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK | JSON_PRETTY_PRINT) + ); + /*if ($result === false) { + echo 'COULD NOT' . $filename; + }*/ + return ($result !== false); + } + +} diff --git a/lib/classes/HTAccess.php b/lib/classes/HTAccess.php new file mode 100644 index 0000000..7d79960 --- /dev/null +++ b/lib/classes/HTAccess.php @@ -0,0 +1,436 @@ +') !== false); + + $dir = FileHelper::dirName($filename); + $dirId = Paths::getAbsDirId($dir); + if ($dirId !== false) { + if ($containsRules) { + self::addToActiveHTAccessDirsArray($dirId); + } else { + self::removeFromActiveHTAccessDirsArray($dirId); + } + } + } + + return $success; + } + + public static function saveHTAccessRules($rootId, $rules, $createIfMissing = true) { + $filename = Paths::getAbsDirById($rootId) . '/.htaccess'; + return self::saveHTAccessRulesToFile($filename, $rules, $createIfMissing); + } + + /* only called in this file */ + public static function saveHTAccessRulesToFirstWritableHTAccessDir($dirs, $rules) + { + foreach ($dirs as $dir) { + if (self::saveHTAccessRulesToFile($dir . '/.htaccess', $rules, true)) { + return $dir; + } + } + return false; + } + + + /** + * Try to deactivate all .htaccess rules. + * If success, we return true. + * If we fail, we return an array of filenames that have problems + * @return true|array + */ + public static function deactivateHTAccessRules($comment = '# Plugin is deactivated') { + + $rootsToClean = Paths::getImageRootIds(); + $rootsToClean[] = 'home'; + $rootsToClean[] = 'cache'; + $failures = []; + $successes = []; + + foreach ($rootsToClean as $imageRootId) { + $dir = Paths::getAbsDirById($imageRootId); + $filename = $dir . '/.htaccess'; + if (!FileHelper::fileExists($filename)) { + //error_log('exists not:' . $filename); + continue; + } else { + if (self::haveWeRulesInThisHTAccessBestGuess($filename)) { + if (self::saveHTAccessRulesToFile($filename, $comment, false)) { + $successes[] = $imageRootId; + } else { + $failures[] = $imageRootId; + } + } else { + //error_log('no rules:' . $filename); + } + } + } + $success = (count($failures) == 0); + return [$success, $failures, $successes]; + } + + public static function testLinks($config) { + /* + if (isset($_SERVER['HTTP_ACCEPT']) && (strpos($_SERVER['HTTP_ACCEPT'], 'image/webp') !== false )) { + if ($config['operation-mode'] != 'no-conversion') { + if ($config['image-types'] != 0) { + $webpExpressRoot = Paths::getWebPExpressPluginUrlPath(); + $links = ''; + if ($config['enable-redirection-to-converter']) { + $links = '
'; + $links .= 'Convert test image (show debug)
'; + $links .= 'Convert test image
'; + } + // TODO: webp-realizer test links (to missing webp) + if ($config['enable-redirection-to-webp-realizer']) { + } + + // TODO: test link for testing redirection to existing + if ($config['redirect-to-existing-in-htaccess']) { + + } + + return $links; + } + } + }*/ + return ''; + } + + + public static function getHTAccessDirRequirements() { + $minRequired = 'index'; + if (Paths::isWPContentDirMovedOutOfAbsPath()) { + $minRequired = 'wp-content'; + $pluginToo = Paths::isPluginDirMovedOutOfWpContent() ? 'yes' : 'no'; + $uploadToo = Paths::isUploadDirMovedOutOfWPContentDir() ? 'yes' : 'no'; + } else { + // plugin requirement depends... + // - if user grants access to 'index', the requirement is Paths::isPluginDirMovedOutOfAbsPath() + // - if user grants access to 'wp-content', the requirement is Paths::isPluginDirMovedOutOfWpContent() + $pluginToo = 'depends'; + + // plugin requirement depends... + // - if user grants access to 'index', we should be fine, as UPLOADS is always in ABSPATH. + // - if user grants access to 'wp-content', the requirement is Paths::isUploadDirMovedOutOfWPContentDir() + $uploadToo = 'depends'; + } + + // We need upload too for rewrite rules when destination structure is image-roots. + // but it is also good otherwise. So lets always do it. + + $uploadToo = 'yes'; + + return [ + $minRequired, + $pluginToo, // 'yes', 'no' or 'depends' + $uploadToo + ]; + } + + public static function saveRules($config, $showMessage = true) { + list($success, $failedDeactivations, $successfulDeactivations) = self::deactivateHTAccessRules('# The rules have left the building'); + + $rootsToPutRewritesIn = $config['scope']; + if ($config['destination-structure'] == 'doc-root') { + // Commented out to quickfix #338 + // $rootsToPutRewritesIn = Paths::filterOutSubRoots($rootsToPutRewritesIn); + } + + $dirsContainingWebps = []; + + $mingled = ($config['destination-folder'] == 'mingled'); + if ($mingled) { + $dirsContainingWebps[] = 'uploads'; + } + $scopeOtherThanUpload = (str_replace('uploads', '', implode(',', $config['scope'])) != ''); + + if ($scopeOtherThanUpload || (!$mingled)) { + $dirsContainingWebps[] = 'cache'; + } + + $dirsToPutRewritesIn = array_unique(array_merge($rootsToPutRewritesIn, $dirsContainingWebps)); + + $failedWrites = []; + $successfullWrites = []; + foreach ($dirsToPutRewritesIn as $rootId) { + $dirContainsSourceImages = in_array($rootId, $rootsToPutRewritesIn); + $dirContainsWebPImages = in_array($rootId, $dirsContainingWebps); + + $rules = HTAccessRules::generateHTAccessRulesFromConfigObj( + $config, + $rootId, + $dirContainsSourceImages, + $dirContainsWebPImages + ); + $success = self::saveHTAccessRules( + $rootId, + $rules, + true + ); + if ($success) { + $successfullWrites[] = $rootId; + + // Remove it from $successfulDeactivations (if it is there) + if (($key = array_search($rootId, $successfulDeactivations)) !== false) { + unset($successfulDeactivations[$key]); + } + } else { + $failedWrites[] = $rootId; + + // Remove it from $failedDeactivations (if it is there) + if (($key = array_search($rootId, $failedDeactivations)) !== false) { + unset($failedDeactivations[$key]); + } + } + } + + $success = ((count($failedDeactivations) == 0) && (count($failedWrites) == 0)); + + $return = [$success, $successfullWrites, $successfulDeactivations, $failedWrites, $failedDeactivations]; + if ($showMessage) { + self::showSaveRulesMessages($return); + } + return $return; + } + + public static function showSaveRulesMessages($saveRulesResult) + { + list($success, $successfullWrites, $successfulDeactivations, $failedWrites, $failedDeactivations) = $saveRulesResult; + + $msg = ''; + if (count($successfullWrites) > 0) { + $msg .= '

Rewrite rules were saved to the following files:

'; + foreach ($successfullWrites as $rootId) { + $rootIdName = $rootId; + if ($rootIdName == 'cache') { + $rootIdName = 'webp folder'; + } + $msg .= '' . Paths::getAbsDirById($rootId) . '/.htaccess (' . $rootIdName . ')
'; + } + } + + if (count($successfulDeactivations) > 0) { + $msg .= '

Rewrite rules were removed from the following files:

'; + foreach ($successfulDeactivations as $rootId) { + $rootIdName = $rootId; + if ($rootIdName == 'cache') { + $rootIdName = 'webp folder'; + } + $msg .= '' . Paths::getAbsDirById($rootId) . '/.htaccess (' . $rootIdName . ')
'; + } + } + + if ($msg != '') { + Messenger::addMessage( + ($success ? 'success' : 'info'), + $msg + ); + } + + if (count($failedWrites) > 0) { + $msg = '

Failed writing rewrite rules to the following files:

'; + foreach ($failedWrites as $rootId) { + $msg .= '' . Paths::getAbsDirById($rootId) . '/.htaccess (' . $rootId . ')
'; + } + $msg .= 'You need to change the file permissions to allow WebP Express to save the rules.'; + Messenger::addMessage('error', $msg); + } else { + if (count($failedDeactivations) > 0) { + $msg = '

Failed deleting unused rewrite rules in the following files:

'; + foreach ($failedDeactivations as $rootId) { + $msg .= '' . Paths::getAbsDirById($rootId) . '/.htaccess (' . $rootId . ')
'; + } + $msg .= 'You need to change the file permissions to allow WebP Express to remove the rules or ' . + 'remove them manually'; + Messenger::addMessage('error', $msg); + } + } + } + +} diff --git a/lib/classes/HTAccessCapabilityTestRunner.php b/lib/classes/HTAccessCapabilityTestRunner.php new file mode 100644 index 0000000..c572378 --- /dev/null +++ b/lib/classes/HTAccessCapabilityTestRunner.php @@ -0,0 +1,187 @@ + 10]); + //echo '
' . print_r($response, true) . '
'; + if (is_wp_error($response)) { + return null; + } + if (wp_remote_retrieve_response_code($response) != '200') { + return false; + } + $body = wp_remote_retrieve_body($response); + return ($body == 'pong'); + } + + private static function runNamedTest($testName) + { + switch ($testName) { + case 'canRunTestScriptInWOD': + $url = Paths::getWebPExpressPluginUrl() . '/wod/ping.php'; + return self::canRunPingPongTestScript($url); + + case 'canRunTestScriptInWOD2': + $url = Paths::getWebPExpressPluginUrl() . '/wod2/ping.php'; + return self::canRunPingPongTestScript($url); + + case 'htaccessEnabled': + return self::runTestInWebPExpressContentDir('htaccessEnabled'); + + case 'modHeadersLoaded': + return self::runTestInWebPExpressContentDir('modHeadersLoaded'); + + case 'modHeaderWorking': + return self::runTestInWebPExpressContentDir('headerSetWorks'); + + case 'modRewriteWorking': + return self::runTestInWebPExpressContentDir('rewriteWorks'); + + case 'passThroughEnvWorking': + return self::runTestInWebPExpressContentDir('passingInfoFromRewriteToScriptThroughEnvWorks'); + + case 'passThroughHeaderWorking': + // pretend it fails because .htaccess rules aren't currently generated correctly + return false; + return self::runTestInWebPExpressContentDir('passingInfoFromRewriteToScriptThroughRequestHeaderWorks'); + + case 'grantAllAllowed': + return self::runTestInWebPExpressContentDir('grantAllCrashTester'); + } + } + + private static function runOrGetCached($testName) + { + if (!isset(self::$cachedResults)) { + self::$cachedResults = []; + } + if (!isset(self::$cachedResults[$testName])) { + self::$cachedResults[$testName] = self::runNamedTest($testName); + } + return self::$cachedResults[$testName]; + } + + /** + * Run one of the htaccess capability tests. + * Three possible outcomes: true, false or null (null if request fails) + */ + private static function runTestInWebPExpressContentDir($testName) + { + $baseDir = Paths::getWebPExpressContentDirAbs() . '/htaccess-capability-tests'; + $baseUrl = Paths::getContentUrl() . '/webp-express/htaccess-capability-tests'; + + $hct = new HtaccessCapabilityTester($baseDir, $baseUrl); + $hct->setHttpRequester(new WPHttpRequester()); + + try { + switch ($testName) { + case 'htaccessEnabled': + return $hct->htaccessEnabled(); + case 'rewriteWorks': + return $hct->rewriteWorks(); + case 'addTypeWorks': + return $hct->addTypeWorks(); + case 'modHeadersLoaded': + return $hct->moduleLoaded('headers'); + case 'headerSetWorks': + return $hct->headerSetWorks(); + case 'requestHeaderWorks': + return $hct->requestHeaderWorks(); + case 'passingInfoFromRewriteToScriptThroughRequestHeaderWorks': + return $hct->passingInfoFromRewriteToScriptThroughRequestHeaderWorks(); + case 'passingInfoFromRewriteToScriptThroughEnvWorks': + return $hct->passingInfoFromRewriteToScriptThroughEnvWorks(); + case 'grantAllCrashTester': + $rules = <<<'EOD' + + + Order deny,allow + Allow from all + + + Require all granted + + +EOD; + return $hct->crashTest($rules, 'grant-all'); + } + + } catch (\Exception $e) { + return null; + } + //error_log('test: ' . $testName . ':' . (($testResult === true) ? 'ok' : ($testResult === false ? 'failed' : 'hm'))); + + throw new \Exception('Unknown test:' . $testName); + } + + + public static function modRewriteWorking() + { + return self::runOrGetCached('modRewriteWorking'); + } + + public static function htaccessEnabled() + { + return self::runOrGetCached('htaccessEnabled'); + } + + public static function modHeadersLoaded() + { + return self::runOrGetCached('modHeadersLoaded'); + } + + public static function modHeaderWorking() + { + return self::runOrGetCached('modHeaderWorking'); + } + + public static function passThroughEnvWorking() + { + return self::runOrGetCached('passThroughEnvWorking'); + } + + public static function passThroughHeaderWorking() + { + return self::runOrGetCached('passThroughHeaderWorking'); + } + + public static function grantAllAllowed() + { + return self::runOrGetCached('grantAllAllowed'); + } + + public static function canRunTestScriptInWOD() + { + return self::runOrGetCached('canRunTestScriptInWOD'); + } + + public static function canRunTestScriptInWOD2() + { + return self::runOrGetCached('canRunTestScriptInWOD2'); + } + + +} diff --git a/lib/classes/HTAccessRules.php b/lib/classes/HTAccessRules.php new file mode 100644 index 0000000..cc7be0b --- /dev/null +++ b/lib/classes/HTAccessRules.php @@ -0,0 +1,1200 @@ + Paths::getWodUrlPath(), + ]; + foreach ($oldConfig['paths-used-in-htaccess'] as $prop => $value) { + if (isset($pathsGoingToBeUsedInHtaccess[$prop])) { + if ($value != $pathsGoingToBeUsedInHtaccess[$prop]) { + return true; + } + } + } + return false; + } + + /** + * + * Note that server variables are only allowed some places in the .htaccess. + * It is for example not allowed in CondPattern so something like this will not work: + * RewriteCond %{REQUEST_FILENAME} (?i)(%{DOCUMENT_ROOT}/wordpress/wp-content/themes/)(.*)(\.jpe?g|\.png)$ + */ + private static function replaceDocRootWithApacheTokenIfDocRootAvailable($absPath) + { + // TODO: I would like to test this thoroughly before using so we do nothing now: + return $absPath; + + if (PathHelper::isDocRootAvailable()) { + if (strpos($absPath, $_SERVER['DOCUMENT_ROOT']) === 0) { + return "%{DOCUMENT_ROOT}" . substr($absPath, strlen($_SERVER['DOCUMENT_ROOT'])); + } + } + return $absPath; + } + + /** + * Decides if .htaccess rules needs to be updated. + * + * The result is positive under these circumstances: + * - If there is no existing config.json (it must mean that there are no rules, and so they need "updating") + * - If existing config.json is corrupt or not readable + * - The new config.json is compared to the old. If any of the properties that will affect the .htaccess has + * changed, well, it needs updating. Also, if there is a new property, it needs update, unless the + * value of that new property would not have any effect + * - If the url path to the "wod" folder has changed (actually, to wod/webp-on-demand, but if one of these changes, so does the other) + * - TODO: Should we not also compare (some of) the capability tests? + * - TODO: Should we not also compare some paths, ie. Paths::getContentDirRelToPluginDir(), which is used in + * HTAccessRules::webpRealizerRules() + * - TODO: Some changes would not really require regeneration. We could do a more fine-grained + * check. For example, many changes matters not (ie "wod-url-path") if redirection to + * both webp-realizer and webp-on-demand are disabled. + * + */ + public static function doesRewriteRulesNeedUpdate($newConfig) { + if (!Config::isConfigFileThere()) { + // this properly means that rewrite rules have never been generated + return true; + } + + $oldConfig = Config::loadConfig(); + if ($oldConfig === false) { + // corrupt or not readable + return true; + } + + // $propsToCompare is set like this: + // Keys: properties that should trigger .htaccess update if changed + // Values: The behaviour before that property was intruduced + $propsToCompare = [ + 'forward-query-string' => true, + 'image-types' => 1, + 'redirect-to-existing-in-htaccess' => false, + 'only-redirect-to-converter-on-cache-miss' => false, + 'success-response' => 'converted', + 'cache-control' => 'no-header', + 'cache-control-custom' => 'public, max-age:3600', + 'cache-control-max-age' => 'one-week', + 'cache-control-public' => true, + 'enable-redirection-to-webp-realizer' => false, + 'enable-redirection-to-converter' => true, + 'destination-folder' => 'separate', + 'destination-extension' => 'append', + 'destination-structure' => 'doc-root', + 'scope' => ['themes', 'uploads'], + 'prevent-using-webps-larger-than-original' => true, + ]; + + // If one of the props have changed, we need to update. + // And we also need update if one of them doesn't exist in the old config, unless + // the new property/value has same effect as before the property was introduced. + foreach ($propsToCompare as $prop => $behaviourBeforeIntroduced) { + if (!isset($newConfig[$prop])) { + continue; + } + if (!isset($oldConfig[$prop])) { + // Do not trigger .htaccess update if the new value results + // in same old behaviour (before this option was introduced) + if ($newConfig[$prop] == $behaviourBeforeIntroduced) { + continue; + } else { + // Otherwise DO trigger .htaccess update + return true; + } + } + if ($newConfig[$prop] != $oldConfig[$prop]) { + return true; + } + } + + /* + if (isset($newConfig['redirect-to-existing-in-htaccess']) && $newConfig['redirect-to-existing-in-htaccess']) { + $propsToCompare['destination-folder'] = 'separate'; + $propsToCompare['destination-extension'] = 'append'; + $propsToCompare['destination-structure'] = 'doc-root'; + }*/ + + + if (self::arePathsUsedInHTAccessOutdated2($oldConfig)) { + return true; + } + return false; + } + + /** + * @return string Info in comments. + */ + private static function infoRules() + { + + return "# The rules below have been dynamically created by WebP Express in accordance with the plugin settings\n" . + "# DO NOT EDIT MANUALLY (unless you are prepared that your changes might be overridden by WebP Express)" . "\n" . + "# The following parameters have been in play to produce the rules:\n" . + "#\n# WebP Express options:\n" . + "# - Operation mode: " . self::$config['operation-mode'] . "\n" . + "# - Redirection to existing webp: " . + (self::$config['redirect-to-existing-in-htaccess'] ? 'enabled' : 'disabled') . "\n" . + "# - Redirection to converter: " . + (self::$config['enable-redirection-to-converter'] ? 'enabled' : 'disabled') . "\n" . + "# - Redirection to converter to create missing webp files upon request for the webp: " . + (self::$config['enable-redirection-to-webp-realizer'] ? 'enabled' : 'disabled') . "\n" . + + "# - Destination folder: " . self::$config['destination-folder'] . "\n" . + "# - Destination extension: " . self::$config['destination-extension'] . "\n" . + "# - Destination structure: " . self::$config['destination-structure'] . (((self::$config['destination-structure'] == 'doc-root') && (!self::$useDocRootForStructuringCacheDir)) ? ' (overruled!)' : '') . "\n" . + "# - Image types: " . str_replace('?', '', implode(', ', self::$fileExtensions)) . "\n" . + '# - Alter HTML enabled?: ' . (self::$alterHtmlEnabled ? 'yes' : 'no') . "\n" . + + "#\n# Wordpress/Server configuration:\n" . + '# - Document root availablity: ' . Paths::docRootStatusText() . "\n" . + + "#\n# .htaccess capability test results:\n" . + "# - mod_header working?: " . + (self::$capTests['modHeaderWorking'] === true ? 'yes' : (self::$capTests['modHeaderWorking'] === false ? 'no' : 'could not be determined')) . "\n" . + "# - pass variable from .htaccess to script through header working?: " . + (self::$capTests['passThroughHeaderWorking'] === true ? 'yes' : (self::$capTests['passThroughHeaderWorking'] === false ? 'no' : 'could not be determined')) . "\n" . + "# - pass variable from .htaccess to script through environment variable working?: " . + (self::$capTests['passThroughEnvWorking'] === true ? 'yes' : (self::$capTests['passThroughEnvWorking'] === false ? 'no' : 'could not be determined')) . "\n" . + + "#\n# Role of the dir that this .htaccess is located in:\n" . + '# - Is this .htaccess in a dir containing source images?: ' . (self::$dirContainsSourceImages ? 'yes' : 'no') . "\n" . + '# - Is this .htaccess in a dir containing webp images?: ' . (self::$dirContainsWebPImages ? 'yes' : 'no') . "\n" . + "\n"; + } + + /** + * @return string rules for cache control + */ + private static function cacheRules() + { + // Build cache control rules + $ccRules = ''; + $cacheControlHeader = Config::getCacheControlHeader(self::$config); + if ($cacheControlHeader != '') { + $ccRules .= "# Set Cache-Control header for requests to webp images\n"; + $ccRules .= "\n"; + $ccRules .= " \n"; + $ccRules .= " Header set Cache-Control \"" . $cacheControlHeader . "\"\n"; + $ccRules .= " \n"; + $ccRules .= "\n\n"; + + // Fall back to mod_expires if mod_headers is unavailable + if (self::$modHeaderDefinitelyUnavailable) { + $cacheControl = self::$config['cache-control']; + + $expires = ''; + if ($cacheControl == 'custom') { + $expires = ''; + + // Do not add Expire header if private is set + // - because then the user don't want caching in proxies / CDNs. + // the Expires header doesn't differentiate between private/public + if (!(preg_match('/private/', self::$config['cache-control-custom']))) { + if (preg_match('/max-age=(\d+)/', self::$config['cache-control-custom'], $matches)) { + if (isset($matches[1])) { + $expires = $matches[1] . ' seconds'; + } + } + } + + } elseif ($cacheControl == 'no-header') { + $expires = ''; + } elseif ($cacheControl == 'set') { + if (self::$config['cache-control-public']) { + $cacheControlOptions = [ + 'no-header' => '', + 'one-second' => '1 seconds', + 'one-minute' => '1 minutes', + 'one-hour' => '1 hours', + 'one-day' => '1 days', + 'one-week' => '1 weeks', + 'one-month' => '1 months', + 'one-year' => '1 years', + ]; + $expires = $cacheControlOptions[self::$config['cache-control-max-age']]; + } + } + + if ($expires != '') { + // in case mod_headers is missing, try mod_expires + $ccRules .= "# Fall back to mod_expires if mod_headers is unavailable\n"; + $ccRules .= "\n"; + $ccRules .= " \n"; + $ccRules .= " ExpiresActive On\n"; + $ccRules .= " ExpiresByType image/webp \"access plus " . $expires . "\"\n"; + $ccRules .= " \n"; + $ccRules .= "\n\n"; + } + } + } + return $ccRules; + } + + /** + * @return string rules for redirecting to existing + */ + private static function redirectToExistingRules() + { + $rules = ''; + + if (self::$mingled) { + // TODO: + // Only write mingled rules for "uploads" dir. + // - UNLESS no .htaccess has been placed in uploads dir (is unwritable) (in that case also write for wp-content / index) + // (self::$htaccessDir == 'uploads') + $rules .= " # Redirect to existing converted image in same dir (if browser supports webp)\n"; + $rules .= " RewriteCond %{HTTP_ACCEPT} image/webp\n"; + + if (self::$htaccessDir == 'index') { + // TODO: Add the following rule if configured to + if (false) { + // TODO: Full path to wp-admin from doc-root - if possible + // (that is: if document root is available). + // ie: RewriteCond %{REQUEST_URI} ^/?wordpress/wp-admin + $rules .= " RewriteCond %{REQUEST_URI} !wp-admin\n"; + } + } + +// $rules .= " RewriteCond %{REQUEST_FILENAME} (?i)(.*)(" . self::$fileExtIncludingDot . ")$\n"; + + // self::$appendWebP cannot be used, we need this: + // (because we are not sure there are a .htaccess in the uploads folder) + + if (self::$useDocRootForStructuringCacheDir) { + if (self::$config['destination-extension'] == 'append') { + $rules .= " RewriteCond %{REQUEST_FILENAME}.webp -f\n"; + //$rules .= " RewriteCond " . self::$docRootString . "/" . self::$htaccessDirRelToDocRoot . "/$1.$2.webp -f\n"; + $rules .= " RewriteRule ^/?(.*)\.(" . self::$fileExt . ")$ $1.$2.webp [NC,T=image/webp,E=EXISTING:1," . (self::$setAddVaryEnvInRedirect ? 'E=ADDVARY:1,' : '') . "L]\n\n"; + } else { + // extension: set to webp + + //$rules .= " RewriteCond " . self::$docRootString . "/" . self::$htaccessDirRelToDocRoot . "/$1.webp -f\n"; + //$rules .= " RewriteRule " . $rewriteRuleStart . "\.(" . self::$fileExt . ")$ $1.webp [T=image/webp,E=EXISTING:1," . (self::$setAddVaryEnvInRedirect ? 'E=ADDVARY:1,' : '') . "L]\n\n"; + + // Got these new rules here: https://www.digitalocean.com/community/tutorials/how-to-create-and-serve-webp-images-to-speed-up-your-website + // (but are they actually better than the ones we use for append?) + $rules .= " RewriteCond %{REQUEST_URI} (?i)(.*)(" . self::$fileExtIncludingDot . ")$\n"; + $rules .= " RewriteCond " . self::$docRootString . "%1\.webp -f\n"; + $rules .= " RewriteRule (?i)(.*)(" . self::$fileExtIncludingDot . ")$ %1\.webp [T=image/webp,E=EXISTING:1," . (self::$setAddVaryEnvInRedirect ? 'E=ADDVARY:1,' : '') . "L]\n\n"; + + // Instead of using REQUEST_URI, I can use REQUEST_FILENAME and remove DOCUMENT_ROOT + // I suppose REQUEST_URI is what was requested (ie "/wp-content/uploads/image.jpg"). + // REQUEST_FILENAME is the filesystem path. (ie "/var/www/example.com/uploads-moved/image.jpg") + // But it cant be, because then the digitalocean solution would not work in above case. + // TODO: investigate + + // RewriteRule (?i)(.*)(\.jpe?g|\.png)$ %1\.webp [T=image/webp,E=EXISTING:1,E=ADDVARY:1,L] + } + } else { + $appendWebP = !(self::$config['destination-extension'] == 'set'); + + $rules .= " RewriteCond %{REQUEST_FILENAME} (?i)(.*)(" . self::$fileExtIncludingDot . ")$\n"; + $rules .= " RewriteCond %1" . ($appendWebP ? "%2" : "") . "\.webp -f\n"; + $rules .= " RewriteRule (?i)(.*)(" . self::$fileExtIncludingDot . ")$ %1" . ($appendWebP ? "%2" : "") . + "\.webp [T=image/webp,E=EXISTING:1," . (self::$setAddVaryEnvInRedirect ? 'E=ADDVARY:1,' : '') . "L]\n\n"; + + } + +/* + + */ + } + + if (self::$htaccessDir != 'uploads') { + //return '# temporalily disabled'; + } + + // Redirect to existing converted image in cache-dir. + // Do not write these rules for uploads in mingled (there are no "uploads" images in cache-dir when in mingled mode) + if (!(self::$mingled && (self::$htaccessDir == 'uploads'))) { + $rules .= " # Redirect to existing converted image in cache-dir (if browser supports webp)\n"; + $rules .= " RewriteCond %{HTTP_ACCEPT} image/webp\n"; + + if (self::$useDocRootForStructuringCacheDir) { + $cacheDirRel = Paths::getCacheDirRelToDocRoot() . '/doc-root'; + + $rules .= " RewriteCond %{REQUEST_FILENAME} -f\n"; + $rules .= " RewriteCond " . + self::$docRootString . + "/" . $cacheDirRel . "/" . self::$htaccessDirRelToDocRoot . "/$1.$2.webp -f\n"; + $rules .= " RewriteRule ^/?(.+)\.(" . self::$fileExt . ")$ /" . $cacheDirRel . "/" . self::$htaccessDirRelToDocRoot . + "/$1.$2.webp [NC,T=image/webp,E=EXISTING:1," . (self::$setAddVaryEnvInRedirect ? 'E=ADDVARY:1,' : '') . "L]\n\n"; + + } else { + // Make sure source image exists + $rules .= " RewriteCond %{REQUEST_FILENAME} -f\n"; + + // Find relative path of source (accessible as %2%3) + $rules .= " RewriteCond %{REQUEST_FILENAME} (?i)(" . self::$htaccessDirAbs . "/)(.*)(" . self::$fileExtIncludingDot . ")$\n"; + + // Make sure there is a webp in the cache-dir + $cacheDirForThisRoot = Paths::getCacheDirForImageRoot( + self::$config['destination-folder'], + 'image-roots', + self::$htaccessDir + ); + $cacheDirForThisRoot = PathHelper::fixAbsPathToUseUnresolvedDocRoot($cacheDirForThisRoot); + $cacheDirForThisRoot = PathHelper::backslashesToForwardSlashes($cacheDirForThisRoot); #512 + $rules .= " RewriteCond " . $cacheDirForThisRoot . "/%2%3.webp -f\n"; + //RewriteCond /var/www/webp-express-tests/we0/wp-content-moved/webp-express/webp-images/uploads/%2%3.webp -f + + $urlPath = '/' . Paths::getContentUrlPath() . "/webp-express/webp-images/" . self::$htaccessDir . "/%2" . (self::$appendWebP ? "%3" : "") . "\.webp"; + //$rules .= " RewriteCond %1" . (self::$appendWebP ? "%2" : "") . "\.webp -f\n"; + $rules .= " RewriteRule (?i)(.*)(" . self::$fileExtIncludingDot . ")$ " . $urlPath . + " [T=image/webp,E=EXISTING:1," . (self::$setAddVaryEnvInRedirect ? 'E=ADDVARY:1,' : '') . "L]\n\n"; + } + + //$rules .= " RewriteRule ^\/?(.*)\.(" . self::$fileExt . ")$ /" . $cacheDirRel . "/" . self::$htaccessDirRelToDocRoot . "/$1.$2.webp [NC,T=image/webp,E=EXISTING:1,L]\n\n"; + } + + return $rules; + } + + private static function webpRealizerRules() + { + /* + # Pass REQUEST_FILENAME to PHP in request header + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{DOCUMENT_ROOT}/wordpress/uploads-moved/$1 -f + RewriteRule ^(.*)\.(webp)$ - [E=REQFN:%{REQUEST_FILENAME}] + + RequestHeader set REQFN "%{REQFN}e" env=REQFN + + + # WebP Realizer: Redirect non-existing webp images to converter when a corresponding jpeg/png is found + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{DOCUMENT_ROOT}/wordpress/uploads-moved/$1 -f + RewriteRule ^(.*)\.(webp)$ /plugins-moved/webp-express/wod/webp-realizer.php?wp-content=wp-content-moved [NC,L] + */ + + $rules = ''; + $rules .= "# WebP Realizer: Redirect non-existing webp images to webp-realizer.php, which will locate corresponding jpg/png, \n" . + "# convert it, and deliver the freshly converted webp\n"; + $rules .= "\n" . + " RewriteEngine On\n"; + + + if (self::$useDocRootForStructuringCacheDir) { + /* + Generate something like this: + + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^/?(.+)\.(webp)$ /plugins-moved/webp-express/wod/webp-realizer.php [E=DESTINATIONREL:wp-content-moved/$0,E=WPCONTENT:wp-content-moved,NC,L] + */ + $rules .= " RewriteCond %{REQUEST_FILENAME} !-f\n"; + $params = []; + $flags = []; + if (!self::$passThroughEnvVarDefinitelyUnavailable) { + $flags[] = 'E=DESTINATIONREL:' . self::$htaccessDirRelToDocRoot . '/$0'; + } + if (!self::$passThroughEnvVarDefinitelyUnavailable) { + $flags[] = 'E=WPCONTENT:' . Paths::getContentDirRel(); + } + $flags[] = 'NC'; + $flags[] = 'L'; + + $passRelativePathToSourceInQS = !(self::$passThroughEnvVarDefinitelyAvailable || self::$passThroughHeaderDefinitelyAvailable); + if ($passRelativePathToSourceInQS) { + $params[] = 'xdestination-rel=x' . self::$htaccessDirRelToDocRoot . '/$1.$2'; + } + if (!self::$passThroughEnvVarDefinitelyAvailable) { + $params[] = "wp-content=" . Paths::getContentDirRel(); + } + + // When matching from the beginning (^), we need the "/?" in order to make it work on litespeed too. + // Here is why: https://openlitespeed.org/kb/apache-rewrite-rules-in-openlitespeed/ + $rewriteRuleStart = '^/?(.+)'; + $rules .= " RewriteRule " . $rewriteRuleStart . "\.(webp)$ " . + "/" . self::getWebPRealizerUrlPath() . + ((count($params) > 0) ? "?" . implode('&', $params) : '') . + " [" . implode(',', $flags) . "]\n\n"; + } else { + /* + Generate something like this: + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule (?i).*(\.jpe?g|\.png)\.webp$ /plugins-moved/webp-express/wod/webp-realizer.php [E=WE_WP_CONTENT_REL_TO_PLUGIN_DIR:../wp-content-moved,E=WE_DESTINATION_REL_HTACCESS:$0,E=WE_HTACCESS_ID:cache,NC,L] + */ + // Add condition for making sure the webp does not already exist + $rules .= " RewriteCond %{REQUEST_FILENAME} !-f\n"; + + $params = []; + $flags = []; + + if (!self::$passThroughEnvVarDefinitelyUnavailable) { + //$flags[] = 'E=WE_WP_CONTENT_REL_TO_PLUGIN_DIR:' . Paths::getContentDirRelToPluginDir(); + $flags[] = 'E=WE_WP_CONTENT_REL_TO_WE_PLUGIN_DIR:' . Paths::getContentDirRelToWebPExpressPluginDir(); + $flags[] = 'E=WE_DESTINATION_REL_HTACCESS:$0'; + $flags[] = 'E=WE_HTACCESS_ID:' . self::$htaccessDir; // this will btw either be "uploads" or "cache" + } + $flags[] = 'NC'; // case-insensitive match (so file extension can be jpg, JPG or even jPg) + $flags[] = 'L'; + + if (!self::$passThroughEnvVarDefinitelyAvailable) { + //$params[] = 'xwp-content-rel-to-plugin-dir=x' . Paths::getContentDirRelToPluginDir(); + $params[] = 'xwp-content-rel-to-we-plugin-dir=x' . Paths::getContentDirRelToWebPExpressPluginDir(); + $params[] = 'xdestination-rel-htaccess=x$0'; + $params[] = 'htaccess-id=' . self::$htaccessDir; + } + + // self::$appendWebP cannot be used, we need the following in order for + // it to work for uploads in: Mingled, "Set to WebP", "Image roots". + // TODO! Will it work for ie theme images? + // - well, it should, because the script is passed $0. Not matching the ".png" part of the filename + // only means it is a bit more greedy than it has to + $appendWebP = !(self::$config['destination-extension'] == 'set'); + + $rules .= " RewriteRule (?i).*" . ($appendWebP ? "(" . self::$fileExtIncludingDot . ")" : "") . "\.webp$ " . + "/" . self::getWebPRealizerUrlPath() . + (count($params) > 0 ? "?" . implode('&', $params) : "") . + " [" . implode(',', $flags) . "]\n"; + + /* + Generate something like this: + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule (?i).*\.webp$ /plugins-moved/webp-express/wod/webp-realizer.php [E=WE_WP_CONTENT_REL_TO_PLUGIN_DIR:../wp-content-moved,E=WE_DESTINATION_REL_IMAGE_ROOT:$0,E=WE_IMAGE_ROOT_ID:wp-content,NC,L] + */ +/* + // Add condition for making sure the webp does not already exist + $rules .= " RewriteCond %{REQUEST_FILENAME} !-f\n"; + + $params = []; + $flags = []; + + if (!self::$passThroughEnvVarDefinitelyUnavailable) { + $flags[] = 'E=WE_WP_CONTENT_REL_TO_PLUGIN_DIR:' . Paths::getContentDirRelToPluginDir(); + $flags[] = 'E=WE_DESTINATION_REL_IMAGE_ROOT:$0'; + $flags[] = 'E=WE_IMAGE_ROOT_ID:' . self::$htaccessDir; + } + $flags[] = 'NC'; // case-insensitive match (so file extension can be jpg, JPG or even jPg) + $flags[] = 'L'; + + if (!self::$passThroughEnvVarDefinitelyAvailable) { + $params[] = 'image-root-id=' . self::$htaccessDir; + $params[] = 'xdestination-rel-image-root=x$0'; + $params[] = 'xwp-content-rel-to-plugin-dir=x' . Paths::getContentDirRelToPluginDir(); + } + + // self::$appendWebP cannot be used, we need the following in order for + // it to work for uploads in: Mingled, "Set to WebP", "Image roots". + // TODO! Will it work for ie theme images? + // - well, it should, because the script is passed $0. Not matching the ".png" part of the filename + // only means it is a bit more greedy than it has to + $appendWebP = !(self::$config['destination-extension'] == 'set'); + + $rules .= " RewriteRule (?i).*" . ($appendWebP ? "(" . self::$fileExtIncludingDot . ")" : "") . "\.webp$ " . + "/" . self::getWebPRealizerUrlPath() . + (count($params) > 0 ? "?" . implode('&', $params) : "") . + " [" . implode(',', $flags) . "]\n\n"; +*/ + + + /* + Generate something like this: + + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} (?i)(/var/www/webp-express-tests/we0/wordpress/uploads-moved/)(.*)(\.jpe?g|\.png)(\.webp)$ + RewriteRule (?i).*\.webp$ /plugins-moved/webp-express/wod/webp-realizer.php?root-id=uploads&xdest-rel-to-root-id=x%2%3%4 [E=WE_WP_CONTENT_REL_TO_PLUGIN_DIR:../wp-content-moved,E=REQFN:%{REQUEST_FILENAME},NC,L] + + */ + /* + // Bugger! When the requested file does not exist, %{REQUEST_FILENAME} will always contain the full path. + // - it is set to the closest existing path plus one path component. + // So we cannot use %{REQUEST_FILENAME} for webp realizer. + // It seems we must use REQUEST_URI. But this could get tricky as we may not have access to the resolved document root in the scripts + + // Add condition for making sure the webp does not already exist + $rules .= " RewriteCond %{REQUEST_FILENAME} !-f\n"; + + $rules .= " RewriteCond %{REQUEST_FILENAME} (?i)(" . + self::$htaccessDirAbs . "/)(.*)" . (self::$appendWebP ? "(" . self::$fileExtIncludingDot . ")" : "") . "(\.webp)$\n"; + + $params = []; + $flags = []; + + if (!self::$passThroughEnvVarDefinitelyUnavailable) { + $flags[] = 'E=WE_WP_CONTENT_REL_TO_PLUGIN_DIR:' . Paths::getContentDirRelToPluginDir(); + $flags[] = 'E=WEDESTINATIONABS:%0'; + } + $flags[] = 'NC'; // case-insensitive match (so file extension can be jpg, JPG or even jPg) + $flags[] = 'L'; + + if (!self::$passThroughEnvVarDefinitelyAvailable) { + $params[] = 'root-id=' . self::$htaccessDir; + $params[] = 'xdest-rel-to-root-id=x%2%3' . (self::$appendWebP ? "%4" : ""); + } + + $rules .= " RewriteRule (?i).*\.webp$ " . + "/" . self::getWebPRealizerUrlPath() . + (count($params) > 0 ? "?" . implode('&', $params) : "") . + " [" . implode(',', $flags) . "]\n\n"; + */ + } + + /*if (!self::$config['redirect-to-existing-in-htaccess']) { + $rules .= self::cacheRules(); + }*/ + $rules .= "\n\n"; + + return $rules; + } + + private static function webpOnDemandRules() + { + $setEnvVar = !self::$passThroughEnvVarDefinitelyUnavailable; + $passRelativePathToSourceInQS = !(self::$passThroughEnvVarDefinitelyAvailable || self::$passThroughHeaderDefinitelyAvailable); + + $rules = ''; + + // Do not add header magic if passing through env is definitely working + // Do not add either, if we definitily know it isn't working + /* + if ((!self::$passThroughEnvVarDefinitelyAvailable) && (!self::$passThroughHeaderDefinitelyUnavailable)) { + if (self::$config['enable-redirection-to-converter']) { + $rules .= " # Pass REQUEST_FILENAME to webp-on-demand.php in request header\n"; + //$rules .= $basicConditions; + //$rules .= " RewriteRule ^(.*)\.(" . self::$fileExt . ")$ - [E=REQFN:%{REQUEST_FILENAME}]\n" . + $rules .= " \n" . + " RequestHeader set REQFN \"%{REQFN}e\" env=REQFN\n" . + " \n\n"; + + } + if (self::$config['enable-redirection-to-webp-realizer']) { + // We haven't implemented a clever way to pass through header for webp-realizer yet + } + }*/ + $rules .= " # Redirect images to webp-on-demand.php "; + if (self::$config['only-redirect-to-converter-for-webp-enabled-browsers']) { + $rules .= "(if browser supports webp)\n"; + } else { + $rules .= "(regardless whether browser supports webp or not!)\n"; + } + if (self::$config['only-redirect-to-converter-for-webp-enabled-browsers']) { + $rules .= " RewriteCond %{HTTP_ACCEPT} image/webp\n"; + } + + if (self::$useDocRootForStructuringCacheDir) { + /* + Generate something like this: + + RewriteCond %{HTTP_ACCEPT} image/webp + RewriteCond %{REQUEST_FILENAME} -f + RewriteRule ^/?(.+)\.(jpe?g|png)$ /plugins-moved/webp-express/wod/webp-on-demand.php [NC,L,E=REQFN:%{REQUEST_FILENAME},E=WPCONTENT:wp-content-moved] + */ + + $params = []; + $flags = ['NC', 'L']; + if ($setEnvVar) { + $flags[] = 'E=REQFN:%{REQUEST_FILENAME}'; + } + $rules .= " RewriteCond %{REQUEST_FILENAME} -f\n"; + if ($passRelativePathToSourceInQS) { + $params[] = 'xsource-rel=x' . self::$htaccessDirRelToDocRoot . '/$1.$2'; + } + if (!self::$passThroughEnvVarDefinitelyAvailable) { + $params[] = "wp-content=" . Paths::getContentDirRel(); + } + if (!self::$passThroughEnvVarDefinitelyUnavailable) { + $flags[] = 'E=WPCONTENT:' . Paths::getContentDirRel(); + } + + // TODO: When $rewriteRuleStart is empty, we don't need the .*, do we? - test + $rewriteRuleStart = '^/?(.+)'; + $rules .= " RewriteRule " . $rewriteRuleStart . "\.(" . self::$fileExt . ")$ " . + "/" . self::getWodUrlPath() . + (count($params) > 0 ? "?" . implode('&', $params) : "") . + " [" . implode(',', $flags) . "]\n"; + + } else { + + /* + Create something like this: + RewriteCond %{REQUEST_FILENAME} -f + RewriteCond %{REQUEST_FILENAME} (?i)(.*)(\.jpe?g|\.png)$ + RewriteRule (?i).*$ /wordpress/wp-content/plugins/webp-express/wod/webp-on-demand.php [E=WE_WP_CONTENT_REL_TO_WE_PLUGIN_DIR:../..,E=WE_SOURCE_REL_HTACCESS:$0,E=WE_HTACCESS_ID:uploads,NC,L] + */ + + // Making sure the source exists + $rules .= " RewriteCond %{REQUEST_FILENAME} -f\n"; + + $params = []; + $flags = []; + + if (!self::$passThroughEnvVarDefinitelyUnavailable) { + //$flags[] = 'E=WE_WP_CONTENT_REL_TO_PLUGIN_DIR:' . Paths::getContentDirRelToPluginDir(); + $flags[] = 'E=WE_WP_CONTENT_REL_TO_WE_PLUGIN_DIR:' . Paths::getContentDirRelToWebPExpressPluginDir(); + $flags[] = 'E=WE_SOURCE_REL_HTACCESS:$0'; + $flags[] = 'E=WE_HTACCESS_ID:' . self::$htaccessDir; // this will btw be one of the image roots. It will not be "cache" + } + $flags[] = 'NC'; // case-insensitive match (so file extension can be jpg, JPG or even jPg) + $flags[] = 'L'; + + if (!self::$passThroughEnvVarDefinitelyAvailable) { + //$params[] = 'xwp-content-rel-to-plugin-dir=x' . Paths::getContentDirRelToPluginDir(); + $params[] = 'xwp-content-rel-to-we-plugin-dir=x' . Paths::getContentDirRelToWebPExpressPluginDir(); + $params[] = 'xsource-rel-htaccess=x$0'; + $params[] = 'htaccess-id=' . self::$htaccessDir; + } + + $rules .= " RewriteCond %{REQUEST_FILENAME} (?i)(.*)(" . self::$fileExtIncludingDot . ")$\n"; + + $rules .= " RewriteRule (?i).*$ " . + "/" . self::getWodUrlPath() . + (count($params) > 0 ? "?" . implode('&', $params) : "") . + " [" . implode(',', $flags) . "]\n"; + + // self::$appendWebP cannot be used, we need the following in order for + // it to work for uploads in: Mingled, "Set to WebP", "Image roots". + // TODO! Will it work for ie theme images? + // - well, it should, because the script is passed $0. Not matching the ".png" part of the filename + // only means it is a bit more greedy than it has to + /* + $appendWebP = !(self::$config['destination-extension'] == 'set'); + + $rules .= " RewriteRule (?i).*" . ($appendWebP ? "(" . self::$fileExtIncludingDot . ")" : "") . "$ " . + "/" . self::getWodUrlPath() . + (count($params) > 0 ? "?" . implode('&', $params) : "") . + " [" . implode(',', $flags) . "]\n"; + +*/ + /* +*/ + + /* + Create something like this (for wp-content): + + # Redirect to existing converted image in cache-dir (if browser supports webp) + RewriteCond %{HTTP_ACCEPT} image/webp + RewriteCond %{REQUEST_FILENAME} (?i)(/var/www/webp-express-tests/we0/wp-content-moved/)(.*)(\.jpe?g|\.png)$ + RewriteRule (?i)(.*)(\.jpe?g|\.png)$ /plugins-moved/webp-express/wod/webp-on-demand.php?root-id=wp-content&xsource-rel-to-root-id=%2%3 + + PS: Actually, the whole REQUEST_FILENAME could be passed in querystring by adding "&req-fn=%0" to above. + */ + /* + $rules .= " RewriteCond %{REQUEST_FILENAME} (?i)(" . + self::$htaccessDirAbs . "/)(.*)(" . self::$fileExtIncludingDot . ")$\n"; + + $params = []; + $flags = []; + + if (!self::$passThroughEnvVarDefinitelyUnavailable) { + $flags[] = 'E=WE_WP_CONTENT_REL_TO_PLUGIN_DIR:' . Paths::getContentDirRelToPluginDir(); + $flags[] = 'E=REQFN:%{REQUEST_FILENAME}'; + } + $flags[] = 'NC'; // case-insensitive match (so file extension can be jpg, JPG or even jPg) + $flags[] = 'L'; + + $params[] = 'root-id=' . self::$htaccessDir; + $params[] = 'xsource-rel-to-root-id=x%2' . (self::$appendWebP ? "%3" : ""); + + $rules .= " RewriteRule (?i)(.*)(" . self::$fileExtIncludingDot . ")$ " . + "/" . self::getWodUrlPath() . + (count($params) > 0 ? "?" . implode('&', $params) : "") . + " [" . implode(',', $flags) . "]\n"; + */ + + /* + TODO: NO, this will not do on systems that cannot pass through ENV. + (Or is REQUEST_FILENAME useable at all? If it is, then we could perhaps + catch the whole %{REQUEST_FILENAME} and pass it in %1) + + $params = []; + $flags = ['NC', 'L']; + + if ($passRelativePathToSourceInQS) { + $params[] = 'xsource-rel-to-plugin-dir=x' . self::$htaccessDirRelToDocRoot . '/$1.$2'; + } + if (!self::$passThroughEnvVarDefinitelyAvailable) { + $params[] = "xwp-content-rel-to-plugin-dir=x" . Paths::getContentDirRelToPluginDir(); + } + +// $rules .= " RewriteCond %{REQUEST_FILENAME} -f\n"; + $rules .= " RewriteCond %{REQUEST_FILENAME} (?i)(" . + self::$htaccessDirAbs . "/)(.*)(" . self::$fileExtIncludingDot . ")$\n"; + + $rules .= " RewriteRule (?i)(.*)(" . self::$fileExtIncludingDot . ")$ " . + "/" . self::getWodUrlPath() . + (count($params) > 0 ? "?" . implode('&', $params) : "") . + " [" . implode(',', $flags) . "]\n"; + + //$urlPath = '/' . Paths::getUrlPathById('plugins') . "/%2" . (self::$appendWebP ? "%3" : "") . "\.webp"; + // $urlPath = '/' . Paths::getUrlPathById(self::$htaccessDir) . "/%2" . (self::$appendWebP ? "%3" : "") . "\.webp"; + //$urlPath = '/' . Paths::getContentUrlPath() . "/webp-express/webp-images/" . self::$htaccessDir . "/%2" . (self::$appendWebP ? "%3" : "") . "\.webp"; + //$rules .= " RewriteCond %1" . (self::$appendWebP ? "%2" : "") . "\.webp -f\n"; + //$rules .= " RewriteRule (?i)(.*)(" . self::$fileExtIncludingDot . ")$ " . $urlPath ." [T=image/webp,E=EXISTING:1," . (self::$setAddVaryEnvInRedirect ? 'E=ADDVARY:1,' : '') . "L]\n\n"; + */ + + + + + + } + + + /* + $rules .= " RewriteCond %{REQUEST_FILENAME} (?i)(" . self::$htaccessDirAbs . "/)(.*)(" . self::$fileExtIncludingDot . ")$\n"; + $urlPath = '/' . Paths::getContentUrlPath() . "/webp-express/webp-images/" . self::$htaccessDir . "/%2" . (self::$appendWebP ? "%3" : "") . "\.webp"; + //$rules .= " RewriteCond %1" . (self::$appendWebP ? "%2" : "") . "\.webp -f\n"; + $rules .= " RewriteRule (?i)(.*)(" . self::$fileExtIncludingDot . ")$ " . $urlPath . + " [T=image/webp,E=EXISTING:1," . (self::$setAddVaryEnvInRedirect ? 'E=ADDVARY:1,' : '') . "L]\n\n"; + */ + + /* + $rules .= " RewriteCond %{REQUEST_FILENAME} (?i)(.*)(\.jpe?g|\.png)$\n"; + $rules .= " RewriteCond %1%2\.webp -f\n"; + $rules .= " RewriteRule (?i)(.*)(\.jpe?g|\.png)$ %1%2\.webp [T=image/webp,E=EXISTING:1,E=ADDVARY:1,L]\n"; + */ + + $rules .= "\n"; + return $rules; + } + + private static function setInternalProperties($config, $htaccessDir = 'index') + { + self::$useDocRootForStructuringCacheDir = ( + ($config['destination-structure'] == 'doc-root') && + Paths::canUseDocRootForStructuringCacheDir() + ); + self::$htaccessDir = $htaccessDir; + self::$htaccessDirAbs = Paths::getAbsDirById(self::$htaccessDir); + + self::$htaccessDirRelToDocRoot = ''; + if (self::$useDocRootForStructuringCacheDir) { + self::$htaccessDirRelToDocRoot = PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed( + self::$htaccessDirAbs + ); + } + + // When using the absolute dir, the rewrite rules needs document root and does not work + // if the symlinks have been resolved. + // We can fix this - but only if document root is available and resolvable. + // - which is sad, because the image-roots was introduced in order to get it to work on setups + // where it isn't. + self::$htaccessDirAbs = PathHelper::fixAbsPathToUseUnresolvedDocRoot(self::$htaccessDirAbs); + + // Fix config. + $defaults = [ + 'operation-mode' => 'varied-image-responses', + 'enable-redirection-to-converter' => true, + 'forward-query-string' => true, + 'image-types' => 1, + 'do-not-pass-source-in-query-string' => false, + 'redirect-to-existing-in-htaccess' => false, + 'only-redirect-to-converter-on-cache-miss' => false, + 'destination-folder' => 'separate', + 'destination-extension' => 'append', + 'destination-structure' => 'doc-root', + 'success-response' => 'converted', + ]; + $config = array_merge($defaults, $config); + + if (!isset($config['base-htaccess-on-these-capability-tests'])) { + $config['base-htaccess-on-these-capability-tests'] = Config::runAndStoreCapabilityTests($config); + } + // We currently accept that the following capability tests might not + // have been run (we did not want to force recreation of .htaccess because of these) + // - "modHeaderWorking" + // - "canRunTestScriptInWOD" + // - "canRunTestScriptInWOD2" + if (!isset($config['base-htaccess-on-these-capability-tests']['modHeaderWorking'])) { + $config['base-htaccess-on-these-capability-tests']['modHeaderWorking'] = HTAccessCapabilityTestRunner::modHeaderWorking(); + } + if (!isset($config['base-htaccess-on-these-capability-tests']['canRunTestScriptInWOD'])) { + $config['base-htaccess-on-these-capability-tests']['canRunTestScriptInWOD'] = HTAccessCapabilityTestRunner::canRunTestScriptInWOD(); + } + if (!isset($config['base-htaccess-on-these-capability-tests']['canRunTestScriptInWOD2'])) { + $config['base-htaccess-on-these-capability-tests']['canRunTestScriptInWOD2'] = HTAccessCapabilityTestRunner::canRunTestScriptInWOD2(); + } + + self::$config = $config; + + + if (!isset($config['alter-html']['enabled'])) { + self::$alterHtmlEnabled = false; + } else { + self::$alterHtmlEnabled = true; + } + + $capTests = self::$config['base-htaccess-on-these-capability-tests']; + + self::$docRootString = '%{DOCUMENT_ROOT}'; + if (defined('WEBPEXPRESS_DOCUMENT_ROOT_IN_HTACCESS')) { + self::$docRootString = constant('WEBPEXPRESS_DOCUMENT_ROOT_IN_HTACCESS'); + }; + + self::$modHeaderDefinitelyUnavailable = ($capTests['modHeaderWorking'] === false); + self::$passThroughHeaderDefinitelyUnavailable = ($capTests['passThroughHeaderWorking'] === false); + self::$passThroughHeaderDefinitelyAvailable = ($capTests['passThroughHeaderWorking'] === true); + + self::$passThroughEnvVarDefinitelyUnavailable = ($capTests['passThroughEnvWorking'] === false); + self::$passThroughEnvVarDefinitelyAvailable = ($capTests['passThroughEnvWorking'] === true); + + self::$canDefinitelyRunTestScriptInWOD = ($capTests['canRunTestScriptInWOD'] === true); + self::$canDefinitelyRunTestScriptInWOD2 = ($capTests['canRunTestScriptInWOD2'] === true); + + + self::$capTests = $capTests; + + self::$imageTypes = self::$config['image-types']; + self::$fileExtensions = []; + if (self::$imageTypes & 1) { + self::$fileExtensions[] = 'jpe?g'; + } + if (self::$imageTypes & 2) { + self::$fileExtensions[] = 'png'; + } + self::$fileExt = implode('|', self::$fileExtensions); + self::$fileExtIncludingDot = "\." . implode("|\.", self::$fileExtensions); + + self::$mingled = (self::$config['destination-folder'] == 'mingled'); + + // TODO: If we cannot store all .htaccess files we would like, we need to take into account which dir + $setWebPExt = ((self::$config['destination-extension'] == 'set') && (self::$htaccessDir == 'uploads')); + self::$appendWebP = !$setWebPExt; + } + + public static function addVaryHeaderRules() + { + $rules = [ + '# Add "Vary: Accept" header in order to make proxies aware that the response varies depending', + '# on the "accept" request header (which is the one browsers use to signal if they support webp).', + '# In this folder, there are only webp files, so there is no need for any other logic than the ', + '# check which ensures that mod_headers is available.', + '', + ' Header append "Vary" "Accept"', + '', + '' + ]; + return implode("\n", $rules); + } + + public static function addVaryHeaderEnvRules($indent = 0) + { + $rules = []; + $rules[] = "# Set Vary:Accept header if we came here by way of our redirect, which set the ADDVARY environment variable"; + $rules[] = "# The purpose is to make proxies and CDNs aware that the response varies with the Accept header"; + $rules[] = ""; + $rules[] = " "; + $rules[] = " # Apache appends \"REDIRECT_\" in front of the environment variables defined in mod_rewrite, but LiteSpeed does not"; + $rules[] = " # So, the next lines are for Apache, in order to set environment variables without \"REDIRECT_\""; + $rules[] = " SetEnvIf REDIRECT_EXISTING 1 EXISTING=1"; + $rules[] = " SetEnvIf REDIRECT_ADDVARY 1 ADDVARY=1"; + $rules[] = ""; + $rules[] = " Header append \"Vary\" \"Accept\" env=ADDVARY"; + $rules[] = ""; + + if (self::$config['redirect-to-existing-in-htaccess']) { + $rules[] = " # Set X-WebP-Express header for diagnose purposes"; + //" SetEnvIf REDIRECT_WOD 1 WOD=1\n\n" . + //" # Set the debug header\n" . + $rules[] = " Header set \"X-WebP-Express\" \"Redirected directly to existing webp\" env=EXISTING"; + //" Header set \"X-WebP-Express\" \"Redirected to image converter\" env=WOD\n" . + } + $rules[] = " "; + $rules[] = ""; + $rules[] = ""; + + if ($indent > 0) { + $indentStr = ''; + for ($x=0; $x<$indent; $x++) { + $indentStr .= ' '; + } + foreach ($rules as $i => $rule) { + if ($rule != '') { + $rules[$i] = $indentStr . $rule; + } + } + } + return implode("\n", $rules); + } + + private static function getWodUrlPath() + { + // We prefer the "wod" folder over "wod2" (when it works), simply because it is older + // and we should not change things without having a good reason. + if (self::$canDefinitelyRunTestScriptInWOD) { + return Paths::getWodUrlPath(); + } + + // We however prefer the "wod2" folder when "wod" does not work, even if + // "wod2" doesn't work either. Why? Less things can go wrong in "wod2", so trying to fix + // it should be more straight forward. + return Paths::getWod2UrlPath(); + } + + private static function getWebPRealizerUrlPath() + { + // We prefer the "wod" folder over "wod2", simply because it is older + // and we should not change things without having a good reason. + if (self::$canDefinitelyRunTestScriptInWOD) { + return Paths::getWebPRealizerUrlPath(); + } + return Paths::getWebPRealizer2UrlPath(); + } + + // https://stackoverflow.com/questions/34124819/mod-rewrite-set-custom-header-through-htaccess + /** + * + * PS: $config has a property "base-htaccess-on-these-capability-tests", which will be used. + * make sure that this is up-to-date before calling this method. + * It is updated with $config->runAndStoreCapabilityTests() + */ + public static function generateHTAccessRulesFromConfigObj($config, $htaccessDir = 'index', $dirContainsSourceImages = true, $dirContainsWebPImages = true) + { + self::setInternalProperties($config, $htaccessDir); + self::$dirContainsSourceImages = $dirContainsSourceImages; + self::$dirContainsWebPImages = $dirContainsWebPImages; + + /* + if ( + (!self::$config['enable-redirection-to-converter']) && + (!self::$config['redirect-to-existing-in-htaccess']) && + (!self::$config['enable-redirection-to-webp-realizer']) + ) { + return '# WebP Express does not need to write any rules (it has not been set up to redirect to converter, nor' . + ' to existing webp, and the "convert non-existing webp-files upon request" option has not been enabled)'; + }*/ + + if (self::$imageTypes == 0) { + return '# WebP Express disabled (no image types has been choosen to be converted/redirected)'; + } + + self::$setAddVaryEnvInRedirect = self::$config['redirect-to-existing-in-htaccess']; + if (self::$modHeaderDefinitelyUnavailable) { + self::$setAddVaryEnvInRedirect = false; + } + + /* Build rules */ + $rules = ''; + $rules .= self::infoRules(); + + $variedImageResponses = + (self::$config['redirect-to-existing-in-htaccess']) || + (self::$config['enable-redirection-to-converter']); + + $addVaryHeaderUsingModHeader = $variedImageResponses; + + /* + TODO: (#447) + We should not add the "Header append" code if it is disallowed + in the server config (ie if "FileInfo" isn't in the AllowOverride list) + Why? Well, it will result in 500 internal error on the image requests + (or errors in the log, if configured to "NonFatal") + .. But this requires a bit of effort, as it might be that it is allowed + in some dirs but not in others. + If mod_headers simply isn't installed, the system behaves fine (thanks to + the "IfModule" directive. So we should actually add the code, when that is + the case (as the server setting might change for the better) + + if (self::$modHeaderDefinitelyUnavailable) { + //$addVaryHeaderUsingModHeader = false; + }*/ + + if ($dirContainsSourceImages && $variedImageResponses) { + $rules .= "# Rules for handling requests for source images\n"; + $rules .= "# ---------------------------------------------\n\n"; + $rules .= "\n" . + " RewriteEngine On\n\n"; + + $rules .= " # Escape hatch #1: Adding ?dontreplace to an url can be used to bypass redirection\n"; + $rules .= " RewriteCond %{QUERY_STRING} dontreplace$\n"; + $rules .= " RewriteCond %{REQUEST_FILENAME} -f\n"; + $rules .= " RewriteRule . - [L]\n\n"; + + $rules .= " # Escape hatch #2: Placing an empty file in the same folder as the jpeg/png which has same file name, but \".dontreplace\" appended will bypass redirection\n"; + $rules .= " RewriteCond %{REQUEST_FILENAME} (?i)(.*)(\.jpe?g|\.png)$\n"; + $rules .= " RewriteCond %1%2\.dontreplace -f\n"; + $rules .= " RewriteRule . - [L]\n\n"; + + $rules .= " # Deprecated escape hatch: Adding ?original to an url can be used to bypass redirection\n"; + $rules .= " RewriteCond %{QUERY_STRING} original$\n"; + $rules .= " RewriteCond %{REQUEST_FILENAME} -f\n"; + $rules .= " RewriteRule . - [L]\n\n"; + + $rules .= " # Deprecated escape hatch: Placing an empty file in the same folder as the jpeg/png which has same file name, but \".do-not-convert\" appended will bypass redirection\n"; + $rules .= " RewriteCond %{REQUEST_FILENAME} (?i)(.*)(\.jpe?g|\.png)$\n"; + $rules .= " RewriteCond %1%2\.do-not-convert -f\n"; + $rules .= " RewriteRule . - [L]\n\n"; + + if ($config['prevent-using-webps-larger-than-original']) { + $rules .= " # Avoid redirecting to webp files that are bigger than the original\n"; + $rules .= " RewriteCond %{REQUEST_FILENAME} -f\n"; + + // Find relative path of source (accessible as %2%3) + $rules .= " RewriteCond %{REQUEST_FILENAME} (?i)(" . self::$htaccessDirAbs . "/)(.*)(" .self::$fileExtIncludingDot . ")$\n"; + + // Check if dummy file exists + $cacheDirForThisRoot = PathHelper::fixAbsPathToUseUnresolvedDocRoot( + Paths::getBiggerThanSourceDirAbs() . '/' . self::$htaccessDir + ); + $rules .= " RewriteCond " . + PathHelper::backslashesToForwardSlashes( + self::replaceDocRootWithApacheTokenIfDocRootAvailable($cacheDirForThisRoot) + ) . + "/%2%3.webp -f\n"; + + $rules .= " RewriteRule . - [L]\n\n"; + + } + + // In the future, we could let user add exeptions in UI. Also for folders + // in order to make this work for folders, we will need to update the .htaccess + // and list the exceptions here with rules like this: + // RewriteRule ^uploads/2021/06/ - [L] + + if (self::$config['redirect-to-existing-in-htaccess']) { + $rules .= self::redirectToExistingRules(); + } + + if (self::$config['enable-redirection-to-converter']) { + $rules .= self::webpOnDemandRules(); + } + + //if (self::$setAddVaryEnvInRedirect) { + if ($addVaryHeaderUsingModHeader) { + $rules .= " # Make sure that browsers which does not support webp also gets the Vary:Accept header\n" . + " # when requesting images that would be redirected to webp on browsers that does.\n"; + + $rules .= " \n"; + $rules .= ' ' . "\n"; + $rules .= ' Header append "Vary" "Accept"' . "\n"; + $rules .= " \n"; + $rules .= " \n\n"; + } + + /* + " \n" . + " SetEnvIf Request_URI \"\.(" . self::$fileExt . ")$\" ADDVARY\n" . + " \n\n"; + */ + + //self::$setAddVaryEnvInRedirect = (self::$config['enable-redirection-to-converter'] && (self::$config['success-response'] == 'converted')) || (self::$config['redirect-to-existing-in-htaccess']); + + /* + if (self::$setAddVaryEnvInRedirect) { + if ($dirContainsWebPImages) { + $rules .= self::addVaryHeaderEnvRules(2); + } + }*/ + $rules .= "\n"; + } /*else { + if ($dirContainsWebPImages) { + $rules .= self::addVaryHeaderEnvRules(); + } + }*/ + if ($dirContainsWebPImages) { + $rules .= "\n# Rules for handling requests for webp images\n"; + $rules .= "# ---------------------------------------------\n\n"; + if (self::$config['enable-redirection-to-webp-realizer']) { + $rules .= self::webpRealizerRules(); + } + $rules .= self::cacheRules(); + + /* + if ( + (self::$config['enable-redirection-to-webp-realizer']) || + (self::$config['redirect-to-existing-in-htaccess']) + ) { + }*/ + if ($addVaryHeaderUsingModHeader) { + if (!$dirContainsSourceImages && !self::$alterHtmlEnabled) { + // Simple rules for Vary:Accept #444 + // These can be used when we are sure that the webp in this folder is never + // requested directly. + // The simple rules are prefered when possible because they are more robust + // and doesn't depend on mod_setenvif + + // TODO: Use simple rules if it can be proved that mod_setenvif isn't working + $rules .= self::addVaryHeaderRules(); + } else { + // Advanced rules, which ensures that direct requests for webps doesn't get + // Vary:Accept header added + $rules .= self::addVaryHeaderEnvRules(); + } + } + + $rules .= "\n# Register webp mime type \n"; + $rules .= "\n"; + $rules .= " AddType image/webp .webp\n"; + $rules .= "\n"; + } + + /*if (self::$config['redirect-to-existing-in-htaccess']) { + $rules .= + "\n" . + " # Append Vary Accept header, when the rules above are redirecting to existing webp\n" . + " # or existing jpg" . + + " # Apache appends \"REDIRECT_\" in front of the environment variables, but LiteSpeed does not.\n" . + " # These next line is for Apache, in order to set environment variables without \"REDIRECT_\"\n" . + " SetEnvIf REDIRECT_WEBPACCEPT 1 WEBPACCEPT=1\n\n" . + + " # Make CDN caching possible.\n" . + " # The effect is that the CDN will cache both the webp image and the jpeg/png image and return the proper\n" . + " # image to the proper clients (for this to work, make sure to set up CDN to forward the \"Accept\" header)\n" . + " Header append Vary Accept env=WEBPACCEPT\n" . + "\n\n"; + }*/ + + return $rules; + } +} diff --git a/lib/classes/HandleDeleteFileHook.php b/lib/classes/HandleDeleteFileHook.php new file mode 100644 index 0000000..90e615f --- /dev/null +++ b/lib/classes/HandleDeleteFileHook.php @@ -0,0 +1,42 @@ +imageRootDef = $imageRootDef; + $this->id = $imageRootDef['id']; + } + + /** + * Get / calculate abs path. + * + * If "rel-path" is set and document root is available, the abs path will be calculated from the relative path. + * Otherwise the "abs-path" is returned. + * @throws Exception In case rel-path is not + */ + public function getAbsPath() + { + $def = $this->imageRootDef; + if (isset($def['rel-path']) && PathHelper::isDocRootAvailable()) { + return rtrim($_SERVER["DOCUMENT_ROOT"], '/') . '/' . $def['rel-path']; + } elseif (isset($def['abs-path'])) { + return $def['abs-path']; + } else { + if (!isset($def['rel-path'])) { + throw new \Exception( + 'Image root definition in config file is must either have a "rel-path" or "abs-path" property defined. ' . + 'Probably your system setup has changed. Please re-save WebP Express options and regenerate .htaccess' + ); + } else { + throw new \Exception( + 'Image root definition in config file is defined by "rel-path". However, DOCUMENT_ROOT is unavailable so we ' . + 'cannot use that (as the rel-path is relative to that. ' . + 'Probably your system setup has changed. Please re-save WebP Express options and regenerate .htaccess' + ); + } + } + } + +} diff --git a/lib/classes/ImageRoots.php b/lib/classes/ImageRoots.php new file mode 100644 index 0000000..7e1229d --- /dev/null +++ b/lib/classes/ImageRoots.php @@ -0,0 +1,52 @@ +imageRootsDef = $imageRootsDef; + + $this->imageRoots = []; + foreach ($imageRootsDef as $i => $def) + { + $this->imageRoots[] = new ImageRoot($def); + } + } + + /** + * Get image root by id. + * + * @return \WebPExpress\ImageRoot An image root object + */ + public function byId($id) + { + foreach ($this->imageRoots as $i => $imageRoot) { + if ($imageRoot->id == $id) { + return $imageRoot; + } + } + throw new \Exception('Image root not found'); + } + + /** + * Get the image roots array + * + * @return array An array of ImageRoot objects + */ + public function getArray() + { + return $this->imageRoots; + } +} diff --git a/lib/classes/KeepEwwwSubscriptionAlive.php b/lib/classes/KeepEwwwSubscriptionAlive.php new file mode 100644 index 0000000..cad65e9 --- /dev/null +++ b/lib/classes/KeepEwwwSubscriptionAlive.php @@ -0,0 +1,60 @@ +ewww converter in order to keep the subscription alive' + ); + State::setState('last-ewww-optimize', time()); + } else { + Messenger::addMessage( + 'warning', + 'Failed optimizing regular jpg with ewww converter in order to keep the subscription alive' + ); + } + } + + public static function keepAliveIfItIsTime($config = null) { + + $timeSinseLastSuccesfullOptimize = time() - State::getState('last-ewww-optimize', 0); + if ($timeSinseLastSuccesfullOptimize > 3 * 30 * 24 * 60 * 60) { + + $timeSinseLastOptimizeAttempt = time() - State::getState('last-ewww-optimize-attempt', 0); + if ($timeSinseLastOptimizeAttempt > 14 * 24 * 60 * 60) { + State::setState('last-ewww-optimize-attempt', time()); + self::keepAlive($config); + } + } + + } + +} diff --git a/lib/classes/LogPurge.php b/lib/classes/LogPurge.php new file mode 100644 index 0000000..0a5153a --- /dev/null +++ b/lib/classes/LogPurge.php @@ -0,0 +1,99 @@ + $onlyPng, + 'only-with-corresponding-original' => false + ]; + + $numDeleted = 0; + $numFailed = 0; + + $dir = Paths::getLogDirAbs(); + list($numDeleted, $numFailed) = self::purgeLogFilesInDir($dir); + FileHelper::removeEmptySubFolders($dir); + + return [ + 'delete-count' => $numDeleted, + 'fail-count' => $numFailed + ]; + + //$successInRemovingCacheDir = FileHelper::rrmdir(Paths::getCacheDirAbs()); + + } + + + /** + * Purge log files in a dir + * + * @return [num files deleted, num files failed to delete] + */ + private static function purgeLogFilesInDir($dir) + { + if (!@file_exists($dir) || !@is_dir($dir)) { + return [0, 0]; + } + + $numFilesDeleted = 0; + $numFilesFailedDeleting = 0; + + $fileIterator = new \FilesystemIterator($dir); + while ($fileIterator->valid()) { + $filename = $fileIterator->getFilename(); + + if (($filename != ".") && ($filename != "..")) { + + if (@is_dir($dir . "/" . $filename)) { + list($r1, $r2) = self::purgeLogFilesInDir($dir . "/" . $filename); + $numFilesDeleted += $r1; + $numFilesFailedDeleting += $r2; + } else { + + // its a file + // Run through filters, which each may set "skipThis" to true + + $skipThis = false; + + // filter: It must have ".md" extension + if (!$skipThis && !preg_match('#\.md$#', $filename)) { + $skipThis = true; + } + + if (!$skipThis) { + if (@unlink($dir . "/" . $filename)) { + $numFilesDeleted++; + } else { + $numFilesFailedDeleting++; + } + } + } + } + $fileIterator->next(); + } + return [$numFilesDeleted, $numFilesFailedDeleting]; + } + + public static function processAjaxPurgeLog() + { + + if (!check_ajax_referer('webpexpress-ajax-purge-log-nonce', 'nonce', false)) { + wp_send_json_error('The security nonce has expired. You need to reload the settings page (press F5) and try again)'); + wp_die(); + } + $result = self::purge($config); + echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK | JSON_PRETTY_PRINT); + wp_die(); + } +} diff --git a/lib/classes/Messenger.php b/lib/classes/Messenger.php new file mode 100644 index 0000000..50969d6 --- /dev/null +++ b/lib/classes/Messenger.php @@ -0,0 +1,96 @@ + $entry) { + if ($entry['message'] == $msg) { + return; + } + } + $pendingMessages[] = ['level' => $level, 'message' => $msg]; + State::setState('pendingMessages', $pendingMessages); + } + + public static function printMessage($level, $msg) { + if (!(self::$printedStyles)) { + global $wp_version; + if (floatval(substr($wp_version, 0, 3)) < 4.1) { + // Actually, I don't know precisely what version the styles were introduced. + // They are there in 4.1. They are not there in 4.0 + self::printMessageStylesForOldWordpress(); + } + self::$printedStyles = true; + } + + //$msg = __( $msg, 'webp-express'); // uncommented. We should add some sprintf-like functionality before making the plugin translatable + printf( + '
%2$s
', + //esc_attr('notice notice-' . $level . ' is-dismissible'), + esc_attr('notice notice-' . $level), + $msg + ); + } + + private static function printMessageStylesForOldWordpress() { + ?> + + $dirObj) { + if ($dirObj->isDir()) { + if (realpath($path) == $dir) { + //return $path; + $relPath = self::getRelDir(realpath($_SERVER['DOCUMENT_ROOT']), $path); + if (strpos($relPath, '../') !== 0) { + return $relPath; + } + } + } + } +*/ + // Ok, the above works - but when subfolders to the symlink is referenced. Ie referencing uploads when wp-content is symlinked + // - dir is already resolved (ie: /disk/the-content/uploads) + // - document root is ie. /var/www/website/wordpress + // - the unresolved symlink is ie. /var/www/website/wordpress/wp-content/uploads + // - we do not know what the unresolved symlink is + // The result should be "wp-content/uploads". But how do we get to that result? + + // What if we collect all symlinks below document root in a assoc array? + // ['/disk/the-content' => 'wp-content'] + // Input is: '/disk/the-content/uploads' + // 1. We check the symlinks and substitute. We get: 'wp-content/uploads'. + // 2. We test if realpath($_SERVER['DOCUMENT_ROOT'] . '/' . 'wp-content/uploads') equals input. + // It seems I have a solution! + // - I shall continue work soon! - for a 0.15.1 release (test instance #26) + // PS: cache the result of the symlinks in docroot collector. + + throw new \Exception( + 'Cannot get relative path from document root to dir without resolving to directory traversal. ' . + 'It seems the dir is not below document root' + ); + +/* + if (!self::pathExistsAndIsResolvable($dir)) { + throw new \Exception('Cannot calculate relative path from document root to dir. The path given is not resolvable (realpath fails)'); + } + + + // Check if relPath starts with "../" + if (strpos($relPath, '../') === 0) { + + // Unresolved failed. Try with document root resolved + $relPath = self::getRelDir(realpath($_SERVER['DOCUMENT_ROOT']), $dir); + + if (strpos($relPath, '../') === 0) { + + // Try with both resolved + $relPath = self::getRelDir($dir, $dir); + throw new \Exception('Cannot calculate relative path from document root to dir. The path given is not within document root'); + } + } + + + } + + return $relPath; + } else { + // We cannot get the resolved doc-root. + // This might be ok as long as the (resolved) path we are examining begins with the configured doc-root. + $relPath = self::getRelDir($_SERVER['DOCUMENT_ROOT'], $dir); + + // Check if relPath starts with "../" (it may not) + if (strpos($relPath, '../') === 0) { + + // Well, that did not work. We can try the resolved path instead. + if (!self::pathExistsAndIsResolvable($dir)) { + throw new \Exception('Cannot calculate relative path from document root to dir. The path given is not resolvable (realpath fails)'); + } + + $relPath = self::getRelDir($_SERVER['DOCUMENT_ROOT'], realpath($dir)); + if (strpos($relPath, '../') === 0) { + + // That failed too. + // Either it is in fact outside document root or it is because of a special setup. + throw new \Exception( + 'Cannot calculate relative path from document root to dir. Either the path given is not within the configured document root or ' . + 'it is because of a special setup. The document root is outside open_basedir. If it is also symlinked, but the other Wordpress paths ' . + 'are not using that same symlink, it will not be possible to calculate the relative path.' + ); + } + } + return $relPath; + }*/ + } + + public static function canCalculateRelPathFromDocRootToDir($dir) + { + try { + $relPath = self::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed($dir); + } catch (\Exception $e) { + return false; + } + return true; + } + + /** + * Find closest existing folder with symlinks expandend, using realpath. + * + * Note that if the input or the closest existing folder is outside open_basedir, no folder will + * be found and an empty string will be returned. + * + * @return string closest existing path or empty string if none found (due to open_basedir restriction) + */ + public static function findClosestExistingFolderSymLinksExpanded($input) { + + // The strategy is to first try the supplied directory. If it fails, try the parent, etc. + $dir = $input; + + // We count the levels up to avoid infinite loop - as good practice. It ought not to get that far + $levelsUp = 0; + + while ($levelsUp < 100) { + // We suppress warning because we are aware that we might get a + // open_basedir restriction warning. + $realPathResult = @realpath($dir); + if ($realPathResult !== false) { + return $realPathResult; + } + // Stop at root. This will happen if the original path is outside basedir. + if (($dir == '/') || (strlen($dir) < 4)) { + return ''; + } + // Peal off one directory + $dir = @dirname($dir); + $levelsUp++; + } + return ''; + } + + /** + * Look if filepath is within a dir path (both by string matching and by using realpath, see notes). + * + * Note that the naive string match does not resolve '..'. You might want to call ::canonicalize first. + * Note that the realpath match requires: 1. that the dir exist and is within open_basedir + * 2. that the closest existing folder within filepath is within open_basedir + * + * @param string $filePath Path to file. It may be non-existing. + * @param string $dirPath Path to dir. It must exist and be within open_basedir in order for the realpath match to execute. + */ + public static function isFilePathWithinDirPath($filePath, $dirPath) + { + // See if $filePath begins with $dirPath + '/'. + if (strpos($filePath, $dirPath . '/') === 0) { + return true; + } + + if (strpos(self::canonicalize($filePath), self::canonicalize($dirPath) . '/') === 0) { + return true; + } + + + // Also try with symlinks expanded. + // As symlinks can only be retrieved with realpath and realpath fails with non-existing paths, + // we settle with checking if closest existing folder in the filepath is within the dir. + // If that is the case, then surely, the complete filepath is also within the dir. + // Note however that it might be that the closest existing folder is not within the dir, while the + // file would be (if it existed) + // For WebP Express, we are pretty sure that the dirs we are checking against (uploads folder, + // wp-content, plugins folder) exists. So getting the closest existing folder should be sufficient. + // but could it be that these are outside open_basedir on some setups? Perhaps on a few systems. + if (self::pathExistsAndIsResolvable($dirPath)) { + $closestExistingDirOfFile = PathHelper::findClosestExistingFolderSymLinksExpanded($filePath); + if (strpos($closestExistingDirOfFile, realpath($dirPath) . '/') === 0) { + return true; + } + } + + return false; + } + + /** + * Look if path is within a dir path. Also tries expanding symlinks + * + * @param string $path Path to examine. It may be non-existing. + * @param string $dirPath Path to dir. It must exist in order for symlinks to be expanded. + */ + public static function isPathWithinExistingDirPath($path, $dirPath) + { + if ($path == $dirPath) { + return true; + } + // See if $filePath begins with $dirPath + '/'. + if (strpos($path, $dirPath . '/') === 0) { + return true; + } + + // Also try with symlinks expanded (see comments in ::isFilePathWithinDirPath()) + $closestExistingDir = PathHelper::findClosestExistingFolderSymLinksExpanded($path); + if (strpos($closestExistingDir . '/', $dirPath . '/') === 0) { + return true; + } + return false; + } + + public static function frontslasher($str) + { + // TODO: replace backslash with frontslash + return $str; + } + + /** + * Replace double slash with single slash. ie '/var//www/' => '/var/www/' + * This allows you to lazely concatenate paths with '/' and then call this method to clean up afterwards. + * Also removes triple slash etc. + */ + public static function fixDoubleSlash($str) + { + return preg_replace('/\/\/+/', '/', $str); + } + + /** + * Remove trailing slash, if any + */ + public static function untrailSlash($str) + { + return rtrim($str, '/'); + //return preg_replace('/\/$/', '', $str); + } + + public static function backslashesToForwardSlashes($path) { + return str_replace( "\\", '/', $path); + } + + // Canonicalize a path by resolving '../' and './'. It also replaces backslashes with forward slash + // Got it from a comment here: http://php.net/manual/en/function.realpath.php + // But fixed it (it could not handle './../') + public static function canonicalize($path) { + + $parts = explode('/', $path); + + // Remove parts containing just '.' (and the empty holes afterwards) + $parts = array_values(array_filter($parts, function($var) { + return ($var != '.'); + })); + + // Remove parts containing '..' and the preceding + $keys = array_keys($parts, '..'); + foreach($keys as $keypos => $key) { + array_splice($parts, $key - ($keypos * 2 + 1), 2); + } + return implode('/', $parts); + } + + public static function dirname($path) { + return self::canonicalize($path . '/..'); + } + + /** + * Get base name of a path (the last component of a path - ie the filename). + * + * This function operates natively on the string and is not locale aware. + * It only works with "/" path separators. + * + * @return string the last component of a path + */ + public static function basename($path) { + $parts = explode('/', $path); + return array_pop($parts); + } + + /** + * Returns absolute path from a relative path and root + * The result is canonicalized (dots and double-dots are resolved) + * + * @param $path Absolute path or relative path + * @param $root What the path is relative to, if its relative + */ + public static function relPathToAbsPath($path, $root) + { + return self::canonicalize(self::fixDoubleSlash($root . '/' . $path)); + } + + /** + * isAbsPath + * If path starts with '/', it is considered an absolute path (no Windows support) + * + * @param $path Path to inspect + */ + public static function isAbsPath($path) + { + return (substr($path, 0, 1) == '/'); + } + + /** + * Returns absolute path from a path which can either be absolute or relative to second argument. + * If path starts with '/', it is considered an absolute path. + * The result is canonicalized (dots and double-dots are resolved) + * + * @param $path Absolute path or relative path + * @param $root What the path is relative to, if its relative + */ + public static function pathToAbsPath($path, $root) + { + if (self::isAbsPath($path)) { + // path is already absolute + return $path; + } else { + return self::relPathToAbsPath($path, $root); + } + } + + /** + * Get relative path between two absolute paths + * Examples: + * from '/var/www' to 'var/ddd'. Result: '../ddd' + * from '/var/www' to 'var/www/images'. Result: 'images' + * from '/var/www' to 'var/www'. Result: '.' + */ + public static function getRelDir($fromPath, $toPath) + { + $fromDirParts = explode('/', str_replace('\\', '/', self::canonicalize(self::untrailSlash($fromPath)))); + $toDirParts = explode('/', str_replace('\\', '/', self::canonicalize(self::untrailSlash($toPath)))); + $i = 0; + while (($i < count($fromDirParts)) && ($i < count($toDirParts)) && ($fromDirParts[$i] == $toDirParts[$i])) { + $i++; + } + $rel = ""; + for ($j = $i; $j < count($fromDirParts); $j++) { + $rel .= "../"; + } + + for ($j = $i; $j < count($toDirParts); $j++) { + $rel .= $toDirParts[$j]; + if ($j < count($toDirParts)-1) { + $rel .= '/'; + } + } + if ($rel == '') { + $rel = '.'; + } + return $rel; + } + +} diff --git a/lib/classes/Paths.php b/lib/classes/Paths.php new file mode 100644 index 0000000..b2753ff --- /dev/null +++ b/lib/classes/Paths.php @@ -0,0 +1,879 @@ + $rootId, + ]; + $absPath = self::getAbsDirById($rootId); + if ($canUseDocRootForRelPaths) { + $obj['rel-path'] = PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed($absPath); + } else { + $obj['abs-path'] = $absPath; + } + $obj['url'] = self::getUrlById($rootId); + $mapping[] = $obj; + } + return $mapping; + } + + public static function getImageRootsDef() + { + return self::getImageRootsDefForSelectedIds(self::getImageRootIds()); + } + + public static function filterOutSubRoots($rootIds) + { + // Get dirs of enabled roots + $dirs = []; + foreach ($rootIds as $rootId) { + $dirs[] = self::getAbsDirById($rootId); + } + + // Filter out dirs which are below other dirs + $dirsToSkip = []; + foreach ($dirs as $dirToExamine) { + foreach ($dirs as $dirToCompareAgainst) { + if ($dirToExamine == $dirToCompareAgainst) { + continue; + } + if (self::isDirInsideDir($dirToExamine, $dirToCompareAgainst)) { + $dirsToSkip[] = $dirToExamine; + break; + } + } + } + $dirs = array_diff($dirs, $dirsToSkip); + + // back to ids + $result = []; + foreach ($dirs as $dir) { + $result[] = self::getAbsDirId($dir); + } + return $result; + } + + public static function createDirIfMissing($dir) + { + if (!@file_exists($dir)) { + // We use the wp_mkdir_p, because it takes care of setting folder + // permissions to that of parent, and handles creating deep structures too + wp_mkdir_p($dir); + } + return file_exists($dir); + } + + /** + * Find out if $dir1 is inside - or equal to - $dir2 + */ + public static function isDirInsideDir($dir1, $dir2) + { + $rel = PathHelper::getRelDir($dir2, $dir1); + return (substr($rel, 0, 3) != '../'); + } + + /** + * Return absolute dir. + * + * - Path is canonicalized (without resolving symlinks) + * - trailing dash is removed - we don't use that around here. + * + * We do not resolve symlinks anymore. Information was lost that way. + * And in some cases we needed the unresolved path - for example in the .htaccess. + */ + public static function getAbsDir($dir) + { + $dir = PathHelper::canonicalize($dir); + return rtrim($dir, '/'); + /* + $result = realpath($dir); + if ($result === false) { + $dir = PathHelper::canonicalize($dir); + } else { + $dir = $result; + }*/ + + } + + // ------------ Home Dir ------------- + + // PS: Home dir is not the same as index dir. + // For example, if Wordpress folder has been moved (method 2), the home dir could be below. + public static function getHomeDirAbs() + { + if (!function_exists('get_home_path')) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + return self::getAbsDir(get_home_path()); + } + + // ------------ Index Dir (WP root dir) ------------- + // (The Wordpress installation dir- where index.php and wp-load.php resides) + + public static function getIndexDirAbs() + { + // We used to return self::getAbsDir(ABSPATH), which used realpath. + // It has been changed now, as it seems we do not need realpath for ABSPATH, as it is defined + // (in wp-load.php) as dirname(__FILE__) . "/" and according to this link, __FILE__ returns resolved paths: + // https://stackoverflow.com/questions/3221771/how-do-you-get-php-symlinks-and-file-to-work-together-nicely + // AND a user reported an open_basedir restriction problem thrown by realpath($_SERVER['DOCUMENT_ROOT']), + // due to symlinking and opendir restriction (see #322) + + return rtrim(ABSPATH, '/'); + + // TODO: read up on this, regarding realpath: + // https://github.com/twigphp/Twig/issues/2707 + + } + + // ------------ .htaccess dir ------------- + // (directory containing the relevant .htaccess) + // (see https://github.com/rosell-dk/webp-express/issues/36) + + + + public static function canWriteHTAccessRulesHere($dirName) { + return FileHelper::canEditOrCreateFileHere($dirName . '/.htaccess'); + } + + public static function canWriteHTAccessRulesInDir($dirId) { + return self::canWriteHTAccessRulesHere(self::getAbsDirById($dirId)); + } + + public static function returnFirstWritableHTAccessDir($dirs) + { + foreach ($dirs as $dir) { + if (self::canWriteHTAccessRulesHere($dir)) { + return $dir; + } + } + return false; + } + + // ------------ Content Dir (the "WP" content dir) ------------- + + public static function getContentDirAbs() + { + return self::getAbsDir(WP_CONTENT_DIR); + } + public static function getContentDirRel() + { + return PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed(self::getContentDirAbs()); + } + public static function getContentDirRelToPluginDir() + { + return PathHelper::getRelDir(self::getPluginDirAbs(), self::getContentDirAbs()); + } + public static function getContentDirRelToWebPExpressPluginDir() + { + return PathHelper::getRelDir(self::getWebPExpressPluginDirAbs(), self::getContentDirAbs()); + } + + + public static function isWPContentDirMoved() + { + return (self::getContentDirAbs() != (ABSPATH . 'wp-content')); + } + + public static function isWPContentDirMovedOutOfAbsPath() + { + return !(self::isDirInsideDir(self::getContentDirAbs(), ABSPATH)); + } + + // ------------ Themes Dir ------------- + + public static function getThemesDirAbs() + { + return self::getContentDirAbs() . '/themes'; + } + + // ------------ WebPExpress Content Dir ------------- + // (the "webp-express" directory inside wp-content) + + public static function getWebPExpressContentDirAbs() + { + return self::getContentDirAbs() . '/webp-express'; + } + + public static function getWebPExpressContentDirRel() + { + return PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed(self::getWebPExpressContentDirAbs()); + } + + public static function createContentDirIfMissing() + { + return self::createDirIfMissing(self::getWebPExpressContentDirAbs()); + } + + // ------------ Upload Dir ------------- + public static function getUploadDirAbs() + { + $upload_dir = wp_upload_dir(null, false); + return self::getAbsDir($upload_dir['basedir']); + } + public static function getUploadDirRel() + { + return PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed(self::getUploadDirAbs()); + } + + /* + public static function getUploadDirAbs() + { + if ( defined( 'UPLOADS' ) ) { + return ABSPATH . rtrim(UPLOADS, '/'); + } else { + return self::getContentDirAbs() . '/uploads'; + } + }*/ + + public static function isUploadDirMovedOutOfWPContentDir() + { + return !(self::isDirInsideDir(self::getUploadDirAbs(), self::getContentDirAbs())); + } + + public static function isUploadDirMovedOutOfAbsPath() + { + return !(self::isDirInsideDir(self::getUploadDirAbs(), ABSPATH)); + } + + // ------------ Config Dir ------------- + + public static function getConfigDirAbs() + { + return self::getWebPExpressContentDirAbs() . '/config'; + } + + public static function getConfigDirRel() + { + return PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed(self::getConfigDirAbs()); + } + + public static function createConfigDirIfMissing() + { + $configDir = self::getConfigDirAbs(); + // Using code from Wordfence bootstrap.php... + // Why not simply use wp_mkdir_p ? - it sets the permissions to same as parent. Isn't that better? + // or perhaps not... - Because we need write permissions in the config dir. + if (!is_dir($configDir)) { + @mkdir($configDir, 0775); + @chmod($configDir, 0775); + @file_put_contents(rtrim($configDir . '/') . '/.htaccess', << +Require all denied + + +Order deny,allow +Deny from all + +APACHE + ); + @chmod($configDir . '/.htaccess', 0664); + } + return is_dir($configDir); + } + + public static function getConfigFileName() + { + return self::getConfigDirAbs() . '/config.json'; + } + + public static function getWodOptionsFileName() + { + return self::getConfigDirAbs() . '/wod-options.json'; + } + + // ------------ Cache Dir ------------- + + public static function getCacheDirAbs() + { + return self::getWebPExpressContentDirAbs() . '/webp-images'; + } + + public static function getCacheDirRelToDocRoot() + { + return PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed(self::getCacheDirAbs()); + } + + public static function getCacheDirForImageRoot($destinationFolder, $destinationStructure, $imageRootId) + { + if (($destinationFolder == 'mingled') && ($imageRootId == 'uploads')) { + return self::getUploadDirAbs(); + } + + if ($destinationStructure == 'doc-root') { + $relPath = PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed( + self::getAbsDirById($imageRootId) + ); + return self::getCacheDirAbs() . '/doc-root/' . $relPath; + } else { + return self::getCacheDirAbs() . '/' . $imageRootId; + } + } + + public static function createCacheDirIfMissing() + { + return self::createDirIfMissing(self::getCacheDirAbs()); + } + + // ------------ Log Dir ------------- + + public static function getLogDirAbs() + { + return self::getWebPExpressContentDirAbs() . '/log'; + } + + // ------------ Bigger-than-source dir ------------- + + public static function getBiggerThanSourceDirAbs() + { + return self::getWebPExpressContentDirAbs() . '/webp-images-bigger-than-source'; + } + + // ------------ Plugin Dir (all plugins) ------------- + + public static function getPluginDirAbs() + { + return self::getAbsDir(WP_PLUGIN_DIR); + } + + + public static function isPluginDirMovedOutOfAbsPath() + { + return !(self::isDirInsideDir(self::getPluginDirAbs(), ABSPATH)); + } + + public static function isPluginDirMovedOutOfWpContent() + { + return !(self::isDirInsideDir(self::getPluginDirAbs(), self::getContentDirAbs())); + } + + // ------------ WebP Express Plugin Dir ------------- + + public static function getWebPExpressPluginDirAbs() + { + return self::getAbsDir(WEBPEXPRESS_PLUGIN_DIR); + } + + // ------------------------------------ + // --------- Url paths ---------- + // ------------------------------------ + + /** + * Get url path (relative to domain) from absolute url. + * Ie: "http://example.com/blog" => "blog" + * Btw: By "url path" we shall always mean relative to domain + * By "url" we shall always mean complete URL (with domain and everything) + * (or at least something that starts with it...) + * + * Also note that in this library, we never returns trailing or leading slashes. + */ + public static function getUrlPathFromUrl($url) + { + $parsed = parse_url($url); + if (!isset($parsed['path'])) { + return ''; + } + if (is_null($parsed['path'])) { + return ''; + } + $path = untrailingslashit($parsed['path']); + return ltrim($path, '/\\'); + } + + public static function getUrlById($dirId) { + switch ($dirId) { + case 'wp-content': + return self::getContentUrl(); + case 'index': + return self::getHomeUrl(); + case 'home': + return self::getHomeUrl(); + case 'plugins': + return self::getPluginsUrl(); + case 'uploads': + return self::getUploadUrl(); + case 'themes': + return self::getThemesUrl(); + } + return false; + } + + /** + * Get destination root url and path, provided rootId and some configuration options + * + * This method kind of establishes the overall structure of the cache dir. + * (but not quite, as the logic is also in ConverterHelperIndependent::getDestination). + * + * @param string $rootId + * @param DestinationOptions $destinationOptions + * + * @return array url and abs-path of destination root + */ + public static function destinationRoot($rootId, $destinationOptions) + { + if (($destinationOptions->mingled) && ($rootId == 'uploads')) { + return [ + 'url' => self::getUrlById('uploads'), + 'abs-path' => self::getUploadDirAbs() + ]; + } else { + + // Its within these bases: + $destUrl = self::getUrlById('wp-content') . '/webp-express/webp-images'; + $destPath = self::getAbsDirById('wp-content') . '/webp-express/webp-images'; + + if (($destinationOptions->useDocRoot) && self::canUseDocRootForStructuringCacheDir()) { + $relPathFromDocRootToSourceImageRoot = PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed( + self::getAbsDirById($rootId) + ); + return [ + 'url' => $destUrl . '/doc-root/' . $relPathFromDocRootToSourceImageRoot, + 'abs-path' => $destPath . '/doc-root/' . $relPathFromDocRootToSourceImageRoot + ]; + } else { + $extraPath = ''; + if (is_multisite() && (get_current_blog_id() != 1)) { + $extraPath = '/sites/' . get_current_blog_id(); // #510 + } + return [ + 'url' => $destUrl . '/' . $rootId . $extraPath, + 'abs-path' => $destPath . '/' . $rootId . $extraPath + ]; + } + } + } + + public static function getRootAndRelPathForDestination($destinationPath, $imageRoots) { + foreach ($imageRoots->getArray() as $i => $imageRoot) { + $rootPath = $imageRoot->getAbsPath(); + if (strpos($destinationPath, realpath($rootPath)) !== false) { + $relPath = substr($destinationPath, strlen(realpath($rootPath)) + 1); + return [$imageRoot->id, $relPath]; + } + } + return ['', '']; + } + + + + // PST: + // appendOrSetExtension() have been copied from ConvertHelperIndependent. + // TODO: I should complete the move ASAP. + + /** + * Append ".webp" to path or replace extension with "webp", depending on what is appropriate. + * + * If destination-folder is set to mingled and destination-extension is set to "set" and + * the path is inside upload folder, the appropriate thing is to SET the extension. + * Otherwise, it is to APPEND. + * + * @param string $path + * @param string $destinationFolder + * @param string $destinationExt + * @param boolean $inUploadFolder + */ + public static function appendOrSetExtension($path, $destinationFolder, $destinationExt, $inUploadFolder) + { + if (($destinationFolder == 'mingled') && ($destinationExt == 'set') && $inUploadFolder) { + return preg_replace('/\\.(jpe?g|png)$/i', '', $path) . '.webp'; + } else { + return $path . '.webp'; + } + } + + /** + * Get destination root url and path, provided rootId and some configuration options + * + * This method kind of establishes the overall structure of the cache dir. + * (but not quite, as the logic is also in ConverterHelperIndependent::getDestination). + * + * @param string $rootId + * @param string $relPath + * @param string $destinationFolder ("mingled" or "separate") + * @param string $destinationExt ('append' or 'set') + * @param string $destinationStructure ("doc-root" or "image-roots") + * + * @return array url and abs-path of destination + */ + /* + public static function destinationPath($rootId, $relPath, $destinationFolder, $destinationExt, $destinationStructure) { + + // TODO: Current logic will not do! + // We must use ConvertHelper::getDestination for the abs path. + // And we must use logic from AlterHtmlHelper to get the URL + // Perhaps this method must be abandonned + + $root = self::destinationRoot($rootId, $destinationFolder, $destinationStructure); + $inUploadFolder = ($rootId == 'upload'); + $relPath = ConvertHelperIndependent::appendOrSetExtension($relPath, $destinationFolder, $destinationExt, $inUploadFolder); + + return [ + 'abs-path' => $root['abs-path'] . '/' . $relPath, + 'url' => $root['url'] . '/' . $relPath, + ]; + } + + public static function destinationPathConvenience($rootId, $relPath, $config) { + return self::destinationPath( + $rootId, + $relPath, + $config['destination-folder'], + $config['destination-extension'], + $config['destination-structure'] + ); + }*/ + + public static function getDestinationPathCorrespondingToSource($source, $destinationOptions) { + return Destination::getDestinationPathCorrespondingToSource( + $source, + Paths::getWebPExpressContentDirAbs(), + Paths::getUploadDirAbs(), + $destinationOptions, + new ImageRoots(self::getImageRootsDef()) + ); + } + + public static function getUrlPathById($dirId) { + return self::getUrlPathFromUrl(self::getUrlById($dirId)); + } + + public static function getHostNameOfUrl($url) { + $urlComponents = parse_url($url); + /* ie: + ( + [scheme] => http + [host] => we0 + [path] => /wordpress/uploads-moved + )*/ + + if (!isset($urlComponents['host'])) { + return ''; + } else { + return $urlComponents['host']; + } + } + + // Get complete home url (no trailing slash). Ie: "http://example.com/blog" + public static function getHomeUrl() + { + if (!function_exists('home_url')) { + // silence is golden? + // bad joke. Need to handle this... + } + return untrailingslashit(home_url()); + } + + /** Get home url, relative to domain. Ie "" or "blog" + * If home url is for example http://example.com/blog/, the result is "blog" + */ + public static function getHomeUrlPath() + { + return self::getUrlPathFromUrl(self::getHomeUrl()); + } + + + public static function getUploadUrl() + { + $uploadDir = wp_upload_dir(null, false); + return untrailingslashit($uploadDir['baseurl']); + } + + public static function getUploadUrlPath() + { + return self::getUrlPathFromUrl(self::getUploadUrl()); + } + + public static function getContentUrl() + { + return untrailingslashit(content_url()); + } + + public static function getContentUrlPath() + { + return self::getUrlPathFromUrl(self::getContentUrl()); + } + + public static function getThemesUrl() + { + return self::getContentUrl() . '/themes'; + } + + /** + * Get Url to plugins (base) + */ + public static function getPluginsUrl() + { + return untrailingslashit(plugins_url()); + } + + /** + * Get Url to WebP Express plugin (this is in fact an incomplete URL, you need to append ie '/webp-on-demand.php' to get a full URL) + */ + public static function getWebPExpressPluginUrl() + { + return untrailingslashit(plugins_url('', WEBPEXPRESS_PLUGIN)); + } + + public static function getWebPExpressPluginUrlPath() + { + return self::getUrlPathFromUrl(self::getWebPExpressPluginUrl()); + } + + public static function getWodFolderUrlPath() + { + return + self::getWebPExpressPluginUrlPath() . + '/wod'; + } + + public static function getWod2FolderUrlPath() + { + return + self::getWebPExpressPluginUrlPath() . + '/wod2'; + } + + public static function getWodUrlPath() + { + return + self::getWodFolderUrlPath() . + '/webp-on-demand.php'; + } + + public static function getWod2UrlPath() + { + return + self::getWod2FolderUrlPath() . + '/webp-on-demand.php'; + } + + public static function getWebPRealizerUrlPath() + { + return + self::getWodFolderUrlPath() . + '/webp-realizer.php'; + } + + public static function getWebPRealizer2UrlPath() + { + return + self::getWod2FolderUrlPath() . + '/webp-realizer.php'; + } + + public static function getWebServiceUrl() + { + //return self::getWebPExpressPluginUrl() . '/wpc.php'; + //return self::getHomeUrl() . '/webp-express-server'; + return self::getHomeUrl() . '/webp-express-web-service'; + } + + public static function getUrlsAndPathsForTheJavascript() + { + return [ + 'urls' => [ + 'webpExpressRoot' => self::getWebPExpressPluginUrlPath(), + 'content' => self::getContentUrlPath(), + ], + 'filePaths' => [ + 'webpExpressRoot' => self::getWebPExpressPluginDirAbs(), + 'destinationRoot' => self::getCacheDirAbs(), + ] + ]; + } + + public static function getSettingsUrl() + { + if (!function_exists('admin_url')) { + require_once ABSPATH . 'wp-includes/link-template.php'; + } + if (Multisite::isNetworkActivated()) { + // network_admin_url is also defined in link-template.php. + return network_admin_url('settings.php?page=webp_express_settings_page'); + } else { + return admin_url('options-general.php?page=webp_express_settings_page'); + } + } + +} diff --git a/lib/classes/PlatformInfo.php b/lib/classes/PlatformInfo.php new file mode 100644 index 0000000..eba4681 --- /dev/null +++ b/lib/classes/PlatformInfo.php @@ -0,0 +1,122 @@ +(here).' + ); + } else { + $rulesResult = HTAccess::saveRules($config, false); + + $rulesSaveSuccess = $rulesResult[0]; + if ($rulesSaveSuccess) { + Messenger::addMessage( + 'success', + 'WebP Express re-activated successfully.
' . + 'The image redirections are in effect again.

' . + 'Just a quick reminder: If you at some point change the upload directory or move Wordpress, ' . + 'the .htaccess files will need to be regenerated.
' . + 'You do that by re-saving the settings ' . + '(here)' + ); + } else { + Messenger::addMessage( + 'warning', + 'WebP Express could not regenerate the rewrite rules
' . + 'You need to change some permissions. Head to the ' . + 'settings page ' . + 'and try to save the settings there (it will provide more information about the problem)' + ); + } + + HTAccess::showSaveRulesMessages($rulesResult); + } + } + + private static function activateFirstTime() + { + // First check basic requirements. + // ------------------------------- + + if (PlatformInfo::isMicrosoftIis()) { + Messenger::addMessage( + 'warning', + 'You are on Microsoft IIS server. ' . + 'WebP Express should work on Windows now, but it has not been tested thoroughly.' + + ); + } + + if (!version_compare(PHP_VERSION, '5.5.0', '>=')) { + Messenger::addMessage( + 'warning', + 'You are on a very old version of PHP. WebP Express may not work correctly. Your PHP version:' . phpversion() + ); + } + + // Next issue warnings, if any + // ------------------------------- + + if (PlatformInfo::isApache() || PlatformInfo::isLiteSpeed()) { + // all is well. + } else { + Messenger::addMessage( + 'warning', + 'You are not on Apache server, nor on LiteSpeed. WebP Express only works out of the box on Apache and LiteSpeed.
' . + 'But you may get it to work. WebP Express will print you rewrite rules for Apache. You could try to configure your server to do similar routing.
' . + 'Btw: your server is: ' . $_SERVER['SERVER_SOFTWARE'] + ); + } + + // Welcome! + // ------------------------------- + Messenger::addMessage( + 'info', + 'WebP Express was installed successfully. To start using it, you must ' . + 'configure it here.' + ); + + } +} diff --git a/lib/classes/PluginDeactivate.php b/lib/classes/PluginDeactivate.php new file mode 100644 index 0000000..3169802 --- /dev/null +++ b/lib/classes/PluginDeactivate.php @@ -0,0 +1,36 @@ +Sorry, can't let you disable WebP Express!

" . + 'There are rewrite rules in the .htaccess that could not be removed. If these are not removed, it would break all images.
' . + 'Please make your .htaccess writable and then try to disable WebPExpress again.
Alternatively, remove the rules manually in your .htaccess file and try disabling again.' . + '
It concerns the following files:
'; + + + foreach ($failures as $rootId) { + $msg .= '- ' . Paths::getAbsDirById($rootId) . '/.htaccess
'; + } + + Messenger::addMessage( + 'error', + $msg + ); + + wp_redirect(admin_url('options-general.php?page=webp_express_settings_page')); + exit; + } + } +} diff --git a/lib/classes/PluginPageScript.php b/lib/classes/PluginPageScript.php new file mode 100644 index 0000000..7110966 --- /dev/null +++ b/lib/classes/PluginPageScript.php @@ -0,0 +1,26 @@ +' . $script . ''; + } + } + } + + wp_register_script('webpexpress-plugin-page', plugins_url($jsDir . '/plugin-page.js', dirname(dirname(__FILE__))), [], '1.9.0'); + wp_enqueue_script('webpexpress-plugin-page'); + } +} diff --git a/lib/classes/PluginUninstall.php b/lib/classes/PluginUninstall.php new file mode 100644 index 0000000..70764ca --- /dev/null +++ b/lib/classes/PluginUninstall.php @@ -0,0 +1,33 @@ + $optionName) { + Option::deleteOption($optionName); + } + + // remove content dir (config plus images plus htaccess-tests) + FileHelper::rrmdir(Paths::getWebPExpressContentDirAbs()); + } +} diff --git a/lib/classes/Sanitize.php b/lib/classes/Sanitize.php new file mode 100644 index 0000000..34b3953 --- /dev/null +++ b/lib/classes/Sanitize.php @@ -0,0 +1,31 @@ +getMessage()); + wp_die(); + } + $result = ''; + if (method_exists(__CLASS__, $testId)) { + + // The following call sets self::$next. + $result = call_user_func(array(__CLASS__, $testId)); + } else { + $result = ['Unknown test: ' . $testId]; + self::$next = 'break'; + } + + $response = [ + 'result' => $result, + 'next' => self::$next + ]; + echo json_encode($response, JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK | JSON_PRETTY_PRINT); + wp_die(); + } + +} diff --git a/lib/classes/SelfTestHelper.php b/lib/classes/SelfTestHelper.php new file mode 100644 index 0000000..9a54835 --- /dev/null +++ b/lib/classes/SelfTestHelper.php @@ -0,0 +1,792 @@ +get_error_message(); + //$log[] = print_r($wpResult, true); + return [false, $log, $results]; + } + if (!is_wp_error($wpResult) && !isset($wpResult['headers'])) { + $wpResult['headers'] = []; + } + $results[] = $wpResult; + $responseCode = $wpResult['response']['code']; + + $log[] = 'Response: ' . $responseCode . ' ' . $wpResult['response']['message']; + $log = array_merge($log, SelfTestHelper::printHeaders($wpResult['headers'])); + + if (isset($wpResult['headers']['content-type'])) { + if (strpos($wpResult['headers']['content-type'], 'text/html') !== false) { + if (isset($wpResult['body']) && (!empty($wpResult['body']))) { + $log[] = 'Body:'; + $log[] = print_r($wpResult['body'], true); + } + } + } + + if (($responseCode == '302') || ($responseCode == '301')) { + if ($maxRedirects > 0) { + if (isset($wpResult['headers']['location'])) { + $url = $wpResult['headers']['location']; + if (strpos($url, 'http') !== 0) { + $url = $requestUrl . $url; + } + $log[] = 'Following that redirect'; + + list($success, $newLog, $newResult) = self::remoteGet($url, $args, $maxRedirects - 1); + $log = array_merge($log, $newLog); + $results = array_merge($results, $newResult); + + return [$success, $log, $results]; + + } + } else { + $log[] = 'Not following the redirect (max redirects exceeded)'; + } + } + + $success = ($responseCode == '200'); + return [$success, $log, $results]; + } + + public static function hasHeaderContaining($headers, $headerToInspect, $containString) + { + if (!isset($headers[$headerToInspect])) { + return false; + } + + // If there are multiple headers, check all + if (gettype($headers[$headerToInspect]) == 'string') { + $h = [$headers[$headerToInspect]]; + } else { + $h = $headers[$headerToInspect]; + } + foreach ($h as $headerValue) { + if (stripos($headerValue, $containString) !== false) { + return true; + } + } + return false; + } + + public static function hasVaryAcceptHeader($headers) + { + if (!isset($headers['vary'])) { + return false; + } + + // There may be multiple Vary headers. Or they might be combined in one. + // Both are acceptable, according to https://stackoverflow.com/a/28799169/842756 + if (gettype($headers['vary']) == 'string') { + $varyHeaders = [$headers['vary']]; + } else { + $varyHeaders = $headers['vary']; + } + foreach ($varyHeaders as $headerValue) { + $values = explode(',', $headerValue); + foreach ($values as $value) { + if (strtolower($value) == 'accept') { + return true; + } + } + } + return false; + } + + /** + * @param string $rule existing|webp-on-demand|webp-realizer + */ + public static function diagnoseNoVaryHeader($rootId, $rule) + { + $log = []; + $log[] = '**However, we did not receive a Vary:Accept header. ' . + 'That header should be set in order to tell proxies that the response varies depending on the ' . + 'Accept header. Otherwise browsers not supporting webp might get a cached webp and vice versa.**{: .warn}'; + + $log[] = 'Too technical? '; + $log[] = 'Here is an explanation of what this means: ' . + 'Some companies have set up proxies which caches resources. This way, if employee A have downloaded an ' . + 'image and employee B requests it, the proxy can deliver the image directly to employee B without needing to ' . + 'send a request to the server. ' . + 'This is clever, but it can go wrong. If B for some reason is meant to get another image than A, it will not ' . + 'happen, as the server does not get the request. That is where the Vary header comes in. It tells the proxy ' . + 'that the image is dependent upon something. In this case, we need to signal proxies that the image depends upon ' . + 'the "Accept" header, as this is the one browsers use to tell the server if it accepts webps or not. ' . + 'We do that using the "Vary:Accept" header. However - it is missing :( ' . + 'Which means that employees at (larger) companies might experience problems if some are using browsers ' . + 'that supports webp and others are using browsers that does not. Worst case is that the request to an image ' . + 'is done with a browser that supports webp, as this will cache the webp in the proxy, and deliver webps to ' . + 'all employees - even to those who uses browsers that does not support webp. These employees will get blank images.'; + + if ($rule == 'existing') { + $log[] = 'So, what should you do? **I would recommend that you either try to fix the problem with the missing Vary:Accept ' . + 'header or change to "CDN friendly" mode.**{: .warn}'; + } elseif ($rule == 'webp-on-demand') { + $log[] = 'So, what should you do? **I would recommend that you either try to fix the problem with the missing Vary:Accept ' . + 'header or disable the "Enable redirection to converter?" option and use another way to get the images converted - ie ' . + 'Bulk Convert or Convert on Upload**{: .warn}'; + } + + + + return $log; + } + + public static function hasCacheControlOrExpiresHeader($headers) + { + if (isset($headers['cache-control'])) { + return true; + } + if (isset($headers['expires'])) { + return true; + } + return false; + } + + + public static function flattenHeaders($headers) + { + $log = []; + foreach ($headers as $headerName => $headerValue) { + if (gettype($headerValue) == 'array') { + foreach ($headerValue as $i => $value) { + $log[] = [$headerName, $value]; + } + } else { + $log[] = [$headerName, $headerValue]; + } + } + return $log; + } + + public static function printHeaders($headers) + { + $log = []; + $log[] = '#### Response headers:'; + + $headersFlat = self::flattenHeaders($headers); + // + foreach ($headersFlat as $i => list($headerName, $headerValue)) { + if ($headerName == 'x-webp-express-error') { + $headerValue = '**' . $headerValue . '**{: .error}'; + } + $log[] = '- ' . $headerName . ': ' . $headerValue; + } + $log[] = ''; + return $log; + } + + private static function trueFalseNullString($var) + { + if ($var === true) { + return 'yes'; + } + if ($var === false) { + return 'no'; + } + return 'could not be determined'; + } + + public static function systemInfo() + { + $log = []; + $log[] = '#### System info:'; + $log[] = '- PHP version: ' . phpversion(); + $log[] = '- OS: ' . PHP_OS; + $log[] = '- Server software: ' . $_SERVER["SERVER_SOFTWARE"]; + $log[] = '- Document Root status: ' . Paths::docRootStatusText(); + if (PathHelper::isDocRootAvailable()) { + $log[] = '- Document Root: ' . $_SERVER['DOCUMENT_ROOT']; + } + if (PathHelper::isDocRootAvailableAndResolvable()) { + if ($_SERVER['DOCUMENT_ROOT'] != realpath($_SERVER['DOCUMENT_ROOT'])) { + $log[] = '- Document Root (symlinked resolved): ' . realpath($_SERVER['DOCUMENT_ROOT']); + } + } + + $log[] = '- Document Root: ' . Paths::docRootStatusText(); + $log[] = '- Apache module "mod_rewrite" enabled?: ' . self::trueFalseNullString(PlatformInfo::gotApacheModule('mod_rewrite')); + $log[] = '- Apache module "mod_headers" enabled?: ' . self::trueFalseNullString(PlatformInfo::gotApacheModule('mod_headers')); + return $log; + } + + public static function wordpressInfo() + { + $log = []; + $log[] = '#### Wordpress info:'; + $log[] = '- Version: ' . get_bloginfo('version'); + $log[] = '- Multisite?: ' . self::trueFalseNullString(is_multisite()); + $log[] = '- Is wp-content moved?: ' . self::trueFalseNullString(Paths::isWPContentDirMoved()); + $log[] = '- Is uploads moved out of wp-content?: ' . self::trueFalseNullString(Paths::isUploadDirMovedOutOfWPContentDir()); + $log[] = '- Is plugins moved out of wp-content?: ' . self::trueFalseNullString(Paths::isPluginDirMovedOutOfWpContent()); + + $log[] = ''; + + $log[] = '#### Image roots (absolute paths)'; + foreach (Paths::getImageRootIds() as $rootId) { + $absDir = Paths::getAbsDirById($rootId); + + if (PathHelper::pathExistsAndIsResolvable($absDir) && ($absDir != realpath($absDir))) { + $log[] = '*' . $rootId . '*: ' . $absDir . ' (resolved for symlinks: ' . realpath($absDir) . ')'; + } else { + $log[] = '*' . $rootId . '*: ' . $absDir; + + } + } + + $log[] = '#### Image roots (relative to document root)'; + foreach (Paths::getImageRootIds() as $rootId) { + $absPath = Paths::getAbsDirById($rootId); + if (PathHelper::canCalculateRelPathFromDocRootToDir($absPath)) { + $log[] = '*' . $rootId . '*: ' . PathHelper::getRelPathFromDocRootToDirNoDirectoryTraversalAllowed($absPath); + } else { + $log[] = '*' . $rootId . '*: ' . 'n/a (not within document root)'; + } + } + + $log[] = '#### Image roots (URLs)'; + foreach (Paths::getImageRootIds() as $rootId) { + $url = Paths::getUrlById($rootId); + $log[] = '*' . $rootId . '*: ' . $url; + } + + + return $log; + } + + public static function configInfo($config) + { + $log = []; + $log[] = '#### WebP Express configuration info:'; + $log[] = '- Destination folder: ' . $config['destination-folder']; + $log[] = '- Destination extension: ' . $config['destination-extension']; + $log[] = '- Destination structure: ' . $config['destination-structure']; + //$log[] = 'Image types: ' . ; + //$log[] = ''; + $log[] = '(To view all configuration, take a look at the config file, which is stored in *' . Paths::getConfigFileName() . '*)'; + //$log[] = '- Config file: (config.json)'; + //$log[] = "'''\n" . json_encode($config, JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK | JSON_PRETTY_PRINT) . "\n'''\n"; + return $log; + } + + public static function htaccessInfo($config, $printRules = true) + { + $log = []; + //$log[] = '*.htaccess info:*'; + //$log[] = '- Image roots with WebP Express rules: ' . implode(', ', HTAccess::getRootsWithWebPExpressRulesIn()); + $log[] = '#### .htaccess files that WebP Express have placed rules in the following files:'; + $rootIds = HTAccess::getRootsWithWebPExpressRulesIn(); + foreach ($rootIds as $imageRootId) { + $log[] = '- ' . Paths::getAbsDirById($imageRootId) . '/.htaccess'; + } + + foreach ($rootIds as $imageRootId) { + $log = array_merge($log, self::rulesInImageRoot($config, $imageRootId)); + } + + return $log; + } + + public static function rulesInImageRoot($config, $imageRootId) + { + $log = []; + $file = Paths::getAbsDirById($imageRootId) . '/.htaccess'; + $log[] = '#### WebP rules in *' . + ($imageRootId == 'cache' ? 'webp image cache' : $imageRootId) . '*:'; + $log[] = 'File: ' . $file; + if (!HTAccess::haveWeRulesInThisHTAccess($file)) { + $log[] = '**NONE!**{: .warn}'; + } else { + $weRules = HTAccess::extractWebPExpressRulesFromHTAccess($file); + // remove unindented comments + //$weRules = preg_replace('/^\#\s[^\n\r]*[\n\r]+/ms', '', $weRules); + + // remove comments in the beginning + $weRulesArr = preg_split("/\r\n|\n|\r/", $weRules); // https://stackoverflow.com/a/11165332/842756 + while ((strlen($weRulesArr[0]) > 0) && ($weRulesArr[0][0] == '#')) { + array_shift($weRulesArr); + } + $weRules = implode("\n", $weRulesArr); + + $log[] = '```' . $weRules . '```'; + } + return $log; + } + + public static function rulesInUpload($config) + { + return self::rulesInImageRoot($config, 'uploads'); + } + + public static function allInfo($config) + { + $log = []; + + $log = array_merge($log, self::systemInfo()); + $log = array_merge($log, self::wordpressInfo()); + $log = array_merge($log, self::configInfo($config)); + $log = array_merge($log, self::capabilityTests($config)); + $log = array_merge($log, self::htaccessInfo($config, true)); + //$log = array_merge($log, self::rulesInImageRoot($config, 'upload')); + //$log = array_merge($log, self::rulesInImageRoot($config, 'wp-content')); + return $log; + } + + public static function capabilityTests($config) + { + $capTests = $config['base-htaccess-on-these-capability-tests']; + $log = []; + $log[] = '#### Live tests of .htaccess capabilities / system configuration:'; + $log[] = 'Unless noted otherwise, the tests are run in *wp-content/webp-express/htaccess-capability-tester*. '; + $log[] = 'WebPExpress currently treats the results as they neccessarily applies to all scopes (upload, themes, etc), '; + $log[] = 'but note that a server might be configured to have mod_rewrite disallowed in some folders and allowed in others.'; + /*$log[] = 'Exactly what you can do in a *.htaccess* depends on the server setup. WebP Express ' . + 'makes some live tests to verify if a certain feature in fact works. This is done by creating ' . + 'test files (*.htaccess* files and php files) in a dir inside the content dir and running these. ' . + 'These test results are used when creating the rewrite rules. Here are the results:';*/ + +// $log[] = ''; + $log[] = '- .htaccess files enabled?: ' . self::trueFalseNullString(HTAccessCapabilityTestRunner::htaccessEnabled()); + $log[] = '- mod_rewrite working?: ' . self::trueFalseNullString(HTAccessCapabilityTestRunner::modRewriteWorking()); + $log[] = '- mod_headers loaded?: ' . self::trueFalseNullString(HTAccessCapabilityTestRunner::modHeadersLoaded()); + $log[] = '- mod_headers working (header set): ' . self::trueFalseNullString(HTAccessCapabilityTestRunner::modHeaderWorking()); + //$log[] = '- passing variables from *.htaccess* to PHP script through environment variable working?: ' . self::trueFalseNullString($capTests['passThroughEnvWorking']); + $log[] = '- passing variables from *.htaccess* to PHP script through environment variable working?: ' . self::trueFalseNullString(HTAccessCapabilityTestRunner::passThroughEnvWorking()); + $log[] = '- Can run php test file in plugins/webp-express/wod/ ?: ' . self::trueFalseNullString(HTAccessCapabilityTestRunner::canRunTestScriptInWOD()); + $log[] = '- Can run php test file in plugins/webp-express/wod2/ ?: ' . self::trueFalseNullString(HTAccessCapabilityTestRunner::canRunTestScriptInWOD2()); + $log[] = '- Directives for granting access like its done in wod/.htaccess allowed?: ' . self::trueFalseNullString(HTAccessCapabilityTestRunner::grantAllAllowed()); + /*$log[] = '- pass variable from *.htaccess* to script through header working?: ' . + self::trueFalseNullString($capTests['passThroughHeaderWorking']);*/ + return $log; + } + + public static function diagnoseFailedRewrite($config, $headers) + { + if (($config['destination-structure'] == 'image-roots') && (!PathHelper::isDocRootAvailableAndResolvable())) { + $log[] = 'The problem is probably this combination:'; + if (!PathHelper::isDocRootAvailable()) { + $log[] = '1. Your document root isn`t available'; + } else { + $log[] = '1. Your document root isn`t resolvable for symlinks (it is probably subject to open_basedir restriction)'; + } + $log[] = '2. Your document root is symlinked'; + $log[] = '3. The wordpress function that tells the path of the uploads folder returns the symlink resolved path'; + + $log[] = 'I cannot check if your document root is in fact symlinked (as document root isnt resolvable). ' . + 'But if it is, there you have it. The line beginning with "RewriteCond %{REQUEST_FILENAME}"" points to your resolved root, ' . + 'but it should point to your symlinked root. WebP Express cannot do that for you because it cannot discover what the symlink is. ' . + 'Try changing the line manually. When it works, you can move the rules outside the WebP Express block so they dont get ' . + 'overwritten. OR you can change your server configuration (document root / open_basedir restrictions)'; + } + + //$log[] = '## Diagnosing'; + + //if (PlatformInfo::isNginx()) { + if (strpos($headers['server'], 'nginx') === 0) { + + // Nginx + $log[] = 'Notice that you are on Nginx and the rules that WebP Express stores in the *.htaccess* files probably does not ' . + 'have any effect. '; + $log[] = 'Please read the "I am on Nginx" section in the FAQ (https://wordpress.org/plugins/webp-express/)'; + $log[] = 'And did you remember to restart the nginx service after updating the configuration?'; + + $log[] = 'PS: If you cannot get the redirect to work, you can simply rely on Alter HTML as described in the FAQ.'; + return $log; + } + + $modRewriteWorking = HTAccessCapabilityTestRunner::modRewriteWorking(); + if ($modRewriteWorking !== null) { + $log[] = 'Running a special designed capability test to test if rewriting works with *.htaccess* files'; + } + if ($modRewriteWorking === true) { + $log[] = 'Result: Yes, rewriting works.'; + $log[] = 'It seems something is wrong with the *.htaccess* rules then. You could try ' . + 'to change "Destination structure" - the rules there are quite different.'; + $log[] = 'It could also be that the server has cached the configuration a while. Some servers ' . + 'does that. In that case, simply give it a few minutes and try again.'; + } elseif ($modRewriteWorking === false) { + $log[] = 'Result: No, rewriting does not seem to work within *.htaccess* rules.'; + if (PlatformInfo::definitelyNotGotModRewrite()) { + $log[] = 'It actually seems "mod_write" is disabled on your server. ' . + '**You must enable mod_rewrite on the server**'; + } elseif (PlatformInfo::definitelyGotApacheModule('mod_rewrite')) { + $log[] = 'However, "mod_write" *is* enabled on your server. This seems to indicate that ' . + '*.htaccess* files has been disabled for configuration on your server. ' . + 'In that case, you need to copy the WebP Express rules from the *.htaccess* files into your virtual host configuration files. ' . + '(WebP Express generates multiple *.htaccess* files. Look in the upload folder, the wp-content folder, etc).'; + $log[] = 'It could however alse simply be that your server simply needs some time. ' . + 'Some servers caches the *.htaccess* rules for a bit. In that case, simply give it a few minutes and try again.'; + } else { + $log[] = 'However, this could be due to your server being a bit slow on picking up changes in *.htaccess*.' . + 'Give it a few minutes and try again.'; + } + } else { + // The mod_rewrite test could not conclude anything. + if (PlatformInfo::definitelyNotGotApacheModule('mod_rewrite')) { + $log[] = 'It actually seems "mod_write" is disabled on your server. ' . + '**You must enable mod_rewrite on the server**'; + } elseif (PlatformInfo::definitelyGotApacheModule('mod_rewrite')) { + $log[] = '"mod_write" is enabled on your server, so rewriting ought to work. ' . + 'However, it could be that your server setup has disabled *.htaccess* files for configuration. ' . + 'In that case, you need to copy the WebP Express rules from the *.htaccess* files into your virtual host configuration files. ' . + '(WebP Express generates multiple *.htaccess* files. Look in the upload folder, the wp-content folder, etc). '; + } else { + $log[] = 'It seems something is wrong with the *.htaccess* rules. '; + $log[] = 'Or perhaps the server has cached the configuration a while. Some servers ' . + 'does that. In that case, simply give it a few minutes and try again.'; + } + } + $log[] = 'Note that if you cannot get redirection to work, you can switch to "CDN friendly" mode and ' . + 'rely on the "Alter HTML" functionality to point to the webp images. If you do a bulk conversion ' . + 'and make sure that "Convert upon upload" is activated, you should be all set. Alter HTML even handles ' . + 'inline css (unless you select "picture tag" syntax). It does however not handle images in external css or ' . + 'which is added dynamically with javascript.'; + + $log[] = '## Info for manually diagnosing'; + $log = array_merge($log, self::allInfo($config)); + return $log; + } + + public static function diagnoseWod403or500($config, $rootId, $responseCode) + { + $log = []; + + $htaccessRules = SelfTestHelper::rulesInImageRoot($config, $rootId); + $rulesText = implode('', $htaccessRules); + $rulesPointsToWod = (strpos($rulesText, '/wod/') > 0); + $rulesPointsToWod2 = (strpos($rulesText, '/wod2/') !== false); + + $log[] = ''; + $log[] = '**diagnosing**'; + $canRunTestScriptInWod = HTAccessCapabilityTestRunner::canRunTestScriptInWOD(); + $canRunTestScriptInWod2 = HTAccessCapabilityTestRunner::canRunTestScriptInWOD2(); + $canRunInAnyWod = ($canRunTestScriptInWod || $canRunTestScriptInWod2); + + $responsePingPhp = wp_remote_get(Paths::getPluginsUrl() . '/webp-express/wod/ping.php', ['timeout' => 7]); + $pingPhpResponseCode = wp_remote_retrieve_response_code($responsePingPhp); + + $responsePingText = wp_remote_get(Paths::getPluginsUrl() . '/webp-express/wod/ping.txt', ['timeout' => 7]); + $pingTextResponseCode = wp_remote_retrieve_response_code($responsePingText); + + if ($responseCode == 500) { + $log[] = 'The response was a *500 Internal Server Error*. There can be different reasons for that. ' . + 'Lets dig a bit deeper...'; + } + + $log[] = 'Examining where the *.htaccess* rules in the ' . $rootId . ' folder points to. '; + + if ($rulesPointsToWod) { + $log[] = 'They point to **wod**/webp-on-demand.php'; + } elseif ($rulesPointsToWod2) { + $log[] = 'They point to **wod2**/webp-on-demand.php'; + } else { + $log[] = '**There are no redirect rule to *webp-on-demand.php* in the .htaccess!**{: .warn}'; + $log[] = 'Here is the rules:'; + $log = array_merge($log, $htaccessRules); + } + + if ($rulesPointsToWod) { + $log[] = 'Requesting simple test script "wod/ping.php"... ' . + 'Result: ' . ($pingPhpResponseCode == '200' ? 'ok' : 'failed (response code: ' . $pingPhpResponseCode . ')'); + //'Result: ' . ($canRunTestScriptInWod ? 'ok' : 'failed'); + + if ($canRunTestScriptInWod) { + if ($responseCode == '500') { + $log[] = ''; + $log[] = '**As the test script works, it would seem that the explanation for the 500 internal server ' . + 'error is that the PHP script (webp-on-demand.php) crashes. ' . + 'You can help me by enabling debugging and post the error on the support forum on Wordpress ' . + '(https://wordpress.org/support/plugin/webp-express/), or create an issue on github ' . + '(https://github.com/rosell-dk/webp-express/issues)**'; + $log[] = ''; + } + } else { + $log[] = 'Requesting simple test file "wod/ping.txt". ' . + 'Result: ' . ($pingTextResponseCode == '200' ? 'ok' : 'failed (response code: ' . $pingTextResponseCode . ')'); + + if ($canRunTestScriptInWod2) { + if ($responseCode == 500) { + if ($pingTextResponseCode == '500') { + $log[] = 'The problem appears to be that the *.htaccess* placed in *plugins/webp-express/wod/.htaccess*' . + ' contains auth directives ("Allow" and "Request") and your server is set up to go fatal about it. ' . + 'Luckily, it seems that running scripts in the "wod2" folder works. ' . + '**What you need to do is simply to click the "Save settings and force new .htacess rules"' . + ' button. WebP Express wil then change the .htaccess rules to point to the "wod2" folder**'; + } else { + $log[] = 'The problem appears to be running PHP scripts in the "wod". ' . + 'Luckily, it seems that running scripts in the "wod2" folder works ' . + '(it has probably something to do with the *.htaccess* file placed in "wod"). ' . + '**What you need to do is simply to click the "Save settings and force new .htacess rules"' . + ' button. WebP Express wil then change the .htaccess rules to point to the "wod2" folder**'; + } + } elseif ($responseCode == 403) { + $log[] = 'The problem appears to be running PHP scripts in the "wod". ' . + 'Luckily, it seems that running scripts in the "wod2" folder works ' . + '(it could perhaps have something to do with the *.htaccess* file placed in "wod", ' . + 'although it ought not result in a 403). **What you need to do is simply to click the "Save settings and force new .htacess rules"' . + ' button. WebP Express wil then change the .htaccess rules to point to the "wod2" folder**'; + } + + return $log; + } + } + } + + $log[] = 'Requesting simple test script "wod2/ping.php". Result: ' . ($canRunTestScriptInWod2 ? 'ok' : 'failed'); + $responsePingText2 = wp_remote_get(Paths::getPluginsUrl() . '/webp-express/wod2/ping.txt', ['timeout' => 7]); + $pingTextResponseCode2 = wp_remote_retrieve_response_code($responsePingText2); + $log[] = 'Requesting simple test file "wod2/ping.txt". ' . + 'Result: ' . ($pingTextResponseCode == '200' ? 'ok' : 'failed (response code: ' . $pingTextResponseCode2 . ')'); + + if ($rulesPointsToWod2) { + if ($canRunTestScriptInWod2) { + if ($responseCode == '500') { + $log[] = ''; + $log[] = '**As the test script works, it would seem that the explanation for the 500 internal server ' . + 'error is that the PHP script (webp-on-demand.php) crashes. ' . + 'You can help me by enabling debugging and post the error on the support forum on Wordpress ' . + '(https://wordpress.org/support/plugin/webp-express/), or create an issue on github ' . + '(https://github.com/rosell-dk/webp-express/issues)**'; + $log[] = ''; + } + } else { + if ($canRunTestScriptInWod) { + $log[] = ''; + $log[] = 'The problem appears to be running PHP scripts in the "wod2" folder. ' . + 'Luckily, it seems that running scripts in the "wod" folder works ' . + '**What you need to do is simply to click the "Save settings and force new .htacess rules"' . + ' button. WebP Express wil then change the .htaccess rules to point to the "wod" folder**'; + $log[] = ''; + } else { + if ($responseCode == 500) { + + if ($pingTextResponseCode2 == '500') { + $log[] = 'All our requests results in 500 Internal Error. Even ' . + 'the request to plugins/webp-express/wod2/ping.txt. ' . + 'Surprising!'; + } else { + $log[] = 'The internal server error happens for php files, but not txt files. ' . + 'It could be the result of a restrictive server configuration or the works of a security plugin. ' . + 'Try to examine the .htaccess file in the plugins folder and its parent folders. ' . + 'Or try to look in the httpd.conf. Look for the "AllowOverride" and the "AllowOverrideList" directives. '; + } + + //$log[] = 'We get *500 Internal Server Error*'; + /* + It can for example be that the *.htaccess* ' . + 'in the ' . $rootId . ' folder (or a parent folder) contains directives that the server either ' . + 'doesnt support or has not allowed (using AllowOverride in ie httpd.conf). It could also be that the redirect succeded, ' . + 'but the *.htaccess* in the folder of the script (or a parent folder) results in such problems. Also, ' . + 'it could be that the script (webp-on-demand.php) for some reason fails.'; + + */ + } + } + + + } + } + return $log; + } +} diff --git a/lib/classes/SelfTestRedirectAbstract.php b/lib/classes/SelfTestRedirectAbstract.php new file mode 100644 index 0000000..19c158d --- /dev/null +++ b/lib/classes/SelfTestRedirectAbstract.php @@ -0,0 +1,115 @@ +config = $config; + } + + /** + * Run test for either jpeg or png + * + * @param string $rootId (ie "uploads" or "themes") + * @param string $imageType ("jpeg" or "png") + * @return array [$success, $result, $createdTestFiles] + */ + abstract protected function runTestForImageType($rootId, $imageType); + + abstract protected function getSuccessMessage(); + + private function doRunTestForRoot($rootId) + { + // return [true, ['hello'], false]; +// return [false, SelfTestHelper::diagnoseFailedRewrite($this->config, $headers), false]; + + $result = []; + + //$result[] = '*hello* with *you* and **you**. ok! FAILED'; + $result[] = '## ' . $rootId; + //$result[] = 'This test examines image responses "from the outside".'; + + $createdTestFiles = false; + + if ($this->config['image-types'] & 1) { + list($success, $subResult, $createdTestFiles) = $this->runTestForImageType($rootId, 'jpeg'); + $result = array_merge($result, $subResult); + + if ($success) { + if ($this->config['image-types'] & 2) { + $result[] = '### Performing same tests for PNG'; + list($success, $subResult, $createdTestFiles2) = $this->runTestForImageType($rootId, 'png'); + $createdTestFiles = $createdTestFiles || $createdTestFiles2; + if ($success) { + //$result[count($result) - 1] .= '. **ok**{: .ok}'; + $result[] .= 'All tests passed for PNG as well.'; + $result[] = '(I shall spare you for the report, which is almost identical to the one above)'; + } else { + $result = array_merge($result, $subResult); + } + } + } + } else { + list($success, $subResult, $createdTestFiles) = $this->runTestForImageType($rootId, 'png'); + $result = array_merge($result, $subResult); + } + + if ($success) { + $result[] = '### Results for ' . strtoupper($rootId); + + $result[] = $this->getSuccessMessage(); + } + return [true, $result, $createdTestFiles]; + } + + private function runTestForRoot($rootId) + { + // TODO: move that method to here + SelfTestHelper::cleanUpTestImages($rootId, $this->config); + + // Run the actual test + list($success, $result, $createdTestFiles) = $this->doRunTestForRoot($rootId); + + // Clean up test images again. We are very tidy around here + if ($createdTestFiles) { + $result[] = 'Deleting test images'; + SelfTestHelper::cleanUpTestImages($rootId, $this->config); + } + + return [$success, $result]; + } + + abstract protected function startupTests(); + + protected function startTest() + { + + list($success, $result) = $this->startupTests(); + + if (!$success) { + return [false, $result]; + } + + if (!file_exists(Paths::getConfigFileName())) { + $result[] = 'Hold on. You need to save options before you can run this test. There is no config file yet.'; + return [true, $result]; + } + + if ($this->config['image-types'] == 0) { + $result[] = 'No image types have been activated, nothing to test'; + return [true, $result]; + } + + foreach ($this->config['scope'] as $rootId) { + list($success, $subResult) = $this->runTestForRoot($rootId); + $result = array_merge($result, $subResult); + } + //list($success, $result) = self::runTestForRoot('uploads', $this->config); + + return [$success, $result]; + } + +} diff --git a/lib/classes/SelfTestRedirectToConverter.php b/lib/classes/SelfTestRedirectToConverter.php new file mode 100644 index 0000000..8b06176 --- /dev/null +++ b/lib/classes/SelfTestRedirectToConverter.php @@ -0,0 +1,239 @@ + [ + 'ACCEPT' => 'image/webp' + ], + ]; + list($success, $remoteGetLog, $results) = SelfTestHelper::remoteGet($requestUrl, $requestArgs); + $headers = $results[count($results)-1]['headers']; + $log = array_merge($log, $remoteGetLog); + + if (!$success) { + //$log[count($log) - 1] .= '. FAILED'; + $log[] = 'The request FAILED'; + //$log = array_merge($log, $remoteGetLog); + + if (isset($results[0]['response']['code'])) { + $responseCode = $results[0]['response']['code']; + if (($responseCode == 500) || ($responseCode == 403)) { + + $log = array_merge($log, SelfTestHelper::diagnoseWod403or500($this->config, $rootId, $responseCode)); + + //$log[] = 'or that there is an .htaccess file in the '; + } +// $log[] = print_r($results[0]['response']['code'], true); + } + //$log[] = 'The test cannot be completed'; + //$log[count($log) - 1] .= '. FAILED'; + return [false, $log, $createdTestFiles]; + } + //$log[count($log) - 1] .= '. ok!'; + //$log[] = '*' . $requestUrl . '*'; + + //$log = array_merge($log, SelfTestHelper::printHeaders($headers)); + + if (!isset($headers['content-type'])) { + $log[] = 'Bummer. There is no "content-type" response header. The test FAILED'; + return [false, $log, $createdTestFiles]; + } + + if ($headers['content-type'] == 'image/' . $imageType) { + $log[] = 'Bummer. As the "content-type" header reveals, we got the ' . $imageType . '.'; + $log[] = 'The test **failed**{: .error}.'; + $log[] = 'Now, what went wrong?'; + + if (isset($headers['x-webp-convert-log'])) { + //$log[] = 'Inspect the "x-webp-convert-log" headers above, and you ' . + // 'should have your answer (it is probably because you do not have any conversion methods working).'; + if (SelfTestHelper::hasHeaderContaining($headers, 'x-webp-convert-log', 'Performing fail action: original')) { + $log[] = 'The answer lies in the "x-convert-log" response headers: ' . + '**The conversion failed**{: .error}. '; + } + } else { + $log[] = 'Well, there is indication that the redirection isnt working. ' . + 'The PHP script should set "x-webp-convert-log" response headers, but there are none. '; + 'While these headers could have been eaten in a Cloudflare-like setup, the problem is '; + 'probably that the redirection simply failed'; + + $log[] = '### Diagnosing redirection problems'; + $log = array_merge($log, SelfTestHelper::diagnoseFailedRewrite($this->config, $headers)); + } + return [false, $log, $createdTestFiles]; + } + + if ($headers['content-type'] != 'image/webp') { + $log[] = 'However. As the "content-type" header reveals, we did not get a webp' . + 'Surprisingly we got: "' . $headers['content-type'] . '"'; + $log[] = 'The test FAILED.'; + return [false, $log, $createdTestFiles]; + } + + if (isset($headers['x-webp-convert-log'])) { + $log[] = 'Alrighty. We got a webp, and we got it from the PHP script. **Great!**{: .ok}'; + } else { + if (count($results) > 1) { + if (isset($results[0]['headers']['x-webp-convert-log'])) { + $log[] = '**Great!**{: .ok}. The PHP script created a webp and redirected the image request ' . + 'back to itself. A refresh, if you wish. The refresh got us the webp (relying on there being ' . + 'a rule which redirect images to existing converted images for webp-enabled browsers - which there is!). ' . + (SelfTestHelper::hasVaryAcceptHeader($headers) ? 'And we got the Vary:Accept header set too. **Super!**{: .ok}!' : ''); + } + } else { + $log[] = 'We got a webp. However, it seems we did not get it from the PHP script.'; + + } + + //$log[] = print_r($return, true); + //error_log(print_r($return, true)); + } + + if (!SelfTestHelper::hasVaryAcceptHeader($headers)) { + $log = array_merge($log, SelfTestHelper::diagnoseNoVaryHeader($rootId, 'webp-on-demand')); + $noWarningsYet = false; + } + if (!SelfTestHelper::hasCacheControlOrExpiresHeader($headers)) { + $log[] = '**Notice: No cache-control or expires header has been set. ' . + 'It is recommended to do so. Set it nice and big once you are sure the webps have a good quality/compression compromise.**{: .warn}'; + } + $log[] = ''; + + + // Check browsers NOT supporting webp + // ----------------------------------- + $log[] = '### Now lets check that browsers *not* supporting webp gets the ' . strtoupper($imageType); + $log[] = 'Making a HTTP request for the test image (without setting the "Accept" header)'; + list($success, $remoteGetLog, $results) = SelfTestHelper::remoteGet($requestUrl); + $headers = $results[count($results)-1]['headers']; + $log = array_merge($log, $remoteGetLog); + + if (!$success) { + $log[] = 'The request FAILED'; + $log[] = 'The test cannot be completed'; + //$log[count($log) - 1] .= '. FAILED'; + return [false, $log, $createdTestFiles]; + } + //$log[count($log) - 1] .= '. ok!'; + //$log[] = '*' . $requestUrl . '*'; + + //$log = array_merge($log, SelfTestHelper::printHeaders($headers)); + + if (!isset($headers['content-type'])) { + $log[] = 'Bummer. There is no "content-type" response header. The test FAILED'; + return [false, $log, $createdTestFiles]; + } + + if ($headers['content-type'] == 'image/webp') { + $log[] = '**Bummer**{: .error}. As the "content-type" header reveals, we got the webp. ' . + 'So even browsers not supporting webp gets webp. Not good!'; + $log[] = 'The test FAILED.'; + + $log[] = '### What to do now?'; + // TODO: We could examine the headers for common CDN responses + + $log[] = 'First, examine the response headers above. Is there any indication that ' . + 'the image is returned from a CDN cache? ' . + $log[] = 'If there is: Check out the ' . + '*How do I configure my CDN in “Varied image responses” operation mode?* section in the FAQ ' . + '(https://wordpress.org/plugins/webp-express/)'; + + if (PlatformInfo::isApache()) { + $log[] = 'If not: please report this in the forum, as it seems the .htaccess rules '; + $log[] = 'just arent working on your system.'; + } elseif (PlatformInfo::isNginx()) { + $log[] = 'Also, as you are on Nginx, check out the ' . + ' "I am on Nginx" section in the FAQ (https://wordpress.org/plugins/webp-express/)'; + } else { + $log[] = 'If not: please report this in the forum, as it seems that there is something ' . + 'in the *.htaccess* rules generated by WebP Express that are not working.'; + } + + $log[] = '### System info (for manual diagnosing):'; + $log = array_merge($log, SelfTestHelper::allInfo($this->config)); + + + return [false, $log, $createdTestFiles]; + } + + if ($headers['content-type'] != 'image/' . $imageType) { + $log[] = 'Bummer. As the "content-type" header reveals, we did not get the ' . $imageType . + 'Surprisingly we got: "' . $headers['content-type'] . '"'; + $log[] = 'The test FAILED.'; + return [false, $log, $createdTestFiles]; + } + $log[] = 'Alrighty. We got the ' . $imageType . '. **Great!**{: .ok}.'; + + if (!SelfTestHelper::hasVaryAcceptHeader($headers)) { + $log = array_merge($log, SelfTestHelper::diagnoseNoVaryHeader($rootId, 'webp-on-demand')); + $noWarningsYet = false; + } + + return [$noWarningsYet, $log, $createdTestFiles]; + } + + protected function getSuccessMessage() + { + return 'Everything **seems to work**{: .ok} as it should. ' . + 'However, a check is on the TODO: ' . + 'TODO: Check that disabled image types does not get converted. '; + } + + public function startupTests() + { + $log[] = '# Testing redirection to converter'; + if (!$this->config['enable-redirection-to-converter']) { + $log[] = 'Turned off, nothing to test (if you just turned it on without saving, remember: this is a live test so you need to save settings)'; + return [false, $log]; + } + return [true, $log]; + } + + public static function runTest() + { + $config = Config::loadConfigAndFix(false); + $me = new SelfTestRedirectToConverter($config); + return $me->startTest(); + } + +} diff --git a/lib/classes/SelfTestRedirectToExisting.php b/lib/classes/SelfTestRedirectToExisting.php new file mode 100644 index 0000000..154c4d6 --- /dev/null +++ b/lib/classes/SelfTestRedirectToExisting.php @@ -0,0 +1,250 @@ +config['destination-folder'], + $this->config['destination-extension'], + $this->config['destination-structure'], + $sourceFileName, + $imageType + ); + $log = array_merge($log, $subResult); + if (!$success) { + $log[] = 'The test cannot be completed'; + return [false, $log, $createdTestFiles]; + } + + $requestUrl = Paths::getUrlById($rootId) . '/webp-express-test-images/' . $sourceFileName; + $log[] = '### Lets check that browsers supporting webp gets the WEBP when the ' . strtoupper($imageType) . ' is requested'; + $log[] = 'Making a HTTP request for the test image (pretending to be a client that supports webp, by setting the "Accept" header to "image/webp")'; + $requestArgs = [ + 'headers' => [ + 'ACCEPT' => 'image/webp' + ] + ]; + + list($success, $remoteGetLog, $results) = SelfTestHelper::remoteGet($requestUrl, $requestArgs); + $headers = $results[count($results)-1]['headers']; + $log = array_merge($log, $remoteGetLog); + + if (!$success) { + $log[] = 'The test cannot be completed, as the HTTP request failed. This does not neccesarily mean that the redirections ' . + "aren't" . ' working, but it means you will have to check it manually. Check out the FAQ on how to do this. ' . + 'You might also want to check out why a simple HTTP request could not be issued. WebP Express uses such requests ' . + 'for detecting system capabilities, which are used when generating .htaccess files. These tests are not essential, but ' . + 'it would be best to have them working. I can inform that the Wordpress function *wp_remote_get* was used for the HTTP request ' . + 'and the URL was: ' . $requestUrl; + + return [false, $log, $createdTestFiles]; + } + //$log[count($log) - 1] .= '. ok!'; + //$log[] = '*' . $requestUrl . '*'; + + //$log = array_merge($log, SelfTestHelper::printHeaders($headers)); + + if (!isset($headers['content-type'])) { + $log[] = 'Bummer. There is no "content-type" response header. The test FAILED'; + return [false, $log, $createdTestFiles]; + } + if ($headers['content-type'] != 'image/webp') { + + if ($headers['content-type'] == 'image/' . $imageType) { + $log[] = 'Bummer. As the "content-type" header reveals, we got the ' . $imageType . '. '; + } else { + $log[] = 'Bummer. As the "content-type" header reveals, we did not get a webp' . + 'Surprisingly we got: "' . $headers['content-type'] . '"'; + } + + if (isset($headers['content-length'])) { + if ($headers['content-length'] == '6964') { + $log[] = 'However, the content-length reveals that we actually GOT the webp ' . + '(we know that the file we put is exactly 6964 bytes). ' . + 'So it is "just" the content-type header that was not set correctly.'; + + if (PlatformInfo::isNginx()) { + $log[] = 'As you are on Nginx, you probably need to add the following line ' . + 'in your *mime.types* configuration file: '; + $log[] = '```image/webp webp;```'; + } else { + $log[] = 'Perhaps you dont have *mod_mime* installed, or the following lines are not in a *.htaccess* ' . + 'in the folder containing the webp (or a parent):'; + $log[] = "```\n AddType image/webp .webp\n```"; + + $log[] = '### .htaccess status'; + $log = array_merge($log, SelfTestHelper::htaccessInfo($this->config, true)); + } + + $log[] = 'The test **FAILED**{: .error}.'; + } else { + $log[] = 'Additionally, the content-length reveals that we did not get the webp ' . + '(we know that the file we put is exactly 6964 bytes). ' . + 'So we can conclude that the rewrite did not happen'; + $log[] = 'The test **FAILED**{: .error}.'; + $log[] = '#### Diagnosing rewrites'; + $log = array_merge($log, SelfTestHelper::diagnoseFailedRewrite($this->config, $headers)); + } + } else { + $log[] = 'In addition, we did not get a *content-length* header either.' . + $log[] = 'It seems we can conclude that the rewrite did not happen.'; + $log[] = 'The test **FAILED**{: .error}.'; + $log[] = '#### Diagnosing rewrites'; + $log = array_merge($log, SelfTestHelper::diagnoseFailedRewrite($this->config, $headers)); + } + + return [false, $log, $createdTestFiles]; + } + + if (isset($headers['x-webp-convert-log'])) { + $log[] = 'Bummer. Although we did get a webp, we did not get it as a result of a direct ' . + 'redirection. This webp was returned by the PHP script. Although this works, it takes more ' . + 'resources to ignite the PHP engine for each image request than redirecting directly to the image.'; + $log[] = 'The test FAILED.'; + + $log[] = 'It seems something went wrong with the redirection.'; + $log[] = '#### Diagnosing redirects'; + $log = array_merge($log, SelfTestHelper::diagnoseFailedRewrite($this->config, $headers)); + + return [false, $log, $createdTestFiles]; + } else { + $log[] = 'Alrighty. We got a webp. Just what we wanted. **Great!**{: .ok}'; + } + + if (!SelfTestHelper::hasVaryAcceptHeader($headers)) { + $log = array_merge($log, SelfTestHelper::diagnoseNoVaryHeader($rootId, 'existing')); + $noWarningsYet = false; + } + + if (!SelfTestHelper::hasCacheControlOrExpiresHeader($headers)) { + $log[] = '**Notice: No cache-control or expires header has been set. ' . + 'It is recommended to do so. Set it nice and big once you are sure the webps have a good quality/compression compromise.**{: .warn}'; + } + $log[] = ''; + + + // Check browsers NOT supporting webp + // ----------------------------------- + $log[] = '### Now lets check that browsers *not* supporting webp gets the ' . strtoupper($imageType); + $log[] = 'Making a HTTP request for the test image (without setting the "Accept" header)'; + list($success, $remoteGetLog, $results) = SelfTestHelper::remoteGet($requestUrl); + $headers = $results[count($results)-1]['headers']; + $log = array_merge($log, $remoteGetLog); + + if (!$success) { + $log[] = 'The request FAILED'; + $log[] = 'The test cannot be completed'; + return [false, $log, $createdTestFiles]; + } + //$log[count($log) - 1] .= '. ok!'; + //$log[] = '*' . $requestUrl . '*'; + + //$log = array_merge($log, SelfTestHelper::printHeaders($headers)); + + if (!isset($headers['content-type'])) { + $log[] = 'Bummer. There is no "content-type" response header. The test FAILED'; + return [false, $log, $createdTestFiles]; + } + + if ($headers['content-type'] == 'image/webp') { + $log[] = '**Bummer**{: .error}. As the "content-type" header reveals, we got the webp. ' . + 'So even browsers not supporting webp gets webp. Not good!'; + $log[] = 'The test FAILED.'; + + $log[] = '#### What to do now?'; + // TODO: We could examine the headers for common CDN responses + + $log[] = 'First, examine the response headers above. Is there any indication that ' . + 'the image is returned from a CDN cache? ' . + $log[] = 'If there is: Check out the ' . + '*How do I configure my CDN in “Varied image responses” operation mode?* section in the FAQ ' . + '(https://wordpress.org/plugins/webp-express/)'; + + if (PlatformInfo::isApache()) { + $log[] = 'If not: please report this in the forum, as it seems the .htaccess rules '; + $log[] = 'just arent working on your system.'; + } elseif (PlatformInfo::isNginx()) { + $log[] = 'Also, as you are on Nginx, check out the ' . + ' "I am on Nginx" section in the FAQ (https://wordpress.org/plugins/webp-express/)'; + } else { + $log[] = 'If not: please report this in the forum, as it seems that there is something ' . + 'in the *.htaccess* rules generated by WebP Express that are not working.'; + } + + $log[] = '### System info (for manual diagnosing):'; + $log = array_merge($log, SelfTestHelper::allInfo($this->config)); + + + return [false, $log, $createdTestFiles]; + } + + if ($headers['content-type'] != 'image/' . $imageType) { + $log[] = 'Bummer. As the "content-type" header reveals, we did not get the ' . $imageType . + 'Surprisingly we got: "' . $headers['content-type'] . '"'; + $log[] = 'The test FAILED.'; + return [false, $log, $createdTestFiles]; + } + $log[] = 'Alrighty. We got the ' . $imageType . '. **Great!**{: .ok}.'; + + if (!SelfTestHelper::hasVaryAcceptHeader($headers)) { + $log = array_merge($log, SelfTestHelper::diagnoseNoVaryHeader($rootId, 'existing')); + $noWarningsYet = false; + } + + return [$noWarningsYet, $log, $createdTestFiles]; + } + + protected function getSuccessMessage() + { + return 'Everything **seems to work**{: .ok} as it should. ' . + 'However, a couple of things were not tested (it is on the TODO). ' . + 'TODO 1: If one image type is disabled, check that it does not redirect to webp (unless redirection to converter is set up). ' . + 'TODO 2: Test that redirection to webp only is triggered when the webp exists. '; + } + + public function startupTests() + { + $log[] = '# Testing redirection to existing webp'; + if (!$this->config['redirect-to-existing-in-htaccess']) { + $log[] = 'Turned off, nothing to test (if you just turned it on without saving, remember: this is a live test so you need to save settings)'; + return [false, $log]; + } + return [true, $log]; + } + + public static function runTest() + { + $config = Config::loadConfigAndFix(false); + $me = new SelfTestRedirectToExisting($config); + return $me->startTest(); + } +} diff --git a/lib/classes/SelfTestRedirectToWebPRealizer.php b/lib/classes/SelfTestRedirectToWebPRealizer.php new file mode 100644 index 0000000..1e94d25 --- /dev/null +++ b/lib/classes/SelfTestRedirectToWebPRealizer.php @@ -0,0 +1,257 @@ + [ + 'ACCEPT' => 'image/webp' + ] + ]; + list($success, $remoteGetLog, $results) = SelfTestHelper::remoteGet($requestUrl, $requestArgs); + $headers = $results[count($results)-1]['headers']; + $log = array_merge($log, $remoteGetLog); + + + if (!$success) { + //$log[count($log) - 1] .= '. FAILED'; + //$log[] = '*' . $requestUrl . '*'; + + $log[] = 'The test **failed**{: .error}'; + + if (isset($results[0]['response']['code'])) { + $responseCode = $results[0]['response']['code']; + if (($responseCode == 500) || ($responseCode == 403)) { + + $log = array_merge($log, SelfTestHelper::diagnoseWod403or500($this->config, $rootId, $responseCode)); + return [false, $log, $createdTestFiles]; + //$log[] = 'or that there is an .htaccess file in the '; + } +// $log[] = print_r($results[0]['response']['code'], true); + } + + $log[] = 'Why did it fail? It could either be that the redirection rule did not trigger ' . + 'or it could be that the PHP script could not locate a source image corresponding to the destination URL. ' . + 'Currently, this analysis cannot dertermine which was the case and it cannot be helpful ' . + 'if the latter is the case (sorry!). However, if the redirection rules are the problem, here is some info:'; + + $log[] = '### Diagnosing redirection problems (presuming it is the redirection to the script that is failing)'; + $log = array_merge($log, SelfTestHelper::diagnoseFailedRewrite($this->config, $headers)); + + + //$log[count($log) - 1] .= '. FAILED'; + return [false, $log, $createdTestFiles]; + } + //$log[count($log) - 1] .= '. ok!'; + //$log[] = '*' . $requestUrl . '*'; + + //$log = array_merge($log, SelfTestHelper::printHeaders($headers)); + + if (!isset($headers['content-type'])) { + $log[] = 'Bummer. There is no "content-type" response header. The test FAILED'; + return [false, $log, $createdTestFiles]; + } + + if ($headers['content-type'] == 'image/' . $imageType) { + $log[] = 'Bummer. As the "content-type" header reveals, we got the ' . $imageType . '.'; + $log[] = 'The test **failed**{: .error}.'; + $log[] = 'Now, what went wrong?'; + + if (isset($headers['x-webp-convert-log'])) { + //$log[] = 'Inspect the "x-webp-convert-log" headers above, and you ' . + // 'should have your answer (it is probably because you do not have any conversion methods working).'; + if (SelfTestHelper::hasHeaderContaining($headers, 'x-webp-convert-log', 'Performing fail action: original')) { + $log[] = 'The answer lies in the "x-convert-log" response headers: ' . + '**The conversion failed**{: .error}. '; + } + } else { + $log[] = 'Well, there is indication that the redirection isnt working. ' . + 'The PHP script should set "x-webp-convert-log" response headers, but there are none. '; + 'While these headers could have been eaten in a Cloudflare-like setup, the problem is '; + 'probably that the redirection simply failed'; + + $log[] = '### Diagnosing redirection problems'; + $log = array_merge($log, SelfTestHelper::diagnoseFailedRewrite($this->config, $headers)); + } + return [false, $log, $createdTestFiles]; + } + + if ($headers['content-type'] != 'image/webp') { + $log[] = 'However. As the "content-type" header reveals, we did not get a webp' . + 'Surprisingly we got: "' . $headers['content-type'] . '"'; + $log[] = 'The test FAILED.'; + return [false, $log, $createdTestFiles]; + } + + $log[] = '**Alrighty**{: .ok}. We got a webp.'; + if (isset($headers['x-webp-convert-log'])) { + $log[] = 'The "x-webp-convert-log" headers reveals we got the webp from the PHP script. **Great!**{: .ok}'; + } else { + $log[] = 'Interestingly, there are no "x-webp-convert-log" headers even though ' . + 'the PHP script always produces such. Could it be you have some weird setup that eats these headers?'; + } + + if (SelfTestHelper::hasVaryAcceptHeader($headers)) { + $log[] = 'All is however not super-duper:'; + + $log[] = '**Notice: We received a Vary:Accept header. ' . + 'That header need not to be set. Actually, it is a little bit bad for performance ' . + 'as proxies are currently doing a bad job maintaining several caches (in many cases they simply do not)**{: .warn}'; + $noWarningsYet = false; + } + if (!SelfTestHelper::hasCacheControlOrExpiresHeader($headers)) { + $log[] = '**Notice: No cache-control or expires header has been set. ' . + 'It is recommended to do so. Set it nice and big once you are sure the webps have a good quality/compression compromise.**{: .warn}'; + } + $log[] = ''; + + return [$noWarningsYet, $log, $createdTestFiles]; + } + +/* + private static function doRunTest($this->config) + { + $log = []; + $log[] = '# Testing redirection to converter'; + + $createdTestFiles = false; + if (!file_exists(Paths::getConfigFileName())) { + $log[] = 'Hold on. You need to save options before you can run this test. There is no config file yet.'; + return [true, $log, $createdTestFiles]; + } + + + if ($this->config['image-types'] == 0) { + $log[] = 'No image types have been activated, nothing to test'; + return [true, $log, $createdTestFiles]; + } + + if ($this->config['image-types'] & 1) { + list($success, $subResult, $createdTestFiles) = self::runTestForImageType($this->config, 'jpeg'); + $log = array_merge($log, $subResult); + + if ($success) { + if ($this->config['image-types'] & 2) { + $log[] = '### Performing same tests for PNG'; + list($success, $subResult, $createdTestFiles2) = self::runTestForImageType($this->config, 'png'); + $createdTestFiles = $createdTestFiles || $createdTestFiles2; + if ($success) { + //$log[count($log) - 1] .= '. **ok**{: .ok}'; + $log[] .= 'All tests passed for PNG as well.'; + $log[] = '(I shall spare you for the report, which is almost identical to the one above)'; + } else { + $log = array_merge($log, $subResult); + } + } + } + } else { + list($success, $subResult, $createdTestFiles) = self::runTestForImageType($this->config, 'png'); + $log = array_merge($log, $subResult); + } + + if ($success) { + $log[] = '### Conclusion'; + $log[] = 'Everything **seems to work**{: .ok} as it should. ' . + 'However, notice that this test only tested an image which was placed in the *uploads* folder. ' . + 'The rest of the image roots (such as theme images) have not been tested (it is on the TODO). ' . + 'Also on the TODO: If one image type is disabled, check that it does not redirect to the conversion script. ' . + 'These things probably work, though.'; + } + + + return [true, $log, $createdTestFiles]; + }*/ + + protected function getSuccessMessage() + { + return 'Everything **seems to work**{: .ok} as it should. ' . + 'However, a check is on the TODO: ' . + 'TODO: Check that disabled image types does not get converted. '; + } + + public function startupTests() + { + $log[] = '# Testing "WebP Realizer" functionality'; + if (!$this->config['enable-redirection-to-webp-realizer']) { + $log[] = 'Turned off, nothing to test (if you just turned it on without saving, remember: this is a live test so you need to save settings)'; + return [false, $log]; + } + return [true, $log]; + } + + public static function runTest() + { + $config = Config::loadConfigAndFix(false); + $me = new SelfTestRedirectToWebPRealizer($config); + return $me->startTest(); + } + + +} diff --git a/lib/classes/State.php b/lib/classes/State.php new file mode 100644 index 0000000..f318339 --- /dev/null +++ b/lib/classes/State.php @@ -0,0 +1,45 @@ + "Warning", + E_NOTICE => "Notice", + E_STRICT => "Strict Notice", + E_DEPRECATED => "Deprecated", + E_USER_DEPRECATED => "User Deprecated", + ]; + + if (isset($errorTypes[$errno])) { + $errType = $errorTypes[$errno]; + } else { + $errType = "Warning ($errno)"; + } + + $msg = $errType . ': ' . $errstr . ' in ' . $errfile . ', line ' . $errline; + self::$warnings[] = $msg; + + // suppress! + return true; + } + /** + * Get a test result object OR false, if tests cannot be made. + * + * @return object|false + */ + public static function getConverterStatus() { + //return false; + + // Is result cached? + if (isset(self::$converterStatus)) { + return self::$converterStatus; + } + $source = Paths::getWebPExpressPluginDirAbs() . '/test/small-q61.jpg'; + $destination = Paths::getUploadDirAbs() . '/webp-express-test-conversion.webp'; + if (!FileHelper::canCreateFile($destination)) { + $destination = Paths::getContentDirAbs() . '/webp-express-test-conversion.webp'; + } + if (!FileHelper::canCreateFile($destination)) { + self::$converterStatus = false; // // cache the result + return false; + } + $workingConverters = []; + $errors = []; + self::$warnings = []; + + // We need wod options. + // But we cannot simply use loadWodOptions - because that would leave out the deactivated + // converters. And we need to test all converters - even the deactivated ones. + // So we load config, set "deactivated" to false, and generate Wod options from the config + $config = Config::loadConfigAndFix(); + + // set deactivated to false on all converters + foreach($config['converters'] as &$converter) { + $converter['deactivated'] = false; + } + + $options = Config::generateWodOptionsFromConfigObj($config); + $options['converters'] = ConvertersHelper::normalize($options['webp-convert']['convert']['converters']); + + $previousErrorHandler = set_error_handler( + array('\WebPExpress\TestRun', "warningHandler"), + E_WARNING | E_USER_WARNING | E_NOTICE | E_USER_NOTICE + ); + + $warnings = []; + //echo '
' . print_r($options, true) . '
'; + foreach ($options['converters'] as $converter) { + $converterId = $converter['converter']; + self::$warnings = []; + try { + $converterOptions = array_merge($options, $converter['options']); + unset($converterOptions['converters']); + + //ConverterHelper::runConverter($converterId, $source, $destination, $converterOptions); + $converterInstance = ConverterFactory::makeConverter( + $converterId, + $source, + $destination, + $converterOptions + ); + // Note: We now suppress warnings. + // WebPConvert logs warnings but purposefully does not stop them - warnings should generally not be + // stopped. However, as these warnings are logged in conversion log, it is preferable not to make them + // bubble here. # + $converterInstance->doConvert(); + + if (count(self::$warnings) > 0) { + $warnings[$converterId] = self::$warnings; + } + $workingConverters[] = $converterId; + } catch (\Exception $e) { + $errors[$converterId] = $e->getMessage(); + } catch (\Throwable $e) { + $errors[$converterId] = $e->getMessage(); + } + } + + restore_error_handler(); + //print_r($errors); + + // cache the result + self::$converterStatus = [ + 'workingConverters' => $workingConverters, + 'errors' => $errors, + 'warnings' => $warnings, + ]; + return self::$converterStatus; + } + + + public static $localQualityDetectionWorking = null; // to cache the result + + public static function isLocalQualityDetectionWorking() { + if (isset(self::$localQualityDetectionWorking)) { + return self::$localQualityDetectionWorking; + } else { + $q = JpegQualityDetector::detectQualityOfJpg( + Paths::getWebPExpressPluginDirAbs() . '/test/small-q61.jpg' + ); + self::$localQualityDetectionWorking = ($q === 61); + return self::$localQualityDetectionWorking; + } + } +} diff --git a/lib/classes/Validate.php b/lib/classes/Validate.php new file mode 100644 index 0000000..c28c78c --- /dev/null +++ b/lib/classes/Validate.php @@ -0,0 +1,27 @@ +getMessage()); + wp_die(); + } + } +/* +{ + "converters": [ + { + "converter": "cwebp", + "options": { + "use-nice": true, + "try-common-system-paths": true, + "try-supplied-binary-for-os": true, + "method": 6, + "low-memory": true, + "command-line-options": "" + }, + "working": true + }, + { + "converter": "vips", + "options": { + "smart-subsample": false, + "preset": "none" + }, + "working": false + }, + { + "converter": "imagemagick", + "options": { + "use-nice": true + }, + "working": true, + "deactivated": true + }, + { + "converter": "graphicsmagick", + "options": { + "use-nice": true + }, + "working": false + }, + { + "converter": "ffmpeg", + "options": { + "use-nice": true, + "method": 4 + }, + "working": false + }, + { + "converter": "wpc", + "working": false, + "options": { + "api-key": "" + } + }, + { + "converter": "ewww", + "working": false + }, + { + "converter": "imagick", + "working": false + }, + { + "converter": "gmagick", + "working": false + }, + { + "converter": "gd", + "options": { + "skip-pngs": false + }, + "working": false + } + ] +}*/ + public static function processConversionSettings() { + require_once __DIR__ . "/../../vendor/autoload.php"; + $availableConverters = Stack::getAvailableConverters(); + + /* + $converters = []; + //$supportsEncoding = []; + foreach ($availableConverters as $converter) { + $converters[] = [ + 'id' => $converter, + 'name' => $converter + ]; + /*if () { + $supportsEncoding[] = $converter; + }*/ + //} + + $webpConvertOptionDefinitions = WebPConvert::getConverterOptionDefinitions(); + + $config = Config::loadConfigAndFix(); + $defaults = [ + 'auto-limit' => (isset($config['quality-auto']) && $config['quality-auto']), + 'alpha-quality' => $config['alpha-quality'], + 'quality' => $config['max-quality'], + 'encoding' => $config['jpeg-encoding'], + 'near-lossless' => ($config['jpeg-enable-near-lossless'] ? $config['jpeg-near-lossless'] : 100), + 'metadata' => $config['metadata'], + 'stack-converters' => ConvertersHelper::getActiveConverterIds($config), + + // 'method' (I could copy from cwebp...) + // 'sharp-yuv' (n/a) + // low-memory (n/a) + // auto-filter (n/a) + // preset (n/a) + // size-in-percentage (I could copy from cwebp...) + ]; + + $good = ConvertersHelper::getWorkingAndActiveConverterIds($config); + if (isset($good[0])) { + $defaults['converter'] = $good[0]; + } + //'converter' => 'ewww', + + + // TODO:add PNG options + $pngDefaults = [ + 'encoding' => $config['png-encoding'], + 'near-lossless' => ($config['png-enable-near-lossless'] ? $config['png-near-lossless'] : 100), + 'quality' => $config['png-quality'], + ]; + + + // Filter active converters + foreach ($config['converters'] as $converter) { + /*if (isset($converter['deactivated']) && ($converter['deactivated'])) { + //continue; + }*/ + if (isset($converter['options'])) { + foreach ($converter['options'] as $optionName => $optionValue) { + $defaults[$converter['converter'] . '-' . $optionName] = $optionValue; + } + + } + } + + + $systemStatus = [ + 'converterRequirements' => [ + 'gd' => [ + 'extensionLoaded' => extension_loaded('gd'), + 'compiledWithWebP' => function_exists('imagewebp'), + ] + // TODO: Add more! + ] + ]; + +//getUnsupportedDefaultOptions + //supportedStandardOptions: { + $defaults['png'] = $pngDefaults; + + return [ + //'converters' => $converters, + 'defaults' => $defaults, + //'pngDefaults' => $pngDefaults, + 'options' => $webpConvertOptionDefinitions, + 'systemStatus' => $systemStatus + ]; + + /* + $config = Config::loadConfigAndFix(); + // 'working', 'deactivated' + $foundFirstWorkingAndActive = false; + foreach ($config['converters'] as $converter) { + $converters[] = [ + 'id' => $converter['converter'], + 'name' => $converter['converter'] + ]; + if ($converter['working']) { + if + } + if (!$foundFirstWorkingAndActive) { + + } + }*/ + + return [ + 'converters' => $converters + ]; + } + + /* + * Get mime + * @return string + */ + private static function setMime($path, &$info) { + require_once __DIR__ . "/../../vendor/autoload.php"; + $mimeResult = ImageMimeTypeGuesser::detect($path); + if (!$mimeResult) { + return; + } + $info['mime'] = $mimeResult; + if ($mimeResult == 'image/webp') { + $handle = @fopen($path, 'r'); + if ($handle !== false) { + // 20 bytes is sufficient for all our sniffers, except image/svg+xml. + // The svg sniffer takes care of reading more + $sampleBin = @fread($handle, 20); + if ($sampleBin !== false) { + if (preg_match("/^RIFF.{4}WEBPVP8\ /", $sampleBin) === 1) { + $info['mime'] .= ' (lossy)'; + } else if (preg_match("/^RIFF.{4}WEBPVP8L/", $sampleBin) === 1) { + $info['mime'] .= ' (lossless)'; + } + } + } + + } + } + + public static function processInfo() { + + Validate::postHasKey('args'); + + //$args = json_decode(sanitize_text_field(stripslashes($_POST['args'])), true); + + //$args = $_POST['args']; + $args = self::getArgs(); + if (!array_key_exists('path', $args)) { + throw new \Exception('"path" argument missing for command'); + } + + $path = SanityCheck::pathWithoutDirectoryTraversal($args['path']); + $path = ltrim($path, '/'); + $pathTokens = explode('/', $path); + + $rootId = array_shift($pathTokens); // Shift off the first item, which is the scope + $relPath = implode('/', $pathTokens); + $config = Config::loadConfigAndFix(); + /*$rootIds = Paths::filterOutSubRoots($config['scope']); + if (!in_array($rootId, $rootIds)) { + throw new \Exception('Invalid scope (have you perhaps changed the scope setting after igniting the file manager?)'); + }*/ + $rootIds = $rootIds = Paths::getImageRootIds(); + + $absPath = Paths::getAbsDirById($rootId) . '/' . $relPath; + //absPathExistsAndIsFile + SanityCheck::absPathExists($absPath); + + $result = [ + 'original' => [ + //'filename' => $absPath, + //'abspath' => $absPath, + 'size' => filesize($absPath), + // PS: I keep "&original" because some might have set up Nginx rules for ?original + 'url' => Paths::getUrlById($rootId) . '/' . $relPath . '?' . SelfTestHelper::randomDigitsAndLetters(8) . '&dontreplace&original', + ] + ]; + self::setMime($absPath, $result['original']); + + // TODO: NO! + // We must use ConvertHelper::getDestination for the abs path. + // And we must use logic from AlterHtmlHelper to get the URL + //error_log('path:' . $absPathDest); + + $destinationOptions = DestinationOptions::createFromConfig($config); + if ($destinationOptions->useDocRoot) { + if (!(Paths::canUseDocRootForStructuringCacheDir())) { + $destinationOptions->useDocRoot = false; + } + } + $imageRoots = new ImageRoots(Paths::getImageRootsDef()); + $destinationPath = Paths::getDestinationPathCorrespondingToSource($absPath, $destinationOptions); + list($rootId, $destRelPath) = Paths::getRootAndRelPathForDestination($destinationPath, $imageRoots); + if ($rootId != '') { + $absPathDest = Paths::getAbsDirById($rootId) . '/' . $destRelPath; + $destinationUrl = Paths::getUrlById($rootId) . '/' . $destRelPath; + + SanityCheck::absPath($absPathDest); + + if (@file_exists($absPathDest)) { + $result['converted'] = [ + //'abspath' => $absPathDest, + 'size' => filesize($absPathDest), + 'url' => $destinationUrl . '?' . SelfTestHelper::randomDigitsAndLetters(8), + ]; + self::setMime($absPathDest, $result['converted']); + } + + // Get log, if exists. Ignore errors. + $log = ''; + try { + $logFile = ConvertHelperIndependent::getLogFilename($absPath, Paths::getLogDirAbs()); + if (@file_exists($logFile)) { + $logContent = file_get_contents($logFile); + if ($log !== false) { + $log = $logContent; + } + } + } + catch (\Exception $e) { + //throw $e; + } + + $result['log'] = $log; + } + + + //$destinationUrl = DestinationUrl:: + + /* + error_log('dest:' . $destinationPath); + error_log('dest root:' . $rootId); + error_log('dest path:' . $destRelPath); + error_log('dest abs-dir:' . Paths::getAbsDirById($rootId) . '/' . $destRelPath); + error_log('dest url:' . Paths::getUrlById($rootId) . '/' . $destRelPath); + */ + + //error_log('url:' . $destinationPath); + //error_log('destinationOptions' . print_r($destinationOptions, true)); + + /* + $destination = Paths::destinationPathConvenience($rootId, $relPath, $config); + $absPathDest = $destination['abs-path']; + SanityCheck::absPath($absPathDest); + error_log('path:' . $absPathDest); + + if (@file_exists($absPathDest)) { + $result['converted'] = [ + 'abspath' => $destination['abs-path'], + 'size' => filesize($destination['abs-path']), + 'url' => $destination['url'], + 'log' => '' + ]; + } + */ + return $result; + } + + /** + * Translate path received (ie "/uploads/2021/...") to absolute path. + * + * @param string $path + * + * @return array [$absPath, $relPath, $rootId] + * @throws \Exception if root id is invalid or path doesn't pass sanity check + */ + private static function analyzePathReceived($path) { + try { + $path = SanityCheck::pathWithoutDirectoryTraversal($path); + $path = ltrim($path, '/'); + $pathTokens = explode('/', $path); + + $rootId = array_shift($pathTokens); + $relPath = implode('/', $pathTokens); + + $rootIds = Paths::getImageRootIds(); + if (!in_array($rootId, $rootIds)) { + throw new \Exception('Invalid rootId'); + } + if ($relPath == '') { + $relPath = '.'; + } + + $absPath = PathHelper::canonicalize(Paths::getAbsDirById($rootId) . '/' . $relPath); + SanityCheck::absPathExists($absPath); + + return [$absPath, $relPath, $rootId]; + } + catch (\Exception $e) { + //throw new \Exception('Invalid path received (' . $e->getMessage() . ')'); + throw new \Exception('Invalid path'); + } + } + + public static function processGetFolder() { + + Validate::postHasKey('args'); + + //$args = json_decode(sanitize_text_field(stripslashes($_POST['args'])), true); + + $args = self::getArgs(); + if (!array_key_exists('path', $args)) { + throw new \Exception('"path" argument missing for command'); + } + + $path = SanityCheck::noStreamWrappers($args['path']); + //$pathTokens = explode('/', $path); + if ($path == '') { + $result = [ + 'children' => [ + [ + 'name' => '/', + 'isDir' => true, + 'nickname' => 'scope' + ] + ] + ]; + return $result; + } + + $config = Config::loadConfigAndFix(); + $rootIds = Paths::getImageRootIds(); + if ($path == '/') { + $rootIds = Paths::filterOutSubRoots($config['scope']); + $result = ['children'=>[]]; + foreach ($rootIds as $rootId) { + $result['children'][] = [ + 'name' => $rootId, + 'isDir' => true, + ]; + } + return $result; + } + list($absPath, $relPath, $rootId) = self::analyzePathReceived($path); + + $listOptions = BulkConvert::defaultListOptions($config); + $listOptions['root'] = Paths::getAbsDirById($rootId); + + $listOptions['filter']['only-unconverted'] = false; + $listOptions['flattenList'] = false; + $listOptions['max-depth'] = 0; + + //throw new \Exception('Invalid rootId' . print_r($listOptions)); + + $list = BulkConvert::getListRecursively($relPath, $listOptions); + + return ['children' => $list]; + } + + public static function processGetTree() { + $config = Config::loadConfigAndFix(); + $rootIds = Paths::filterOutSubRoots($config['scope']); + + $listOptions = [ + //'root' => Paths::getUploadDirAbs(), + 'ext' => $config['destination-extension'], + 'destination-folder' => $config['destination-folder'], /* hm, "destination-folder" is a bad name... */ + 'webExpressContentDirAbs' => Paths::getWebPExpressContentDirAbs(), + 'uploadDirAbs' => Paths::getUploadDirAbs(), + 'useDocRootForStructuringCacheDir' => (($config['destination-structure'] == 'doc-root') && (Paths::canUseDocRootForStructuringCacheDir())), + 'imageRoots' => new ImageRoots(Paths::getImageRootsDefForSelectedIds($config['scope'])), // (Paths::getImageRootsDef() + 'filter' => [ + 'only-converted' => false, + 'only-unconverted' => false, + 'image-types' => $config['image-types'], + ], + 'flattenList' => false + ]; + + $children = []; + foreach ($rootIds as $rootId) { + $listOptions['root'] = Paths::getAbsDirById($rootId); + $grandChildren = BulkConvert::getListRecursively('.', $listOptions); + $children[] = [ + 'name' => $rootId, + 'isDir' => true, + 'children' => $grandChildren + ]; + } + return ['name' => '', 'isDir' => true, 'isOpen' => true, 'children' => $children]; + + } + + private static function getArgs() { + //return $_POST['args']; + + $args = $_POST['args']; +// $args = '{\"path\":\"\"}'; + //$args = '{"path":"hollo"}'; + + //error_log('get args:' . gettype($args)); + //error_log(print_r($args, true)); + //error_log(print_r(($_POST['args'] + ''), true)); + + //error_log('type:' . gettype($_POST['args'])); + $args = json_decode('"' . $args . '"', true); + $args = json_decode($args, true); + //error_log('decoded:' . gettype($args)); + //error_log(print_r($args, true)); + //$args = json_decode($args, true); + + return $args; + } + + public static function processConvert() { + + Validate::postHasKey('args'); + + //$args = json_decode(sanitize_text_field(stripslashes($_POST['args'])), true); + + $args = self::getArgs(); + if (!array_key_exists('path', $args)) { + throw new \Exception('"path" argument missing for command'); + } + + $path = SanityCheck::noStreamWrappers($args['path']); + + $convertOptions = null; + if (isset($args['convertOptions'])) { + $convertOptions = $args['convertOptions']; + $convertOptions['log-call-arguments'] = true; + //unset($convertOptions['converter']); + //$convertOptions['png'] = ['quality' => 7]; + //$convertOptions['png-quality'] = 8; + } + + //error_log(print_r(json_encode($convertOptions, JSON_PRETTY_PRINT), true)); + + list($absPath, $relPath, $rootId) = self::analyzePathReceived($path); + + $convertResult = Convert::convertFile($absPath, null, $convertOptions); + + $result = [ + 'success' => $convertResult['success'], + 'data' => $convertResult['msg'], + 'log' => $convertResult['log'], + 'args' => $args, // for debugging. TODO + ]; + $info = []; + if (isset($convertResult['filesize-webp'])) { + $info['size'] = $convertResult['filesize-webp']; + } + if (isset($convertResult['destination-url'])) { + $info['url'] = $convertResult['destination-url'] . '?' . SelfTestHelper::randomDigitsAndLetters(8); + } + if (isset($convertResult['destination-path'])) { + self::setMime($convertResult['destination-path'], $info); + } + + $result['converted'] = $info; + return $result; + + /*if (!array_key_exists('convertOptions', $args)) { + throw new \Exception('"convertOptions" argument missing for command'); + } + //return ['success' => true, 'optionsReceived' => $args['convertOptions']]; + */ + + + /* + $path = SanityCheck::pathWithoutDirectoryTraversal($args['path']); + $path = ltrim($path, '/'); + $pathTokens = explode('/', $path); + + $rootId = array_shift($pathTokens); // Shift off the first item, which is the scope + $relPath = implode('/', $pathTokens); + $config = Config::loadConfigAndFix(); + $rootIds = Paths::filterOutSubRoots($config['scope']); + if (!in_array($rootId, $rootIds)) { + throw new \Exception('Invalid scope'); + } + + $absPath = Paths::getAbsDirById($rootId) . '/' . $relPath; + //absPathExistsAndIsFile + SanityCheck::absPathExists($absPath); */ + } + + public static function processDeleteConverted() { + + Validate::postHasKey('args'); + + //$args = json_decode(sanitize_text_field(stripslashes($_POST['args'])), true); + + //$args = $_POST['args']; + $args = self::getArgs(); + if (!array_key_exists('path', $args)) { + throw new \Exception('"path" argument missing for command'); + } + + $path = SanityCheck::noStreamWrappers($args['path']); + list($absPath, $relPath, $rootId) = self::analyzePathReceived($path); + + $config = Config::loadConfigAndFix(); + $destinationOptions = DestinationOptions::createFromConfig($config); + if ($destinationOptions->useDocRoot) { + if (!(Paths::canUseDocRootForStructuringCacheDir())) { + $destinationOptions->useDocRoot = false; + } + } + $destinationPath = Paths::getDestinationPathCorrespondingToSource($absPath, $destinationOptions); + + if (@!file_exists($destinationPath)) { + throw new \Exception('file not found: ' . $destinationPath); + } + + if (@!unlink($destinationPath)) { + throw new \Exception('failed deleting file'); + } + + $result = [ + 'success' => true, + 'data' => $destinationPath + ]; + return $result; + + } + +} diff --git a/lib/classes/WCFMPage.php b/lib/classes/WCFMPage.php new file mode 100644 index 0000000..6befa13 --- /dev/null +++ b/lib/classes/WCFMPage.php @@ -0,0 +1,54 @@ +' . + '

WebP Express Conversion Browser

' . + ''; + + echo '
loading
'; + //include WEBPEXPRESS_PLUGIN_DIR . '/lib/options/page.php'; + + /* require_once __DIR__ . "/../../vendor/autoload.php"; +// print_r(WebPConvert::getConverterOptionDefinitions('png', false, true)); + echo '
' .
+            print_r(
+                json_encode(
+                    WebPConvert::getConverterOptionDefinitions('png', false, true),
+                    JSON_PRETTY_PRINT
+                ),
+                true
+            ) . '
';*/ + } + + /* We add directly to head instead, to get the type="module" + public static function enqueueScripts() { + $ver = '0'; + wp_register_script('wcfileman', plugins_url('js/wcfm/index.js', WEBPEXPRESS_PLUGIN), [], $ver); + wp_enqueue_script('wcfileman'); + }*/ + + public static function addToHead() { + $baseUrl = plugins_url('lib/wcfm', WEBPEXPRESS_PLUGIN); + //$url = plugins_url('js/conversion-manager/index.be5d792e.js ', WEBPEXPRESS_PLUGIN); + + $wcfmNonce = wp_create_nonce('webpexpress-wcfm-nonce'); + echo 'window.webpExpressWCFMNonce = "' . $wcfmNonce . '";'; + + echo ''; + //echo ''; + + echo ''; + echo ''; + } + +} diff --git a/lib/classes/WPHttpRequester.php b/lib/classes/WPHttpRequester.php new file mode 100644 index 0000000..5faaf8e --- /dev/null +++ b/lib/classes/WPHttpRequester.php @@ -0,0 +1,36 @@ + 10]); + //echo '
' . print_r($response, true) . '
'; + + if (is_wp_error($response)) { + return new HttpResponse($response->get_error_message(), '0', []); + } else { + $body = wp_remote_retrieve_body($response); + $statusCode = wp_remote_retrieve_response_code($response); + $headersDict = wp_remote_retrieve_headers($response); + if (method_exists($headersDict, 'getAll')) { + $headersMap = $headersDict->getAll(); + } else { + $headersMap = []; + } + return new HttpResponse($body, $statusCode, $headersMap); + } + } +} diff --git a/lib/classes/WebPOnDemand.php b/lib/classes/WebPOnDemand.php new file mode 100644 index 0000000..a6e46ea --- /dev/null +++ b/lib/classes/WebPOnDemand.php @@ -0,0 +1,285 @@ +byId($dirIdOfHtaccess)->getAbsPath() . '/' . $sourceRelHtaccess; + return $source; + } + + private static function getSource() { + if (self::$usingDocRoot) { + $source = self::getSourceDocRoot(); + } else { + $source = self::getSourceNoDocRoot(); + } + return $source; + } + + private static function processRequestNoTryCatch() { + + self::loadConfig(); + + $options = self::$options; + $wodOptions = self::$wodOptions; + $serveOptions = $options['webp-convert']; + $convertOptions = &$serveOptions['convert']; + //echo '
' . print_r($wodOptions, true) . '
'; exit; + + + // Validate that WebPExpress was configured to redirect to this conversion script + // (but do not require that for Nginx) + // ------------------------------------------------------------------------------ + self::$checking = 'settings'; + if (stripos($_SERVER["SERVER_SOFTWARE"], 'nginx') === false) { + if (!isset($wodOptions['enable-redirection-to-converter']) || ($wodOptions['enable-redirection-to-converter'] === false)) { + throw new ValidateException('Redirection to conversion script is not enabled'); + } + } + + // Check source (the image to be converted) + // -------------------------------------------- + self::$checking = 'source'; + + // Decode URL in case file contains encoded symbols (#413) + $source = urldecode(self::getSource()); + + //self::exitWithError($source); + + $imageRoots = self::getImageRootsDef(); + + // Get upload dir + $uploadDirAbs = $imageRoots->byId('uploads')->getAbsPath(); + + // Check destination path + // -------------------------------------------- + self::$checking = 'destination path'; + $destination = ConvertHelperIndependent::getDestination( + $source, + $wodOptions['destination-folder'], + $wodOptions['destination-extension'], + self::$webExpressContentDirAbs, + $uploadDirAbs, + self::$usingDocRoot, + self::getImageRootsDef() + ); + + //$destination = SanityCheck::absPathIsInDocRoot($destination); + $destination = SanityCheck::pregMatch('#\.webp$#', $destination, 'Does not end with .webp'); + + //self::exitWithError($destination); + + // Done with sanitizing, lets get to work! + // --------------------------------------- + self::$checking = 'done'; + + if (isset($wodOptions['success-response']) && ($wodOptions['success-response'] == 'original')) { + $serveOptions['serve-original'] = true; + $serveOptions['serve-image']['headers']['vary-accept'] = false; + } else { + $serveOptions['serve-image']['headers']['vary-accept'] = true; + } +//echo $source . '
' . $destination; exit; + + /* + // No caching! + // - perhaps this will solve it for WP engine. + // but no... Perhaps a 302 redirect to self then? (if redirect to existing is activated). + // TODO: try! + //$serveOptions['serve-image']['headers']['vary-accept'] = false; + + */ +/* + include_once __DIR__ . '/../../vendor/autoload.php'; + $convertLogger = new \WebPConvert\Loggers\BufferLogger(); + \WebPConvert\WebPConvert::convert($source, $destination, $serveOptions['convert'], $convertLogger); + header('Location: ?fresh' , 302); +*/ + + if (isset($_SERVER['WPENGINE_ACCOUNT'])) { + // Redirect to self rather than serve directly for WP Engine. + // This overcomes that Vary:Accept header set from PHP is lost on WP Engine. + // To prevent endless loop in case "redirect to existing webp" isn't set up correctly, + // only activate when destination is missing. + // (actually it does not prevent anything on wpengine as the first request is cached! + // -even though we try to prevent it:) + // Well well. Those users better set up "redirect to existing webp" as well! + $serveOptions['serve-image']['headers']['cache-control'] = true; + $serveOptions['serve-image']['headers']['expires'] = false; + $serveOptions['serve-image']['cache-control-header'] = 'no-store, no-cache, must-revalidate, max-age=0'; + //header("Pragma: no-cache", true); + + if (!@file_exists($destination)) { + $serveOptions['redirect-to-self-instead-of-serving'] = true; + } + } + + $loggingEnabled = (isset($wodOptions['enable-logging']) ? $wodOptions['enable-logging'] : true); + $logDir = ($loggingEnabled ? self::$webExpressContentDirAbs . '/log' : null); + + ConvertHelperIndependent::serveConverted( + $source, + $destination, + $serveOptions, + $logDir, + 'Conversion triggered with the conversion script (wod/webp-on-demand.php)' + ); + + BiggerThanSourceDummyFiles::updateStatus( + $source, + $destination, + self::$webExpressContentDirAbs, + self::getImageRootsDef(), + $wodOptions['destination-folder'], + $wodOptions['destination-extension'] + ); + + self::fixConfigIfEwwwDiscoveredNonFunctionalApiKeys(); + } + + public static function processRequest() { + try { + self::processRequestNoTryCatch(); + } catch (SanityException $e) { + self::exitWithError('Sanity check failed for ' . self::$checking . ': '. $e->getMessage()); + } catch (ValidateException $e) { + self::exitWithError('Validation failed for ' . self::$checking . ': '. $e->getMessage()); + } catch (\Exception $e) { + if (self::$checking == 'done') { + self::exitWithError('Error occured during conversion/serving:' . $e->getMessage()); + } else { + self::exitWithError('Error occured while calculating ' . self::$checking . ': '. $e->getMessage()); + } + } + } +} diff --git a/lib/classes/WebPRealizer.php b/lib/classes/WebPRealizer.php new file mode 100644 index 0000000..4eb8697 --- /dev/null +++ b/lib/classes/WebPRealizer.php @@ -0,0 +1,276 @@ +byId('uploads')->getAbsPath() . '/' . $destinationRelHtaccess; + } elseif ($dirIdOfHtaccess == 'cache') { + return $imageRoots->byId('wp-content')->getAbsPath() . '/webp-express/webp-images/' . $destinationRelHtaccess; + } + /* + $pathTokens = explode('/', $destinationRelCacheRoot); + $imageRootId = array_shift($pathTokens); + $destinationRelSpecificCacheRoot = implode('/', $pathTokens); + + $imageRootId = SanityCheck::pregMatch( + '#^[a-z\-]+$#', + $imageRootId, + 'The image root ID is not a valid root id' + ); + + // TODO: Validate that the root id is in scope + + if (count($pathTokens) == 0) { + throw new \Exception('invalid destination argument. It must contain dashes.'); + } + + return $imageRoots->byId($imageRootId)->getAbsPath() . '/' . $destinationRelSpecificCacheRoot; + +/* + if ($imageRootId !== false) { + + //$imageRootId = self::getEnvPassedInRewriteRule('WE_IMAGE_ROOT_ID'); + if ($imageRootId !== false) { + $imageRootId = SanityCheck::pregMatch('#^[a-z\-]+$#', $imageRootId, 'The image root ID passed in ENV is not a valid root-id'); + + $destinationRelImageRoot = self::getEnvPassedInRewriteRule('WE_DESTINATION_REL_IMAGE_ROOT'); + if ($destinationRelImageRoot !== false) { + $destinationRelImageRoot = SanityCheck::pathWithoutDirectoryTraversal($destinationRelImageRoot); + } + $imageRoots = self::getImageRootsDef(); + return $imageRoots->byId($imageRootId)->getAbsPath() . '/' . $destinationRelImageRoot; + } + + if (isset($_GET['xdestination-rel-image-root'])) { + $xdestinationRelImageRoot = SanityCheck::noControlChars($_GET['xdestination-rel-image-root']); + $destinationRelImageRoot = SanityCheck::pathWithoutDirectoryTraversal(substr($xdestinationRelImageRoot, 1)); + + $imageRootId = SanityCheck::noControlChars($_GET['image-root-id']); + SanityCheck::pregMatch('#^[a-z\-]+$#', $imageRootId, 'Not a valid root-id'); + + $imageRoots = self::getImageRootsDef(); + return $imageRoots->byId($imageRootId)->getAbsPath() . '/' . $destinationRelImageRoot; + } + + throw new \Exception('Argument for destination file missing'); + //WE_DESTINATION_REL_IMG_ROOT*/ + + /* + $destAbs = SanityCheck::noControlChars(self::getEnvPassedInRewriteRule('WEDESTINATIONABS')); + if ($destAbs !== false) { + return SanityCheck::pathWithoutDirectoryTraversal($destAbs); + } + + // Check querystring (relative path) + if (isset($_GET['xdest-rel-to-root-id'])) { + $xdestRelToRootId = SanityCheck::noControlChars($_GET['xdest-rel-to-root-id']); + $destRelToRootId = SanityCheck::pathWithoutDirectoryTraversal(substr($xdestRelToRootId, 1)); + + $rootId = SanityCheck::noControlChars($_GET['root-id']); + SanityCheck::pregMatch('#^[a-z]+$#', $rootId, 'Not a valid root-id'); + return self::getRootPathById($rootId) . '/' . $destRelToRootId; + } + */ + + } + + private static function getDestination() { + self::$checking = 'destination path'; + if (self::$usingDocRoot) { + $destination = self::getDestinationDocRoot(); + } else { + $destination = self::getDestinationNoDocRoot(); + } + SanityCheck::pregMatch('#\.webp$#', $destination, 'Does not end with .webp'); + + return $destination; + } + + private static function processRequestNoTryCatch() { + + self::loadConfig(); + + $options = self::$options; + $wodOptions = self::$wodOptions; + $serveOptions = $options['webp-convert']; + $convertOptions = &$serveOptions['convert']; + //echo '
' . print_r($wodOptions, true) . '
'; exit; + + + // Validate that WebPExpress was configured to redirect to this conversion script + // (but do not require that for Nginx) + // ------------------------------------------------------------------------------ + self::$checking = 'settings'; + if (stripos($_SERVER["SERVER_SOFTWARE"], 'nginx') === false) { + if (!isset($wodOptions['enable-redirection-to-webp-realizer']) || ($wodOptions['enable-redirection-to-webp-realizer'] === false)) { + throw new ValidateException('Redirection to webp realizer is not enabled'); + } + } + + // Get destination + // -------------------------------------------- + self::$checking = 'destination'; + // Decode URL in case file contains encoded symbols (#413) + $destination = urldecode(self::getDestination()); + + //self::exitWithError($destination); + + // Validate source path + // -------------------------------------------- + $checking = 'source path'; + $source = ConvertHelperIndependent::findSource( + $destination, + $wodOptions['destination-folder'], + $wodOptions['destination-extension'], + self::$usingDocRoot ? 'doc-root' : 'image-roots', + self::$webExpressContentDirAbs, + self::getImageRootsDef() + ); + //self::exitWithError('source:' . $source); + //echo '

destination:

' . $destination . '

source:

' . $source; exit; + + if ($source === false) { + header('X-WebP-Express-Error: webp-realizer.php could not find an existing jpg/png that corresponds to the webp requested', true); + + $protocol = isset($_SERVER["SERVER_PROTOCOL"]) ? $_SERVER["SERVER_PROTOCOL"] : 'HTTP/1.0'; + header($protocol . " 404 Not Found"); + die(); + //echo 'destination requested:
' . $destination . ''; + } + //$source = SanityCheck::absPathExistsAndIsFileInDocRoot($source); + + // Done with sanitizing, lets get to work! + // --------------------------------------- + $serveOptions['add-vary-header'] = false; + $serveOptions['fail'] = '404'; + $serveOptions['fail-when-fail-fails'] = '404'; + $serveOptions['serve-image']['headers']['vary-accept'] = false; + + $loggingEnabled = (isset($wodOptions['enable-logging']) ? $wodOptions['enable-logging'] : true); + $logDir = ($loggingEnabled ? self::$webExpressContentDirAbs . '/log' : null); + + ConvertHelperIndependent::serveConverted( + $source, + $destination, + $serveOptions, + $logDir, + 'Conversion triggered with the conversion script (wod/webp-realizer.php)' + ); + + BiggerThanSourceDummyFiles::updateStatus( + $source, + $destination, + self::$webExpressContentDirAbs, + self::getImageRootsDef(), + $wodOptions['destination-folder'], + $wodOptions['destination-extension'] + ); + + self::fixConfigIfEwwwDiscoveredNonFunctionalApiKeys(); + } + + public static function processRequest() { + try { + self::processRequestNoTryCatch(); + } catch (SanityException $e) { + self::exitWithError('Sanity check failed for ' . self::$checking . ': '. $e->getMessage()); + } catch (ValidateException $e) { + self::exitWithError('Validation failed for ' . self::$checking . ': '. $e->getMessage()); + } catch (\Exception $e) { + self::exitWithError('Error occured while calculating ' . self::$checking . ': '. $e->getMessage()); + } + } +} diff --git a/lib/classes/WodConfigLoader.php b/lib/classes/WodConfigLoader.php new file mode 100644 index 0000000..263ddb0 --- /dev/null +++ b/lib/classes/WodConfigLoader.php @@ -0,0 +1,252 @@ + $item) { + if (substr($key, -$len) == $envName) { + return $item; + } + } + return false; + } + + protected static function getWebPExpressContentDirWithDocRoot() + { + // Get relative path to wp-content + // -------------------------------- + self::$checking = 'Relative path to wp-content dir'; + + // Passed in env variable? + $wpContentDirRel = self::getEnvPassedInRewriteRule('WPCONTENT'); + if ($wpContentDirRel === false) { + // Passed in QS? + if (isset($_GET['wp-content'])) { + $wpContentDirRel = SanityCheck::pathWithoutDirectoryTraversal($_GET['wp-content']); + } else { + // In case above fails, fall back to standard location + $wpContentDirRel = 'wp-content'; + } + } + + // Check WebP Express content dir + // --------------------------------- + self::$checking = 'WebP Express content dir'; + + $webExpressContentDirAbs = SanityCheck::absPathExistsAndIsDir(self::$docRoot . '/' . $wpContentDirRel . '/webp-express'); + return $webExpressContentDirAbs; + } + + protected static function getWebPExpressContentDirNoDocRoot() { + // Check wp-content + // ---------------------- + self::$checking = 'relative path between webp-express plugin dir and wp-content dir'; + + // From v0.22.0, we pass relative to webp-express dir rather than to the general plugin dir. + // - this allows symlinking the webp-express dir. + $wpContentDirRelToWEPluginDir = self::getEnvPassedInRewriteRule('WE_WP_CONTENT_REL_TO_WE_PLUGIN_DIR'); + if (!$wpContentDirRelToWEPluginDir) { + // Passed in QS? + if (isset($_GET['xwp-content-rel-to-we-plugin-dir'])) { + $xwpContentDirRelToWEPluginDir = SanityCheck::noControlChars($_GET['xwp-content-rel-to-we-plugin-dir']); + $wpContentDirRelToWEPluginDir = SanityCheck::pathDirectoryTraversalAllowed(substr($xwpContentDirRelToWEPluginDir, 1)); + } + } + + // Old .htaccess rules from before 0.22.0 passed relative path to general plugin dir. + // these rules must still be supported, which is what we do here: + if (!$wpContentDirRelToWEPluginDir) { + self::$checking = 'relative path between plugin dir and wp-content dir'; + + $wpContentDirRelToPluginDir = self::getEnvPassedInRewriteRule('WE_WP_CONTENT_REL_TO_PLUGIN_DIR'); + if ($wpContentDirRelToPluginDir === false) { + // Passed in QS? + if (isset($_GET['xwp-content-rel-to-plugin-dir'])) { + $xwpContentDirRelToPluginDir = SanityCheck::noControlChars($_GET['xwp-content-rel-to-plugin-dir']); + $wpContentDirRelToPluginDir = SanityCheck::pathDirectoryTraversalAllowed(substr($xwpContentDirRelToPluginDir, 1)); + + } else { + throw new \Exception('Path to wp-content was not received in any way'); + } + } + $wpContentDirRelToWEPluginDir = $wpContentDirRelToPluginDir . '..'; + } + + + // Check WebP Express content dir + // --------------------------------- + self::$checking = 'WebP Express content dir'; + + $pathToWEPluginDir = dirname(dirname(__DIR__)); + $webExpressContentDirAbs = SanityCheck::pathDirectoryTraversalAllowed($pathToWEPluginDir . '/' . $wpContentDirRelToWEPluginDir . '/webp-express'); + + //$pathToPluginDir = dirname(dirname(dirname(__DIR__))); + //$webExpressContentDirAbs = SanityCheck::pathDirectoryTraversalAllowed($pathToPluginDir . '/' . $wpContentDirRelToPluginDir . '/webp-express'); + //echo $webExpressContentDirAbs; exit; + if (@!file_exists($webExpressContentDirAbs)) { + throw new \Exception('Dir not found'); + } + $webExpressContentDirAbs = @realpath($webExpressContentDirAbs); + if ($webExpressContentDirAbs === false) { + throw new \Exception('WebP Express content dir is outside restricted open_basedir!'); + } + return $webExpressContentDirAbs; + } + + protected static function getImageRootsDef() + { + if (!isset(self::$wodOptions['image-roots'])) { + throw new \Exception('No image roots defined in config.'); + } + return new ImageRoots(self::$wodOptions['image-roots']); + } + + protected static function loadConfig() { + + $usingDocRoot = !( + isset($_GET['xwp-content-rel-to-we-plugin-dir']) || + self::getEnvPassedInRewriteRule('WE_WP_CONTENT_REL_TO_WE_PLUGIN_DIR') || + isset($_GET['xwp-content-rel-to-plugin-dir']) || + self::getEnvPassedInRewriteRule('WE_WP_CONTENT_REL_TO_PLUGIN_DIR') + ); + self::$usingDocRoot = $usingDocRoot; + + if ($usingDocRoot) { + // Check DOCUMENT_ROOT + // ---------------------- + self::$checking = 'DOCUMENT_ROOT'; + $docRootAvailable = PathHelper::isDocRootAvailableAndResolvable(); + if (!$docRootAvailable) { + throw new \Exception( + 'Document root is no longer available. It was available when the .htaccess rules was created and ' . + 'the rules are based on that. You need to regenerate the rules (or fix your document root configuration)' + ); + } + + $docRoot = SanityCheck::absPath($_SERVER["DOCUMENT_ROOT"]); + $docRoot = rtrim($docRoot, '/'); + self::$docRoot = $docRoot; + } + + if ($usingDocRoot) { + self::$webExpressContentDirAbs = self::getWebPExpressContentDirWithDocRoot(); + } else { + self::$webExpressContentDirAbs = self::getWebPExpressContentDirNoDocRoot(); + } + + // Check config file name + // --------------------------------- + self::$checking = 'config file'; + + $configFilename = self::$webExpressContentDirAbs . '/config/wod-options.json'; + if (!file_exists($configFilename)) { + throw new \Exception('Configuration file was not found (wod-options.json)'); + } + + // Check config file + // -------------------- + $configLoadResult = file_get_contents($configFilename); + if ($configLoadResult === false) { + throw new \Exception('Cannot open config file'); + } + $json = SanityCheck::isJSONObject($configLoadResult); + + self::$options = json_decode($json, true); + self::$wodOptions = self::$options['wod']; + } + + /** + * Must be called after conversion. + */ + protected static function fixConfigIfEwwwDiscoveredNonFunctionalApiKeys() + { + if (isset(Ewww::$nonFunctionalApiKeysDiscoveredDuringConversion)) { + // We got an invalid or exceeded api key (at least one). + //error_log('look:' . print_r(Ewww::$nonFunctionalApiKeysDiscoveredDuringConversion, true)); + EwwwTools::markApiKeysAsNonFunctional( + Ewww::$nonFunctionalApiKeysDiscoveredDuringConversion, + self::$webExpressContentDirAbs . '/config' + ); + } + + } +} diff --git a/lib/debug.php b/lib/debug.php new file mode 100644 index 0000000..667af92 --- /dev/null +++ b/lib/debug.php @@ -0,0 +1,18 @@ +
WebP Express 0.19.0 introduced a new conversion method: ffmpeg, which works on your system. ' . + 'You now have a conversion method that works! To start using it, you must go to settings and click save.', + $msgId, + [ + ['text' => 'Take me to the settings', 'redirect-to-settings' => true], + ['text' => 'Dismiss'], + ] +); diff --git a/lib/dismissable-global-messages/0.19.0/meet-ffmpeg-better-than-ewww.php b/lib/dismissable-global-messages/0.19.0/meet-ffmpeg-better-than-ewww.php new file mode 100644 index 0000000..6cdcc73 --- /dev/null +++ b/lib/dismissable-global-messages/0.19.0/meet-ffmpeg-better-than-ewww.php @@ -0,0 +1,17 @@ + 'Take me to the settings', 'redirect-to-settings' => true], + ['text' => 'Dismiss'], + ] +); diff --git a/lib/dismissable-global-messages/0.19.0/meet-ffmpeg-better-than-gd.php b/lib/dismissable-global-messages/0.19.0/meet-ffmpeg-better-than-gd.php new file mode 100644 index 0000000..0b75ace --- /dev/null +++ b/lib/dismissable-global-messages/0.19.0/meet-ffmpeg-better-than-gd.php @@ -0,0 +1,16 @@ + 'Take me to the settings', 'redirect-to-settings' => true], + ['text' => 'Dismiss'], + ] +); diff --git a/lib/dismissable-messages/0.14.0/say-hello-to-vips.php b/lib/dismissable-messages/0.14.0/say-hello-to-vips.php new file mode 100644 index 0000000..daaeb7c --- /dev/null +++ b/lib/dismissable-messages/0.14.0/say-hello-to-vips.php @@ -0,0 +1,39 @@ +I have some good news and... more good news! WebP Express now supports Vips and Vips is working on your server. ' . + 'Vips is one of the best method for converting WebPs, on par with cwebp, which you also have working. ' . + 'You may want to use Vips instead of cwebp. Your choice.

', + '0.14.0/say-hello-to-vips', + 'Got it!' + ); + } else { + DismissableMessages::printDismissableMessage( + 'info', + '

I have some good news and... more good news! WebP Express now supports Vips and Vips is working on your server. ' . + 'Vips is one of the best method for converting WebPs and has therefore been inserted at the top of the list.' . + '

', + '0.14.0/say-hello-to-vips', + 'Got it!' + ); + } +} else { + // show message? +} diff --git a/lib/dismissable-messages/0.14.0/suggest-enable-pngs.php b/lib/dismissable-messages/0.14.0/suggest-enable-pngs.php new file mode 100644 index 0000000..b4f64a9 --- /dev/null +++ b/lib/dismissable-messages/0.14.0/suggest-enable-pngs.php @@ -0,0 +1,10 @@ +WebP Express 0.14 has new options for the conversions. Especially, it can now produce lossless webps, and ' . + 'it can automatically try both lossy and lossless and select the smallest. You can play around with the ' . + 'new options when your click "test" next to a converter.

' . + '

Once satisfied, dont forget to ' . + 'wipe your existing converted files (there is a "Delete converted files" button for that here on this page).

', + '0.14.0/suggest-wipe-because-lossless', + 'Got it!' + ); +} else { + if ($firstActiveAndWorkingConverterId == 'gd') { + foreach ($workingConvertersIds as $workingId) { + if (in_array($workingId, $convertersSupportingEncodingAuto)) { + DismissableMessages::printDismissableMessage( + 'info', + '

WebP Express 0.14 has new options for the conversions. Especially, it can now produce lossless webps, and ' . + 'it can automatically try both lossy and lossless and select the smallest. You can play around with the ' . + 'new options when your click "test" next to a converter.

' . + '

Once satisfied, dont forget to wipe your existing converted files (there is a "Delete converted files" ' . + 'button for that here on this page)

' . + '

Btw: The "gd" conversion method that you are using does not support lossless encoding ' . + '(in fact Gd only supports very few conversion options), but fortunately, you have at least one ' . + 'other conversion method working, so you can simply start using that instead.

', + '0.14.0/suggest-wipe-because-lossless', + 'Got it!' + ); + break; + } + } + } +} diff --git a/lib/dismissable-messages/0.15.0/new-scope-setting-content.php b/lib/dismissable-messages/0.15.0/new-scope-setting-content.php new file mode 100644 index 0000000..4a92fca --- /dev/null +++ b/lib/dismissable-messages/0.15.0/new-scope-setting-content.php @@ -0,0 +1,12 @@ +You are running on NGINX. WebP Express works well on NGINX, however this UI is not streamlined NGINX yet.

' . + '

You should head over to the ' . + 'NGINX section in the FAQ' . + ' to learn how to use WebP Express on NGINX

';*/ + + +DismissableMessages::printDismissableMessage( + 'warning', + '

You are running on NGINX. WebP Express works well on NGINX, however this UI is not streamlined NGINX yet.

' . + '

You should head over to the ' . + 'NGINX section in the FAQ' . + ' to learn how to use WebP Express on NGINX

', + '0.16.0/nginx-link-to-faq', + 'Got it!' +); diff --git a/lib/dismissable-messages/0.23.0/elementor.php b/lib/dismissable-messages/0.23.0/elementor.php new file mode 100644 index 0000000..a158c1d --- /dev/null +++ b/lib/dismissable-messages/0.23.0/elementor.php @@ -0,0 +1,34 @@ +experiments->is_feature_active( 'e_optimized_css_loading' ) === false) { + $showMessage = true; + } + } catch (\Exception $e) { + // Well, just bad luck. + } +} + +if ($showMessage) { + DismissableMessages::printDismissableMessage( + 'info', + '

' . + 'You see this message because you using Elementor, you rely solely on Alter HTML for webp, and Elementor is currently set up to use external css. ' . + 'You might want to reconfigure Elementor so it inlines the CSS. This will allow Alter HTML to replace the image urls of backgrounds. ' . + 'To reconfigure, go to Elementor > Settings > Experiments and activate "Improved CSS Loading". ' . + 'Note: This requires that Alter HTML is configured to "Replace image URLs". ' . + 'For more information, ' . + 'head over here' . + '

', + '0.23.0/elementor', + 'Got it!' + ); +} else { + DismissableMessages::dismissMessage('0.23.0/elementor'); +} diff --git a/lib/migrate/migrate.php b/lib/migrate/migrate.php new file mode 100644 index 0000000..2e83634 --- /dev/null +++ b/lib/migrate/migrate.php @@ -0,0 +1,44 @@ +' . + 'Please create the folder manually, or change the file permissions of your wp-content folder.' + ); + return false; + } else { + if (!Paths::createConfigDirIfMissing()) { + Messenger::printMessage( + 'error', + 'For migration to 0.5.0, WebP Express needs to create a directory "webp-express/config" under your wp-content folder, but does not have permission to do so.
' . + 'Please create the folder manually, or change the file permissions.' + ); + return false; + } + + + if (!Paths::createCacheDirIfMissing()) { + Messenger::printMessage( + 'error', + 'For migration to 0.5.0, WebP Express needs to create a directory "webp-express/webp-images" under your wp-content folder, but does not have permission to do so.
' . + 'Please create the folder manually, or change the file permissions.' + ); + return false; + } + } + return true; +} + +function webp_express_migrate1_createDummyConfigFiles() +{ + // TODO... + return true; +} + +function webpexpress_migrate1_migrateOptions() +{ + $converters = json_decode(Option::getOption('webp_express_converters', '[]'), true); + foreach ($converters as &$converter) { + unset ($converter['id']); + } + + $options = [ + 'image-types' => intval(Option::getOption('webp_express_image_types_to_convert', 1)), + 'max-quality' => intval(Option::getOption('webp_express_max_quality', 80)), + 'fail' => Option::getOption('webp_express_failure_response', 'original'), + 'converters' => $converters, + 'forward-query-string' => true + ]; + if ($options['max-quality'] == 0) { + $options['max-quality'] = 80; + if ($options['image-types'] == 0) { + $options['image-types'] = 1; + } + } + if ($options['converters'] == null) { + $options['converters'] = []; + } + + // TODO: Save + //Messenger::addMessage('info', 'Options:
' .  print_r($options, true) . '
'); +// $htaccessExists = Config::doesHTAccessExists(); + + $config = $options; + + //$htaccessExists = Config::doesHTAccessExists(); + //$rules = HTAccess::generateHTAccessRulesFromConfigObj($config); + + if (Config::saveConfigurationFile($config)) { + $options = Config::generateWodOptionsFromConfigObj($config); + if (Config::saveWodOptionsFile($options)) { + + Messenger::addMessage( + 'success', + 'WebP Express has successfully migrated its configuration to 0.5.0' + ); + + //Config::saveConfigurationAndHTAccessFilesWithMessages($config, 'migrate'); + //$rulesResult = HTAccess::saveRules($config); // Commented out because rules are going to be saved in migrate12 + /* + 'mainResult' // 'index', 'wp-content' or 'failed' + 'minRequired' // 'index' or 'wp-content' + 'pluginToo' // 'yes', 'no' or 'depends' + 'pluginFailed' // true if failed to write to plugin folder (it only tries that, if pluginToo == 'yes') + 'pluginFailedBadly' // true if plugin failed AND it seems we have rewrite rules there + 'overidingRulesInWpContentWarning' // true if main result is 'index' but we cannot remove those in wp-content + 'rules' // the rules that were generated + */ + /* + $mainResult = $rulesResult['mainResult']; + $rules = $rulesResult['rules']; + + if ($mainResult != 'failed') { + Messenger::addMessage( + 'success', + 'WebP Express has successfully migrated its configuration and updated the rewrite rules to 0.5.0' + ); + } else { + Messenger::addMessage( + 'warning', + 'WebP Express has successfully migrated its configuration.' . + 'However, WebP Express could not update the rewrite rules
' . + 'You need to change some permissions. Head to the ' . + 'settings page ' . + 'and try to save the settings there (it will provide more information about the problem)' + ); + } + */ + } else { + Messenger::addMessage( + 'error', + 'For migration to 0.5.0, WebP Express failed saving options file. ' . + 'You must grant us write access to your wp-config folder.
' . + 'Tried to save to: "' . Paths::getWodOptionsFileName() . '"' . + 'Fix the file permissions and reload
' + ); + return false; + } + } else { + Messenger::addMessage( + 'error', + 'For migration to 0.5.0, WebP Express failed saving configuration file.
' . + 'You must grant us write access to your wp-config folder.
' . + 'Tried to save to: "' . Paths::getConfigFileName() . '"' . + 'Fix the file permissions and reload
' + ); + return false; + } + + //saveConfigurationFile + //return $options; + return true; +} + +function webpexpress_migrate1_deleteOldOptions() { + $optionsToDelete = [ + 'webp_express_max_quality', + 'webp_express_image_types_to_convert', + 'webp_express_failure_response', + 'webp_express_converters', + 'webp-express-inserted-rules-ok', + 'webp-express-configured', + 'webp-express-pending-messages', + 'webp-express-just-activated', + 'webp-express-message-pending', + 'webp-express-failed-inserting-rules', + 'webp-express-deactivate', + 'webp_express_fail_action', + 'webp_express_method', + 'webp_express_quality' + + ]; + foreach ($optionsToDelete as $i => $optionName) { + Option::deleteOption($optionName); + } +} + +/* helper. Remove dir recursively. No warnings - fails silently */ +function webpexpress_migrate1_rrmdir($dir) { + if (@is_dir($dir)) { + $objects = @scandir($dir); + foreach ($objects as $object) { + if ($object != "." && $object != "..") { + if (@is_dir($dir."/".$object)) + webpexpress_migrate1_rrmdir($dir."/".$object); + else + @unlink($dir."/".$object); + } + } + @rmdir($dir); + } +} + +function webpexpress_migrate1_deleteOldWebPImages() { + $upload_dir = wp_upload_dir(); + $destinationRoot = trailingslashit($upload_dir['basedir']) . 'webp-express'; + webpexpress_migrate1_rrmdir($destinationRoot); +} + +if (webp_express_migrate1_createFolders()) { + if (webp_express_migrate1_createDummyConfigFiles()) { + if (webpexpress_migrate1_migrateOptions()) { + webpexpress_migrate1_deleteOldOptions(); + webpexpress_migrate1_deleteOldWebPImages(); + Option::updateOption('webp-express-migration-version', '1'); + } + } +} diff --git a/lib/migrate/migrate10.php b/lib/migrate/migrate10.php new file mode 100644 index 0000000..836a602 --- /dev/null +++ b/lib/migrate/migrate10.php @@ -0,0 +1,68 @@ +WebP Express options for 0.14.9.' + ); + Option::updateOption('webp-express-migration-version', '10'); + + } else { + Messenger::addMessage( + 'error', + 'Failed migrating webp express options to 0.14.9. Probably you need to grant write permissions in your wp-content folder.' + ); + } + +} + +webpexpress_migrate10(); diff --git a/lib/migrate/migrate11.php b/lib/migrate/migrate11.php new file mode 100644 index 0000000..b0a1887 --- /dev/null +++ b/lib/migrate/migrate11.php @@ -0,0 +1,77 @@ +WebP Express options for 0.15.0.' + ); + Option::updateOption('webp-express-migration-version', '11'); + + } else { + Messenger::addMessage( + 'error', + 'Failed migrating webp express options to 0.15.0. Probably you need to grant write permissions in your wp-content folder.' + ); + } + +} + +webpexpress_migrate11(); diff --git a/lib/migrate/migrate12.php b/lib/migrate/migrate12.php new file mode 100644 index 0000000..9329611 --- /dev/null +++ b/lib/migrate/migrate12.php @@ -0,0 +1,40 @@ +go to the settings page to fix.' + ); + + }*/ + + $forceHtaccessRegeneration = true; + $result = Config::saveConfigurationAndHTAccess($config, $forceHtaccessRegeneration); + + if ($result['saved-both-config']) { + Option::updateOption('webp-express-migration-version', '12'); + + } else { + Messenger::addMessage( + 'error', + 'Failed migrating webp express options to 0.15.1. Probably you need to grant write permissions in your wp-content folder.' + ); + } + +} + +webpexpress_migrate12(); diff --git a/lib/migrate/migrate13.php b/lib/migrate/migrate13.php new file mode 100644 index 0000000..22a67e7 --- /dev/null +++ b/lib/migrate/migrate13.php @@ -0,0 +1,42 @@ + 0) { + // the user already has a better conversion method working. No reason to disturb + return; + } + + if (in_array('gd', $workingConverterIds)) { + DismissableGlobalMessages::addDismissableMessage('0.19.0/meet-ffmpeg-better-than-gd'); + } elseif (in_array('ewww', $workingConverterIds)) { + DismissableGlobalMessages::addDismissableMessage('0.19.0/meet-ffmpeg-better-than-ewww'); + } else { + DismissableGlobalMessages::addDismissableMessage('0.19.0/meet-ffmpeg-a-working-conversion-method'); + } + +} + +function webpexpress_migrate13() { + Option::updateOption('webp-express-migration-version', '13'); + + webpexpress_migrate13_add_ffmpeg_message_if_relevant(); +} + +webpexpress_migrate13(); diff --git a/lib/migrate/migrate14.php b/lib/migrate/migrate14.php new file mode 100644 index 0000000..3e4a787 --- /dev/null +++ b/lib/migrate/migrate14.php @@ -0,0 +1,36 @@ +Go to the settings page to check it out.' + ); + } +} +Messenger::addMessage( + 'info', + 'WebP Express can now be configured to cache the webp images. You might want to ' . + 'do that.' +); + + +Option::updateOption('webp-express-migration-version', '2'); diff --git a/lib/migrate/migrate3.php b/lib/migrate/migrate3.php new file mode 100644 index 0000000..7e9aa46 --- /dev/null +++ b/lib/migrate/migrate3.php @@ -0,0 +1,119 @@ +issue #96). ' . + 'The bug has been fixed, but unfortunately the file permissions does not allow WebP Convert to clean up the file structure. ' . + 'To clean up manually, delete all folders in your wp-content/webp-express/webp-images folder beginning with "doc-root" (but not the "doc-root" folder itself)' + ); + } + } + } + + // Show "Whats new" message. + // We test the version, because we do not want a whole lot of "whats new" messages + // to show when updating many versions in one go. Just the recent, please. + if (WEBPEXPRESS_MIGRATION_VERSION == '3') { + Messenger::addMessage( + 'info', + 'New in WebP Express 0.8.0:' . + '
    ' . + '
  • New conversion method, which calls imagick binary directly
  • ' . + '
  • Made sure not to trigger LFI warning i Wordfence (to activate, click the force .htaccess button)
  • ' . + "
  • Imagick can now be configured to set quality to auto on systems where the auto option isn't generally available
  • " . + '
  • and more...
  • ' . + '
' . + '' . + '
Roadmap / wishlist:' . + '
    ' . + '
  • Rule in .htaccess to serve already converted images immediately (optional)
  • ' . + '
  • Better NGINX support (print rules that needs to be manually inserted in nginx.conf)
  • ' . + '
  • Diagnose button
  • ' . + '
  • A file explorer for viewing converted images, reconverting them, and seeing them side by side with the original
  • ' . + '
  • IIS support, WAMP support, Multisite support
  • ' . + '
  • and more...
  • ' . + '
' . + 'Please help me making this happen faster / happen at all by donating even a small sum. ' . + 'Buy me a coffee, ' . + 'or support me on patreon.com' . + '' + ); + } + + + // PSST: When creating new migration files, remember to update WEBPEXPRESS_MIGRATION_VERSION in admin.php + Option::updateOption('webp-express-migration-version', '3'); + +} + +webpexpress_migrate3(); diff --git a/lib/migrate/migrate4.php b/lib/migrate/migrate4.php new file mode 100644 index 0000000..36f2377 --- /dev/null +++ b/lib/migrate/migrate4.php @@ -0,0 +1,64 @@ +operation modes. Your configuration almost fits the mode called ' . + 'Standard, however as you have set the Response on failure option to something other than ' . + 'Original, your setup has been put into Tweaked mode. ' . + 'You might want to go and change that.' + ); + } + } + + if (isset($config['redirect-to-existing-in-htaccess']) && ($config['redirect-to-existing-in-htaccess'])) { + Messenger::addMessage( + 'info', + 'In WebP Express 0.10, the .htaccess rules has been altered a bit: The Cache-Control header is now set when ' . + 'redirecting directly to an existing webp image.
' . + 'You might want to go to the options page and re-save settings in order to regenerate the .htaccess rules.' + ); + } + + if (!isset($config['redirect-to-existing-in-htaccess'])) { + Messenger::addMessage( + 'info', + 'In WebP Express 0.10, the "Redirect directly to converted image when available" option is no longer in beta. ' . + 'You might want to go and activate it.' + ); + } + + } + + // PSST: When creating new migration files, remember to update WEBPEXPRESS_MIGRATION_VERSION in admin.php + Option::updateOption('webp-express-migration-version', '4'); + +} + +webpexpress_migrate4(); diff --git a/lib/migrate/migrate5.php b/lib/migrate/migrate5.php new file mode 100644 index 0000000..13cf14f --- /dev/null +++ b/lib/migrate/migrate5.php @@ -0,0 +1,50 @@ +WebP Express options for 0.11+' + ); + + // PSST: When creating new migration files, remember to update WEBPEXPRESS_MIGRATION_VERSION in admin.php + Option::updateOption('webp-express-migration-version', '5'); + + } else { + Messenger::addMessage( + 'error', + 'Failed migrating WebP Express options to 0.11+. Probably you need to grant write permissions in your wp-content folder.' + ); + } + +} + +webpexpress_migrate5(); diff --git a/lib/migrate/migrate6.php b/lib/migrate/migrate6.php new file mode 100644 index 0000000..400cf82 --- /dev/null +++ b/lib/migrate/migrate6.php @@ -0,0 +1,68 @@ +WebP Express options for 0.12. '; + // The webp realizer rules where errornous, so recreate rules, if necessary. (see issue #195) + + if (($config['enable-redirection-to-webp-realizer']) && ($config['destination-folder'] != 'mingled')) { + //HTAccess::saveRules($config); // Commented out because rules are going to be saved in migrate12 + $msg .= 'Also fixed buggy .htaccess rules. '; + } + + if (!$config['alter-html']['enabled']) { + if ($config['operation-mode'] == 'varied-responses') { + $msg .= '
In WebP Express 0.12, the Alter HTML option is no longer in beta. ' . + 'You should consider to go and activate it - ' . + 'It works great in Varied Image Responses mode too. '; + } else { + $msg .= '
In WebP Express 0.12, Alter HTML is no longer in beta. ' . + 'Now would be a good time to go and activate it!. '; + } + } + + // Display announcement. But only show while it is fresh news (we don't want this to show when one is upgrading from 0.11 to 0.14 or something) + // - the next release with a migration in it will not show the announcement + if (WEBPEXPRESS_MIGRATION_VERSION == 7) { + $msg .= '

Btw: From this release and onward, WebP Express is multisite compliant.'; + } + + Messenger::addMessage( + 'info', + $msg + ); + + if ($config['operation-mode'] == 'no-conversion') { + Messenger::addMessage( + 'info', + 'WebP Express introduces a new operation mode: "No conversion". ' . + 'Your configuration has been migrated to this mode, because your previous settings matched that mode (nothing where set up to trigger a conversion).' + ); + } + + // PSST: When creating new migration files, remember to update WEBPEXPRESS_MIGRATION_VERSION in admin.php + Option::updateOption('webp-express-migration-version', '7'); + + // Not completely sure if this could fail miserably, so commented out. + // We should probably do it in upcoming migrations + // \WebPExpress\KeepEwwwSubscriptionAlive::keepAliveIfItIsTime($config); + + } else { + Messenger::addMessage( + 'error', + 'Failed migrating webp express options to 0.12+. Probably you need to grant write permissions in your wp-content folder.' + ); + } + +} + +webpexpress_migrate7(); diff --git a/lib/migrate/migrate8.php b/lib/migrate/migrate8.php new file mode 100644 index 0000000..b2555f5 --- /dev/null +++ b/lib/migrate/migrate8.php @@ -0,0 +1,98 @@ +' . + 'You have been using Gd to convert PNGs. ' . + 'However, due to a bug, in some cases transparency was lost in the webp. ' . + 'It is recommended that you delete and reconvert all PNGs. ' . + 'There are new buttons for doing just that on the ' . + 'settings screen (look below the conversion methods).' + ); + + } else { + + Messenger::addMessage( + 'info', + 'Service notice from WebP Express:
' . + 'You have configured Gd to skip converting PNGs. ' . + 'However, the Gd conversion method has been fixed and is doing ok now!' + ); + + + } + } + } + } + + if (WEBPEXPRESS_MIGRATION_VERSION == '8') { + Messenger::addMessage( + 'info', + 'New in WebP Express 0.13.0:' . + '
    ' . + '
  • Bulk Conversion
  • ' . + '
  • New option to automatically convert images upon upload
  • ' . + '
  • Better support for Windows servers
  • ' . + '
  • - and more
  • ' . + '
' + ); + + } + + Option::updateOption('webp-express-migration-version', '8'); + + // Find out if Gd is the first active and working converter. + // We check wod options, because it has already filtered out the disabled converters. + /* + $options = Config::loadWodOptions(); + if ($options !== false) { + $converters = $options['converters']; + if (is_array($converters) && count($converters) > 0) { + + if ($converters[0]['converter'] == 'gd') { + if (isset($converters[0]['options']) && ($converters[0]['options']['skip-pngs'] === true)) { + // + } + } + } + } + + if ($config['operation-mode'] != 'no-conversion') { + Config::getConverterByName('gd') + }*/ + + +} + +webpexpress_migrate8(); diff --git a/lib/migrate/migrate9.php b/lib/migrate/migrate9.php new file mode 100644 index 0000000..634f3cc --- /dev/null +++ b/lib/migrate/migrate9.php @@ -0,0 +1,204 @@ + $c) { + if ($c['converter'] == $converterId) { + $indexOfVips = $i; + $vips = $c; + break; + } + } + if ($indexOfVips > 0) { + // remove vips found + array_splice($config['converters'], $indexOfVips, 1); + + // Insert vips at the top + array_unshift($config['converters'], $vips); + + } + return false; +} + +function webpexpress_migrate9() { + + $config = Config::loadConfigAndFix(false); // false, because we do not need to test if quality detection is working + $converters = &$config['converters']; + if (is_array($converters)) { + + + foreach ($converters as $i => $converter) { + if (!isset($converter['converter'])) { + continue; + } + if ($converter['converter'] == 'gmagickbinary') { + $converters[$i]['converter'] = 'graphicsmagick'; + } + if ($converter['converter'] == 'imagickbinary') { + $converters[$i]['converter'] = 'imagemagick'; + } + } + + // Change specific converter options + foreach ($converters as &$converter) { + if (!isset($converter['converter'])) { + continue; + } + if (!isset($converter['options'])) { + // #273 + $converter['options'] = []; + continue; + } + $options = &$converter['options']; + + switch ($converter['converter']) { + case 'gd': + if (isset($options['skip-pngs'])) { + $options['png'] = [ + 'skip' => $options['skip-pngs'] + ]; + unset($options['skip-pngs']); + } + break; + case 'wpc': + if (isset($options['url'])) { + $options['api-url'] = $options['url']; + unset($options['url']); + } + break; + case 'ewww': + if (isset($options['key'])) { + $options['api-key'] = $options['key']; + unset($options['key']); + } + if (isset($options['key-2'])) { + $options['api-key-2'] = $options['key-2']; + unset($options['key-2']); + } + break; + } + } + + $firstActiveAndWorkingConverterId = ConvertersHelper::getFirstWorkingAndActiveConverterId($config); + + // If it aint cwebp, move vips to the top! + if ($firstActiveAndWorkingConverterId != 'cwebp') { + $vips = webpexpress_migrate9_moveConverterToTop($config, 'vips'); + } + +/* + if ($config['image-types'] == 1) { + Messenger::addStickyMessage( + 'info', + 'WebP Express 0.14 handles PNG to WebP conversions quite well. Perhaps it is time to enable PNGs? ' . + 'Go to the options page to change the "Image types to work on" option.', + 2, + 'Got it!' + ); + }*/ + + if ($config['image-types'] == 1) { + DismissableMessages::addDismissableMessage('0.14.0/suggest-enable-pngs'); + } + DismissableMessages::addDismissableMessage('0.14.0/suggest-wipe-because-lossless'); + DismissableMessages::addDismissableMessage('0.14.0/say-hello-to-vips'); + + /* + $convertersSupportingEncodingAuto = ['cwebp', 'vips', 'imagick', 'imagemagick', 'gmagick', 'graphicsmagick']; + + if (in_array($firstActiveAndWorkingConverterId, $convertersSupportingEncodingAuto)) { + Messenger::addStickyMessage( + 'info', + 'WebP Express 0.14 has new options for the conversions. Especially, it can now produce lossless webps, and ' . + 'it can automatically try both lossy and lossless and select the smallest. You can play around with the ' . + 'new options when your click "test" next to a converter. Once satisfied, dont forget to ' . + 'wipe your existing converted files (there is a "Delete converted files" button for that on the ' . + 'options page)', + 1, + 'Got it!' + ); + } else { + //error_log('working converters: ' . print_r(ConvertersHelper::getWorkingConverterIds($config), true)); + $workingIds = ConvertersHelper::getWorkingConverterIds($config); + + if ($firstActiveAndWorkingConverterId == 'gd') { + foreach ($workingIds as $workingId) { + if (in_array($workingId, $convertersSupportingEncodingAuto)) { + Messenger::addStickyMessage( + 'info', + 'WebP Express 0.14 has new options for the conversions. Especially, it can now produce lossless webps, and ' . + 'it can automatically try both lossy and lossless and select the smallest. You can play around with the ' . + 'new options when your click "test" next to a converter. Once satisfied, dont forget to ' . + 'wipe your existing converted files (there is a "Delete converted files" button for that on the ' . + 'options page). ' . + '

Btw: The "gd" conversion method that you are using does not support lossless encoding ' . + '(in fact Gd only supports very few conversion options), but fortunately, you have the ' . + '"' . $workingId . '" conversion method working, so you can simply start using that instead.', + 1, + 'Got it!' + ); + break; + } + } + + } + // + } + */ + } + + // #235 + $config['cache-control-custom'] = preg_replace('#max-age:#', 'max-age=', $config['cache-control-custom']); + + // #272 + if ($config['fail'] == 'report-as-image') { + $config['fail'] = 'report'; + } + + // Force htaccess ? + $forceHtaccessRegeneration = $config['redirect-to-existing-in-htaccess']; + + // Save both configs and perhaps also htaccess + $result = Config::saveConfigurationAndHTAccess($config, $forceHtaccessRegeneration); + + if ($result['saved-both-config']) { + Messenger::addMessage( + 'info', + 'Successfully migrated WebP Express options for 0.14. ' + ); + Option::updateOption('webp-express-migration-version', '9'); + + } else { + Messenger::addMessage( + 'error', + 'Failed migrating webp express options to 0.14+. Probably you need to grant write permissions in your wp-content folder.' + ); + } + +} + +webpexpress_migrate9(); diff --git a/lib/options/css/das-popup.css b/lib/options/css/das-popup.css new file mode 100644 index 0000000..54595a5 --- /dev/null +++ b/lib/options/css/das-popup.css @@ -0,0 +1,24 @@ +#das_overlay { + background: #000; + opacity: 0.7; + filter: alpha(opacity=70); + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 100051; +} + +.das-popup { + position: fixed; + background-color: #fff; + z-index: 100052; + visibility: hidden; + text-align: left; + top: 50%; + left: 50%; + -webkit-box-shadow: 0 3px 6px rgba( 0, 0, 0, 0.3 ); + box-shadow: 0 3px 6px rgba( 0, 0, 0, 0.3 ); + padding: 20px; +} diff --git a/lib/options/css/images/checker.png b/lib/options/css/images/checker.png new file mode 100644 index 0000000000000000000000000000000000000000..bf04623b8f2aed9313eb3271b4cc4835ae1364ea GIT binary patch literal 189 zcmeAS@N?(olHy`uVBq!ia0vp^3qY8W8Axtq{F(=(7>k44ofy`glX(f`a29w(7Bet# z3xhBt!>lZ!b9VGB9v3Z~SS_%O-GOQriA%UEgOn4o4Ij+f;sfa&oe||2!aL a&DWSbVJqnquOvYZWbkzLb6Mw<&;$U@5jlGR literal 0 HcmV?d00001 diff --git a/lib/options/css/images/drag-handle.svg b/lib/options/css/images/drag-handle.svg new file mode 100644 index 0000000..e31b49b --- /dev/null +++ b/lib/options/css/images/drag-handle.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/lib/options/css/test-convert.css b/lib/options/css/test-convert.css new file mode 100644 index 0000000..40dd318 --- /dev/null +++ b/lib/options/css/test-convert.css @@ -0,0 +1,101 @@ +#tc_content { + display: flex; +} + +#tc_content > * { + width: 50%; +} + +#tc_conversion_options label { + font-weight: bold; +} + +#tc_conversion_options label + select { + margin-left: 6px; +} + +@media (max-width:600px) { + #tc_content { + display: block; + } +} + +/* Comparison slider * + ------------------- */ +.cd-image-container { + position: relative; + width: 100%; + background: #dc717d url(images/checker.png) repeat center center; + margin-bottom: 5px; +} + +.cd-image-container img { + display: block; +} + +.cd-image-label { + display: inline-block; + position: absolute; + z-index: 10; + color: #dc717d; + top: 10px; + font-weight: bold; + font-size: 18px; + /*text-shadow: 2px 2px 0px white;*/ + padding: 2px 4px; + background-color: #eee; + border: 1px solid #ccc; +} +.cd-image-label.original { + left: 15px; +} +.cd-image-label.webp { + right: 15px; +} + +.cd-resize-img { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 50%; + overflow: hidden; + /* Force Hardware Acceleration in WebKit */ + transform: translateZ(0); + backface-visibility: hidden; + border-right: 2px dotted black; +} +.is-visible .cd-resize-img { + width: 50%; + /* bounce in animation of the modified image */ + animation: cd-bounce-in 0.7s; +} +.cd-handle.draggable { + background-color: #445b7c; +} +.cd-handle { + position: absolute; + height: 44px; + width: 44px; + left: 50%; + top: 50%; + margin-left: -22px; + margin-top: -22px; + border-radius: 50%; + background: #dc717d url(images/drag-handle.svg) no-repeat center center; + cursor: move; + /*box-shadow: 0 0 0 2px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.3);*/ + opacity: 100; +} + +@keyframes cd-bounce-in { + 0% { + width: 0; + } + 60% { + width: 55%; + } + 100% { + width: 50%; + } +} diff --git a/lib/options/css/webp-express-options-page.css b/lib/options/css/webp-express-options-page.css new file mode 100644 index 0000000..cd367fb --- /dev/null +++ b/lib/options/css/webp-express-options-page.css @@ -0,0 +1,653 @@ +/*input[name=webp_express_converters] { + display: none; + width: 100%; +}*/ + +/* break long lines in pre (when we are showing .htaccess inside notice) */ +.notice pre { + white-space: pre-wrap; + word-wrap: break-word; +} + +/* Classes used in our pseudo markdown. */ +.webpexpress.md h1 { + margin: 1em 0 1em; + font-size: 2em; + font-weight: normal; +} +.webpexpress.md h2 { + margin: 1em 0 0.5em; + font-size: 1.6em; + font-weight: normal; + text-transform: uppercase; + text-align: center; + padding: 10px 0; + background-color: #ccc; +} +.webpexpress.md h3 { + margin: 1em 0 0.5em; + font-size: 1.4em; + font-weight: normal; +} +.webpexpress.md h4 { + font-size: 1.0em; + margin: 1em 0 0.5em; + /*font-weight: 500;*/ + font-weight: normal; + font-style: italic; +} +.webpexpress.md .warn { + background-color: yellow; +} +.webpexpress.md .ok { + color: green; +} +.webpexpress.md .error { + color: red; +} + + + +/* remove the padding on the row with the hidden "webp_express_converters" setting. + it is the last row, we happen to know... */ + /* +.form-table tr:last-of-type > * { + padding: 0; +}*/ + +@media (min-width: 782px) { + #webpexpress_settings .form-table > tbody > tr > th { + padding-top: 5px; + padding-bottom: 5px; + width: 200px; + } + #webpexpress_settings .form-table > tbody > tr > td { + padding-top: 1px; + padding-bottom: 5px; + } + +} + +#converters li { + border: 1px solid grey; + box-shadow: 0 1px 1px rgba(0,0,0,.04); + line-height: 1.4em; + background-color: #fff; + padding: 6px 10px 0; + max-width: 400px; + min-height: 26px; + position: relative; + font-size: 12px; + margin-bottom: 4px; +} +#converters li { + cursor: move; +} +#converters li:hover { + border-color: #000; + box-shadow: 0 1px 2px rgba(0,0,0,.2); +} +#converters li.deactivated { + border-color: #ccc; + background-color: #f0f0f0; + min-height: auto; + height: 22px; + padding-top: 3px; +} +#converters li.deactivated:hover { + border-color: #999; +} +#converters li.deactivated, +#converters li.deactivated a.configure-converter, +#converters li.deactivated a.test-converter, +#converters li.deactivated a.activate-converter { + color: #888; +} +#converters li.not-operational { + border-style: dotted; + border-color: #666; +} +#converters li.not-operational:hover { + border-style: solid; +} +#converters li a { + cursor: pointer; +} + +#converters li[data-id='gmagick'] a.configure-converter { + visibility: hidden; +} + +#converters li > * { + vertical-align: middle; + +} +#converters li > div { + display: inline-block; + line-height: 1; +} +#converters li > .text { + padding-left: 10px; + width: 160px; +} + +#converters li > a.btn { +/* border: 1px solid transparent; + border-radius: 8px; + */ + padding: 2px 6px; + margin-right: 4px; + text-decoration: none; +} + +#converters li > a.btn:hover { + text-decoration: underline; + /*border-color: #666; + background-color: #eee;*/ +} +#converters li > a.configure-converter { +} +#converters li .status { + font-size: 10px; + position: absolute; + right: 7px; + bottom: 3px; +} + +#converters li .status svg { + padding: 0px; +} +#converters li svg#status_ok { + color: #008000; +} +#converters li.deactivated svg#status_ok { + color: #99cc99; +} +#converters li svg#status_not_ok { + color: #b11010; /* 444444 */ +} +#converters li svg#status_warning { + color: #dc0; +} + +#converters li.deactivated svg#status_not_ok { + color: #999999; +} +#converters li.deactivated .status { + bottom: unset; +} +#converters li.deactivated .status svg { + width: 15px; + padding: 0; +} + +#converters li .popup, +.help .popup { + display: none; + position: absolute; + border: 1px solid #666; + z-index:2; + background-color: #ffffaa; + padding: 8px 10px; + /*left: 22px; + top: 20px;*/ + margin-top: -5px; + /*white-space: nowrap;*/ + min-width: 150px; +} + +.popup, +.popup p { + font-weight: normal; + text-align: left; + line-height: 1.5; + font-size: 14px; + color: #000; +} + +.help .popup.narrow { + width: 200px; +} +.help .popup { + width: 250px; +} +.help .popup.wide { + width: 350px; +} +.help .popup.wider { + width: 450px; +} +.help .popup.even-wider { + width: 600px; +} +.help .popup.widest { + width: 750px; +} +@media (max-width: 1150px) { + .help .popup { + max-width: 620px; + } +} +@media (max-width: 900px) { + .help .popup { + max-width: 560px; + } +} +@media (max-width: 830px) { + .help .popup { + max-width: 530px; + } +} +@media (max-width: 500px) { + .help .popup { + max-width: 380px; + } +} +@media (max-width: 400px) { + .help .popup { + max-width: 280px; + } +} + +.help .popup > p:first-child { + margin-top: 0; +} +.help .popup > p:last-child { + margin-bottom: 0; +} +#converters li.operational .popup { + background-color: #80ff80; +} +#converters li.has-warnings .popup { + background-color: #ff5; +} + +/* #converters li .status:hover .popup,*/ +#converters li:hover .status .popup, +.help:hover .popup { + display: block; +} +#converters.dragging li:hover .status .popup { + display: none; + +} + + +/* +#converters li > a.remove-converter { + color: red; +}*/ + + +.converter-options label { + min-width: 80px; + display: inline-block; + padding-top: 2px; + vertical-align: top; +} + +.converter-options p { + padding: 0; +} + +.converter-options .info { + margin-bottom: 20px; +} +.converter-options button { + margin-top: 15px; +} +.converter-options button.button-primary { + margin-right: 10px; +} + +.converter-options div { + margin-bottom: 15px; +} + +.help { + text-align: center; + display: inline-block; + border-radius: 50%; + background-color: #00a0ee; + width: 16px; + height: 16px; + border: 0px solid #00a0ee; + color: white; + font-weight: bolder; + margin-left: 7px; + cursor: pointer; + font-size: 12px; + line-height: 16px; + vertical-align: top; + font-family: sans-serif; + position: relative; + font-style: normal; + pointer-events: all; +} + +select + .help, +input + .help { + margin-left: 1px; /* bring help icons closer to select and input boxes */ + margin-top: 3px; +} +.help { /* new look! */ + background-color: transparent; + border: 1px solid #999; + color: #333; +} +.help.no-margin-left { + margin-left: 0px; +} +.help.set-margin-right { + margin-right: 7px; +} + + +/* +.converter-options.wpc #wpc_web_services_div > p { + margin-top: 0; + padding-top: 2px; + margin-bottom: 3px; +} +#wpc_web_services_div { + margin-bottom: 0px; +}*/ + +.converter-options.wpc div, +.converter-options.vips div, +.converter-options.imagemagick div, +.converter-options.graphicsmagick div { + margin-bottom: 5px; +} +.converter-options.vips label, +.converter-options.wpc label, +.converter-options.imagemagick label, +.converter-options.graphicsmagick label { + display: inline-block; +} +.converter-options.wpc label { + width: 110px; +} +.converter-options.vips label { + width: 143px; +} +.converter-options.imagemagick label, +.converter-options.graphicsmagick label { + width: 110px; +} +#wpc_url { + min-width: 400px; +} +#wpc_change_api_key, +#wpc_set_api_key { + display:inline-block; + line-height:22px +} + + +#whitelist_table { + max-width: 400px; + width: 100%; +} +.whitelist td { + padding: 5px 0; +} +.whitelist td.quota input{ + max-width: 40px; +} +.whitelist td.remove { + padding-left: 8px; +} +.whitelist td.remove a { + color: red; + font-size: 12px; +} +.whitelist td.whitelist-add-site { + text-align: left; +} + +#password_helptext, +#whitelist_site_helptext, +#whitelist_quota_helptext { + display: none; +} + +.whitelist-popup-content label { + min-width: 125px; + display: inline-block; +} +/* +.popup-content { + padding-top: 20px; +} +.popup-content > div { + margin: 10px 0; +} +*/ + +#whitelist_div ul { + list-style: disc; + list-style-position: inside; + position: relative; + width: 400px; + max-width: 400px; +} + +#whitelist_div .whitelist-links, +#wpc_web_services_div .wpc-links { + position: absolute; + right: 0; + text-align: right; + display: inline-block; +} +#whitelist_div .whitelist-links a, +#wpc_web_services_div .wpc-links a { + padding-left: 10px; +} + +#whitelist_properties_popup label { + width: 110px; + display: inline-block; +} + +#whitelist_change_api_key_div { + line-height: 35px; +} + +.das-popup.mode-edit .hide-in-edit { + display: none; +} + +.das-popup.mode-add .hide-in-add { + display: none; +} +/* +#wpc_web_services_div ul { + list-style: disc; + list-style-position: inside; + position: relative; + width: 300px; + max-width: 300px; +} + +#wpc_properties_popup label { + width: 110px; + display: inline-block; +} +*/ + +#webpexpress_settings .block { + position: relative; + margin: 0 auto 0.5rem; + padding: 0 1rem; + box-sizing: border-box; + background-color: #fff; + box-shadow: 0 0 0 1px rgba(200,215,225,0.25), 0 1px 2px #e9eff3; +} + +#webpexpress_settings .block.buttons { + padding: 10px; + position: sticky; + top: 30px; + z-index: 1; +} + +@media (min-width: 768px) { + #webpexpress_settings .block { + padding: 10px 1.5rem 20px; + } +} + +#webpexpress_settings .block.buttons p.submit { + margin: 0; + padding: 0; +} + +#webpexpress_settings .block.buttons table { + float: right; +} + +#alter_html_options_div > div > label { + font-style: italic; +} + +/* +#alter_html_options_div > div { + margin-top: 15px; +}*/ + +.form-table table th { + font-weight: unset; + padding: 5px 0px; + width: auto; +} + +.form-table table td { + padding: 0 10px; + width: auto; +} + + +.form-table td input[type=checkbox] { + margin-top: 6px; +} + +.form-table h2 { + /*font-size: 1.6em;*/ + text-transform: uppercase; + color: #222; +} +.form-table th[colspan='2'] { + font-weight: normal; + font-size: 14px; +} +.form-table th.header-section h2 { + margin-top: 30px; + margin-bottom: 10px; +} +.form-table th.header-section h2 + p { + margin-top: 10px; +} +.form-table th.header-section p, +.form-table th.header-section h2, +fieldset.block p, +fieldset.block div.p, +input { + max-width: 750px; +} + +.toggler.effect-slider { + overflow-y: hidden; + transition: all 0.3s ease-in-out; + /*transition: all 0.5s cubic-bezier(0, 1, 0.5, 1);*/ +} +.toggler.effect-slider.closed { + transition: all 0.5s cubic-bezier(0, 1, 0.5, 1); + max-height: 0px !important; +} + +.toggler.effect-opacity.closed { + opacity: 0.5; + pointer-events: none; + font-weight: 300 !important; +} +.form-table .toggler.effect-opacity.closed th { + font-weight: 400 !important; +} + +.toggler.effect-visibility { + /*position: static; + visibility: inherit;*/ +} +.toggler.effect-visibility.closed { + visibility: hidden; + position: absolute; +} + +.form-table h4 { + margin-bottom: 0; + text-decoration: underline; +} + +table.designed { + border-collapse: collapse; + margin-bottom: 20px; + /*box-shadow: 2px 2px 5px 2px rgba(0, 0, 0, 0.1)*/ +} +table.designed th { + font-weight: bold; + background-color: #fff; +} +table.designed td, +table.designed th { + text-align: left; + padding: 9px 17px; + border: 1px solid #999; + vertical-align: top; +} +table.designed td, +table.designed th, +table.designed p { + font-size: 12px; + line-height: 1.3; +} +table.designed td > p:first-child { + margin-top: 0; +} +table.designed tr:nth-child(odd) > td { + background-color: #eee; +} +table.designed tr:nth-child(even) > td { + background-color: #ddd; +} +table.designed th:first-child { + border-left-width: 0; +} +table.designed tr:first-child > th { + border-top-width: 0; +} +table.designed tr > *:last-child { + border-right-width: 0; +} + +#hide_alterhtml_chart_btn { + margin-bottom: 20px; +} + +/*table.designed tr:last-child > * { + border-bottom-width: 0; +}*/ + +ul.with-bullets { + padding-left: 20px; + list-style: unset; +} + +#conversionlog_content { + overflow-y: scroll; + top: 20px; + bottom: 72px; + position: absolute; + right: 0; + left: 3%; +} diff --git a/lib/options/enqueue_scripts.php b/lib/options/enqueue_scripts.php new file mode 100644 index 0000000..c658ef0 --- /dev/null +++ b/lib/options/enqueue_scripts.php @@ -0,0 +1,150 @@ +' . $script . ''; + } + } +} + +wp_register_script('sortable', plugins_url($jsDir . '/sortable.min.js', __FILE__), [], '1.9.0'); +wp_enqueue_script('sortable'); + +wp_register_script('daspopup', plugins_url($jsDir . '/das-popup.js', __FILE__), [], $ver); +wp_enqueue_script('daspopup'); + +wp_register_script('escapehtml', plugins_url($jsDir . '/escapeHTML.js', __FILE__), [], $ver); +wp_enqueue_script('escapehtml'); + +$config = Config::getConfigForOptionsPage(); + +// selftest +wp_register_script('webpexpress_selftest', plugins_url($jsDir . '/self-test.js', __FILE__), ['escapehtml'], $ver); +wp_enqueue_script('webpexpress_selftest'); + +// Add converter, bulk convert and whitelist script, EXCEPT for "no conversion" mode +if (!(isset($config['operation-mode']) && ($config['operation-mode'] == 'no-conversion'))) { + + // Remove empty options arrays. + // These cause trouble in json because they are encoded as [] rather than {} + + foreach ($config['converters'] as &$converter) { + if (isset($converter['options']) && (count(array_keys($converter['options'])) == 0)) { + unset($converter['options']); + } + } + + // Converters + // ---------- + wp_register_script('converters', plugins_url($jsDir . '/converters.js', __FILE__), ['sortable', 'daspopup', 'escapehtml'], $ver); + + // PS: no escaping/sanitizing needed as json_encode always produces something safe + webp_express_add_inline_script( + 'converters', + 'window.webpExpressPaths = ' . json_encode(Paths::getUrlsAndPathsForTheJavascript()) . ';', + 'before' + ); + + // PS: no escaping/sanitizing needed as json_encode always produces something safe + webp_express_add_inline_script( + 'converters', + 'window.converters = ' . json_encode($config['converters']) . ';', + 'before' + ); + wp_enqueue_script('converters'); + + // Whitelist + // --------- + wp_register_script('whitelist', plugins_url($jsDir . '/whitelist.js', __FILE__), ['daspopup', 'escapehtml'], $ver); + + // PS: no escaping/sanitizing needed as json_encode always produces something safe + webp_express_add_inline_script('whitelist', 'window.whitelist = ' . json_encode($config['web-service']['whitelist']) . ';', 'before'); + wp_enqueue_script('whitelist'); + + // bulk convert + wp_register_script('bulkconvert', plugins_url($jsDir . '/bulk-convert.js', __FILE__), ['escapehtml'], $ver); + wp_enqueue_script('bulkconvert'); + + // test convert + wp_register_script('testconvert', plugins_url($jsDir . '/test-convert.js', __FILE__), ['escapehtml'], $ver); + $canDisplayWebp = (isset($_SERVER['HTTP_ACCEPT']) && (strpos($_SERVER['HTTP_ACCEPT'], 'image/webp') !== false )); + + /* + AlterHTMLHelper::getWebPUrlInImageRoot( + Paths::getPluginUrl() . '/webp-express', // source url + $baseId, + Paths::getPluginUrl(), // base url + Paths::getPluginDirAbs() // base dir + ); + + getRelUrlPath()*/ + + webp_express_add_inline_script('testconvert', 'window.canDisplayWebp = ' . ($canDisplayWebp ? 'true' : 'false') . ';', 'before'); + wp_enqueue_script('testconvert'); + + wp_register_script('image-comparison-slider', plugins_url($jsDir . '/image-comparison-slider.js', __FILE__), [], $ver); + wp_enqueue_script('image-comparison-slider'); + + + // purge cache + wp_register_script('purgecache', plugins_url($jsDir . '/purge-cache.js', __FILE__), [], $ver); + wp_enqueue_script('purgecache'); + + // purge log + wp_register_script('purgelog', plugins_url($jsDir . '/purge-log.js', __FILE__), [], $ver); + wp_enqueue_script('purgelog'); + +} + +//wp_register_script('api_keys', plugins_url($jsDir . 'api-keys.js', __FILE__), ['daspopup'], '0.7.0-dev8'); +//wp_enqueue_script('api_keys'); + +wp_register_script( 'page', plugins_url($jsDir . '/page.js', __FILE__), [], $ver); + +// TODO: Add all vars needed to this array (whitelist, converters, etc) +$javascriptVars = [ + 'ajax-nonces' => [ + 'convert' => wp_create_nonce('webpexpress-ajax-convert-nonce'), + 'list-unconverted-files' => wp_create_nonce('webpexpress-ajax-list-unconverted-files-nonce'), + 'purge-cache' => wp_create_nonce('webpexpress-ajax-purge-cache-nonce'), + 'purge-log' => wp_create_nonce('webpexpress-ajax-purge-log-nonce'), + 'view-log' => wp_create_nonce('webpexpress-ajax-view-log-nonce'), + 'self-test' => wp_create_nonce('webpexpress-ajax-self-test-nonce'), + ], + 'can-use-doc-root-for-structuring' => Paths::canUseDocRootForRelPaths() +]; +webp_express_add_inline_script( + 'page', + 'window.webpExpress = ' . json_encode($javascriptVars, JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK), + 'before' +); +wp_enqueue_script('page'); + + +// Register styles +wp_register_style('webp-express-options-page-css', plugins_url('css/webp-express-options-page.css', __FILE__), null, $ver); +wp_enqueue_style('webp-express-options-page-css'); + +wp_register_style('test-convert-css', plugins_url('css/test-convert.css', __FILE__), null, $ver); +wp_enqueue_style('test-convert-css'); + +wp_register_style('das-popup-css', plugins_url('css/das-popup.css', __FILE__), null, $ver); +wp_enqueue_style('das-popup-css'); + +add_thickbox(); diff --git a/lib/options/images/drag-reorder.svg b/lib/options/images/drag-reorder.svg new file mode 100644 index 0000000..52a038e --- /dev/null +++ b/lib/options/images/drag-reorder.svg @@ -0,0 +1,17 @@ + + + + +Created by potrace 1.15, written by Peter Selinger 2001-2017 + + + + + + + diff --git a/lib/options/js/authorized_sites_bak.js b/lib/options/js/authorized_sites_bak.js new file mode 100644 index 0000000..71e2f4a --- /dev/null +++ b/lib/options/js/authorized_sites_bak.js @@ -0,0 +1,70 @@ +/*function updateWhitelistInputValue() { + //var val = []; + + document.getElementsByName('whitelist')[0].value = JSON.stringify(window.whitelist); +} + +function setPassword(i) { + var password = window.prompt('Enter password' + i); + + window.whitelist[i]['password_hashed'] = ''; + window.whitelist[i]['new_password'] = password; + + setWhitelistHTML(); +} +*/ +function setAuthorizedSitesHTML() { + var s = ''; + + if (window.authorizedSites && window.authorizedSites.length > 0) { + s+=''; + s+=''; + s+='' + //s+='' + //s+='' + s+=''; + s==''; + + for (var i=0; i'; + html += errorThrown; + document.getElementById('bulkconvertcontent').innerHTML = html; + }, + success: function(response) { + if ((typeof response == 'object') && (response['success'] == false)) { + html = '

Error

'; + if (response['data'] && ((typeof response['data']) == 'string')) { + html += webpexpress_escapeHTML(response['data']); + } + document.getElementById('bulkconvertcontent').innerHTML = html; + return; + } + if (response == '') { + html = '

Error

'; + html += '

Could not fetch list of files to convert. The server returned nothing (which is unexpected - ' + + 'it is not simply because there are no files to convert.)

'; + document.getElementById('bulkconvertcontent').innerHTML = html; + return; + } + var responseObj; + try { + responseObj = JSON.parse(response); + } catch (e) { + html = '

Error

'; + html += '

The ajax call did not return valid JSON, as expected.

'; + html += '

Check the javascript console to see what was returned.

'; + console.log('The ajax call did not return valid JSON, as expected'); + console.log('Here is what was received:'); + console.log(response); + document.getElementById('bulkconvertcontent').innerHTML = html; + return; + } + var bulkInfo = { + 'groups': responseObj, + 'groupPointer': 0, + 'filePointer': 0, + 'paused': false, + 'webpTotalFilesize': 0, + 'orgTotalFilesize': 0, + }; + window.webpexpress_bulkconvert = bulkInfo; + + // count files + var numFiles = 0; + for (var i=0; i'; + html += '

Note that in a typical setup, you will have redirect rules which trigger conversion when needed, ' + + 'and thus you have no need for bulk conversion. In fact, in that case, you should probably not bulk convert ' + + 'because bulk conversion will also convert images and thumbnails which are not in use, and thus take up ' + + 'more disk space than necessary. The bulk conversion feature was only added in order to make the plugin usable even when ' + + 'there are problems with redirects (ie on Nginx in case you do not have access to the config or on Microsoft IIS). ' + + '


'; + html += ''; + html += ''; + } + document.getElementById('bulkconvertcontent').innerHTML = html; + } + }); +} + +function pauseBulkConversion() { + var bulkInfo = window.webpexpress_bulkconvert; + bulkInfo.paused = true; +} + +function pauseOrResumeBulkConversion() { + var bulkInfo = window.webpexpress_bulkconvert; + bulkInfo.paused = !bulkInfo.paused; + + document.getElementById('bulkPauseResumeBtn').innerText = (bulkInfo.paused ? 'Resume' : 'Pause'); + + if (!bulkInfo.paused) { + convertNextInBulkQueue(); + } +} + +function startBulkConversion() { + var html = '
'; + html += ''; + html += ''; + //html += '
test
'; + //html += '
'; + document.getElementById('bulkconvertcontent').innerHTML = html; + document.getElementById('bulkconvertlog').innerHTML = ''; + + convertNextInBulkQueue(); +} + +function convertDone() { + var bulkInfo = window.webpexpress_bulkconvert; + document.getElementById('bulkconvertlog').innerHTML += '

Done!

' + + '

Total reduction: ' + getReductionHtml(bulkInfo['orgTotalFilesize'], bulkInfo['webpTotalFilesize'], 'Total size of converted originals', 'Total size of converted webp files') + '

' + + document.getElementById('bulkPauseResumeBtn').style.display = 'none'; +} + +function getPrintableSizeInfo(orgSize, webpSize) { + if (orgSize < 10000) { + return { + 'org': orgSize + ' bytes', + 'webp': webpSize + ' bytes' + }; + } else { + return { + 'org': Math.round(orgSize / 1024) + ' kb', + 'webp': Math.round(webpSize / 1024) + ' kb' + }; + } +} + +function getReductionHtml(orgSize, webpSize, sizeOfOriginalText, sizeOfWebpText) { + var reduction = Math.round((orgSize - webpSize)/orgSize * 100); + var sizeInfo = getPrintableSizeInfo(orgSize, webpSize); + var hoverText = sizeOfOriginalText + ': ' + sizeInfo['org'] + '.
' + sizeOfWebpText + ': ' + sizeInfo['webp']; + + // ps: this is all safe to print + return '' + reduction + '%' + + '' + hoverText + '' + + '
'; +} + +function logLn() { + var html = ''; + for (i = 0; i < arguments.length; i++) { + html += arguments[i]; + } + var spanEl = document.createElement('span'); + spanEl.innerHTML = html; + + document.getElementById('bulkconvertlog').appendChild(spanEl); + + //document.getElementById('bulkconvertlog').innerHTML += html; +} + +function webpexpress_viewLog(groupPointer, filePointer) { + +/* + disabled until I am certain that security is in place. + + var bulkInfo = window.webpexpress_bulkconvert; + var group = bulkInfo.groups[groupPointer]; + var filename = group.files[filePointer]; + var source = group.root + '/' + filename; + + var w = Math.min(1200, Math.max(200, document.documentElement.clientWidth - 100)); + var h = Math.max(250, document.documentElement.clientHeight - 80); + + document.getElementById('conversionlog_content').innerHTML = 'loading log...'; // + source; + + jQuery.ajax({ + method: 'POST', + url: ajaxurl, + data: { + 'action': 'webpexpress_view_log', + 'nonce' : window.webpExpress['ajax-nonces']['view-log'], + 'source': source + }, + success: (response) => { + //alert(response); + if ((typeof response == 'object') && (response['success'] == false)) { + html = '

Error

'; + if (response['data'] && ((typeof response['data']) == 'string')) { + html += webpexpress_escapeHTML(response['data']); + } + document.getElementById('conversionlog_content').innerHTML = html; + return; + } + + var result = JSON.parse(response); + + // the "log" result is a simply form of markdown, using just italic, bold and newlines. + // It ought not to return anything evil, but for good practice, let us encode. + result = webpexpress_escapeHTML(result); + + var html = '

Conversion log


' + '
' + result + '
'; + + document.getElementById('conversionlog_content').innerHTML = html; + }, + error: () => { + //responseCallback({requestError: true}); + }, + }); + + //

Conversion log

+ //tb_show('Conversion log', '#TB_inline?inlineId=conversionlog'); + openDasPopup('conversionlog', w, h); +*/ +} + +function convertNextInBulkQueue() { + var html; + var bulkInfo = window.webpexpress_bulkconvert; + //console.log('convertNextInBulkQueue', bulkInfo); + + // Current group might contain 0, - skip if that is the case + while ((bulkInfo.groupPointer < bulkInfo.groups.length) && (bulkInfo.filePointer >= bulkInfo.groups[bulkInfo.groupPointer].files.length)) { + logLn( + '

' + bulkInfo.groups[bulkInfo.groupPointer].groupName + '

', + '

Nothing to convert

' + ); + + bulkInfo.groupPointer++; + bulkInfo.filePointer = 0; + } + + if (bulkInfo.groupPointer >= bulkInfo.groups.length) { + convertDone(); + return; + } + + var group = bulkInfo.groups[bulkInfo.groupPointer]; + var filename = group.files[bulkInfo.filePointer]; + + if (bulkInfo.filePointer == 0) { + logLn('

' + group.groupName + '

'); + } + + logLn('Converting ' + filename + ''); + + var data = { + 'action': 'convert_file', + 'nonce' : window.webpExpress['ajax-nonces']['convert'], + 'filename': group.root + '/' + filename + + //'whatever': ajax_object.we_value // We pass php values differently! + }; + + function responseCallback(response){ + try { + if ((typeof response == 'object') && (response['success'] == false)) { + html = '

Error

'; + if (response['data'] && ((typeof response['data']) == 'string')) { + // disabled. Need to check if it is secure + //html += webpexpress_escapeHTML(response['data']); + } + logLn(html); + return + } + + var result; + + // Handle different types of responses safely + if (typeof response.requestError === 'boolean' && response.requestError) { + result = { + success: false, + msg: 'Request failed', + log: '', + }; + } else if (typeof response === 'string') { + try { + result = JSON.parse(response); + } catch (e) { + result = { + success: false, + msg: 'Invalid response received from server', + log: '', + }; + } + } else if (typeof response === 'object' && response !== null) { + // If it's already an object, check if it has the expected structure + if (typeof response.success !== 'undefined') { + result = response; + } else { + result = { + success: false, + msg: 'Invalid object response received from server', + log: '', + }; + } + } else { + result = { + success: false, + msg: 'Unexpected response type: ' + typeof response, + log: '', + }; + } + + var bulkInfo = window.webpexpress_bulkconvert; + if (!bulkInfo || !bulkInfo.groups || bulkInfo.groupPointer >= bulkInfo.groups.length) { + logLn('Bulk conversion state is invalid
'); + convertDone(); + return; + } + var group = bulkInfo.groups[bulkInfo.groupPointer]; + if (!group || !group.files || bulkInfo.filePointer >= group.files.length) { + logLn('Group or file index is invalid
'); + convertDone(); + return; + } + var filename = group.files[bulkInfo.filePointer]; + + //console.log(result); + + var html = ''; + + var htmlViewLog = ''; + + // uncommented until I'm certain that security is in place + //var htmlViewLog = '  view log'; + if (result['success']) { + + //console.log('nonce tick:' + result['nonce-tick']); + + if (result['new-convert-nonce']) { + //console.log('new convert nonce:' + result['new-convert-nonce']); + window.webpExpress['ajax-nonces']['convert'] = result['new-convert-nonce']; + } + + var orgSize = result['filesize-original']; + var webpSize = result['filesize-webp']; + var orgSizePrint, webpSizePrint; + + bulkInfo['orgTotalFilesize'] += orgSize; + bulkInfo['webpTotalFilesize'] += webpSize; + //'- Saved at: ' + result['destination-path'] + +/* + html += ' ok' + + htmlViewLog + + getReductionHtml(orgSize, webpSize, 'Size of original', 'Size of webp')*/ + + html += ' ok' + + '' + + 'Destination:
' + result['destination-path'] + '

' + + 'Url:
' + result['destination-url'] + '
' + + '
' + + '
' + + getReductionHtml(orgSize, webpSize, 'Size of original', 'Size of webp') + } else { + html += ' failed' + htmlViewLog; + + if (result['msg']) { + // Show the error message but continue processing + html += '
' + webpexpress_escapeHTML(result['msg']) + ''; + } + + // Only stop for critical errors (security nonce issues), not file-specific errors + if (result['stop'] && result['msg'] && result['msg'].indexOf('security nonce') !== -1) { + logLn(html); + logLn('
Bulk conversion stopped due to security error. Please reload the page (F5) and try again.'); + return; + } + + html += '
'; + } + logLn(html); + + + // Get next + bulkInfo.filePointer++; + if (bulkInfo.filePointer == group.files.length) { + bulkInfo.filePointer = 0; + bulkInfo.groupPointer++; + } + if (bulkInfo.groupPointer == bulkInfo.groups.length) { + convertDone(); + } else { + if (bulkInfo.paused) { + document.getElementById('bulkconvertlog').innerHTML += '

on pause
' + + 'Reduction this far: ' + getReductionHtml(bulkInfo['orgTotalFilesize'], bulkInfo['webpTotalFilesize'], 'Total size of originals this far', 'Total size of webp files this far') + '

' + + bulkInfo['orgTotalFilesize'] += orgSize; + bulkInfo['webpTotalFilesize'] += webpSize; + + } else { + convertNextInBulkQueue(); + } + } + + } catch (error) { + // Catch any unexpected errors in responseCallback + logLn(' JavaScript error in response processing: ' + error.message + '
'); + + // Try to continue with next file + var bulkInfo = window.webpexpress_bulkconvert; + if (bulkInfo && bulkInfo.groups && bulkInfo.groupPointer < bulkInfo.groups.length) { + bulkInfo.filePointer++; + if (bulkInfo.filePointer >= bulkInfo.groups[bulkInfo.groupPointer].files.length) { + bulkInfo.filePointer = 0; + bulkInfo.groupPointer++; + } + if (bulkInfo.groupPointer >= bulkInfo.groups.length) { + convertDone(); + } else if (!bulkInfo.paused) { + convertNextInBulkQueue(); + } + } else { + convertDone(); + } + } + + } + + // jQuery.post(ajaxurl, data, responseCallback); + jQuery.ajax({ + method: 'POST', + url: ajaxurl, + data: data, + timeout: 30000, // 30 second timeout per file + success: (response) => { + responseCallback(response); + }, + error: (jqXHR, textStatus, errorThrown) => { + var errorMsg = 'unknown error'; + if (textStatus === 'timeout') { + errorMsg = 'timeout (30s)'; + } else if (textStatus === 'error') { + if (jqXHR.status === 500) { + errorMsg = 'server error (500)'; + } else if (jqXHR.status === 0) { + errorMsg = 'connection failed'; + } else { + errorMsg = 'error (' + jqXHR.status + ')'; + } + } else { + errorMsg = 'error (' + textStatus + ')'; + } + + // Get current file info for logging + var bulkInfo = window.webpexpress_bulkconvert; + var filename = 'unknown file'; + if (bulkInfo && bulkInfo.groups && bulkInfo.groupPointer < bulkInfo.groups.length) { + var group = bulkInfo.groups[bulkInfo.groupPointer]; + if (group && group.files && bulkInfo.filePointer < group.files.length) { + filename = group.files[bulkInfo.filePointer]; + } + } + + logLn('Converting ' + filename + ' ' + errorMsg + '
'); + + // Continue with next file instead of stopping + if (bulkInfo && bulkInfo.groups && bulkInfo.groupPointer < bulkInfo.groups.length) { + var group = bulkInfo.groups[bulkInfo.groupPointer]; + + // Get next + bulkInfo.filePointer++; + if (bulkInfo.filePointer >= group.files.length) { + bulkInfo.filePointer = 0; + bulkInfo.groupPointer++; + } + if (bulkInfo.groupPointer >= bulkInfo.groups.length) { + convertDone(); + } else { + if (!bulkInfo.paused) { + convertNextInBulkQueue(); + } + } + } else { + convertDone(); + } + }, + }); +} diff --git a/lib/options/js/converters.js b/lib/options/js/converters.js new file mode 100644 index 0000000..8c65f39 --- /dev/null +++ b/lib/options/js/converters.js @@ -0,0 +1,557 @@ + + +// Map of converters (are updated with updateConvertersMap) +window.convertersMap = {}; + +window.currentlyEditing = ''; + +function getConversionMethodDescription(converterId) { + var descriptions = { + 'cwebp': 'cwebp', + 'wpc': 'Remote WebP Express', + 'ewww': 'ewww cloud converter', + 'gd': 'Gd extension', + 'imagick': 'Imagick (PHP extension)', + 'gmagick': 'Gmagick (PHP extension)', + 'imagemagick': 'ImageMagick', + 'graphicsmagick': 'GraphicsMagick', + 'vips': 'Vips', + 'ffmpeg': 'ffmpeg', + }; + + if (descriptions[converterId]) { + return descriptions[converterId]; + } + return converterId; +} + +function generateConverterHTML(converter) { + html = '
  • '; + //html += ''; +// html += ''; +// html += ''; + html += ''; + html += '
    '; + html += getConversionMethodDescription(converter['id']); + html += '
    '; + html += '
    configure'; + html += 'test'; + + if (converter.deactivated) { + html += 'activate'; + } + else { + html += 'deactivate'; + } + + html += '
    '; + if (converter['error']) { + html += ''; + html += ''; + html += ''; + } else if (converter['warnings']) { + /*html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '';*/ + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + + html += ''; + } else if (converter.working) { + html += ''; + html += ''; + //html += ''; + html += ''; + } + html += '
    '; + + html += '
  • '; + return html; +} + +/* Set ids on global converters object */ +function setTemporaryIdsOnConverters() { + if (window.converters == undefined) { + console.log('window.converters is undefined. Strange. Please report!'); + return; + } + var numConverterInstances = []; + for (var i=0; i
    + + + + + + diff --git a/lib/options/options/alter-html/alter-html.inc b/lib/options/options/alter-html/alter-html.inc new file mode 100644 index 0000000..7c5e63f --- /dev/null +++ b/lib/options/options/alter-html/alter-html.inc @@ -0,0 +1,16 @@ +
    +

    Alter HTML

    +

    + Enabling this alters the HTML code such that webp images are served to browsers that supports webp. + It is recommended to enable this even when the redirection is also enabled, so the varied image responses are only used for + those images that cannot be replaced in HTML. The varied responses generally leads to poorer caching in proxies and CDN's. And if + users download those images, they will have jpg/png extension, even though they are webp. +

    +
    SiteSaltLimit
    Alter HTML?Alter HTML to either use picture tag syntax for pointing to webp versions or point directly to webps.

    ' . + '

    Pro picture tag syntax: The browser selects the webp if it supports it.

    ' . + '

    Pro image urls: Also works on inline styles

    ' . + (($config['operation-mode'] == 'varied-image-responses') ? + '

    You do not need to enable this in Varied image responses operation mode, but enabling it has some benefits: ' . + 'Caching is improved if you are on a CDN as the webp images that are requested directly does not need to be keyed by the Accept header. ' . + 'Also, when a user downloads an image, it will have the correct extension.

    ' : '') + ); + ?> +
    + + value="true" + type="checkbox" + > +
    +
    +

    + Two distinct methods for altering HTML are supported. View comparison chart + +

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Method 1: Replacing <img> tags with <picture> tagsMethod 2: Replacing image URLs
    How it works +

    + It replaces <img> tags with <picture> tags, adding two <source> tags - one for the original image(s), and one + for the webp image(s). + Browsers that supports webp picks the <source> tag with type attribute set to "image/webp". + A small javascript can be optionally added for dynamically loading picturefill.js on browsers that doesn't support the picture tag. +

    +

    + We are using this library. + You can visit it for more information. +

    +
    + It replaces any image url it can find. + We are using this library. + You can visit it for more information. +
    Page cachingWorks great with page caching, because all browsers are served the same HTML +

    + Does not work with page cachingunless you are using the Cache Enabler plugin, + which is able to maintain two cached versions of each page.

    + + Note: Cache Enabler also has HTML altering, but their implementation has limitations. + It for example doesn't replace background images in inline styles and it does not look for all common lazy load attributes. It also has some problems in edge cases. + For this reason, I recommend activating HTML altering in WebP Express, even when Cache Enabler is used. + By doing that, both plugins will have a go at it (WebP Express comes first). + At least it will take care of the limitations in Cache Enabler. + It does however not cure the edge cases where Cache Enabler replaces things it should not, or crashes + +

    +
    Styling and javascriptMay break because of changed HTML structure. + A selector such as "div > img" will no longer match the image because the immidiate parent is now "picture". + However, a selector such as "div img" will still work. + Luckily, the picture element is not meant to be styled - one still has to target the contained img element. + No problems
    ComprehensivenessOnly replaces <img> tags - other images are untouchedVery comprehensive. Replaces images in inline styles, image urls in lazy load attributes set in <div> or <li> tags, etc.
    +
    + Hide comparison chart +
    + +
    • + "Picture tag" replaces <img> tags with <picture> tags and adds the webp as an extra source. ' . + 'This effectively points webp-enabled browsers to the webp variant and other browsers to ' . + 'the original image.

      ' . + '

      Beware that this structural change may break styling!
      ' . + '(a selector such as "div > img" will no longer match the image because the immidiate parent is now "picture". However, ' . + 'a selector such as "div img" will still work)' . + '

      ' . + '

      PS: This functionality is handled by ' . + 'this external library' . + ' (I have pushed the code to an external library so it can be used by other projects besides this plugin)

      ' + ); + ?> +
    • +
    • + picturefill.js if not. ' . + 'It is recommended to activate this option, unless your theme or another plugin adds such a script. ' . + 'Picture tag support is currently ~94%' + ); + ?> +
    • +
    • + "Image URLs" replaces the image URLs to point to ' . + 'the webp rather than the original. Handles src, srcset, common lazy-load attributes and even ' . + 'inline styles

      ' . + '

      Does not work with page cachingunless you are using the Cache Enabler plugin

      ' . + '

      Note that you will have to do something for the browsers that does not support webp. ' . + 'And that something is in most cases to enable the Only do the replacements in webp enabled browsers option, which ' . + 'will show up when you enable this option. ' . + 'Or you can experiment with javascript solutions. There is for example the webpjs javascript library. ' . + 'But it does not support srcset, which is a showstopper. There are other libraries out there. ' . + '

      PS: This replace functionality is handled by ' . + 'this external library' . + ', created for the purpose.

      ' + ); + ?> +
    • +
    • + +
    • +
    + +
    +
    '; + webpexpress_checkbox( + 'alter-html-for-webps-that-has-yet-to-exist', + (!$config['alter-html']['only-for-webps-that-exists']), + 'Reference webps that haven\'t been converted yet', + 'If you enable this option, there will be references to webp files that doesnt exist yet. ' . + 'And that will be ok! - Just make sure to enable the option to convert missing webp files ' . + 'upon request in the "Redirection rules" section.' + ); + echo '
    '; + } + ?> +
    + + 'Use content filtering hooks (the_content, the_excerpt, etc)', + 'ob' => 'The complete page (using output buffering)', + ], [ + ] + ); + ?> +
    +
    + +
    + $alias) { + ?> + http(s)://
    + + + + +
    + + + +
    + diff --git a/lib/options/options/conversion-options/bulk-convert.inc b/lib/options/options/conversion-options/bulk-convert.inc new file mode 100644 index 0000000..b493290 --- /dev/null +++ b/lib/options/options/conversion-options/bulk-convert.inc @@ -0,0 +1,30 @@ + + + Bulk convert + ' + ); + ?> + + +
    +
    +
    + +
    +
    + + + + +
    + + diff --git a/lib/options/options/conversion-options/conversion-options.inc b/lib/options/options/conversion-options/conversion-options.inc new file mode 100644 index 0000000..b2823da --- /dev/null +++ b/lib/options/options/conversion-options/conversion-options.inc @@ -0,0 +1,18 @@ +
    +

    Conversion

    + + + + + +
    +
    diff --git a/lib/options/options/conversion-options/convert-on-upload.inc b/lib/options/options/conversion-options/convert-on-upload.inc new file mode 100644 index 0000000..b90e1ef --- /dev/null +++ b/lib/options/options/conversion-options/convert-on-upload.inc @@ -0,0 +1,18 @@ + + + Convert on upload + Convert images at the moment they have been uploaded through the media library ' . + '(of course, the "Image types to work on" setting is respected). ' . + 'Be aware that this may slow the down the experience of uploading in the media library, ' . + 'especially if your theme creates many thumbnails.

    ' . + '

    Technically, we are hooking into the handle_upload filter to trigger conversion of the image ' . + 'and the image_make_intermediate_size filter for the thumbnails.

    ' + ); + ?> + + + > + + diff --git a/lib/options/options/conversion-options/converter-options/cwebp.php b/lib/options/options/conversion-options/converter-options/cwebp.php new file mode 100644 index 0000000..f862a95 --- /dev/null +++ b/lib/options/options/conversion-options/converter-options/cwebp.php @@ -0,0 +1,67 @@ + diff --git a/lib/options/options/conversion-options/converter-options/ewww.php b/lib/options/options/conversion-options/converter-options/ewww.php new file mode 100644 index 0000000..7c332c8 --- /dev/null +++ b/lib/options/options/conversion-options/converter-options/ewww.php @@ -0,0 +1,23 @@ + diff --git a/lib/options/options/conversion-options/converter-options/ffmpeg.php b/lib/options/options/conversion-options/converter-options/ffmpeg.php new file mode 100644 index 0000000..3271c93 --- /dev/null +++ b/lib/options/options/conversion-options/converter-options/ffmpeg.php @@ -0,0 +1,35 @@ + diff --git a/lib/options/options/conversion-options/converter-options/gd.php b/lib/options/options/conversion-options/converter-options/gd.php new file mode 100644 index 0000000..867a0ca --- /dev/null +++ b/lib/options/options/conversion-options/converter-options/gd.php @@ -0,0 +1,21 @@ + diff --git a/lib/options/options/conversion-options/converter-options/graphicsmagick.php b/lib/options/options/conversion-options/converter-options/graphicsmagick.php new file mode 100644 index 0000000..9c9fca8 --- /dev/null +++ b/lib/options/options/conversion-options/converter-options/graphicsmagick.php @@ -0,0 +1,32 @@ + diff --git a/lib/options/options/conversion-options/converter-options/imagemagick.php b/lib/options/options/conversion-options/converter-options/imagemagick.php new file mode 100644 index 0000000..f9c69a9 --- /dev/null +++ b/lib/options/options/conversion-options/converter-options/imagemagick.php @@ -0,0 +1,30 @@ + diff --git a/lib/options/options/conversion-options/converter-options/imagick.php b/lib/options/options/conversion-options/converter-options/imagick.php new file mode 100644 index 0000000..7781ed9 --- /dev/null +++ b/lib/options/options/conversion-options/converter-options/imagick.php @@ -0,0 +1,20 @@ + + diff --git a/lib/options/options/conversion-options/converter-options/vips.php b/lib/options/options/conversion-options/converter-options/vips.php new file mode 100644 index 0000000..4b78e4b --- /dev/null +++ b/lib/options/options/conversion-options/converter-options/vips.php @@ -0,0 +1,42 @@ + diff --git a/lib/options/options/conversion-options/converter-options/wpc.php b/lib/options/options/conversion-options/converter-options/wpc.php new file mode 100644 index 0000000..8f25cc2 --- /dev/null +++ b/lib/options/options/conversion-options/converter-options/wpc.php @@ -0,0 +1,88 @@ + diff --git a/lib/options/options/conversion-options/converters.inc b/lib/options/options/conversion-options/converters.inc new file mode 100644 index 0000000..5c2741c --- /dev/null +++ b/lib/options/options/conversion-options/converters.inc @@ -0,0 +1,38 @@ + + + + Conversion method + Go here' + ); ?> + + + +
      + + + + + close'; + } + + include 'converter-options/cwebp.php'; + include 'converter-options/vips.php'; + include 'converter-options/gd.php'; + include 'converter-options/imagick.php'; + include 'converter-options/ewww.php'; + include 'converter-options/wpc.php'; + include 'converter-options/imagemagick.php'; + include 'converter-options/graphicsmagick.php'; + include 'converter-options/ffmpeg.php'; + ?> + + diff --git a/lib/options/options/conversion-options/jpeg.inc b/lib/options/options/conversion-options/jpeg.inc new file mode 100644 index 0000000..5d72271 --- /dev/null +++ b/lib/options/options/conversion-options/jpeg.inc @@ -0,0 +1,164 @@ + + + + + Jpeg options + + + +
      + + + The WebP format supports two types of encoding: lossy and lossless.

      ' . + '

      If you select "Auto", WebP Express will try converting to both encodings ' . + 'and select one that resulted in the smallest file.

      ' . + '

      Note that Gd and Ewww does not support the "Auto" feature. ' . + 'Gd can only produce lossy, and will simply do that. ' . + 'Ewww can not be configured to use a certain encoding, but automatically chooses lossless encoding for PNGs and lossy for JPEGs.' . + '

      ' . + //'

      Also note that Remote WebP Express (if the WebP Express you are ' . + //'connecting to are using one of these, and you are using version 0.14+)' . + '

      You can read more about the option here

      ' + );?> +
      +
      + + + If you select the "Same as the jpeg" option, WebP Express will try to determine the quality of ' . + 'the jpeg and use that for the webp - unless the quality is higher than the limit entered, in which ' . + 'case the limit is used.

      ' . + '

      Ouality detection requires imagick, imagemagick or gmagick (not neccessarily compiled with webp). ' . + 'In case quality detection is not available, the fallback quality is used.

      ' . + ( + $canDetectQuality ? + '

      Quality detection is working, btw :)

      ' : + '

      Quality detection is not currently available on your system. ' . + ((extension_loaded('imagick') && class_exists('\\Imagick')) ? + 'You have imagick, but you need a newer version (You need PECL >= 2.2.2). ' : '' + ) . + 'However, you can still have it by using the Remote WebP Express conversion method (the request for ' . + 'using same quality as the jpeg, as well as the limit and fallback settings will be forwarded to the remote)' . + '

      ' + ) + + ); + /* + if ($canDetectQuality) { + echo helpIcon('All converted images will be encoded with this quality'); + } else { + echo helpIcon('All converted images will be encoded with this quality. ' . + 'For Remote WebP Express and Imagick, you however have the option to use override this, and use ' . + '"auto". With some setup, you can get quality detection working and you will then be able to set ' . + 'quality to "auto" generally. For that you either need to get the imagick extension running ' . + '(PECL >= 2.2.2) or exec() rights and either imagick or gmagick installed.' + ); + } + */ + ?> +
      + + + + + + You do not have quality detection working, btw - which means that all conversions will have the fixed quality entered here' + ) + ); + ?> +
      + +
      + + +
      +
      +
      + + + What? Lossless is lossless, right?. Well, that depends on how you look at it. ' . + 'The webp conversion library has this nifty option called "near lossless preprocessing". The preproccesing manipulates ' . + 'the image before encoding in order to help compressibility.

      ' . + '

      Note that the near-lossless option only is supported by the Cwebp and Vips conversion methods.

      ' . + '

      Read more about the feature here

      ' + ); + ?> +
      + + + Read this to get an informed opinion about appropriate setting.' + ); + ?> +
      +
      + + diff --git a/lib/options/options/conversion-options/logging.inc b/lib/options/options/conversion-options/logging.inc new file mode 100644 index 0000000..7ad7d43 --- /dev/null +++ b/lib/options/options/conversion-options/logging.inc @@ -0,0 +1,24 @@ + + + Enable logging + Store conversion results in log files. ' . + 'This can be useful in order to find out what went wrong in case a conversion failed or ' . + 'the result is poor quality. ' . + 'The log files reside in wp-content/webp-express/log/conversions/. ' . + 'In not too far a future, the conversion logs will be accessible in the File Manager too.' . + '

      ' + ); + ?> + + + > +   + + + + + diff --git a/lib/options/options/conversion-options/metadata.inc b/lib/options/options/conversion-options/metadata.inc new file mode 100644 index 0000000..9c8664d --- /dev/null +++ b/lib/options/options/conversion-options/metadata.inc @@ -0,0 +1,18 @@ +Metadata'; +echo helpIcon( + 'Decide what to do with image metadata, such as Exif. Note that this setting is not supported by the "Gd" conversion method, ' . + 'as it is not possible to copy the metadata with the Gd extension.' +); +echo ''; + +echo ''; +echo ''; +// echo '

      Converted jpeg images will get same quality as original, but not more than this setting. Something between 70-85 is recommended for most websites.

      '; diff --git a/lib/options/options/conversion-options/png.inc b/lib/options/options/conversion-options/png.inc new file mode 100644 index 0000000..7b98349 --- /dev/null +++ b/lib/options/options/conversion-options/png.inc @@ -0,0 +1,99 @@ + + + PNG options + + + +
      + + + The WebP format supports two types of encoding: lossy and lossless. ' . + 'With WebP you can have both transparency AND lossy encoding, so "lossy" encoding is not out of the question, just ' . + 'because the converted file is a PNG.

      ' . + '

      There is no "lossy" option in the combobox, because converting all PNGs to lossy would probably be a bad idea. ' . + 'However, in many cases you get better compression with lossy. So this is what the "auto" option is for. ' . + 'With "Auto", WebP Express will try converting to both encodings ' . + 'and select one that resulted in the smallest file.

      ' . + '

      Note that Gd and Ewww neither supports "Lossless" or "Auto". ' . + 'Gd can only produce lossy, and will simply do that. ' . + 'Ewww can not be configured to use a certain encoding, but automatically chooses lossless encoding for PNGs and lossy for JPEGs.' . + '

      ' . + '

      You can read more about the option here

      ' + );?> +
      +
      + + + + You probably want to set this value a bit higher than the quality for JPEGs. PNGs are often used for icons and graphics, ' . + 'which perhaps demands a bit more quality than photos, which jpegs often are used for.

      ' . + '

      Note that there is no "Same as PNG" option available here as PNGs are lossless

      ' + ); + ?> + + + Note that Gd and Ewww does not support the "Alpha quality" feature. ' . + 'They simply ignore the option and converts the alpha channel losslessly. ' . + '

      ' + ); + ?> +
      +
      + + + What? Lossless is lossless, right?. Well, that depends on how you look at it. ' . + 'The webp conversion library has this nifty option called "near lossless preprocessing". The preproccesing manipulates ' . + 'the image before encoding in order to help compressibility.

      ' . + '

      Note that the near-lossless option only is supported by the Cwebp and Vips conversion methods.

      ' . + '

      Read more about the feature here

      ' + ); + ?> +
      + + + Read this to get an informed opinion about appropriate setting.' + ); + ?> +
      +
      + + diff --git a/lib/options/options/conversion-options/quality.inc b/lib/options/options/conversion-options/quality.inc new file mode 100644 index 0000000..b1d4e6e --- /dev/null +++ b/lib/options/options/conversion-options/quality.inc @@ -0,0 +1,95 @@ +Quality (jpeg -> webp)'; +if ($canDetectQuality) { + echo helpIcon( + 'Quality of webp, when the image being converted is a jpeg. If "Same as the jpeg" is selected, the converted image ' . + 'will get same quality as source. Auto is recommended!' + ); +} else { + echo helpIcon( + '

      Quality of webp, when converting jpegs (0-100)

      ' . + '

      Note: If your system had the capability to detect the quality ' . + 'of jpeg images, you would have had the option to choose "Same as jpeg". To get that option, you ' . + 'either need to get the imagick extension running (PECL >= 2.2.2) or imagick or gmagick installed plus ' . + 'exec() rights.

      ' . + '

      Note: If you use the Remote WebP Express converter, you can configure it to ask the remote ' . + 'to do the automatic quality detection for jpegs. This will override the value entered here.

      ' + ); +} +echo ''; + +if ($canDetectQuality) { + $qualityAuto = $config['quality-auto'];; + echo ''; + + // Max quality + // -------------------- + $maxQuality = $config['max-quality']; + +// echo 'Max quality (0-100)'; + echo '
      Max '; + //echo ''; + echo ''; + echo helpIcon('Quality is expensive byte-wise. For most websites, more than 80 is a waste of bytes. ' . + 'This option allows you to limit the quality to whatever is lowest: ' . + 'the quality of the jpeg or the limit entered here. Recommended value: Somewhere between 50-85'); + //echo ''; + echo '
      '; +} + +// Quality - specific +// -------------------- +$qualitySpecific = $config['quality-specific']; + +echo '
      '; +/* +if ($canDetectQuality) { + echo helpIcon('All converted images will be encoded with this quality'); +} else { + echo helpIcon('All converted images will be encoded with this quality. ' . + 'For Remote WebP Express and Imagick, you however have the option to use override this, and use ' . + '"auto". With some setup, you can get quality detection working and you will then be able to set ' . + 'quality to "auto" generally. For that you either need to get the imagick extension running ' . + '(PECL >= 2.2.2) or exec() rights and either imagick or gmagick installed.' + ); +} +*/ +//echo ''; + +echo ''; +echo helpIcon('Enter number (0 - 100)'); +echo '
      '; +echo ''; + + +// Quality - PNG +// -------------------- +echo 'Quality (png -> webp)'; +echo helpIcon( + 'Quality of webp, when the image that is converted is a png.' +); +echo ''; +echo ''; +echo helpIcon('Enter number (0 - 100). Recommended value: Somewhere between 60-90'); +echo ''; diff --git a/lib/options/options/general/cache-control.inc b/lib/options/options/general/cache-control.inc new file mode 100644 index 0000000..acc33a7 --- /dev/null +++ b/lib/options/options/general/cache-control.inc @@ -0,0 +1,112 @@ + + + + Cache-Control header Optionally set cache-control header for the internally redirected images ' . + '(recommended!)

      '); + break; + case 'cdn-friendly': + echo helpIcon('

      Optionally set cache-control header for webp images'); + break; + default: + echo helpIcon('

      Controls the cache-control header on successful conversion and direct redirection to converted ' . + 'image in .htaccess. In case of convert failure, headers will be sent to prevent caching.

      ' . + '

      PS: In order to set stale-while-revalidate and stale-if-error directives, you must ' . + 'currently choose "Custom". It is a good idea to set these.' . + '

      '); + break; + } + ?> + + + +
      + + here', + 'no-margin-left' + );?> +
      + +
      + + Set either the "public" or "private" directive. Setting this to public means that you are allow caching in shared caches. ' . + 'Only do this, if you are sure your CDN or reverse proxy can handle that the ' . + 'response varies depending on the Accept header.

      ' . + '

      Note: I am not completely sure that all forward proxies handles varied responses. ' . + 'This is discussed here.

      ' + , + 'no-margin-left set-margin-right' + ); + ?> + + + here)', + 'no-margin-left' + );?> +
      +
      + + + + diff --git a/lib/options/options/general/destination-extension.inc b/lib/options/options/general/destination-extension.inc new file mode 100644 index 0000000..1323145 --- /dev/null +++ b/lib/options/options/general/destination-extension.inc @@ -0,0 +1,61 @@ +Filename of the webp files'; + echo helpIcon( + '

      Select under which naming convention the webp files are stored. ' . + 'It is assumed that webp files are located in the same folder as the originals.

      ' . + '' . + '' . + '' . + //'' . + '' . + '' . + '' . + // todo: + '
      PluginConvention
      Cache enablerReplaces extension
      ShortpixelReplaces extension
      EwwwAppends extension
      Optimus HQReplaces extension
      ' + ); + } else { + echo 'File extension'; + echo helpIcon( + '

      Controls the filename of the converted file.

      ' . + '

      The "Append" option result in file names such as "image.png.webp". ' . + 'The "Set" option results in file names such as "image.webp". ' . + 'Note that if you choose "Set", it will be a problem if you ie both have a logo.jpg and a logo.png in the same folder. ' . + 'If you are using WebP Express together with Cache enabler ' . + 'or Shortpixel, set this option to Set"

      ' . + (($config['operation-mode'] == 'cdn-friendly') ? '

      In this mode, the webp files will be stored in the same folder as the originals, except for images that are not inside the uploads folder (these are stored in wp-content/webp-express/webp-images/doc-root).

      ' : '') . + '

      Changing this option will cause existing webp images to be renamed (only those in the upload folder, and only those that has a ' . + 'corresponding source image)

      ' . + '

      Note that this option only applies to the webp images stored in the uploads folder (mingled).

      ' + ); + } + ?> + + + 'Appended ".webp" (ie "image.jpg.webp")', + 'set' => 'Replaced extension (ie "image.webp")', + ], [ + 'append' => 'Original extension is kept and ".webp" is appended. ', + 'set' => 'Original extension is replaced with ".webp".' + ], 'margin-left: 0px; margin-top: 5px'); + + } else { + echo ''; + } + ?> + + + diff --git a/lib/options/options/general/destination-folder.inc b/lib/options/options/general/destination-folder.inc new file mode 100644 index 0000000..de6781b --- /dev/null +++ b/lib/options/options/general/destination-folder.inc @@ -0,0 +1,26 @@ + + + Destination folder' . + 'Mingled:
      ' . + 'When "Mingled" is selected, the webp images will be put in the same folder as the original but only for images in the uploads folder. ' . + 'Other images, such as theme images are stored separately.
      ' . + 'If you are using WebP Express together with Cache enabler or ' . + 'Shortpixel, choose this option

      ' . + + 'In separate folder:
      ' . + 'Images are stored in a separate folder ' . + '(wp-content/webp-express/webp-images/doc-root).' . + '

      Note: Changing this option will cause existing webp images to be moved

      '); + ?> + + + + diff --git a/lib/options/options/general/destination-structure.inc b/lib/options/options/general/destination-structure.inc new file mode 100644 index 0000000..40132cb --- /dev/null +++ b/lib/options/options/general/destination-structure.inc @@ -0,0 +1,42 @@ + + + Destination structureThis setting determines how the converted files are structured within the folder that WebP Express ' . + 'uses for storing webp images (from here on called "the cache root")

      ' . + '

      "document root"
      ' . + 'When "document root" is selected, the webp images will be stored in:
      ' . + '[cache root]/doc-root/[relative path of source image, from document root].
      ' . + '

      ' . + '

      "image roots"
      ' . + 'A Wordpress site has images stored in different locations. Uploaded files are for example stored in the uploads folder, which is ' . + 'usually - but not always - located in the "wp-content" folder. I call the uploads folder an "image root". Other roots are: ' . + '"themes", "plugins", "wp-content" and "index". When the "image roots" setting is selected, the webp files ' . + 'are stored in a structure that mirrors the relative path of the source image within its image root. ' . + 'For "uploads", that location is:
      ' . + '[cache root]/uploads/[relative path of source image, from uploads root].
      ' . + 'More generally we have:
      ' . + '[cache root]/[image root]/[relative path of source image, from its image root].
      ' . + '

      ' . + '

      Which option is best?
      ' . + 'Well, in most cases it does not matter. However, there are hosts out there that have set the document root up incorrectly, ' . + 'so I would generally recommend "Image roots". ' . + 'On Nginx, I however recommend "Document root", as it requires fewer rewrite rules.

      ' + ); + ?> + + + + + diff --git a/lib/options/options/general/general.inc b/lib/options/options/general/general.inc new file mode 100644 index 0000000..468ada2 --- /dev/null +++ b/lib/options/options/general/general.inc @@ -0,0 +1,36 @@ +
      + +

      General

      + + + + + + +
      +
      diff --git a/lib/options/options/general/image-types.inc b/lib/options/options/general/image-types.inc new file mode 100644 index 0000000..ef527db --- /dev/null +++ b/lib/options/options/general/image-types.inc @@ -0,0 +1,40 @@ +'; +switch ($config['operation-mode']) { + case 'varied-image-responses': + echo 'Image types to work on'; + break; + case 'cdn-friendly': + echo 'Image types to convert'; + break; + case 'no-conversion': + echo 'Image types to work on'; + break; + case 'tweaked': + echo 'Image types to send to the converter'; + break; +} +if ($config['operation-mode'] == 'no-conversion') { + echo helpIcon('

      Select which types of images you would like to redirect and/or have altered in the HTML

      '); +} else { + echo helpIcon('

      Beware that some has reported problems with Gd and PNG, but it has not been pinned down when it happens. It is probably on older versions of Gd. Please report any problems with Gd!

      '); +} +echo ''; + +// bitmask +// 1: JPEGs +// 2: PNG's +// Converting only jpegs is thus "1" +// Converting both jpegs and pngs is (1+2) = 3 +$imageTypes = $config['image-types']; + +echo ''; + +echo ''; diff --git a/lib/options/options/general/prevent-using-webps-larger-than-original.inc b/lib/options/options/general/prevent-using-webps-larger-than-original.inc new file mode 100644 index 0000000..25f5995 --- /dev/null +++ b/lib/options/options/general/prevent-using-webps-larger-than-original.inc @@ -0,0 +1,23 @@ + + Prevent using webps larger than originalFor some images, the converted webp might turns out bigger than the original. ' . + 'These are always kept on disk. However, with this option, you can choose whether they should be used or not. ' . + 'If you are motivated by limiting bandwidth usage and having a fast website, keep this option enabled. ' . + 'If you are more concerned about SEO and a penalty for serving jpegs and pngs rather than webps, disable it.' . + '

      ' . + '

      The option is used both when generating .htaccess rules and in Alter HTML.

      ' . + '

      Note: If you are using Alter HTML with picture tags and have images with a srcset ' . + 'attribute, WebP Express generates a source element with type set to "image/webp". This source ' . + 'thus points to a bundle of webp images. From 0.25.5 and forward, when this option is enabled, ' . + 'WebP Express will only add the source element when ALL those webp images are smaller than their originals. ' . + 'In such a situation (using Alter HTML with picture tags), it might be a better strategy to disable this option. ' . + 'Especially if your images generally have the srcset attribute set. Chances are that only a few of the webps in ' . + 'the source set are bigger than the corresponding originals, and chances are that they are only slightly bigger.' . + '

      ' + ); + ?> + + > + + diff --git a/lib/options/options/general/scope.inc b/lib/options/options/general/scope.inc new file mode 100644 index 0000000..55154c3 --- /dev/null +++ b/lib/options/options/general/scope.inc @@ -0,0 +1,59 @@ + + ScopeThis setting determines which folders WebP Express is operational in. ' . + 'If for example "Uploads only" is selected, WebP Express will only convert the upload images and only put ' . + 'an .htaccess file in the uploads folder (if needed). Also, Alter HTML will limit itself to that area.' . + '

      ' . + '

      The "All content" setting will work on uploads, themes, plugins - anything in the "wp-content" ' . + '(or whatever it has been renamed to). It will work on uploads, even if the uploads folder has been ' . + 'configured to reside outside of wp-content - and on plugins, even if plugins has been moved.

      ' + ); + ?> + + + + diff --git a/lib/options/options/operation-mode.inc b/lib/options/options/operation-mode.inc new file mode 100644 index 0000000..84c10a3 --- /dev/null +++ b/lib/options/options/operation-mode.inc @@ -0,0 +1,70 @@ + +
      +

      Operation mode: Think of the operation modes as presets that matches normal use-cases. ' . + 'Usually you want to stick with Varied image responses or perhaps CDN friendly. ' . + 'The Tweaked mode has no presets. That is: Here, you can set all options.

      ' . + '

      Changing from ie. "Varied image responses" mode to "Tweaked" mode enables you to see the underlying options for that mode (and tweak them). ' . + 'Changing back will override the tweaks (you will lose them).

      ' . + '

      You will never loose your converter configurations by changing mode

      '); +?> + +

      + + +

      + In the "Varied image responses" mode, WebP Express creates redirection rules for images, such that a request for a jpeg will + result in a webp – but only if the request comes from a webp-enabled browser. Note that not all CDN's handles varied responses well (see FAQ). + +

      + + +

      + In "CDN friendly" mode, a jpeg is always served as a jpeg. + Instead of varying the image response, WebP Express alters the HTML for webp usage. +
      ? +
      +

      + A couple of options are available for automatically triggering webp conversion. +

      + + +

      +

      + The "No conversion" mode is for scenarios where you are using another plugin for converting images. + Perhaps the other plugin doesn't have the redirection or alter HTML feature, or perhaps it doesn't do it as well + as WebP Express does. PS: The two methods below works great in tandem. +
      +

      + +
      + diff --git a/lib/options/options/redirection-rules/add-vary-header-in-htaccess.inc b/lib/options/options/redirection-rules/add-vary-header-in-htaccess.inc new file mode 100644 index 0000000..7c683ce --- /dev/null +++ b/lib/options/options/redirection-rules/add-vary-header-in-htaccess.inc @@ -0,0 +1,12 @@ + diff --git a/lib/options/options/redirection-rules/do-not-pass-source-path-in-query-string.inc b/lib/options/options/redirection-rules/do-not-pass-source-path-in-query-string.inc new file mode 100644 index 0000000..38351d4 --- /dev/null +++ b/lib/options/options/redirection-rules/do-not-pass-source-path-in-query-string.inc @@ -0,0 +1,11 @@ +Do not pass source in Query String'; +echo helpIcon( + 'You can try unchecking this, if you are experiencing that no images are converted. In v0.8 and below, the .htaccess ' . + 'always passed the filename of the image to the script through the query string. ' . + 'It however seems that passing through an environment variable instead works just fine. ' . + 'As passing it through the query string can cause some firewalls to block the request, we no longer do this per default.' +); +echo ''; +echo ''; +echo ''; diff --git a/lib/options/options/redirection-rules/enable-redirection-to-converter.inc b/lib/options/options/redirection-rules/enable-redirection-to-converter.inc new file mode 100644 index 0000000..a5cd8af --- /dev/null +++ b/lib/options/options/redirection-rules/enable-redirection-to-converter.inc @@ -0,0 +1,25 @@ + + + Enable redirection to converter? + This will add rules in the .htaccess that redirects images (jpeg/png) to the conversion script ("webp-on-demand.php") ' . + 'for browsers that supports webp.

      ' . + '

      If the script detects that the webp already exists, and it is smaller and newer than the original, the ' . + 'webp is served directly. Otherwise the original image is converted and served.

      ' . + '

      The redirect rule is placed below the rule that redirects directly to existing ' . + 'webp files, which means that conversion will only be triggered once.

      ' + ); ?> + + + + value="true" + type="checkbox" + > + + + diff --git a/lib/options/options/redirection-rules/enable-redirection-to-webp-realizer.inc b/lib/options/options/redirection-rules/enable-redirection-to-webp-realizer.inc new file mode 100644 index 0000000..8b82be7 --- /dev/null +++ b/lib/options/options/redirection-rules/enable-redirection-to-webp-realizer.inc @@ -0,0 +1,32 @@ + + + Create webp files upon request? + Enabling this option will add lines in the .htaccess which redirects requests for non-existing webp-files to ' . + 'the converter script (webp-realizer.php). ' . + 'This way you can reference webps before they actually exists.

      ' . + '

      The feature works the following way:' . + '

        ' . + '
      1. WebP adds rules in the .htaccess that redirects requests for non-existing webp files to webp-realizer.php
      2. ' . + '
      3. webp-realizer.php looks for a corresponding jpg/png. ' . + 'If found, it is converted, saved and served. In case no corresponding jpg/png is found, a 404 is issued
      4. ' . + '
      ' . + '

      ' . + 'This only happens once per image. The next time the webp is requested, the rule will not trigger because the webp now exists.' +// '

      This feature allows you to reference webp images before they actually exists. You can ie write:' . +// "

      <picture>\n  <source srcset=\"image.jpg.webp\" type=\"image/webp\" />\n  <img src=\"image.jpg\" /></picture>"
      +
      +        ); ?>
      +    
      +    
      +        
      +            value="true"
      +            type="checkbox"
      +        >
      +        
      +    
      +
      diff --git a/lib/options/options/redirection-rules/only-redirect-to-converter-for-webp-enabled-browsers.inc b/lib/options/options/redirection-rules/only-redirect-to-converter-for-webp-enabled-browsers.inc
      new file mode 100644
      index 0000000..da980c2
      --- /dev/null
      +++ b/lib/options/options/redirection-rules/only-redirect-to-converter-for-webp-enabled-browsers.inc
      @@ -0,0 +1,16 @@
      +
      +    
      +    
      +    
      +        Only redirect to converter for webp-enabled browsers?Accept header contains "image/webp"'
      +        ); ?>
      +        
      +            value="true"
      +            type="checkbox"
      +        >
      +    
      +
      diff --git a/lib/options/options/redirection-rules/only-redirect-to-converter-on-cache-miss.inc b/lib/options/options/redirection-rules/only-redirect-to-converter-on-cache-miss.inc
      new file mode 100644
      index 0000000..a3f779b
      --- /dev/null
      +++ b/lib/options/options/redirection-rules/only-redirect-to-converter-on-cache-miss.inc
      @@ -0,0 +1,18 @@
      +
      +    
      +    
      +    
      +        Only redirect to converter if no webp exists This extra condition is not needed if you enabled the ' .
      +                'Redirect directly to converted image when available option.

      ' . + '

      The option was created in order to make it possible to achieve the functionality behind the ' . + 'Redirect requests for non-existing webp-files to converter option found in the ' . + '"CDN friendly" operation mode.

      ' + ); + ?> + > + + diff --git a/lib/options/options/redirection-rules/redirect-to-existing.inc b/lib/options/options/redirection-rules/redirect-to-existing.inc new file mode 100644 index 0000000..feefe88 --- /dev/null +++ b/lib/options/options/redirection-rules/redirect-to-existing.inc @@ -0,0 +1,40 @@ + + + This will add rules in the .htaccess that redirects directly to existing converted files, for ' . + 'browsers that supports webp.

      ' . + '

      The rule is placed above the rule that redirects to the converter.

      ' + ); + } + + ?> + + + > + + + + '; + include_once __DIR__ . '/../serve-options/cache-control.inc'; + echo '
      '; + }*/ + ?> + + diff --git a/lib/options/options/redirection-rules/redirection-rules.inc b/lib/options/options/redirection-rules/redirection-rules.inc new file mode 100644 index 0000000..7a428ee --- /dev/null +++ b/lib/options/options/redirection-rules/redirection-rules.inc @@ -0,0 +1,67 @@ +
      + +

      .htaccess rules for webp generation

      +

      + Usually, in this operation mode, you would enable the two first options, which effectively + enables the varied image responses (such that a request for a jpeg/png will result in a webp on browsers that supports webp). + The first option makes this happen for images that are already converted. The second option makes it happen even for + images that has not yet been converted, by redirecting the request to the converter, which converts, saves and delivers.

      +

      The third option is only relevant if you are using Alter HTML and want to reference webps that haven't been converted yet.

      + +

      .htaccess rules for webp generation

      + +

      Redirecting jpeg/png to existing webp (varied image response)

      +

      + Enabling this adds rules to the .htaccess which internally redirects jpg/pngs to webp + and sets the Vary:Accept response header. + Beware that special attention is needed if you are using a CDN (see FAQ). +

      + + If a webp already exists, it is served immediately. Otherwise it is converted and then served. +

      Redirection rules

      +
      The options here creates redirection rules in the .htaccess. The two first are used to If you are planning to serve You do not have to enable any of them, + as you can rely solely on altering enable any of them, you Disabling The options here affects the rules created in the .htaccess.
      + + + + + + +
      + +
      diff --git a/lib/options/options/serve-options/response-on-failure.inc b/lib/options/options/serve-options/response-on-failure.inc new file mode 100644 index 0000000..21fbfed --- /dev/null +++ b/lib/options/options/serve-options/response-on-failure.inc @@ -0,0 +1,16 @@ +Response on failure'; +echo helpIcon('Determines what to serve in case the image conversion should fail.'); +echo ''; + +$fail = $config['fail']; +echo ''; +echo ''; +// echo 'Determines what the converter should serve, in case the image conversion should fail. For production servers, recommended value is "Original image". For development servers, choose anything you like, but that'; diff --git a/lib/options/options/serve-options/response-on-success.inc b/lib/options/options/serve-options/response-on-success.inc new file mode 100644 index 0000000..74a0135 --- /dev/null +++ b/lib/options/options/serve-options/response-on-success.inc @@ -0,0 +1,16 @@ +Response on success'; +echo helpIcon( + '

      Determines what to serve when conversion is a success. If you are using the Cache Enabler plugin, set to "Original image", ' . + 'otherwise you would normally set it to "Converted image".

      ' . + '

      If set to "Converted image", a Vary:Accept header will be sent to indicate that the response depends on the Accept header ' . + '(which indicates if a browser supports webp images or not)

      If set to "Original image", make sure to disable the "Redirect ' . + 'directly to converted image when available" option in the Redirect rules

      '); +echo ''; + +$successResponse = $config['success-response']; +echo ''; +echo ''; diff --git a/lib/options/options/serve-options/serve-options.inc b/lib/options/options/serve-options/serve-options.inc new file mode 100644 index 0000000..93c6d95 --- /dev/null +++ b/lib/options/options/serve-options/serve-options.inc @@ -0,0 +1,16 @@ + +
      +

      Serve options

      +

      The options here affects how the image is served after a successful / unsuccessful conversion

      + + + + +
      +
      + diff --git a/lib/options/options/web-service-options/web-service-options.inc b/lib/options/options/web-service-options/web-service-options.inc new file mode 100644 index 0000000..36692f0 --- /dev/null +++ b/lib/options/options/web-service-options/web-service-options.inc @@ -0,0 +1,10 @@ +
      +

      Web service

      + + + + +
      +
      diff --git a/lib/options/options/web-service-options/web-service.inc b/lib/options/options/web-service-options/web-service.inc new file mode 100644 index 0000000..09eeed5 --- /dev/null +++ b/lib/options/options/web-service-options/web-service.inc @@ -0,0 +1,67 @@ +window.whitelist = ' . json_encode($whitelist) . ''; +?> + + Enable web service? + + > + +
      +
      +

      Authorize website

      +

      Edit authorized website

      + + +
      + + +
      +
      + + +
      +
      + + + Change api key +
      +
      + + +
      +

      Psst: The endpoint of the web service is:

      + + +
      + + diff --git a/lib/options/page-messages.php b/lib/options/page-messages.php new file mode 100644 index 0000000..c8d5a49 --- /dev/null +++ b/lib/options/page-messages.php @@ -0,0 +1,374 @@ +experiments->is_feature_active( 'e_optimized_css_loading' ) === false) { + if ($config['redirect-to-existing-in-htaccess'] === false) { + DismissableMessages::addDismissableMessage('0.23.0/elementor'); + } + } + } catch (\Exception $e) { + // Well, just bad luck. + } +} +*/ + +if (($config['operation-mode'] == 'cdn-friendly') && !$config['alter-html']['enabled']) { + //echo print_r(get_option('cache-enabler'), true); + + if ($cacheEnablerActivated) { + if ($webpEnabled) { + Messenger::printMessage( + 'info', + 'You should consider enabling Alter HTML. This is not necessary, as you have Cache Enabler enabled, which alters HTML. ' . + 'However, it is a good idea because currently Cache Enabler does not replace as many URLs as WebP Express (ie ' . + 'background images in inline styles)' + ); + } + + } else { + Messenger::printMessage( + 'warning', + 'You are in CDN friendly mode but have not enabled Alter HTML (and you are not using Cache Enabler either). ' . + 'This is usually a misconfiguration because in this mode, the only way to get webp files delivered ' . + 'is by referencing them in the HTML.' + ); + + } +} + +/* +if (!$anyRedirectionToConverterEnabled && ($config['operation-mode'] == 'cdn-friendly')) { + // this can not happen in varied image responses. it is ok in no-conversion, and also tweaked, because one could wish to tweak the no-conversion mode + Messenger::printMessage( + 'warning', + 'You have not enabled any of the redirects to the converter. ' . + 'At least one of the redirects is required for triggering WebP generation.' + ); +}*/ + +if ($config['alter-html']['enabled'] && !$config['alter-html']['only-for-webps-that-exists'] && !$config['enable-redirection-to-webp-realizer']) { + Messenger::printMessage( + 'warning', + 'You have configured Alter HTML to make references to WebP files that are yet to exist, ' . + 'but you have not enabled the option that makes these files come true when requested. Do that!' + ); +} + +if ($config['enable-redirection-to-webp-realizer'] && $config['alter-html']['enabled'] && $config['alter-html']['only-for-webps-that-exists']) { + Messenger::printMessage( + 'warning', + 'You have enabled the option that redirects requests for non-existing webp files to the converter, ' . + 'but you have not enabled the option to point to these in Alter HTML. Please do that!' + ); +} + +if ($config['image-types'] == 3) { + $workingConverters = ConvertersHelper::getWorkingAndActiveConverters($config); + if (count($workingConverters) == 1) { + if (ConvertersHelper::getConverterId($workingConverters[0]) == 'gd') { + if (isset($workingConverters[0]['options']['skip-pngs']) && $workingConverters[0]['options']['skip-pngs']) { + Messenger::printMessage( + 'warning', + 'You have enabled PNGs, but configured Gd to skip PNGs, and Gd is your only active working converter. ' . + 'This is a bad combination!' + ); + } + } + } +} + + + + +/* +if (Config::isConfigFileThereAndOk() ) { // && PlatformInfo::definitelyGotModEnv() + if (!isset($_SERVER['HTACCESS'])) { + Messenger::printMessage( + 'warning', + "Using rewrite rules in .htaccess files seems to be disabled " . + "(The AllowOverride directive is probably set to None. " . + "It needs to be set to All, or at least FileInfo to allow rewrite rules in .htaccess files.)
      " . + "Disabled .htaccess files is actually a good thing, both performance-wise and security-wise.
      " . + "But it means you will have to insert the following rules into your apache configuration manually:" . + "
      " . htmlentities(print_r(Config::hmmm(), true)) . "
      " + ); + } +}*/ +if (!Paths::createContentDirIfMissing()) { + Messenger::printMessage( + 'error', + 'WebP Express needs to create a directory "webp-express" under your wp-content folder, but does not have permission to do so.
      ' . + 'Please create the folder manually, or change the file permissions of your wp-content folder (failed to create this folder: ' . + esc_html(Paths::getWebPExpressContentDirAbs()) . ')' + ); +} else { + if (!Paths::createConfigDirIfMissing()) { + Messenger::printMessage( + 'error', + 'WebP Express needs to create a directory "webp-express/config" under your wp-content folder, but does not have permission to do so.
      ' . + 'Please create the folder manually, or change the file permissions.' + ); + } + + if (!Paths::createCacheDirIfMissing()) { + Messenger::printMessage( + 'error', + 'WebP Express needs to create a directory "webp-express/webp-images" under your wp-content folder, but does not have permission to do so.
      ' . + 'Please create the folder manually, or change the file permissions.' + ); + } +} + +if (Config::isConfigFileThere()) { + if (!Config::isConfigFileThereAndOk()) { + $json = FileHelper::loadFile(Paths::getConfigFileName()); + if ($json === false) { + Messenger::printMessage( + 'warning', + 'Warning: The configuration file is not ok! (cant be read).
      ' . + 'file: "' . esc_html(Paths::getConfigFileName()) . '"' + ); + } else { + Messenger::printMessage( + 'warning', + 'Warning: The configuration file is not ok! (not valid json).
      ' . + 'file: "' . esc_html(Paths::getConfigFileName()) . '"' + ); + } + + } else { + + if ($config['redirect-to-existing-in-htaccess']) { + if (PlatformInfo::isApacheOrLiteSpeed() && !(HTAccessCapabilityTestRunner::modHeaderWorking())) { + Messenger::printMessage( + 'warning', + 'It seems your server setup does not support headers in .htaccess. You should either fix this (install mod_headers) or ' . + 'deactivate the "Enable direct redirection to existing converted images?" option. Otherwise the Vary:Accept header ' . + 'will not be added and this can result in problems for users behind proxy servers (ie used in larger companies)' + ); + } + } + + $anyRedirectionToConverterEnabled = (($config['enable-redirection-to-converter']) || ($config['enable-redirection-to-webp-realizer'])); + $anyRedirectionEnabled = ($anyRedirectionToConverterEnabled || $config['redirect-to-existing-in-htaccess']); + + if ($anyRedirectionEnabled) { + if (PlatformInfo::isApacheOrLiteSpeed() && PlatformInfo::definitelyNotGotModRewrite()) { + Messenger::printMessage( + 'warning', + "Rewriting isn't enabled on your server. " . + 'You must either switch to "CDN friendly" mode or enable rewriting. ' . + "Tell your host or system administrator to enable the 'mod_rewrite' module. " . + 'If you are on a shared host, chances are that mod_rewrite can be turned on in your control panel.' + ); + } + } + + if ($anyRedirectionToConverterEnabled) { + $canRunInWod = HTAccessCapabilityTestRunner::canRunTestScriptInWOD(); + $canRunInWod2 = HTAccessCapabilityTestRunner::canRunTestScriptInWOD2(); + if (!$canRunInWod && !$canRunInWod2) { + $turnedOn = []; + if ($config['enable-redirection-to-converter']) { + $turnedOn[] = '"Enable redirection to converter"'; + } + if ($config['enable-redirection-to-webp-realizer']) { + $turnedOn[] = '"Create webp files upon request?""'; + } + Messenger::printMessage( + 'warning', + '

      You have turned on ' . implode(' and ', $turnedOn) . + '. However, ' . (count($turnedOn) == 2 ? 'these features' : 'this feature') . + ' does not work on your current server settings / wordpress setup, ' . + ' because the PHP scripts in the plugin folder (in the "wod" and "wod2" subfolders) fails to run ' . + ' when requested directly. You can try to fix the problem or simply turn ' . + (count($turnedOn) == 2 ? 'them' : 'it') . + ' off and rely on "Convert on upload" and "Bulk Convert" to get the images converted.

      ' . + '

      If you are going to try to solve the problem, you need at least one of the following pages ' . + 'to display "pong": ' . + 'wod-test' . + ' or wod2-test' . + '. The problem will typically be found in the server configuration or a security plugin. ' . + 'If one of the links results in a 403 Permission denied, look out for "deny" and "denied" in ' . + 'httpd.conf, /etc/apache/sites-enabled/your-site.conf and in parent .htaccess files.' . + '

      .' + ); + } + // We currently allow the "canRunTestScriptInWOD" test not to be stored, + // If it is not stored, it means .htaccess files are pointing to "wod" + // PS: the logic of where it is stored happens in HTAccessRules::getWodUrlPath + // - we mimic it here. + $pointingToWod = true; // true = pointing to "wod", false = pointing to "wod2" + $hasWODTestBeenRun = isset($storedCapTests['canRunTestScriptInWOD']); + if ($hasWODTestBeenRun && !($storedCapTests['canRunTestScriptInWOD'])) { + $pointingToWod = false; + } + $canOnlyRunInWod = $canRunInWod && !$canRunInWod2; + if ($canOnlyRunInWod && !$pointingToWod) { + Messenger::printMessage( + 'warning', + 'The conversion script cannot currently be run. ' . + 'However, simply click "Save settings and force new .htaccess rules" to fix it. ' . + '(this will point to the script in the "wod" folder rather than "wod2")' + ); + } + + $canOnlyRunInWod2 = $canRunInWod2 && !$canRunInWod; + if ($canOnlyRunInWod2 && $pointingToWod) { + Messenger::printMessage( + 'warning', + 'The conversion script cannot currently be run. ' . + 'However, simply click "Save settings and force new .htaccess rules" to fix it. ' . + '(this will point to the script in the "wod2" folder rather than "wod")' + ); + } + + } + + if (HTAccessRules::arePathsUsedInHTAccessOutdated()) { + + $pathsGoingToBeUsedInHtaccess = [ + 'wod-url-path' => Paths::getWodUrlPath(), + ]; + + $config2 = Config::loadConfig(); + if ($config2 === false) { + Messenger::printMessage( + 'warning', + 'Warning: Config file cannot be loaded. Perhaps clicking ' . + 'Save settings will solve it
      ' + ); + } + + $warningMsg = 'Warning: Wordpress paths have changed since the last time the Rewrite Rules was generated. The rules ' . + 'needs updating! (click Save settings to do so)

      ' . + 'The following have changed:
      '; + + foreach ($config2['paths-used-in-htaccess'] as $prop => $value) { + if (isset($pathsGoingToBeUsedInHtaccess[$prop])) { + if ($value != $pathsGoingToBeUsedInHtaccess[$prop]) { + $warningMsg .= '- ' . $prop . '(was: ' . $value . '- but is now: ' . $pathsGoingToBeUsedInHtaccess[$prop] . ')
      '; + } + } + } + + + Messenger::printMessage( + 'warning', + $warningMsg + ); + } + } +} + +$haveRulesInIndexDir = HTAccess::haveWeRulesInThisHTAccessBestGuess(Paths::getIndexDirAbs() . '/.htaccess'); +$haveRulesInContentDir = HTAccess::haveWeRulesInThisHTAccessBestGuess(Paths::getContentDirAbs() . '/.htaccess'); + +if ($haveRulesInIndexDir && $haveRulesInContentDir) { + // TODO: Use new method for determining if htaccess contains rules. + // (either haveWeRulesInThisHTAccessBestGuess($filename) or haveWeRulesInThisHTAccess($filename)) + if (!HTAccess::saveHTAccessRulesToFile(Paths::getIndexDirAbs() . '/.htaccess', '# WebP Express has placed its rules in your wp-content dir. Go there.', false)) { + Messenger::printMessage( + 'warning', + 'Warning: WebP Express have rules in both your wp-content folder and in your Wordpress folder.
      ' . + 'Please remove those in the .htaccess in your Wordress folder manually, or let us handle it, by granting us write access' + ); + } +} + +$ht = FileHelper::loadFile(Paths::getIndexDirAbs() . '/.htaccess'); +if ($ht !== false) { + $posWe = strpos($ht, '# BEGIN WebP Express'); + $posWo = strpos($ht, '# BEGIN WordPress'); + if (($posWe !== false) && ($posWo !== false) && ($posWe > $posWo)) { + + $haveRulesInIndexDir = HTAccess::haveWeRulesInThisHTAccessBestGuess(Paths::getIndexDirAbs() . '/.htaccess'); + if ($haveRulesInIndexDir) { + Messenger::printMessage( + 'warning', + 'Problem detected. ' . + 'In order for the "Convert non-existing webp-files upon request" functionality to work, you need to either:
      ' . + '- Move the WebP Express rules above the Wordpress rules in the .htaccess file located in your root dir
      ' . + '- Grant the webserver permission to your wp-content dir, so it can create its rules there instead.' + ); + } + } +} diff --git a/lib/options/page-welcome.php b/lib/options/page-welcome.php new file mode 100644 index 0000000..4782eb5 --- /dev/null +++ b/lib/options/page-welcome.php @@ -0,0 +1,160 @@ +'; +echo '

      Welcome!

      '; + +//if ($localQualityDetectionWorking) { + //echo 'Local quality detection working :)'; +//} + +if ($weKnowThereAreNoWorkingConverters) { + // server does not meet the requirements for converting images to webp without resorting to cloud conversion + echo '

      Unfortunately your server cannot convert webp files in PHP without resorting to cloud conversion.

      ' . + '

      But do not despear! - You have options!

      ' . + '
        ' . + '
      1. You can install this plugin on another website, which supports a "local" webp conversion method and connect to that using the "Remote WebP Express" conversion method' . + '
      2. You can purchase a key for the ewww cloud converter. They do not charge credits for webp conversions, so all you ever have to pay is the one dollar start-up fee :)
      3. ' . + '
      4. I have written a template letter which you can try sending to your webhost
      5. ' . + '
      6. You can set up a webp-convert-cloud-service on another server and connect to that. Its open source.
      7. ' . + '
      8. You can try to meet the server requirements of cwebp, imagick, vips, gmagick, ffmpeg or gd. Check out this wiki page on how to do that
      9. ' . + '
      ' . + '

      Of course, there is also the option of using another plugin altogether. ' . + 'I can recommend Optimole. ' . + 'If you want to try that out and want to support me in the process, ' . + 'follow this link ' . + '(it will give me a reward in case you decide to sign up).' . + '

      ' . + "

      Btw, don't worry, your images still works. The rewrite rules will not be saved until you click the " . + '"Save settings" button.

      '; + //'(and you also have "Response on failure" set to "Original image", so they will work even if you click save)

      '; +} else { + echo '

      The rewrite rules are not active yet. They will be activated the first time you click the "Save settings" button.

      '; +} + +//echo 'working converters:'; +//print_r($workingConverters); + +//echo '

      Before you do that, I suggest you find out which converters that works. Start from the top. Click "test" next to a converter to test it. Try also clicking the "configure" buttons

      '; + +/* +if (Paths::isWPContentDirMovedOutOfAbsPath()) { + if (!Paths::canWriteHTAccessRulesHere($wpContentDir)) { + echo '

      Oh, one more thing. Unless you are going to put the rewrite rules into your configuration manually, '; + echo 'WebP Express would be needing to store the rewrite rules in a .htaccess file in your wp-content directory '; + echo '(we need to store them there rather than in your root, because you have moved your wp-content folder out of the Wordpress root). '; + echo 'Please adjust the file permissions of your wp-content dir. '; + + if (Paths::isPluginDirMovedOutOfWpContent()) { + echo '
      But that is not all. Besides moving your wp-content dir, you have also moved your plugin dir... '; + echo 'If you want WebP-Express to work on the images delivered by your plugins, you must also grant write access to your plugin dir (you can revoke the access after we have written the rules).
      '; + } + echo 'You can reload this page aftewards, and this message should be gone

      '; + } else { + if (Paths::isPluginDirMovedOutOfWpContent()) { + echo '

      Oh, one more thing. I can see that your plugin dir has been moved out of your wp-content folder. '; + echo 'If you want WebP-Express to work on the images delivered by your plugins, you must grant write access to your '; + echo 'plugin dir (you can revoke the access after we have written the rules, but beware that the plugin may need '; + echo 'access rights again. Some of the options affects the .htaccess rules. And WebP Express also have to remove the rules if the plugin is disabled)'; + } + } + if (Paths::isUploadDirMovedOutOfWPContentDir()) { + if (!Paths::canWriteHTAccessRulesHere($uploadDir)) { + echo '

      Oh, one more thing. We also need to write rules to your uploads dir (because you have moved it). '; + echo 'Please grant us write access to your '; + if (FileHelper::fileExists($uploadDir . '/.htaccess')) { + echo '.htaccess file in your upload dir'; + } else { + echo 'upload dir, so we can plant an .htaccess there'; + } + echo '. Your upload dir is: ' . $uploadDir . '. '; + echo '- Or alternatively, you can leave it be and update the rules manually, whenever they need to be changed. '; + } + } +} else { + $firstWritable = Paths::returnFirstWritableHTAccessDir([$wpContentDir, $indexDir]); + if ($firstWritable === false) { + echo '

      Oh, one more thing. Unless you are going to put the rewrite rules into your configuration manually, '; + echo 'WebP Express would be needing to store the rewrite rules in a .htaccess file. '; + echo 'However, your current file permissions does not allow that. '; + echo 'WebP Express would prefer to put the rewrite rules into your wp-content folder, but '; + echo 'will store them in your main .htaccess file if it can write to that, but not your wp-content. '; + echo '(The preference for storing in wp-content is simply that it minimizes the risk of conflicts with rules from other plugins. '; + echo 'deeper .htaccess files takes precedence). '; + echo 'Anyway: Could you please adjust the file permissions of either your main .htaccess file or your wp-content dir?'; + echo 'You can reload this page aftewards, and this message should be gone

      '; + } else { + if ($firstWritable != $wpContentDir) { + echo '

      Oh, one more thing. Unless you are going to put the rewrite rules into your configuration manually, '; + echo 'WebP Express would be needing to store the rewrite rules in a .htaccess file. '; + echo 'Your current file permissions does allow us to store rules in your main .htaccess file. '; + echo 'However, WebP Express would prefer to put the rewrite rules into your wp-content folder. '; + echo 'Putting them there will minimize the risk of conflict with rules from other plugins, as '; + echo 'deeper .htaccess files takes precedence. '; + echo 'If you would like the .htaccess file to be stored in your wp-content folder, please adjust your file permissions. '; + echo 'You can reload this page aftewards, and this message should be gone

      '; + } + } + if (Paths::isUploadDirMovedOutOfWPContentDir()) { + if (!Paths::canWriteHTAccessRulesHere($uploadDir)) { + echo '

      Oh, one more thing. We also need to write rules to your uploads dir (because you have moved it). '; + echo 'Please grant us write access to your '; + if (FileHelper::fileExists($uploadDir . '/.htaccess')) { + echo '.htaccess file in your upload dir'; + } else { + echo 'upload dir, so we can plant an .htaccess there'; + } + echo '. Your upload dir is: ' . $uploadDir . '. '; + echo '- Or alternatively, you can leave it and update the rules manually, whenever they need to be changed. '; + } + } + if (Paths::isPluginDirMovedOutOfAbsPath()) { + if (!Paths::canWriteHTAccessRulesHere($pluginDir)) { + echo '

      Oh, one more thing. I see you have moved your plugins dir out of your root. '; + echo 'If you want WebP-Express to work on the images delivered by your plugins, you must also grant write access '; + echo 'to your '; + if (FileHelper::fileExists($pluginDir . '/.htaccess')) { + echo '.htaccess file in your plugin dir'; + } else { + echo 'plugin dir, so we can plant an .htaccess there'; + } + echo ' (you can revoke the access after we have written the rules).'; + echo '

      '; + } + } +} +*/ +/* +if(Paths::canWriteHTAccessRulesHere($wpContentDir)) { + + if ($firstWritable === false) { + echo 'Actually, WebP Express does not have permission to write to your main .htaccess either. Please fix. Preferably '; + } + + + $firstWritable = Paths::returnFirstWritableHTAccessDir([$indexDir, $homeDir]); + if ($firstWritable === false) { + echo 'Actually, WebP Express does not have permission to write to your main .htaccess either. Please fix. Preferably '; + } + if(Paths::canWriteHTAccessRulesHere($wpContentDir)) { + echo 'WebP Express however does have rights to write to your main .htaccess. It will work too - probably. But to minimize risk of conflict with rules from other plugins, I recommended you to adjust the file permissions to allow us to write to a .htaccess file in your wp-content dir'; + } + echo '

      '; +}*/ + +echo ''; diff --git a/lib/options/page.php b/lib/options/page.php new file mode 100644 index 0000000..f4e0e33 --- /dev/null +++ b/lib/options/page.php @@ -0,0 +1,261 @@ + +
      +

      WebP Express Settings

      + + +
      + + +
      +
      + + +
      +'; + //Paths::canWriteHTAccessRulesHere($dir); +}*/ + + +//echo '
      ' . print_r($config['converters'], true) . '
      '; + +//echo 'Working converters:' . print_r($workingConverters, true) . '
      '; +// Generate a custom nonce value. +$webpexpressSaveSettingsNonce = wp_create_nonce('webpexpress-save-settings-nonce'); +?> + +'; +?> + + + +
      + + + + + +
      +
      + 150) { + if (strlen($text) > 300) { + if (strlen($text) > 500) { + if (strlen($text) > 1000) { + $className = 'widest'; + } else { + $className = 'even-wider'; + } + } else { + $className = 'wider'; + } + } else { + $className = 'wide'; + } + } + return '
      ?
      '; +} + +function webpexpress_selectBoxOptions($selected, $options) { + foreach ($options as $optionValue => $text) { + echo ''; + } +} + +function webpexpress_radioButton($optionName, $optionValue, $label, $selectedValue, $helpText = null) { + $id = esc_attr(str_replace('-', '_', $optionName . '_' . $optionValue)); + echo ''; + echo ''; +} + +function webpexpress_radioButtons($optionName, $selected, $options, $helpTexts = [], $style='margin-left: 20px; margin-top: 5px') { + echo '
        '; + foreach ($options as $optionValue => $label) { + echo '
      • '; + webpexpress_radioButton($optionName, $optionValue, $label, $selected, isset($helpTexts[$optionValue]) ? $helpTexts[$optionValue] : null); + echo '
      • '; + } + echo '
      '; +} + +function webpexpress_checkbox($optionName, $checked, $label, $helpText = '') { + $id = esc_attr(str_replace('-', '_', $optionName)); + echo '
      '; + echo ''; + echo ''; + if ($helpText != '') { + echo helpIcon($helpText); + } + echo '
      '; + +} + +include_once 'options/operation-mode.inc'; +include_once 'options/general/general.inc'; + + +/* +idea: + +$options = [ + 'tweaked' => [ + 'general' => [ + 'image-types', + 'destination-folder', + 'destination-extension', + 'cache-control' + ] + ], + ... +]; +*/ + + +if ($config['operation-mode'] != 'tweaked') { +// echo '
      '; +// echo ''; +} + +if ($config['operation-mode'] == 'no-conversion') { + + // General + /* + echo ''; + include_once 'options/conversion-options/destination-extension.inc'; + include_once 'options/general/image-types.inc'; + */ + + include_once 'options/redirection-rules/redirection-rules.inc'; + include_once 'options/alter-html/alter-html.inc'; +} else { + include_once 'options/redirection-rules/redirection-rules.inc'; + include_once 'options/conversion-options/conversion-options.inc'; + //include_once 'options/conversion-options/destination-extension.inc'; + include_once 'options/serve-options/serve-options.inc'; + + include_once 'options/alter-html/alter-html.inc'; + +/* + if ($config['operation-mode'] == 'cdn-friendly') { + include_once 'options/redirection-rules/enable-redirection-to-webp-realizer.inc'; + + // ps: we call it "auto convert", when in this mode + include_once 'options/redirection-rules/enable-redirection-to-converter.inc'; + } + + if ($config['operation-mode'] == 'varied-image-responses') { + include_once 'options/redirection-rules/enable-redirection-to-webp-realizer.inc'; + } + */ + + include_once 'options/web-service-options/web-service-options.inc'; +} + +if ($config['operation-mode'] != 'tweaked') { +// echo '
      '; + echo '

      General

      '; + echo '
      '; +// echo '
      '; +} + +?> + +
      diff --git a/lib/options/submit.php b/lib/options/submit.php new file mode 100644 index 0000000..4a212f0 --- /dev/null +++ b/lib/options/submit.php @@ -0,0 +1,807 @@ + sanitize_text_field($whitelist['label']), + 'ip' => sanitize_text_field($whitelist['ip']), + ]; + if (isset($whitelist['new-api-key'])) { + $obj['new-api-key'] = sanitize_text_field($whitelist['new-api-key']); + } + if (isset($whitelist['uid'])) { + $obj['uid'] = sanitize_text_field($whitelist['uid']); + } + if (isset($whitelist['require-api-key-to-be-crypted-in-transfer'])) { + $obj['require-api-key-to-be-crypted-in-transfer'] = ($whitelist['require-api-key-to-be-crypted-in-transfer'] === true); + } + + $whitelistSanitized[] = $obj; + } + } + return $whitelistSanitized; +} + +/** + * Get sanitized converters. + * + * @return array Sanitized array of the converters json array received in $_POST + */ +function webpexpress_getSanitizedConverters() { + $convertersPosted = (isset($_POST['converters']) ? $_POST['converters'] : '[]'); + $convertersPosted = json_decode(wp_unslash($convertersPosted), true); // holy moly! Wordpress automatically adds slashes to the global POST vars- https://stackoverflow.com/questions/2496455/why-are-post-variables-getting-escaped-in-php + + $convertersSanitized = []; + + // Get list of possible converter ids. + $availableConverterIDs = ConvertersHelper::getDefaultConverterNames(); + + // Add converters one at the time. + foreach ($convertersPosted as $unsanitizedConverter) { + if (!isset($unsanitizedConverter['converter'])) { + continue; + } + + // Only add converter if its ID is a known converter. + if (!in_array($unsanitizedConverter['converter'], $availableConverterIDs)) { + continue; + } + + $sanitizedConverter = []; + $sanitizedConverter['converter'] = $unsanitizedConverter['converter']; + + // Sanitize and add expected fields ("options", "working", "deactivated" and "error") + + // "options" + if (isset($unsanitizedConverter['options'])) { + $sanitizedConverter['options'] = []; + + // Sanitize all (string) options individually + foreach ($unsanitizedConverter['options'] as $optionName => $unsanitizedOptionValue) { + + $acceptedOptions = [ + // vips + 'smart-subsample' => 'boolean', + 'preset' => 'string', + + // gd + 'skip-pngs' => 'boolean', + + // in multiple + "use-nice" => 'boolean', + + // cwebp + "try-common-system-paths" => 'boolean', + "try-supplied-binary-for-os" => 'boolean', + "skip-these-precompiled-binaries" => 'string', + "method" => 'integer', // 0-6, + "size-in-percentage" => 'integer', // 0-100 + "low-memory" => 'boolean', + "command-line-options" => 'string', // webp-convert takes care of sanitizing this very carefully! + "set-size" => 'boolean', + + // wpc + "api-url" => 'string', + "api-version" => 'integer', + "crypt-api-key-in-transfer" => 'boolean', + "new-api-key" => 'string', + + //ewww + "api-key" => 'string', + "api-key-2" => 'string', + ]; + + // check that it is an accepted option name + if (!isset($acceptedOptions[$optionName])) { + continue; + } + + // check that type is as expected + $expectedType = $acceptedOptions[$optionName]; + if (gettype($unsanitizedOptionValue) != $expectedType) { + continue; + } + if ($expectedType == 'string') { + $sanitizedOptionValue = sanitize_text_field($unsanitizedOptionValue); + } else { + // integer and boolean are completely safe! + $sanitizedOptionValue = $unsanitizedOptionValue; + } + if (($optionName == "size-in-percentage") && ($sanitizedOptionValue == '')) { + continue; + } + $sanitizedConverter['options'][$optionName] = $sanitizedOptionValue; + + } + } + + // "working" (bool) + if (isset($unsanitizedConverter['working'])) { + $sanitizedConverter['working'] = ($unsanitizedConverter['working'] === true); + } + + // "deactivated" (bool) + if (isset($unsanitizedConverter['deactivated'])) { + $sanitizedConverter['deactivated'] = ($unsanitizedConverter['deactivated'] === true); + } + + $convertersSanitized[] = $sanitizedConverter; + } + + return $convertersSanitized; +} + +/** + * Get sanitized converters. + * + * @return array Sanitized array of the converters json array received in $_POST + */ +function webpexpress_getSanitizedAlterHtmlHostnameAliases() { + $index = 0; + + $result = []; + while (isset($_POST['alter-html-hostname-alias-' . $index])) { + $alias = webpexpress_getSanitizedText('alter-html-hostname-alias-' . $index, ''); + $alias = preg_replace('#^https?\\:\\/\\/#', '', $alias); + //$alias .= 'hm'; + if ($alias != '') { + $result[] = $alias; + } + $index++; + } + return $result; +} + +/* +------------------------------------------------------ + +Create a sanitized object from the POST data +It reflects the POST data - it has same keys and values - except that the values have been sanitized. + +After this, there must be no more references to $_POST +------------------------------------------------------ +*/ + + +// Sanitizing +$sanitized = [ + // Force htaccess rules + 'force' => isset($_POST['force']), + + + // Operation mode + // -------------- + // Note that "operation-mode" is actually the old mode. The new mode is posted in "change-operation-mode" + 'operation-mode' => webpexpress_getSanitizedChooseFromSet('operation-mode', 'varied-image-responses', [ + 'varied-image-responses', + 'cdn-friendly', + 'no-conversion', + 'tweaked' + ]), + 'change-operation-mode' => webpexpress_getSanitizedChooseFromSet('change-operation-mode', 'varied-image-responses', [ + 'varied-image-responses', + 'cdn-friendly', + 'no-conversion', + 'tweaked' + ]), + + + // General + // -------- + 'image-types' => intval(webpexpress_getSanitizedChooseFromSet('image-types', '3', [ + '0', + '1', + '2', + '3' + ])), + 'scope' => webpexpress_getSanitizedScope(), + 'destination-folder' => webpexpress_getSanitizedChooseFromSet('destination-folder', 'separate', [ + 'separate', + 'mingled', + ]), + 'destination-extension' => webpexpress_getSanitizedChooseFromSet('destination-extension', 'append', [ + 'append', + 'set', + ]), + 'destination-structure' => webpexpress_getSanitizedChooseFromSet('destination-structure', 'doc-root', [ + 'doc-root', + 'image-roots', + ]), + 'cache-control' => webpexpress_getSanitizedChooseFromSet('cache-control', 'no-header', [ + 'no-header', + 'set', + 'custom' + ]), + 'cache-control-max-age' => webpexpress_getSanitizedChooseFromSet('cache-control-max-age', 'one-hour', [ + 'one-second', + 'one-minute', + 'one-hour', + 'one-day', + 'one-week', + 'one-month', + 'one-year', + ]), + 'cache-control-public' => webpexpress_getSanitizedChooseFromSet('cache-control-public', 'public', [ + 'public', + 'private', + ]), + 'cache-control-custom' => webpexpress_getSanitizedCacheControlHeader('cache-control-custom'), + 'prevent-using-webps-larger-than-original' => isset($_POST['prevent-using-webps-larger-than-original']), + + + // Redirection rules + // ----------------- + 'redirect-to-existing-in-htaccess' => isset($_POST['redirect-to-existing-in-htaccess']), + 'enable-redirection-to-converter' => isset($_POST['enable-redirection-to-converter']), + 'only-redirect-to-converter-for-webp-enabled-browsers' => isset($_POST['only-redirect-to-converter-for-webp-enabled-browsers']), + 'only-redirect-to-converter-on-cache-miss' => isset($_POST['only-redirect-to-converter-on-cache-miss']), + 'do-not-pass-source-in-query-string' => isset($_POST['do-not-pass-source-in-query-string']), + 'enable-redirection-to-webp-realizer' => isset($_POST['enable-redirection-to-webp-realizer']), + + + // Conversion options + // ------------------ + 'metadata' => webpexpress_getSanitizedChooseFromSet('metadata', 'none', [ + 'none', + 'all' + ]), + 'jpeg-encoding' => webpexpress_getSanitizedChooseFromSet('jpeg-encoding', 'auto', [ + 'lossy', + 'auto' + ]), + 'jpeg-enable-near-lossless' => webpexpress_getSanitizedChooseFromSet('jpeg-enable-near-lossless', 'on', [ + 'on', + 'off' + ]), + 'quality-auto' => webpexpress_getSanitizedChooseFromSet('quality-auto', 'auto_on', [ + 'auto_on', + 'auto_off' + ]), + 'max-quality' => webpexpress_getSanitizedQuality('max-quality', 80), + 'jpeg-near-lossless' => webpexpress_getSanitizedQuality('jpeg-near-lossless', 60), + 'quality-specific' => webpexpress_getSanitizedQuality('quality-specific', 70), + 'quality-fallback' => webpexpress_getSanitizedQuality('quality-fallback', 70), + 'png-near-lossless' => webpexpress_getSanitizedQuality('png-near-lossless', 60), + 'png-enable-near-lossless' => webpexpress_getSanitizedChooseFromSet('png-enable-near-lossless', 'on', [ + 'on', + 'off' + ]), + 'png-quality' => webpexpress_getSanitizedQuality('png-quality', 85), + 'png-encoding' => webpexpress_getSanitizedChooseFromSet('png-encoding', 'auto', [ + 'lossless', + 'auto' + ]), + 'alpha-quality' => webpexpress_getSanitizedQuality('alpha-quality', 80), + 'convert-on-upload' => isset($_POST['convert-on-upload']), + 'enable-logging' => isset($_POST['enable-logging']), + 'converters' => webpexpress_getSanitizedConverters(), + + + // Serve options + // --------------- + 'fail' => webpexpress_getSanitizedChooseFromSet('fail', 'original', [ + 'original', + '404', + 'report' + ]), + 'success-response' => webpexpress_getSanitizedChooseFromSet('success-response', 'original', [ + 'original', + 'converted', + ]), + + + // Alter html + // ---------- + 'alter-html-enabled' => isset($_POST['alter-html-enabled']), + 'alter-html-only-for-webp-enabled-browsers' => isset($_POST['alter-html-only-for-webp-enabled-browsers']), + 'alter-html-add-picturefill-js' => isset($_POST['alter-html-add-picturefill-js']), + 'alter-html-for-webps-that-has-yet-to-exist' => isset($_POST['alter-html-for-webps-that-has-yet-to-exist']), + 'alter-html-replacement' => webpexpress_getSanitizedChooseFromSet('alter-html-replacement', 'picture', [ + 'picture', + 'url' + ]), + 'alter-html-hooks' => webpexpress_getSanitizedChooseFromSet('alter-html-hooks', 'content-hooks', [ + 'content-hooks', + 'ob' + ]), + 'alter-html-hostname-aliases' => webpexpress_getSanitizedAlterHtmlHostnameAliases(), + + + // Web service + // ------------ + 'web-service-enabled' => isset($_POST['web-service-enabled']), + 'whitelist' => webpexpress_getSanitizedWhitelist(), + +]; + +if (!Paths::canUseDocRootForRelPaths()) { + $sanitized['destination-structure'] = 'image-roots'; +} + +/* +------------------------------------------------------ + +Lets begin working on the data. +Remember: Use $sanitized instead of $_POST + +------------------------------------------------------ +*/ + +$config = Config::loadConfigAndFix(false); // false, because we do not need to test if quality detection is working +$oldConfig = $config; + +// Set options that are available in all operation modes +$config = array_merge($config, [ + 'operation-mode' => $sanitized['operation-mode'], + + 'scope' => $sanitized['scope'], + 'image-types' => $sanitized['image-types'], + 'forward-query-string' => true, +]); + +// Set options that are available in ALL operation modes +$config['cache-control'] = $sanitized['cache-control']; +switch ($sanitized['cache-control']) { + case 'no-header': + break; + case 'set': + $config['cache-control-max-age'] = $sanitized['cache-control-max-age']; + $config['cache-control-public'] = ($sanitized['cache-control-public'] == 'public'); + break; + case 'custom': + $config['cache-control-custom'] = $sanitized['cache-control-custom']; + break; +} +$config['prevent-using-webps-larger-than-original'] = $sanitized['prevent-using-webps-larger-than-original']; + + +// Alter HTML +$config['alter-html'] = []; +$config['alter-html']['enabled'] = $sanitized['alter-html-enabled']; +if ($sanitized['alter-html-replacement'] == 'url') { + $config['alter-html']['only-for-webp-enabled-browsers'] = $sanitized['alter-html-only-for-webp-enabled-browsers']; +} else { + $config['alter-html']['only-for-webp-enabled-browsers'] = false; +} +if ($sanitized['alter-html-replacement'] == 'picture') { + $config['alter-html']['alter-html-add-picturefill-js'] = $sanitized['alter-html-add-picturefill-js']; +} +if ($sanitized['operation-mode'] != 'no-conversion') { + $config['alter-html']['only-for-webps-that-exists'] = (!$sanitized['alter-html-for-webps-that-has-yet-to-exist']); +} else { + $config['alter-html']['only-for-webps-that-exists'] = true; +} + +$config['alter-html']['replacement'] = $sanitized['alter-html-replacement']; +$config['alter-html']['hooks'] = $sanitized['alter-html-hooks']; +$config['alter-html']['hostname-aliases'] = $sanitized['alter-html-hostname-aliases']; + + +// Set options that are available in all operation modes, except the "no-conversion" mode +if ($sanitized['operation-mode'] != 'no-conversion') { + + $config['enable-redirection-to-webp-realizer'] = $sanitized['enable-redirection-to-webp-realizer']; + + // Metadata + // -------- + $config['metadata'] = $sanitized['metadata']; + + // Jpeg + // -------- + $config['jpeg-encoding'] = $sanitized['jpeg-encoding']; + + $auto = ($sanitized['quality-auto'] == 'auto_on'); + $config['quality-auto'] = $auto; + if ($auto) { + $config['max-quality'] = $sanitized['max-quality']; + $config['quality-specific'] = $sanitized['quality-fallback']; + } else { + $config['max-quality'] = 80; + $config['quality-specific'] = $sanitized['quality-specific']; + } + + $config['jpeg-enable-near-lossless'] = ($sanitized['jpeg-enable-near-lossless'] == 'on'); + $config['jpeg-near-lossless'] = $sanitized['jpeg-near-lossless']; + + + // Png + // -------- + $config['png-encoding'] = $sanitized['png-encoding']; + $config['png-quality'] = $sanitized['png-quality']; + $config['png-enable-near-lossless'] = ($sanitized['png-enable-near-lossless'] == 'on'); + $config['png-near-lossless'] = $sanitized['png-near-lossless']; + $config['alpha-quality'] = $sanitized['alpha-quality']; + + // Other conversion options + $config['convert-on-upload'] = $sanitized['convert-on-upload']; + $config['enable-logging'] = $sanitized['enable-logging']; + + + // Web Service + // ------------- + + $config['web-service'] = [ + 'enabled' => $sanitized['web-service-enabled'], + 'whitelist' => $sanitized['whitelist'] + ]; + + // Set existing api keys in web service (we removed them from the json array, for security purposes) + if (isset($oldConfig['web-service']['whitelist'])) { + foreach ($oldConfig['web-service']['whitelist'] as $existingWhitelistEntry) { + foreach ($config['web-service']['whitelist'] as &$whitelistEntry) { + if ($whitelistEntry['uid'] == $existingWhitelistEntry['uid']) { + $whitelistEntry['api-key'] = $existingWhitelistEntry['api-key']; + } + } + } + } + + // Set changed api keys + foreach ($config['web-service']['whitelist'] as &$whitelistEntry) { + if (!empty($whitelistEntry['new-api-key'])) { + $whitelistEntry['api-key'] = $whitelistEntry['new-api-key']; + unset($whitelistEntry['new-api-key']); + } + } + + // Converters + // ------------- + + $config['converters'] = $sanitized['converters']; + + // remove converter ids + foreach ($config['converters'] as &$converter) { + unset ($converter['id']); + } + + // Get existing wpc api key from old config + $existingWpcApiKey = ''; + foreach ($oldConfig['converters'] as &$converter) { + if (isset($converter['converter']) && ($converter['converter'] == 'wpc')) { + if (isset($converter['options']['api-key'])) { + $existingWpcApiKey = $converter['options']['api-key']; + } + } + } + + // Set wpc api key in new config + // - either to the existing, or to a new + foreach ($config['converters'] as &$converter) { + if (isset($converter['converter']) && ($converter['converter'] == 'wpc')) { + unset($converter['options']['_api-key-non-empty']); + if (isset($converter['options']['new-api-key'])) { + $converter['options']['api-key'] = $converter['options']['new-api-key']; + unset($converter['options']['new-api-key']); + } else { + $converter['options']['api-key'] = $existingWpcApiKey; + } + } + } +} + +$config['destination-structure'] = $sanitized['destination-structure']; + +switch ($sanitized['operation-mode']) { + case 'varied-image-responses': + $config = array_merge($config, [ + 'redirect-to-existing-in-htaccess' => $sanitized['redirect-to-existing-in-htaccess'], + 'destination-folder' => $sanitized['destination-folder'], + 'destination-extension' => (($sanitized['destination-folder'] == 'mingled') ? $sanitized['destination-extension'] : 'append'), + 'enable-redirection-to-converter' => $sanitized['enable-redirection-to-converter'], + ]); + break; + case 'cdn-friendly': + $config = array_merge($config, [ + 'destination-folder' => $sanitized['destination-folder'], + 'destination-extension' => (($sanitized['destination-folder'] == 'mingled') ? $sanitized['destination-extension'] : 'append'), + 'enable-redirection-to-converter' => $sanitized['enable-redirection-to-converter'], // PS: its called "autoconvert" in this mode + ]); + break; + case 'no-conversion': + $config = array_merge($config, [ + 'redirect-to-existing-in-htaccess' => $sanitized['redirect-to-existing-in-htaccess'], + 'destination-extension' => $sanitized['destination-extension'], + ]); + break; + case 'tweaked': + $config = array_merge($config, [ + 'enable-redirection-to-converter' => $sanitized['enable-redirection-to-converter'], + 'only-redirect-to-converter-for-webp-enabled-browsers' => $sanitized['only-redirect-to-converter-for-webp-enabled-browsers'], + 'only-redirect-to-converter-on-cache-miss' => $sanitized['only-redirect-to-converter-on-cache-miss'], + 'do-not-pass-source-in-query-string' => $sanitized['do-not-pass-source-in-query-string'], + 'redirect-to-existing-in-htaccess' => $sanitized['redirect-to-existing-in-htaccess'], + 'destination-folder' => $sanitized['destination-folder'], + 'destination-extension' => (($sanitized['destination-folder'] == 'mingled') ? $sanitized['destination-extension'] : 'append'), + 'fail' => $sanitized['fail'], + 'success-response' => $sanitized['success-response'], + ]); + break; +} + +if ($sanitized['operation-mode'] != $sanitized['change-operation-mode']) { + + // Operation mode changed! + $config['operation-mode'] = $sanitized['change-operation-mode']; + $config = Config::applyOperationMode($config); + + if ($config['operation-mode'] == 'varied-image-responses') { + // changing to "varied image responses" mode should enable + // the redirect-to-existing-in-htaccess option + $config['redirect-to-existing-in-htaccess'] = true; + } + + if ($config['operation-mode'] == 'no-conversion') { + // No conversion probably means that there are webps in the system not generated by + // webp express. Schedule a task to mark those that are bigger than originals + wp_schedule_single_event(time() + 30, 'webp_express_task_bulk_update_dummy_files'); + } +} + +// If we are going to save .htaccess, run and store capability tests first +// (we should only store results when .htaccess is updated as well) +if ($sanitized['force'] || HTAccessRules::doesRewriteRulesNeedUpdate($config)) { + Config::runAndStoreCapabilityTests($config); +} + + +$config['environment-when-config-was-saved'] = [ + 'doc-root-available' => PathHelper::isDocRootAvailable(), + 'doc-root-resolvable' => PathHelper::isDocRootAvailableAndResolvable(), + 'doc-root-usable-for-structuring' => Paths::canUseDocRootForRelPaths(), + 'image-roots' => Paths::getImageRootsDef(), + 'document-root' => null, +]; + +if (PathHelper::isDocRootAvailable()) { + $config['document-root'] = $_SERVER['DOCUMENT_ROOT']; +} + +// SAVE! +// ----- +$result = Config::saveConfigurationAndHTAccess($config, $sanitized['force']); + +// Handle results +// --------------- + +if (!$result['saved-both-config']) { + if (!$result['saved-main-config']) { + Messenger::addMessage( + 'error', + 'Failed saving configuration file.
      ' . + 'Current file permissions are preventing WebP Express to save configuration to: "' . Paths::getConfigFileName() . '"' + ); + } else { + Messenger::addMessage( + 'error', + 'Failed saving options file. Check file permissions
      ' . + 'Tried to save to: "' . Paths::getWodOptionsFileName() . '"' + ); + + } +} else { + $changeFolder = ($config['destination-folder'] != $oldConfig['destination-folder']); + $changeExtension = ($config['destination-extension'] != $oldConfig['destination-extension']); + $changeStructure = ($config['destination-structure'] != $oldConfig['destination-structure']); + + if ($changeFolder || $changeExtension || $changeStructure) { + + $relocate = $changeFolder || $changeStructure; + $rename = $changeExtension; + + $actionPastTense = ''; + if ($rename && $relocate) { + $actionPastTense = 'relocated and renamed'; + $actionPresentTense = 'relocate and rename'; + } else { + if ($rename) { + $actionPastTense = 'renamed'; + $actionPresentTense = 'rename'; + } else { + $actionPastTense = 'relocated'; + $actionPresentTense = 'relocate'; + } + } + + list($numFilesMoved, $numFilesFailedMoving) = CacheMover::move($config, $oldConfig); + if ($numFilesFailedMoving == 0) { + if ($numFilesMoved == 0) { + Messenger::addMessage( + 'notice', + 'No cached webp files needed to be ' . $actionPastTense + ); + + } else { + Messenger::addMessage( + 'success', + 'The webp files was ' . $actionPastTense . ' (' . $actionPastTense . ' ' . $numFilesMoved . ' images)' + ); + } + } else { + if ($numFilesMoved == 0) { + Messenger::addMessage( + 'warning', + 'No webp files could not be ' . $actionPastTense . ' (failed to ' . $actionPresentTense . ' ' . $numFilesFailedMoving . ' images)' + ); + } else { + Messenger::addMessage( + 'warning', + 'Some webp files could not be ' . $actionPastTense . ' (failed to ' . $actionPresentTense . ' ' . $numFilesFailedMoving . ' images, but successfully ' . $actionPastTense . ' ' . $numFilesMoved . ' images)' + ); + + } + } + } + + if (!$result['rules-needed-update']) { + Messenger::addMessage( + 'success', + 'Configuration saved. Rewrite rules did not need to be updated. ' . HTAccess::testLinks($config) + ); + } else { + Messenger::addMessage( + 'success', + 'Configuration was saved.' + ); + HTAccess::showSaveRulesMessages($result['htaccess-result']); + } +} + +wp_redirect(Paths::getSettingsUrl()); + +exit(); diff --git a/lib/wcfm/index.0c25b0fb.css b/lib/wcfm/index.0c25b0fb.css new file mode 100644 index 0000000..92a04cf --- /dev/null +++ b/lib/wcfm/index.0c25b0fb.css @@ -0,0 +1 @@ +.splitpanes{display:-webkit-box;display:-ms-flexbox;display:flex;width:100%;height:100%}.splitpanes--vertical{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.splitpanes--horizontal{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.splitpanes--dragging *{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.splitpanes__pane{width:100%;height:100%;overflow:hidden}.splitpanes--vertical .splitpanes__pane{-webkit-transition:width .2s ease-out;transition:width .2s ease-out}.splitpanes--horizontal .splitpanes__pane{-webkit-transition:height .2s ease-out;transition:height .2s ease-out}.splitpanes--dragging .splitpanes__pane{-webkit-transition:none;transition:none}.splitpanes__splitter{-ms-touch-action:none;touch-action:none}.splitpanes--vertical>.splitpanes__splitter{min-width:1px;cursor:col-resize}.splitpanes--horizontal>.splitpanes__splitter{min-height:1px;cursor:row-resize}.splitpanes.default-theme .splitpanes__pane{background-color:#f2f2f2}.splitpanes.default-theme .splitpanes__splitter{background-color:#fff;-webkit-box-sizing:border-box;box-sizing:border-box;position:relative;-ms-flex-negative:0;flex-shrink:0}.splitpanes.default-theme .splitpanes__splitter:after,.splitpanes.default-theme .splitpanes__splitter:before{content:"";position:absolute;top:50%;left:50%;background-color:#00000026;-webkit-transition:background-color .3s;transition:background-color .3s}.splitpanes.default-theme .splitpanes__splitter:hover:after,.splitpanes.default-theme .splitpanes__splitter:hover:before{background-color:#00000040}.splitpanes.default-theme .splitpanes__splitter:first-child{cursor:auto}.default-theme.splitpanes .splitpanes .splitpanes__splitter{z-index:1}.default-theme.splitpanes--vertical>.splitpanes__splitter,.default-theme .splitpanes--vertical>.splitpanes__splitter{width:7px;border-left:1px solid #eee;margin-left:-1px}.default-theme.splitpanes--vertical>.splitpanes__splitter:after,.default-theme .splitpanes--vertical>.splitpanes__splitter:after,.default-theme.splitpanes--vertical>.splitpanes__splitter:before,.default-theme .splitpanes--vertical>.splitpanes__splitter:before{-webkit-transform:translateY(-50%);transform:translateY(-50%);width:1px;height:30px}.default-theme.splitpanes--vertical>.splitpanes__splitter:before,.default-theme .splitpanes--vertical>.splitpanes__splitter:before{margin-left:-2px}.default-theme.splitpanes--vertical>.splitpanes__splitter:after,.default-theme .splitpanes--vertical>.splitpanes__splitter:after{margin-left:1px}.default-theme.splitpanes--horizontal>.splitpanes__splitter,.default-theme .splitpanes--horizontal>.splitpanes__splitter{height:7px;border-top:1px solid #eee;margin-top:-1px}.default-theme.splitpanes--horizontal>.splitpanes__splitter:after,.default-theme .splitpanes--horizontal>.splitpanes__splitter:after,.default-theme.splitpanes--horizontal>.splitpanes__splitter:before,.default-theme .splitpanes--horizontal>.splitpanes__splitter:before{-webkit-transform:translateX(-50%);transform:translate(-50%);width:30px;height:1px}.default-theme.splitpanes--horizontal>.splitpanes__splitter:before,.default-theme .splitpanes--horizontal>.splitpanes__splitter:before{margin-top:-2px}.default-theme.splitpanes--horizontal>.splitpanes__splitter:after,.default-theme .splitpanes--horizontal>.splitpanes__splitter:after{margin-top:1px}.fileitem.selected p[data-v-4fb0cc48]{background:#ccc!important}.fileitem[data-v-4fb0cc48]{vertical-align:middle;white-space:nowrap}.fileitem p[data-v-4fb0cc48]:hover{background:#eee}.fileitem p[data-v-4fb0cc48]{user-select:none;cursor:pointer;margin:0;padding:3px;line-height:25px;border-bottom:1px solid #f2f2f2}.fileitem p .fold-unfold[data-v-4fb0cc48]{user-select:none;cursor:pointer}.fileitem p .fold-unfold.empty[data-v-4fb0cc48]{visibility:hidden}.fileitem p svg.icon-unfold[data-v-4fb0cc48],.fileitem p svg.icon-fold[data-v-4fb0cc48]{width:12px;height:12px;vertical-align:middle;padding:3px;margin-right:3px;display:inline-block;border:0px solid grey}.fileitem p svg.icon-folder[data-v-4fb0cc48],.fileitem p svg.icon-file[data-v-4fb0cc48]{width:20px;height:20px;display:inline;vertical-align:middle;padding-top:1px;padding-bottom:2px;padding-right:5px}.fileitem p svg.icon-file[data-v-4fb0cc48]{margin-left:22px}ul{list-style-type:none;padding:0;margin:0}li{margin:0 0 0 20px}.modal-mask{position:fixed;z-index:9998;top:0;left:0;width:100%;height:100%;background-color:#00000080;display:table}.modal-mask .modal-wrapper{height:100%;width:100%}.modal-mask .modal-wrapper .modal-container{padding:0;background-color:#fff;border-radius:2px;box-shadow:0 2px 8px #00000054;font-family:Helvetica,Arial,sans-serif;position:relative}.modal-mask .modal-wrapper .modal-container .title{position:absolute;left:0px;top:0;right:0;height:20px;background-color:#ccc;padding:5px 0 0 15px;font-size:14px;font-weight:bold}.modal-mask .modal-wrapper .modal-container .close-button{position:absolute;right:3px;top:3px;background-color:#fff;padding:2px 5px;font-size:10px;z-index:999;border-radius:18px;border:1px solid #ccc;line-height:13px}.modal-mask .modal-wrapper .modal-container .close-button:hover{cursor:pointer;background-color:#999}.modal-mask .modal-wrapper .modal-container .modal-body{position:absolute;top:25px;bottom:0px;left:0;right:0;overflow-y:auto;padding:20px}.modal-mask .modal-wrapper .modal-container .modal-body .close-button-with-text{text-align:right;margin-bottom:5px}.modal-mask .modal-wrapper .modal-container .modal-body .close-button-with-text button{padding:3px 20px}.modal-enter-active,.modal-leave-active{transition:opacity .5s ease}.modal-enter-from,.modal-leave-to{opacity:0}.image-viewport{position:relative}.image-viewport>.zoomer{width:100%;border:solid 1px silver;overflow:hidden}.image-viewport>.zoomer img{vertical-align:top;object-fit:contain;width:100%;height:100%;user-drag:none;-webkit-user-drag:none;-moz-user-drag:none}.image-viewport .zoom-info{display:none;position:absolute;bottom:0px;right:0px;padding:1px 4px;font-size:9px;background-color:#fff}.image-viewport:hover .zoom-info{display:block;cursor:pointer}.variant .header .title[data-v-0c38121e]{display:inline-block}.variant .header .size[data-v-0c38121e]{display:inline-block;float:right}.variant .header .zoom[data-v-0c38121e]{display:inline-block;float:right;visibility:hidden}.variant .footer[data-v-0c38121e]{font-style:italic;margin-top:2px}.variant .footer .select[data-v-0c38121e]{float:right}.variant .footer .select button[data-v-0c38121e]{padding:3px 10px}.variant:hover .header .zoom[data-v-0c38121e]{visibility:visible}.variants-component .variants .variant.selected[data-v-25a3327e]{background-color:#ccc}.variants-component .variants .variant[data-v-25a3327e]{display:inline-block;width:47%;margin-right:1%;padding:1%;margin-bottom:20px}.variants-component .variants .variant .header .title[data-v-25a3327e]{display:inline-block}.variants-component .variants .variant .header .size[data-v-25a3327e]{display:inline-block;float:right}.variants-component .variants .variant .header .zoom[data-v-25a3327e]{display:inline-block;float:right;visibility:hidden}.variants-component .variants .variant .footer[data-v-25a3327e]{font-style:italic}.variants-component .variants .variant .footer .select[data-v-25a3327e]{float:right}.variants-component .variants .variant .footer .select button[data-v-25a3327e]{padding:3px 10px}.variants-component .variants .variant:hover .header .zoom[data-v-25a3327e]{visibility:visible}.file-properties .variant-wrap[data-v-1e863e69]{display:inline-block;width:48%;margin-right:2%;margin-bottom:20px;vertical-align:top}.file-properties .variant-wrap .variant-footer[data-v-1e863e69]{font-style:italic}.file-properties .variant-wrap .variant-footer button[data-v-1e863e69]{float:right;padding:3px 10px}.file-properties .variant-wrap .icon-trash[data-v-1e863e69]{width:18px;height:18px;cursor:pointer;float:right}.file-properties .error[data-v-1e863e69]{color:red;font-weight:bold}.file-properties .path[data-v-1e863e69]{margin-bottom:20px}.file-properties .path .path[data-v-1e863e69]{color:#666;font-style:italic}.file-properties .log-button[data-v-1e863e69]{margin-left:15px}.file-properties .icon-converting[data-v-1e863e69]{padding:0 5px}.folder-properties .path{margin-bottom:20px}.folder-properties .path .path{color:#666;font-style:italic}h3[data-v-6ea04f9d]{margin:20px 0}@font-face{font-family:"password";src:url(data:font/woff;charset=utf-8;base64,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) format("woff");font-weight:normal;font-style:normal}.autoui-input .icon-eye{width:18px;height:18px;margin-left:5px;cursor:pointer}.autoui-input .icon-eye:hover{fill:#333}.autoui-input .icon-eye.revealed{fill:#888}.autoui-input input{width:100%;min-height:30px}.autoui-input input.obscured{font-family:"password",sans-serif;font-size:8px}.autoui-input input.sensitive{width:calc(100% - 30px)}.autoui-input input.small{max-width:100px}.slider-target,.slider-target *{-webkit-touch-callout:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-user-select:none;touch-action:none;-ms-user-select:none;-moz-user-select:none;user-select:none;box-sizing:border-box}.slider-target{position:relative}.slider-base,.slider-connects{width:100%;height:100%;position:relative;z-index:1}.slider-connects{overflow:hidden;z-index:0}.slider-connect,.slider-origin{will-change:transform;position:absolute;z-index:1;top:0;right:0;-ms-transform-origin:0 0;-webkit-transform-origin:0 0;-webkit-transform-style:preserve-3d;transform-origin:0 0;transform-style:flat}.slider-connect{height:100%;width:100%}.slider-origin{height:10%;width:10%}.slider-txt-dir-rtl.slider-horizontal .slider-origin{left:0;right:auto}.slider-vertical .slider-origin{width:0}.slider-horizontal .slider-origin{height:0}.slider-handle{-webkit-backface-visibility:hidden;backface-visibility:hidden;position:absolute}.slider-touch-area{height:100%;width:100%}.slider-state-tap .slider-connect,.slider-state-tap .slider-origin{transition:transform .3s}.slider-state-drag *{cursor:inherit!important}.slider-horizontal{height:6px}.slider-horizontal .slider-handle{width:16px;height:16px;top:-6px;right:-8px}.slider-vertical{width:6px;height:300px}.slider-vertical .slider-handle{width:16px;height:16px;top:-8px;right:-6px}.slider-txt-dir-rtl.slider-horizontal .slider-handle{left:-8px;right:auto}.slider-base{background-color:#d4e0e7}.slider-base,.slider-connects{border-radius:3px}.slider-connect{background:#41b883;cursor:pointer}.slider-draggable{cursor:ew-resize}.slider-vertical .slider-draggable{cursor:ns-resize}.slider-handle{width:16px;height:16px;border-radius:50%;background:#fff;border:0;right:-8px;box-shadow:.5px .5px 2px 1px #00000052;cursor:-webkit-grab;cursor:grab}.slider-handle:focus{outline:none}.slider-active{box-shadow:.5px .5px 2px 1px #0000006b;cursor:-webkit-grabbing;cursor:grabbing}[disabled] .slider-connect{background:#b8b8b8}[disabled].slider-handle,[disabled] .slider-handle,[disabled].slider-target{cursor:not-allowed}[disabled] .slider-tooltip{background:#b8b8b8;border-color:#b8b8b8}.slider-tooltip{position:absolute;display:block;font-size:14px;font-weight:500;white-space:nowrap;padding:2px 5px;min-width:20px;text-align:center;color:#fff;border-radius:5px;border:1px solid #41b883;background:#41b883}.slider-horizontal .slider-tooltip{transform:translate(-50%);left:50%;bottom:24px}.slider-horizontal .slider-tooltip:before{content:"";position:absolute;bottom:-10px;left:50%;width:0;height:0;border:5px solid transparent;border-top-color:inherit;transform:translate(-50%)}.slider-vertical .slider-tooltip{transform:translateY(-50%);top:50%;right:24px}.slider-vertical .slider-tooltip:before{content:"";position:absolute;right:-10px;top:50%;width:0;height:0;border:5px solid transparent;border-left-color:inherit;transform:translateY(-50%)}.slider-horizontal .slider-origin>.slider-tooltip{transform:translate(50%);left:auto;bottom:14px}.slider-vertical .slider-origin>.slider-tooltip{transform:translateY(-18px);top:auto;right:18px}.slider-pips,.slider-pips *{box-sizing:border-box}.slider-pips{position:absolute;color:#999}.slider-value{position:absolute;white-space:nowrap;text-align:center}.slider-value-sub{color:#ccc;font-size:10px}.slider-marker{position:absolute;background:#ccc}.slider-marker-large,.slider-marker-sub{background:#aaa}.slider-pips-horizontal{padding:10px 0;height:80px;top:100%;left:0;width:100%}.slider-value-horizontal{transform:translate(-50%,50%)}.slider-rtl .slider-value-horizontal{transform:translate(50%,50%)}.slider-marker-horizontal.slider-marker{margin-left:-1px;width:2px;height:5px}.slider-marker-horizontal.slider-marker-sub{height:10px}.slider-marker-horizontal.slider-marker-large{height:15px}.slider-pips-vertical{padding:0 10px;height:100%;top:0;left:100%}.slider-value-vertical{transform:translateY(-50%);padding-left:25px}.slider-rtl .slider-value-vertical{transform:translateY(50%)}.slider-marker-vertical.slider-marker{width:5px;height:2px;margin-top:-1px}.slider-marker-vertical.slider-marker-sub{width:10px}.slider-marker-vertical.slider-marker-large{width:15px}.slider[data-v-8f297ee6]{padding:10px 0}.slider .slider-connect{background-color:#0075ff}.slider .slider-tooltip{background-color:#0075ff;border-color:#0075ff;display:none}.slider:hover .slider-tooltip{display:block}.slider .slider-active .slider-tooltip{display:block}.vue-toggles{cursor:pointer;display:flex;align-items:center;border-radius:9999px;overflow:hidden;transition:background-color ease .2s,width ease .2s,height ease .2s}.vue-toggles .dot{position:relative;display:flex;align-items:center;border-radius:9999px;box-shadow:0 1px 3px #0000001a,0 1px 2px #0000000f;transition:margin ease .2s}.vue-toggles .text{position:absolute;font-family:inherit;user-select:none;white-space:nowrap}@media all and (-ms-high-contrast: none){.vue-toggles .text{top:50%;transform:translateY(-50%)}}@media (prefers-reduced-motion){.vue-toggles,.vue-toggles *,.vue-toggles *:before,.vue-toggles *:after{animation:none!important;transition:none!important;transition-duration:none!important}}.help-icon{display:inline-block;margin-left:2px}.icon{width:16px;height:16px;vertical-align:top;padding:0 2px 1px}.buttons a:first-child{margin-left:0}.buttons a{margin:0 5px;background-color:#f5f5f5;border-radius:0;color:#000!important;padding:2px 10px;border-color:#fff;text-decoration:none!important;font-family:arial,sans-serif;font-size:13.33px;border-width:1px;border-style:solid;border-bottom-color:#ccc;border-right-color:#ccc}.buttons a:hover{background-color:#fff}.buttons a.mouse-down{border-bottom-color:#fff;border-right-color:#fff;border-top-color:#ccc;border-left-color:#ccc}.auto-component input.method[data-v-26c389a5]{width:40px}.auto-component[data-v-26c389a5]{display:table;width:100%}.auto-component>*[data-v-26c389a5]{display:table-row}.auto-component>*>*[data-v-26c389a5]{display:table-cell;padding:0 0 10px}.auto-component>*>div[data-v-26c389a5]:first-child{min-width:210px;vertical-align:top;padding-top:0}.auto-component>*>div[data-v-26c389a5]:last-child{width:100%;box-sizing:border-box}.fade-enter-active[data-v-26c389a5]{transition:all .5s;opacity:0;transform:scale(.7)}.fade-enter-to[data-v-26c389a5]{opacity:1;transform:scale(1)}.fade-leave-active[data-v-26c389a5]{transition:all .5s;transform:scale(1);opacity:1}.fade-leave-to[data-v-26c389a5]{opacity:0;transform:scale(.7)}@media (max-width: 600px){.auto-component[data-v-26c389a5]{display:block}.auto-component>*[data-v-26c389a5]{display:block}.auto-component>*>*[data-v-26c389a5]{display:block}}.autoui-table[data-v-30912c7e]{display:table}.autoui-table>*[data-v-30912c7e]{display:table-row}.autoui-table>*>*[data-v-30912c7e]{display:table-cell;padding:5px 20px 5px 0}.table-inline-block>div>*[data-v-30912c7e]{display:inline-block;margin-right:10px;padding:5px 20px 5px 0}.table-inline-block>div>*[data-v-30912c7e]:first-child{width:120px}.view-select[data-v-30912c7e]{display:block;text-align:right}.view *[data-v-30912c7e]:first-child{display:inline-block;margin-right:10px}.view *[data-v-30912c7e]:last-child{width:200px;display:inline-block}.convert-options button{float:right;margin-top:10px}.welcome{padding:20px 30px}.welcome h4{margin-bottom:3px}.welcome li{list-style-type:disc}.welcome ul{margin:0 0 20px}.welcome .headline h3{margin-top:0;padding-top:0;font-size:24px}.convertOptionsButton{position:absolute;right:10px;top:8px}.splitpanes__pane{background-color:#fff!important;padding:0;min-height:80px;box-shadow:inset 0 0 3px #0003}.pane-content{height:100%;overflow-y:auto}.pane-content>*{padding:10px 20px}body,html{height:100%;margin:0;padding:0}.mainpanel,#webpconvert-filemanager{height:100%;min-height:300px;position:relative}.mainpanel{min-height:400px}.wcfm,input{font-family:"Avenir",Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;color:#222;font-size:14px}input{padding:5px 8px;border-color:#333;border-width:1px;box-sizing:border-box}div .multiselect{min-height:30px}div .multiselect__select{height:30px;line-height:16px;width:30px}div .multiselect__select:before{border-top-color:#000;border-width:6px 6px 0 6px}div .multiselect__single{margin-bottom:3px}div .multiselect__tags{border-color:#333;border-radius:0;padding:5px 40px 0 5px;min-height:30px}li.multiselect__element{margin-left:0}div .multiselect__tag{margin-bottom:0;padding-bottom:3px;padding-top:3px}fieldset[disabled] .multiselect{pointer-events:none}.multiselect__spinner{position:absolute;right:1px;top:1px;width:48px;height:35px;background:#fff;display:block}.multiselect__spinner:after,.multiselect__spinner:before{position:absolute;content:"";top:50%;left:50%;margin:-8px 0 0 -8px;width:16px;height:16px;border-radius:100%;border-color:#41b883 transparent transparent;border-style:solid;border-width:2px;box-shadow:0 0 0 1px transparent}.multiselect__spinner:before{-webkit-animation:spinning 2.4s cubic-bezier(.41,.26,.2,.62);animation:spinning 2.4s cubic-bezier(.41,.26,.2,.62);-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite}.multiselect__spinner:after{-webkit-animation:spinning 2.4s cubic-bezier(.51,.09,.21,.8);animation:spinning 2.4s cubic-bezier(.51,.09,.21,.8);-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite}.multiselect__loading-enter-active,.multiselect__loading-leave-active{transition:opacity .4s ease-in-out;opacity:1}.multiselect__loading-enter,.multiselect__loading-leave-active{opacity:0}.multiselect,.multiselect__input,.multiselect__single{font-family:inherit;font-size:16px;touch-action:manipulation}.multiselect{box-sizing:content-box;display:block;position:relative;width:100%;min-height:40px;text-align:left;color:#35495e}.multiselect *{box-sizing:border-box}.multiselect:focus{outline:none}.multiselect--disabled{background:#ededed;pointer-events:none;opacity:.6}.multiselect--active{z-index:50}.multiselect--active:not(.multiselect--above) .multiselect__current,.multiselect--active:not(.multiselect--above) .multiselect__input,.multiselect--active:not(.multiselect--above) .multiselect__tags{border-bottom-left-radius:0;border-bottom-right-radius:0}.multiselect--active .multiselect__select{transform:rotate(180deg)}.multiselect--above.multiselect--active .multiselect__current,.multiselect--above.multiselect--active .multiselect__input,.multiselect--above.multiselect--active .multiselect__tags{border-top-left-radius:0;border-top-right-radius:0}.multiselect__input,.multiselect__single{position:relative;display:inline-block;min-height:20px;line-height:20px;border:none;border-radius:5px;background:#fff;padding:0 0 0 5px;width:100%;transition:border .1s ease;box-sizing:border-box;margin-bottom:8px;vertical-align:top}.multiselect__input::-moz-placeholder{color:#35495e}.multiselect__input:-ms-input-placeholder{color:#35495e}.multiselect__input::placeholder{color:#35495e}.multiselect__tag~.multiselect__input,.multiselect__tag~.multiselect__single{width:auto}.multiselect__input:hover,.multiselect__single:hover{border-color:#cfcfcf}.multiselect__input:focus,.multiselect__single:focus{border-color:#a8a8a8;outline:none}.multiselect__single{padding-left:5px;margin-bottom:8px}.multiselect__tags-wrap{display:inline}.multiselect__tags{min-height:40px;display:block;padding:8px 40px 0 8px;border-radius:5px;border:1px solid #e8e8e8;background:#fff;font-size:14px}.multiselect__tag{position:relative;display:inline-block;padding:4px 26px 4px 10px;border-radius:5px;margin-right:10px;color:#fff;line-height:1;background:#41b883;margin-bottom:5px;white-space:nowrap;overflow:hidden;max-width:100%;text-overflow:ellipsis}.multiselect__tag-icon{cursor:pointer;margin-left:7px;position:absolute;right:0;top:0;bottom:0;font-weight:700;font-style:normal;width:22px;text-align:center;line-height:22px;transition:all .2s ease;border-radius:5px}.multiselect__tag-icon:after{content:"\d7";color:#266d4d;font-size:14px}.multiselect__tag-icon:focus:after,.multiselect__tag-icon:hover:after{color:#fff}.multiselect__current{min-height:40px;overflow:hidden;padding:8px 30px 0 12px;white-space:nowrap;border-radius:5px;border:1px solid #e8e8e8}.multiselect__current,.multiselect__select{line-height:16px;box-sizing:border-box;display:block;margin:0;text-decoration:none;cursor:pointer}.multiselect__select{position:absolute;width:40px;height:38px;right:1px;top:1px;padding:4px 8px;text-align:center;transition:transform .2s ease}.multiselect__select:before{position:relative;right:0;top:65%;color:#999;margin-top:4px;border-style:solid;border-width:5px 5px 0 5px;border-color:#999 transparent transparent transparent;content:""}.multiselect__placeholder{color:#adadad;display:inline-block;margin-bottom:10px;padding-top:2px}.multiselect--active .multiselect__placeholder{display:none}.multiselect__content-wrapper{position:absolute;display:block;background:#fff;width:100%;max-height:240px;overflow:auto;border:1px solid #e8e8e8;border-top:none;border-bottom-left-radius:5px;border-bottom-right-radius:5px;z-index:50;-webkit-overflow-scrolling:touch}.multiselect__content{list-style:none;display:inline-block;padding:0;margin:0;min-width:100%;vertical-align:top}.multiselect--above .multiselect__content-wrapper{bottom:100%;border-radius:5px 5px 0 0;border-bottom:none;border-top:1px solid #e8e8e8}.multiselect__content::-webkit-scrollbar{display:none}.multiselect__element{display:block}.multiselect__option{display:block;padding:12px;min-height:40px;line-height:16px;text-decoration:none;text-transform:none;vertical-align:middle;position:relative;cursor:pointer;white-space:nowrap}.multiselect__option:after{top:0;right:0;position:absolute;line-height:40px;padding-right:12px;padding-left:20px;font-size:13px}.multiselect__option--highlight{background:#41b883;outline:none;color:#fff}.multiselect__option--highlight:after{content:attr(data-select);background:#41b883;color:#fff}.multiselect__option--selected{background:#f3f3f3;color:#35495e;font-weight:700}.multiselect__option--selected:after{content:attr(data-selected);color:silver}.multiselect__option--selected.multiselect__option--highlight{background:#ff6a6a;color:#fff}.multiselect__option--selected.multiselect__option--highlight:after{background:#ff6a6a;content:attr(data-deselect);color:#fff}.multiselect--disabled .multiselect__current,.multiselect--disabled .multiselect__select{background:#ededed;color:#a6a6a6}.multiselect__option--disabled{background:#ededed!important;color:#a6a6a6!important;cursor:text;pointer-events:none}.multiselect__option--group{background:#ededed;color:#35495e}.multiselect__option--group.multiselect__option--highlight{background:#35495e;color:#fff}.multiselect__option--group.multiselect__option--highlight:after{background:#35495e}.multiselect__option--disabled.multiselect__option--highlight{background:#dedede}.multiselect__option--group-selected.multiselect__option--highlight{background:#ff6a6a;color:#fff}.multiselect__option--group-selected.multiselect__option--highlight:after{background:#ff6a6a;content:attr(data-deselect);color:#fff}.multiselect-enter-active,.multiselect-leave-active{transition:all .15s ease}.multiselect-enter,.multiselect-leave-active{opacity:0}.multiselect__strong{margin-bottom:8px;line-height:20px;display:inline-block;vertical-align:top}[dir=rtl] .multiselect{text-align:right}[dir=rtl] .multiselect__select{right:auto;left:1px}[dir=rtl] .multiselect__tags{padding:8px 8px 0 40px}[dir=rtl] .multiselect__content{text-align:right}[dir=rtl] .multiselect__option:after{right:auto;left:0}[dir=rtl] .multiselect__clear{right:auto;left:12px}[dir=rtl] .multiselect__spinner{right:auto;left:1px}@-webkit-keyframes spinning{0%{transform:rotate(0)}to{transform:rotate(2turn)}}@keyframes spinning{0%{transform:rotate(0)}to{transform:rotate(2turn)}}div.v-popper--theme-menu .v-popper__inner{background-color:#66f;color:#fff;padding:10px 15px 11px;line-height:1.2;max-width:300px}.v-popper__inner a{color:#fff;text-decoration:underline}.v-popper__inner p{margin-top:0}.v-popper__inner p:last-child{margin-bottom:0}.table-table{display:table}.table-table>*{display:table-row}.table-table>*>*{display:table-cell;padding:5px 20px 5px 0}.table-inline-block>div>*{display:inline-block;margin-right:10px;padding:5px 20px 5px 0}.table-inline-block>div>*:first-child{width:120px}.resize-observer[data-v-b329ee4c]{position:absolute;top:0;left:0;z-index:-1;width:100%;height:100%;border:none;background-color:transparent;pointer-events:none;display:block;overflow:hidden;opacity:0}.resize-observer[data-v-b329ee4c] object{display:block;position:absolute;top:0;left:0;height:100%;width:100%;overflow:hidden;pointer-events:none;z-index:-1}.v-popper--theme-dropdown .v-popper__inner{background:#fff;color:#000;padding:24px;border-radius:6px;box-shadow:0 6px 30px #0000001a}.v-popper--theme-dropdown .v-popper__arrow{border-color:#fff}.v-popper{width:max-content}.v-popper--theme-tooltip .v-popper__inner{background:rgba(0,0,0,.8);color:#fff;border-radius:6px;padding:7px 12px 6px}.v-popper--theme-tooltip .v-popper__arrow{border-color:#000c}.v-popper__popper{z-index:10000}.v-popper__popper.v-popper__popper--hidden{visibility:hidden;opacity:0;transition:opacity .15s,visibility .15s}.v-popper__popper.v-popper__popper--shown{visibility:visible;opacity:1;transition:opacity .15s}.v-popper__popper.v-popper__popper--skip-transition,.v-popper__popper.v-popper__popper--skip-transition>.v-popper__wrapper{transition:none!important}.v-popper__inner{position:relative}.v-popper__arrow-container{width:10px;height:10px}.v-popper__arrow{border-style:solid;position:relative;width:0;height:0}.v-popper__popper[data-popper-placement^=top] .v-popper__arrow{border-width:5px 5px 0 5px;border-left-color:transparent!important;border-right-color:transparent!important;border-bottom-color:transparent!important}.v-popper__popper[data-popper-placement^=bottom] .v-popper__arrow-container{top:0}.v-popper__popper[data-popper-placement^=bottom] .v-popper__arrow{border-width:0 5px 5px 5px;border-left-color:transparent!important;border-right-color:transparent!important;border-top-color:transparent!important;top:-5px}.v-popper__popper[data-popper-placement^=right] .v-popper__arrow{border-width:5px 5px 5px 0;border-left-color:transparent!important;border-top-color:transparent!important;border-bottom-color:transparent!important;left:-5px}.v-popper__popper[data-popper-placement^=left] .v-popper__arrow-container{right:-5px}.v-popper__popper[data-popper-placement^=left] .v-popper__arrow{border-width:5px 0 5px 5px;border-top-color:transparent!important;border-right-color:transparent!important;border-bottom-color:transparent!important;right:-5px} diff --git a/lib/wcfm/index.be5d792e.js b/lib/wcfm/index.be5d792e.js new file mode 100644 index 0000000..6510572 --- /dev/null +++ b/lib/wcfm/index.be5d792e.js @@ -0,0 +1,23 @@ +var ue=Object.defineProperty;var G=Object.getOwnPropertySymbols;var he=Object.prototype.hasOwnProperty,pe=Object.prototype.propertyIsEnumerable;var Z=(t,e,o)=>e in t?ue(t,e,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[e]=o,K=(t,e)=>{for(var o in e||(e={}))he.call(e,o)&&Z(t,o,e[o]);if(G)for(var o of G(e))pe.call(e,o)&&Z(t,o,e[o]);return t};import{_ as v,o as s,c as d,a as Q,b as r,n as $,d as p,e as q,t as _,p as Y,f as H,r as m,g as w,F as B,h as P,i as x,j as A,k as J,m as ee,V as te,w as S,l as W,q as z,v as I,s as me,u as oe,x as ie,y as ne,z as X,T as fe,A as se,B as ge,C as ve}from"./vendor.fa68d508.js";const ye=function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))l(i);new MutationObserver(i=>{for(const n of i)if(n.type==="childList")for(const a of n.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&l(a)}).observe(document,{childList:!0,subtree:!0});function o(i){const n={};return i.integrity&&(n.integrity=i.integrity),i.referrerpolicy&&(n.referrerPolicy=i.referrerpolicy),i.crossorigin==="use-credentials"?n.credentials="include":i.crossorigin==="anonymous"?n.credentials="omit":n.credentials="same-origin",n}function l(i){if(i.ep)return;i.ep=!0;const n=o(i);fetch(i.href,n)}};ye();class L{static post(e,o,l){var i=this;window.wcfmoptions.poster(e,o,function(n){l.call(i,n)},function(){console.log("failure")})}}const _e={},we={style:{position:"absolute",width:"0",height:"0"},width:"0",height:"0",version:"1.1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink"},be=Q(``,1),ke=[be];function xe(t,e){return s(),d("svg",we,ke)}var Ve=v(_e,[["render",xe]]);const Ce={name:"FileItem",emits:["toggle","select"],props:{item:Object},data(){return{hover:!1,selected:!1}},inject:["wcfm"],methods:{onClick(t){this.selected=!0,this.$emit("select",this)},getWCFM(){return this.wcfm},getFullPath(){for(var t=this.$parent,e=[];t!==null&&t.$parent!==null;)t.item&&e.push(t.item.name),t=t.$parent;return e.pop(),e=e.reverse().join("/"),e=e.replace("//","/"),e},infoClick(){this.getWCFM().displayInfo(this.getFullPath())},convertClick(){this.getWCFM().onConvertClick(this.getFullPath())}}},R=t=>(Y("data-v-4fb0cc48"),t=t(),H(),t),ze={key:0,class:"icon-fold"},Se=R(()=>r("use",{"xlink:href":"#icon-fold"},null,-1)),Me=[Se],Ie={key:1,class:"icon-unfold"},Oe=R(()=>r("use",{"xlink:href":"#icon-unfold"},null,-1)),Te=[Oe],$e=R(()=>r("use",{"xlink:href":"#icon-folder"},null,-1)),Be=[$e],Le={key:2,class:"icon-file"},De=R(()=>r("use",{"xlink:href":"#icon-file"},null,-1)),qe=[De];function Ae(t,e,o,l,i,n){return s(),d("div",{class:$({fileitem:!0,selected:i.selected}),onMouseover:e[2]||(e[2]=a=>i.hover=!0),onMouseleave:e[3]||(e[3]=a=>i.hover=!1),onClick:e[4]||(e[4]=(...a)=>n.onClick&&n.onClick(...a))},[r("p",null,[o.item.isDir?(s(),d("span",{key:0,class:$({"fold-unfold":!0,empty:o.item.isEmpty}),onClick:e[0]||(e[0]=a=>this.$emit("toggle"))},[o.item.isOpen?(s(),d("svg",ze,Me)):p("",!0),o.item.isOpen?p("",!0):(s(),d("svg",Ie,Te))],2)):p("",!0),o.item.isDir?(s(),d("svg",{key:1,class:"icon-folder",onClick:e[1]||(e[1]=a=>this.$emit("toggle"))},Be)):p("",!0),o.item.isDir?p("",!0):(s(),d("svg",Le,qe)),q(" "+_(o.item.nickname||o.item.name),1)])],34)}var Pe=v(Ce,[["render",Ae],["__scopeId","data-v-4fb0cc48"]]);const Ue={name:"FileTree",components:{FileItem:Pe},emits:["select"],props:{item:Object},data(){return{loading:!1}},methods:{load(){var t=this;L.post("get-folder",{path:this.$refs.thefileitem.getFullPath()},function(e){t.loading=!0,t.item.children=e.children.sort(function(o,l){return o.isDir&&!l.isDir?-1:!o.isDir&&l.isDir||o.name>l.name?1:o.name(s(),d("li",null,[w(c,{item:g,onSelect:n.onSelect},null,8,["item","onSelect"])]))),256))])):p("",!0)],64)}var He=v(Ue,[["render",Ye]]);const We={name:"Files",components:{FileTree:He},emits:["select"],props:{item:Object,statusText:String},methods:{onSelect(t){this.selectedItem&&(this.selectedItem.selected=!1),this.selectedItem=t,this.$emit("select",this.selectedItem.getFullPath(),this.selectedItem.item.isDir)}},data(){return{selected:null}}},Xe={key:1};function Ee(t,e,o,l,i,n){const a=m("FileTree");return s(),d(B,null,[o.item?(s(),x(a,{key:0,item:o.item,onSelect:n.onSelect},null,8,["item","onSelect"])):p("",!0),o.item?p("",!0):(s(),d("div",Xe,_(o.statusText),1))],64)}var Ge=v(We,[["render",Ee]]);const Ze={name:"Modal",emits:["close"],props:{title:{type:String},closeButtonText:{type:String},width:{type:[Number,String],default:"95%"},maxwidth:{type:[Number,String],default:"700px"},alignment:{type:String,default:"center"},height:{type:[Number,String],default:"92%"},maxheight:{type:[Number,String],default:"700px"},valignment:{type:String,default:"center"}},computed:{containerStyle(){let t={width:this.width,"max-width":this.maxwidth,height:this.height,"max-height":this.maxheight};return this.alignment=="center"&&(t.margin="0px auto"),this.alignment=="right"&&(t.position="absolute",t.right="10px"),this.valignment=="center"&&(t.top="50%",t.transform="translateY(-50%)"),this.alignment=="bottom"&&(t.position="absolute",t.bottom="10px"),t}},methods:{onCloseClick(){this.$emit("close")},registerKeyDownEvent(){let t=this;document.onkeydown=function(e){e=e||window.event;var o=!1;"key"in e?o=e.key==="Escape"||e.key==="Esc":o=e.keyCode===27,o&&t.$emit("close")}}},mounted(){this.registerKeyDownEvent()}},Ke={class:"modal-mask"},Qe={class:"modal-wrapper"},Je={class:"title"},et={class:"modal-body"},tt={class:"content"},ot=q(" default body "),it={class:"close-button-with-text"};function nt(t,e,o,l,i,n){return s(),d("div",Ke,[r("div",Qe,[r("div",{class:"modal-container",style:A(n.containerStyle)},[r("a",{class:"close-button",onClick:e[0]||(e[0]=(...a)=>n.onCloseClick&&n.onCloseClick(...a))},"X"),r("div",Je,_(o.title),1),r("div",et,[r("div",tt,[J(t.$slots,"default",{},()=>[ot]),r("div",it,[r("button",{onClick:e[1]||(e[1]=(...a)=>n.onCloseClick&&n.onCloseClick(...a))},_(o.closeButtonText),1)])])])],4)])])}var ae=v(Ze,[["render",nt]]);const st={name:"ZoomSlider",components:{Slider:ee},emits:["update:zoom"],props:{zoom:{type:Number,default:1}},watch:{exp(t,e){this.$emit("update:zoom",2**t)},zoom(t,e){this.exp=Math.log2(t)}},methods:{sliderFormat(t){return Math.round(2**t*100)+"%"}},mounted(){},data(){return{exp:0}}},at={class:"zoom-slider"};function lt(t,e,o,l,i,n){const a=m("Slider");return s(),d("div",at,[w(a,{modelValue:i.exp,"onUpdate:modelValue":e[0]||(e[0]=c=>i.exp=c),min:-4,max:4,width:100,step:-1,format:n.sliderFormat,tooltipPosition:"bottom",orientation:"horizontal"},null,8,["modelValue","format"])])}var rt=v(st,[["render",lt]]);const ct={name:"ImageViewport",components:{VueZoomer:te,ZoomSlider:rt},emits:["update:zoom","update:translateX","update:translateY","load","resize"],props:{src:{type:String},height:{type:Number,default:500},zoom:{type:Number,default:1},scaleZoomRatio:{type:Number},translateX:{type:Number,default:1},translateY:{type:Number,default:1}},data(){return{ratio:1,ro:null}},watch:{height(t,e){var o;((o=this.$refs)==null?void 0:o.zoomer)&&(this.$refs.zoomer.onWindowResize(),this.$refs.zoomer.refreshContainerPos())},scaleZoomRatio(t){var e;((e=this.$refs)==null?void 0:e.zoomer)&&(this.$refs.zoomer.onWindowResize(),this.$refs.zoomer.refreshContainerPos()),this.$refs.zoomer.scale=this.zoom*this.scaleZoomRatio},zoom(t,e){this.isImageReady(),this.$refs.zoomer.scale=t*this.scaleZoomRatio},translateX(t,e){this.$refs.zoomer.translateX!=t&&(this.$refs.zoomer.translateX=t)},translateY(t,e){this.$refs.zoomer.translateY!=t&&(this.$refs.zoomer.translateY=t)}},methods:{getGoodContainerHeight(){var t,e,o;if((e=(t=this.$refs)==null?void 0:t.theimg)==null?void 0:e.naturalWidth){let l=this.$refs.theimg.naturalWidth/this.$refs.theimg.naturalHeight,n=((o=this.$refs.root)==null?void 0:o.offsetWidth)/l;return n>300&&(n=300),n}return 300},updateContainerHeight(){},isImageReady(){var t,e,o;return!(!((e=(t=this.$refs)==null?void 0:t.theimg)==null?void 0:e.naturalWidth)||!((o=this.$refs.root)==null?void 0:o.offsetWidth))},calcScaleZoomRatio(){var l,i;if(!this.isImageReady())return 1;let t=this.$refs.theimg.naturalWidth/((l=this.$refs.root)==null?void 0:l.offsetWidth),e=this.$refs.theimg.naturalHeight/((i=this.$refs.root)==null?void 0:i.offsetHeight),o=Math.max(t,e);return isNaN(o)?1:o},updateRatio(){},updateScale(){this.zoom&&(this.$refs.zoomer.scale=this.zoom*this.scaleZoomRatio)},zoomToFit(){},onImgLoad(){var t,e;((e=(t=this.$refs)==null?void 0:t.theimg)==null?void 0:e.naturalWidth)&&this.$emit("load")},onResize(){this.$emit("resize")},onDoubleTap(){console.log("double tab - zoom to 100%"),this.$emit("update:zoom",1),this.$emit("update:translateX",0),this.$emit("update:translateY",0)}},mounted(){window.ResizeObserver&&(this.ro=new ResizeObserver(this.onResize).observe(this.$refs.root)),this.$refs.zoomer.tapDetector.onDoubleTap(this.onDoubleTap),this.$watch("$refs.zoomer.scale",(t,e)=>{this.isImageReady(),this.$emit("update:zoom",t/this.scaleZoomRatio)}),this.$watch("$refs.zoomer.translateX",(t,e)=>{this.$emit("update:translateX",t)}),this.$watch("$refs.zoomer.translateY",(t,e)=>{this.$emit("update:translateY",t)})},beforeDestroy(){window.ResizeObserver&&this.ro.unobserve(this.$refs.zoomer)}},dt={ref:"root",class:"image-viewport"},ut=["src"],ht={class:"zoom-info"};function pt(t,e,o,l,i,n){const a=m("v-zoomer");return s(),d("div",dt,[w(a,{ref:"zoomer",class:"zoomer",minScale:.1,maxScale:8,onResize:n.onResize,doubleClickToZoom:!1,style:A({height:o.height+"px"}),pivot:"cursor",limitTranslation:!1,lockPanOnNoScale:!1},{default:S(()=>[r("img",{ref:"theimg",src:o.src,onLoad:e[0]||(e[0]=(...c)=>n.onImgLoad&&n.onImgLoad(...c))},null,40,ut)]),_:1},8,["minScale","onResize","style"]),r("div",ht," zoom: "+_(Math.round(o.zoom*100))+"% ",1)],512)}var mt=v(ct,[["render",pt]]);const ft={name:"Variant",components:{ImageViewport:mt},emits:["select","update:zoom","update:translateX","update:translateY","load","resize"],props:{title:{type:String},info:{type:Object},url:{type:String,default:""},height:{type:Number},zoom:{type:Number},scaleZoomRatio:{type:Number},translateX:{type:Number},translateY:{type:Number},variantIndex:{type:Number}},computed:{imageUrl:function(){var t;return(t=this.info)==null?void 0:t.url},filesize:function(){var e;if(!((e=this.info)==null?void 0:e.size))return"";let t=this.info.size;return t<1024?t+" bytes":(t/=1024,t<1024?Math.round(t*10)/10+" kb":(t/=1024,Math.round(t*10)/10+" MB"))}},methods:{onVariantSelect(){this.$emit("select",this.variantIndex)},onLoad(){this.$emit("load")},zoomToFit(){this.$refs.theport.zoomToFit()}},mounted(){this.$watch("$refs.theport.zoom",(t,e)=>{this.$emit("update:zoom",t)}),this.$watch("$refs.theport.translateX",(t,e)=>{this.$emit("update:translateX",t)}),this.$watch("$refs.theport.translateY",(t,e)=>{this.$emit("update:translateY",t)})},data(){return{}}},gt={class:"variant"},vt={class:"header"},yt={class:"title"},_t={class:"size"};function wt(t,e,o,l,i,n){const a=m("ImageViewport");return s(),d("div",gt,[r("div",vt,[r("div",yt,_(o.title),1),r("div",_t,_(n.filesize),1)]),w(a,{ref:"theport",src:n.imageUrl,height:o.height,zoom:o.zoom,"onUpdate:zoom":e[0]||(e[0]=c=>o.zoom=c),scaleZoomRatio:o.scaleZoomRatio,translateX:o.translateX,"onUpdate:translateX":e[1]||(e[1]=c=>o.translateX=c),translateY:o.translateY,"onUpdate:translateY":e[2]||(e[2]=c=>o.translateY=c),onLoad:n.onLoad,onResize:e[3]||(e[3]=c=>this.$emit("resize"))},null,8,["src","height","zoom","scaleZoomRatio","translateX","translateY","onLoad"])])}var le=v(ft,[["render",wt],["__scopeId","data-v-0c38121e"]]);const bt={name:"Variants",components:{Variant:le},emits:["update:zoom","update:translateX","update:translateY"],props:{file:{type:Object},viewport:{type:Object},height:{type:Number},zoom:{type:Number,default:1},translateX:{type:Number,default:0},translateY:{type:Number,default:0}},watch:{file(t,e){}},methods:{onZoomChange(t){this.$emit("update:zoom",t)},onTranslateXChange(t){this.$emit("update:translateX",t)},onTranslateYChange(t){this.$emit("update:translateY",t)},sliderFormat(t){return Math.round(t*100)+"%"},changeImage(){this.imageUrl=="http://localhost:3000/src/assets/dummy.jpg"?this.imageUrl="http://localhost:3000/src/assets/dummy2.jpg":this.imageUrl="http://localhost:3000/src/assets/dummy.jpg",this.selectedVariant=-1},onVariantSelect(t){this.selectedVariant=t}},mounted(){this.$watch("$refs.variants.zoom",(t,e)=>{})},data(){var t="http://localhost:3000/src/assets/200x100.jpg";return{imageUrl:"",selectedVariant:-1,variants:[{title:"Existing conversion",size:732,url:t},{title:"Lossy, q:20",size:35e5,url:t}]}}},re=t=>(Y("data-v-25a3327e"),t=t(),H(),t),kt={class:"variants-component"},xt=re(()=>r("br",null,null,-1)),Vt=re(()=>r("br",null,null,-1)),Ct={class:"variants"};function zt(t,e,o,l,i,n){const a=m("Variant");return s(),d("div",kt,[q(" File: "+_(o.file)+" ",1),r("button",{onClick:e[0]||(e[0]=c=>n.changeImage())},"Change image"),xt,Vt,r("div",Ct,[(s(!0),d(B,null,P(i.variants,(c,g)=>(s(),x(a,{title:c.title,info:c,variantIndex:g,class:$({selected:g==i.selectedVariant}),height:o.height,zoom:o.zoom,"onUpdate:zoom":[e[1]||(e[1]=u=>o.zoom=u),n.onZoomChange],translateX:o.translateX,"onUpdate:translateX":[e[2]||(e[2]=u=>o.translateX=u),n.onTranslateXChange],translateY:o.translateY,"onUpdate:translateY":[e[3]||(e[3]=u=>o.translateY=u),n.onTranslateYChange],onSelect:n.onVariantSelect},null,8,["title","info","variantIndex","class","height","zoom","translateX","translateY","onSelect","onUpdate:zoom","onUpdate:translateX","onUpdate:translateY"]))),256))])])}var St=v(bt,[["render",zt],["__scopeId","data-v-25a3327e"]]);class U{static escape(e){return e.replace(/./gm,function(o){var l=/[0-9a-zA-Z\!\[\]\(\)\*\#]/;return l.test(o.charAt(0))?o.charAt(0):"&#"+o.charCodeAt(0)+";"})}static md2htmlOneLine(e){return e=U.escape(e),e=e.replace(/\[([^[]+)\]\(([^)]+)\)/gm,function(o,l,i){return''+l+""}),e=e.replace(/(\*\*[^\*]+\*\*)/gm,function(o){return""+o.substr(2,o.length-4)+""}),e=e.replace(/(\*[^\*]+\*)/gm,function(o){return""+o.substr(1,o.length-2)+""}),e.substr(0,1)=="#"&&(e.substr(0,2)=="# "&&(e="

      "+e.substr(2)+"

      "),e.substr(0,3)=="## "&&(e="

      "+e.substr(3)+"

      "),e.substr(0,4)=="### "&&(e="

      "+e.substr(4)+"

      "),e.substr(0,5)=="#### "&&(e="

      "+e.substr(5)+"

      ")),e}static md2html(e){e=e.replace(/\r\n/g,` +`);for(var o=e.split(` +`),l=[],i=0;i")}}const Mt={name:"FileProperties",components:{Variant:le,Variants:St,Modal:ae},props:{file:{type:Object,default:{}}},inject:["wcfm"],computed:{originalMime:function(){var t;return((t=this.originalInfo)==null?void 0:t.mime)?this.originalInfo.mime:""},convertedMime:function(){var t;return((t=this.convertedInfo)==null?void 0:t.mime)?this.convertedInfo.mime:""}},watch:{file(t,e){t.isDir||this.changePath(t.path)}},methods:{onVariantSelect(t){this.selectedVariant=t},onOriginalLoad(){if(this.updateHeight(),this.$refs.original.$refs.theport.calcScaleZoomRatio()>1){let e=this.$refs.original.$refs.theport,o=e.$refs.theimg,l=e.$refs.root,i=o.naturalWidth,n=o.naturalHeight,a=l.offsetWidth,g=this.height/n,u=a/i;this.zoom=Math.min(g,u)}else this.zoom=1;this.translateX=0,this.translateY=0},onOriginalResize(){this.updateHeight()},onConvertClick(){var o;let t=this;this.converting=!0;let e=(o=this.wcfm.$refs.convertOptions)==null?void 0:o.getOptions();t.errorMsg="",L.post("convert",{path:this.path,convertOptions:e},function(l){t.converting=!1,(l==null?void 0:l.success)==!1&&(t.errorMsg=l.data),l.converted&&(t.convertedInfo=l.converted),l.log&&(t.log=U.md2html(l.log))})},onDeleteConvertedClick(){let t=this;L.post("delete-converted",{path:this.path},function(e){(e==null?void 0:e.success)==!1?t.errorMsg=e.data:(t.log="",t.convertedInfo=null)})},updateHeight(){this.$refs.original&&(this.height=this.$refs.original.$refs.theport.getGoodContainerHeight(),this.scaleZoomRatio=this.$refs.original.$refs.theport.calcScaleZoomRatio())},reset(){this.originalInfo=null,this.convertedInfo=null},reload(){this.load()},changePath(t){this.reset(),this.path=t,this.loading=!0,this.errorMsg="",this.log="",this.load()},load(){let t=this;L.post("info",{path:this.path},function(e){(e==null?void 0:e.success)==!1&&(t.errorMsg=e.data),t.loading=!1,t.originalInfo=e.original,e.converted&&(t.convertedInfo=e.converted),e.log&&(t.log=U.md2html(e.log))})}},mounted(){this.file&&(this.path=this.file.path,this.load())},data(){return{zoom:1,scaleZoomRatio:1,translateX:0,translateY:0,height:100,loading:!1,errorMsg:"",originalInfo:null,convertedInfo:null,path:"",log:"",showingLogDialog:!1,converting:!1}}},F=t=>(Y("data-v-1e863e69"),t=t(),H(),t),It={class:"file-properties"},Ot={class:"path"},Tt=q(" Path: "),$t={class:"path"},Bt={key:0,class:"error"},Lt={class:"variant-wrap"},Dt={class:"variant-footer"},qt={class:"variant-wrap"},At={key:0,class:"variant-footer"},Pt=F(()=>r("use",{"xlink:href":"#icon-trash"},null,-1)),Ut=[Pt],jt=["innerHTML"],Rt={key:0},Ft={key:1},Nt={key:0,class:"icon-converting",width:"15",height:"15"},Yt=F(()=>r("use",{"xlink:href":"#icon-loading"},null,-1)),Ht=[Yt],Wt=["disabled"],Xt=F(()=>r("p",null," Above, you see the original image. If it has been converted, you also see the converted image (provided that your browser supports webp). ",-1)),Et=F(()=>r("p",null," You can zoom in on the image, ie using scroll wheel. Both images will zoom, allowing you to compare the quality. Double-click the image to set zoom to 100%. You can also drag the image. ",-1));function Gt(t,e,o,l,i,n){const a=m("Variant"),c=m("Modal"),g=W("tooltip");return s(),d("div",It,[r("div",Ot,[Tt,r("span",$t,_(o.file.path),1)]),i.errorMsg!=""?(s(),d("div",Bt,[r("p",null,"Error: "+_(i.errorMsg),1)])):p("",!0),z(r("div",null,"Getting info...",512),[[I,i.loading]]),r("div",null,[r("div",Lt,[z(w(a,{ref:"original",title:"Original",info:i.originalInfo,height:i.height,zoom:i.zoom,"onUpdate:zoom":e[0]||(e[0]=u=>i.zoom=u),scaleZoomRatio:i.scaleZoomRatio,translateX:i.translateX,"onUpdate:translateX":e[1]||(e[1]=u=>i.translateX=u),translateY:i.translateY,"onUpdate:translateY":e[2]||(e[2]=u=>i.translateY=u),onLoad:n.onOriginalLoad,onResize:n.onOriginalResize},null,8,["info","height","zoom","scaleZoomRatio","translateX","translateY","onLoad","onResize"]),[[I,i.originalInfo]]),r("div",Dt,_(n.originalMime),1)]),r("div",qt,[z(w(a,{title:"Existing conversion",info:i.convertedInfo,height:i.height,zoom:i.zoom,"onUpdate:zoom":e[3]||(e[3]=u=>i.zoom=u),scaleZoomRatio:i.scaleZoomRatio,translateX:i.translateX,"onUpdate:translateX":e[4]||(e[4]=u=>i.translateX=u),translateY:i.translateY,"onUpdate:translateY":e[5]||(e[5]=u=>i.translateY=u)},null,8,["info","height","zoom","scaleZoomRatio","translateX","translateY"]),[[I,i.convertedInfo]]),i.convertedInfo?(s(),d("div",At,[q(_(n.convertedMime)+" ",1),z((s(),d("svg",{class:"icon-trash",onClick:e[6]||(e[6]=(...u)=>n.onDeleteConvertedClick&&n.onDeleteConvertedClick(...u))},Ut,512)),[[g,"Delete conversion"]])])):p("",!0)])]),z(w(c,{title:"Conversion log",closeButtonText:"Ok",width:"95vw",maxwidth:"1400px",height:"95vh",onClose:e[7]||(e[7]=u=>i.showingLogDialog=!1)},{default:S(()=>[r("div",{innerHTML:i.log},null,8,jt)]),_:1},512),[[I,i.showingLogDialog]]),r("div",null,[r("button",{onClick:e[8]||(e[8]=(...u)=>n.onConvertClick&&n.onConvertClick(...u))},[i.convertedInfo?(s(),d("span",Rt,"Reconvert")):p("",!0),i.convertedInfo?p("",!0):(s(),d("span",Ft,"Convert"))]),i.converting?(s(),d("svg",Nt,Ht)):p("",!0),i.log!=""?(s(),d("button",{key:1,onClick:e[9]||(e[9]=u=>i.showingLogDialog=!0),class:"log-button",disabled:i.converting},"View conversion log",8,Wt)):p("",!0)]),Xt,Et])}var Zt=v(Mt,[["render",Gt],["__scopeId","data-v-1e863e69"]]);const Kt={name:"FolderProperties",components:{},props:{file:{type:Object,default:{}}},watch:{file(t,e){}}},Qt={class:"folder-properties"},Jt={class:"path"},eo=q(" Path: "),to={class:"path"},oo=r("p",null," You cannot do anything on folders yet. Browse to an image... ",-1);function io(t,e,o,l,i,n){return s(),d("div",Qt,[r("div",Jt,[eo,r("span",to,_(o.file.path),1)]),oo])}var no=v(Kt,[["render",io]]);const so={name:"Group",components:{},props:{modelValue:{},ui:Object,schema:Object},emits:["update:modelValue"],methods:{onLocalChange(){this.$emit("update:modelValue",this.modelValue)}},mounted(){},data(){return{}}},ao={class:"group"},lo={key:0};function ro(t,e,o,l,i,n){return s(),d("div",ao,[o.ui.title?(s(),d("h3",lo,_(o.ui?o.ui.title:""),1)):p("",!0),J(t.$slots,"default",{},void 0,!0)])}var co=v(so,[["render",ro],["__scopeId","data-v-6ea04f9d"]]);const uo={name:"Input",props:{modelValue:{},schema:Object,sensitive:{type:Boolean,default:!1}},computed:{inputType:function(){if(this.dataType=="string")return"text";if(this.dataType=="int"||this.dataType=="float")return"number"},inputClass:function(){return this.dataType=="int"?"small":this.sensitive?"sensitive"+(this.passwordRevealed?" revealed":" obscured"):""},valueAsString:function(){return this.modelValue.toString()},dataType:function(){var t;if((t=this==null?void 0:this.schema)==null?void 0:t.type)switch(this.schema.type[0]){case"integer":return"int";default:return"string"}else return""}},emits:["update:modelValue"],methods:{updateLocalModel(t){this.stringValue=t.toString()},onEyeClick(){this.passwordRevealed=!this.passwordRevealed},onLocalChange(){var o;switch(((o=this==null?void 0:this.schema)==null?void 0:o.type)?this.schema.type[0]:"string"){case"integer":var e=parseInt(this.stringValue,10);this.$emit("update:modelValue",e);break;default:this.$emit("update:modelValue",this.stringValue);break}}},mounted(){this.updateLocalModel(this.modelValue)},watch:{modelValue(t,e){this.updateLocalModel(t)},option(t,e){}},data(){return{stringValue:"",passwordRevealed:!1}}},ho=["type"],po=r("use",{"xlink:href":"#icon-eye"},null,-1),mo=[po];function fo(t,e,o,l,i,n){return s(),d("div",{onInput:e[2]||(e[2]=a=>n.onLocalChange()),class:"autoui-input"},[z(r("input",{type:n.inputType,class:$(n.inputClass),"onUpdate:modelValue":e[0]||(e[0]=a=>i.stringValue=a)},null,10,ho),[[me,i.stringValue]]),o.sensitive?(s(),d("svg",{key:0,class:$({"icon-eye":!0,revealed:i.passwordRevealed}),onClick:e[1]||(e[1]=(...a)=>n.onEyeClick&&n.onEyeClick(...a))},mo,2)):p("",!0)],32)}var go=v(uo,[["render",fo]]);const vo={name:"MultiSelect",components:{VueMultiselect:oe},props:{modelValue:{},schema:Object,ui:Object},emits:["update:modelValue"],computed:{options:function(){var e,o;var t=[];return((e=this==null?void 0:this.schema)==null?void 0:e.enum)&&(t=this.schema.enum),((o=this==null?void 0:this.ui)==null?void 0:o.options)&&(t=this.ui.options),t}},methods:{updateLocalModel(t){var e=t.toString().trim().split(",");e=e.filter(function(o){return o!=""}),this.valueAsArray=e},onLocalChange(){this.$emit("update:modelValue",this.valueAsArray.join(","))}},mounted(){this.updateLocalModel(this.modelValue)},watch:{modelValue(t,e){this.updateLocalModel(t)}},data(){return{valueAsArray:[]}}},yo={key:0},_o={key:0,class:"multiselect__single"},wo={key:1,class:"multiselect__single"},bo={key:2,class:"multiselect__single"};function ko(t,e,o,l,i,n){const a=m("VueMultiselect");return s(),x(a,{modelValue:i.valueAsArray,"onUpdate:modelValue":[e[0]||(e[0]=c=>i.valueAsArray=c),n.onLocalChange],options:n.options,multiple:!0,"close-on-select":!1,"clear-on-select":!1,"preserve-search":!1,"preselect-first":!1,searchable:!1,allowEmpty:!0,placeholder:""},{selection:S(({values:c,search:g,isOpen:u})=>[u?p("",!0):(s(),d("span",yo,[c.length>0&&c.lengtho.modelValue=c),n.onLocalChange],options:n.options,multiple:!1,"close-on-select":!0,"clear-on-select":!1,"preserve-search":!1,"preselect-first":!1,searchable:!1,selectLabel:"",deselectLabel:"",allowEmpty:!1},null,8,["modelValue","options","onUpdate:modelValue"])}var zo=v(Vo,[["render",Co]]);const So={name:"Slider",components:{VueSlider:ee},props:{modelValue:{},ui:Object,schema:Object},emits:["update:modelValue"],methods:{onLocalChange(){this.$emit("update:modelValue",this.modelValue)}},mounted(){},data(){return{}}},Mo={class:"slider"};function Io(t,e,o,l,i,n){const a=m("VueSlider");return s(),d("div",Mo,[w(a,{modelValue:o.modelValue,"onUpdate:modelValue":[e[0]||(e[0]=c=>o.modelValue=c),n.onLocalChange],min:o.schema.minimum,max:o.schema.maximum},null,8,["modelValue","onUpdate:modelValue","min","max"])])}var Oo=v(So,[["render",Io],["__scopeId","data-v-8f297ee6"]]);const To={name:"Toggle",props:{modelValue:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},reverse:{type:Boolean,default:!1},checkedText:{type:String,default:null},uncheckedText:{type:String,default:null},width:{type:[Number,String],default:75},height:{type:[Number,String],default:25},uncheckedBg:{type:String,default:"#939393"},uncheckedBgHover:{type:String,default:"#838383"},checkedBg:{type:String,default:"#0075ff"},checkedBgHover:{type:String,default:"#005cc8"},disabledBg:{type:String,default:"red"},dotColor:{type:String,default:"#fff"},fontSize:{type:[Number,String],default:"12"},checkedColor:{type:String,default:"#fff"},uncheckedColor:{type:String,default:"#fff"},fontWeight:{type:[Number,String],default:"normal"}},emits:["update:modelValue"],data(){return{checked:!0,hovering:!1}},methods:{toggle(){this.checked=!this.checked,this.$emit("update:modelValue",this.checked)}},mounted(){this.checked=this.modelValue},watch:{modelValue(t,e){this.checked=t}},computed:{bgStyle(){var t;return this.disabled?t=this.disabledBg:this.checked?t=this.hovering?this.checkedBgHover:this.checkedBg:t=this.hovering?this.uncheckedBgHover:this.uncheckedBg,{width:`${this.width}px`,height:`${this.height}px`,background:t}},dotStyle(){const t={background:this.dotColor,width:`${this.height-8}px`,height:`${this.height-8}px`,"min-width":`${this.height-8}px`,"min-height":`${this.height-8}px`,"margin-left":this.checked?`${this.width-(this.height-3)}px`:"5px"};return!this.checked&&this.reverse||this.checked&&!this.reverse?t.marginLeft=`${this.width-(this.height-3)}px`:(this.checked&&this.reverse||!this.checked&&!this.reverse)&&(t.marginLeft="5px"),t},textStyle(){const t={"font-weight":this.fontWeight,"font-size":`${this.fontSize}px`,color:this.checked&&!this.disabled?this.checkedColor:this.uncheckedColor,right:this.checked?`${this.height-3}px`:"auto",left:this.checked?"auto":`${this.height-3}px`};return!this.checked&&this.reverse?(t.right=`${this.height-3}px`,t.left="auto"):this.checked&&this.reverse&&(t.left=`${this.height-3}px`,t.right="auto"),t}}},$o=["aria-checked","aria-readonly"];function Bo(t,e,o,l,i,n){return s(),d("span",{class:"vue-toggles",style:A(n.bgStyle),role:"switch",tabindex:"0","aria-checked":i.checked?"true":"false","aria-readonly":o.disabled?"true":"false",onClick:e[0]||(e[0]=a=>o.disabled?null:this.toggle()),onKeyup:[e[1]||(e[1]=ie(ne(a=>o.disabled?null:this.toggle(),["prevent"]),["enter"])),e[2]||(e[2]=ie(ne(a=>o.disabled?null:this.toggle(),["prevent"]),["space"]))],onMouseleave:e[3]||(e[3]=a=>i.hovering=!1),onMouseover:e[4]||(e[4]=a=>i.hovering=!0)},[r("span",{"aria-hidden":"true",style:A(n.dotStyle),class:"dot"},[z(r("span",{style:A(n.textStyle),class:"text"},_(o.checkedText),5),[[I,o.checkedText&&i.checked]]),z(r("span",{style:A(n.textStyle),class:"text"},_(o.uncheckedText),5),[[I,o.uncheckedText&&!i.checked]])],4)],44,$o)}var Lo=v(To,[["render",Bo]]);const Do={name:"HelpIcon",props:{schema:Object,ui:Object},computed:{descriptionParagraphs(){return this.schema.description.split(/\n\n/)}},data(){return{btnDown:""}},methods:{}},qo={class:"help-icon"},Ao=r("svg",{class:"icon"},[r("use",{"xlink:href":"#icon-help"})],-1),Po={class:"menu-inner"},Uo={key:0,class:"buttons"},jo=["href","onMousedown"];function Ro(t,e,o,l,i,n){const a=m("VMenu");return s(),d("div",qo,[w(a,{delay:{show:100,hide:150}},{popper:S(()=>[r("div",Po,[(s(!0),d(B,null,P(n.descriptionParagraphs,c=>(s(),d("p",null,_(c),1))),256)),o.ui.links?(s(),d("div",Uo,[(s(!0),d(B,null,P(o.ui.links,c=>(s(),d("a",{href:c[1],target:"_blank",class:$({"mouse-down":i.btnDown==c[0]}),onMousedown:g=>i.btnDown=c[0],onMouseup:e[0]||(e[0]=g=>i.btnDown=""),onMouseleave:e[1]||(e[1]=g=>i.btnDown="")},_(c[0]),43,jo))),256))])):p("",!0)])]),default:S(()=>[Ao]),_:1})])}var Fo=v(Do,[["render",Ro]]);const No={name:"AutoComponent",components:{Group:co,Input:go,MultiSelect:xo,Select:zo,Slider:Oo,Toggle:Lo,HelpIcon:Fo},props:{ui:Object,modelValue:{},schema:Object,advancedView:Boolean,expressionContext:String},emits:["componentDataChange"],watch:{modelValue(t,e){this.updateLocalModel(t)},ui(){this.updateLocalModel(this.modelValue)},schema(){},localModel(t){this.$emit("componentDataChange",{value:this.localModel,"data-property":this.ui["data-property"]})}},computed:{componentSchema(){return this.ui.hasOwnProperty("data-property")&&this.schema.hasOwnProperty("properties")&&this.schema.properties.hasOwnProperty(this.ui["data-property"])?this.schema.properties[this.ui["data-property"]]:null},enabled(){return(this==null?void 0:this.ui.advanced)&&!this.advancedView?!1:this.displayExpr?this.displayExpr.evaluate(this.expressionContext):!0}},data(){return{localModel:"",displayExpr:null}},mounted(){this.updateLocalModel(this.modelValue),(this==null?void 0:this.ui.display)&&(this.displayExpr=new X(this.ui.display))},methods:{updateLocalModel(t){this.ui.hasOwnProperty("data-property")&&t.hasOwnProperty(this.ui["data-property"])&&(this.localModel=t[this.ui["data-property"]])},onComponentDataChange(t){this.$emit("componentDataChange",t)}}},Yo={key:0,class:"auto-component"},Ho={key:0},Wo={key:3};function Xo(t,e,o,l,i,n){const a=m("HelpIcon"),c=m("Toggle"),g=m("Slider"),u=m("Select"),C=m("MultiSelect"),V=m("Input"),y=m("AutoComponent",!0),D=m("Group");return s(),x(fe,{name:"fade"},{default:S(()=>{var O,T;return[n.enabled&&o.ui.component?(s(),d("div",Yo,[r("div",null,[o.ui.component!="group"?(s(),d("div",Ho,[r("label",null,_((O=n.componentSchema)==null?void 0:O.title),1),((T=n.componentSchema)==null?void 0:T.description)?(s(),x(a,{key:0,schema:n.componentSchema,ui:o.ui},null,8,["schema","ui"])):p("",!0)])):p("",!0),r("div",{class:$("component-"+o.ui.component)},[o.ui.component=="checkbox"?(s(),x(c,{key:0,modelValue:i.localModel,"onUpdate:modelValue":e[0]||(e[0]=b=>i.localModel=b),height:20,width:40},null,8,["modelValue"])):p("",!0),o.ui.component=="slider"?(s(),x(g,{key:1,modelValue:i.localModel,"onUpdate:modelValue":e[1]||(e[1]=b=>i.localModel=b),schema:n.componentSchema},null,8,["modelValue","schema"])):p("",!0),o.ui.component=="select"?(s(),x(u,{key:2,modelValue:i.localModel,"onUpdate:modelValue":e[2]||(e[2]=b=>i.localModel=b),schema:n.componentSchema,ui:o.ui},null,8,["modelValue","schema","ui"])):p("",!0),o.ui.component=="multi-select"?(s(),d("div",Wo,[w(C,{modelValue:i.localModel,"onUpdate:modelValue":e[3]||(e[3]=b=>i.localModel=b),schema:n.componentSchema,ui:o.ui},null,8,["modelValue","schema","ui"])])):p("",!0),o.ui.component=="input"?(s(),x(V,{key:4,modelValue:i.localModel,"onUpdate:modelValue":e[4]||(e[4]=b=>i.localModel=b),schema:n.componentSchema},null,8,["modelValue","schema"])):p("",!0),o.ui.component=="password"?(s(),x(V,{key:5,sensitive:!0,modelValue:i.localModel,"onUpdate:modelValue":e[5]||(e[5]=b=>i.localModel=b),schema:n.componentSchema},null,8,["modelValue","schema"])):p("",!0),o.ui.component=="group"?(s(),x(D,{key:6,modelValue:i.localModel,"onUpdate:modelValue":e[6]||(e[6]=b=>i.localModel=b),schema:n.componentSchema,ui:o.ui},{default:S(()=>[(s(!0),d(B,null,P(o.ui["sub-components"],b=>(s(),x(y,{ui:b,schema:o.schema,modelValue:o.modelValue,advancedView:o.advancedView,expressionContext:o.expressionContext,onComponentDataChange:n.onComponentDataChange},null,8,["ui","schema","modelValue","advancedView","expressionContext","onComponentDataChange"]))),256))]),_:1},8,["modelValue","schema","ui"])):p("",!0)],2)])])):p("",!0)]}),_:1})}var Eo=v(No,[["render",Xo],["__scopeId","data-v-26c389a5"]]);const Go={name:"AutoUI",components:{AutoComponent:Eo},props:{schema:Object,ui:Object,modelValue:{},expressionContext:String,advancedView:{type:Boolean,default:!1},showAdvancedButton:{type:Boolean,default:!0}},computed:{},data(){return{}},mounted(){},methods:{onComponentDataChange(t){this.modelValue[t["data-property"]]=t.value}}},Zo={class:"autoui"},Ko={key:0,class:"view-select"},Qo=["textContent"];function Jo(t,e,o,l,i,n){const a=m("AutoComponent"),c=W("tooltip");return s(),d("div",Zo,[o.showAdvancedButton?(s(),d("div",Ko,[z(r("button",{textContent:_(o.advancedView?"Hide advanced options":"Show advanced options"),onClick:e[0]||(e[0]=g=>o.advancedView=!o.advancedView)},null,8,Qo),[[c,"Swich between advanced view (all available options) and simple view (most used options)"]])])):p("",!0),w(a,{ui:o.ui,schema:o.schema,modelValue:o.modelValue,advancedView:o.advancedView,expressionContext:o.expressionContext,onComponentDataChange:n.onComponentDataChange},null,8,["ui","schema","modelValue","advancedView","expressionContext","onComponentDataChange"])])}var ei=v(Go,[["render",Jo],["__scopeId","data-v-30912c7e"]]);const ti={name:"ConvertOptions2",components:{AutoUI:ei},computed:{tweakpng(){var t,e;return((e=(t=this==null?void 0:this.general)==null?void 0:t.data)==null?void 0:e.tweakpng)==null?!0:this.general.data.tweakpng}},methods:{isOptionSupportedByConverter(t,e){return!e||e=="stack"||!this.unsupportedBy||!this.unsupportedBy[t]?!0:!this.unsupportedBy[t].includes(e)},getOptions(){var V;if(!((V=this==null?void 0:this.general)==null?void 0:V.data))return{};let t=JSON.parse(JSON.stringify(this.general.data)),e=t.converter,o={};for(var l in t)t.hasOwnProperty(l)&&this.isOptionSupportedByConverter(l,e)&&(o[l]=t[l]);if(i!="stack"){for(var i in this.uniqueOptionIds)if(i!=e)for(var n=0;nf);for(const f in C)for(let h=0;h=0&&(k.display=k.display.replace(/option.encoding/gi,"getOption('encoding')")),k.display='supported("'+h.id+'") && ('+k.display+")"):k.display='supported("'+h.id+'")',o.push(k);let M=Object.assign({},k);M.display?M.display='overriding("'+h.id+'") && ('+M.display+")":M.display='overriding("'+h.id+'")',i.push(M)}}let b=[];for(const f in C){let h={component:"group",title:f+" options","sub-components":[],display:"(option('converter') == '"+f+"') || (option('converter') == 'stack')"};b[f]=[];let k=!1;for(let M=0;M0&&l.push(h)}a.tweakpng=O,t.uniqueOptionIds=K({},b),t.general={ui:{component:"group",title:"General","sub-components":o},data:a},t.uniqueUi={component:"group",title:"","sub-components":l},t.png={ui:{component:"group",title:"PNG tweaks","sub-components":i},data:c},t.unsupportedBy=D,t.schema={title:"Options",type:["object"],properties:n};let ce={option:function(f){return t.isOptionSupportedByConverter(f,t.general.data.converter)?t.general.data[f]:T[f]},imageType:"any",supported:function(f){return t.general.data.converter?t.isOptionSupportedByConverter(f,t.general.data.converter):!0}},de={option:function(f){let k=t.png.data.overrides.indexOf("converting")>-1?t.png.data.converter:t.general.data.converter;return t.isOptionSupportedByConverter(f,k)?t.png.data.overrides.indexOf(f)>-1?t.png.data[f]:t.general.data[f]:T[f]},imageType:"png",overriding:function(f){return t.png.data.overrides.indexOf(f)>-1},supported:function(f){return t.general.data.converter?t.isOptionSupportedByConverter(f,t.general.data.converter):!0}};X.setGlobalContext(ce,"general"),X.setGlobalContext(de,"png")})},data(){return{schema:{},general:{ui:{},data:{quality:40,"alpha-quality":65,"auto-limit":!0,"command-line-options":"","skip-these-precompiled-binaries":""}},png:{ui:{},data:{}},advancedView:!1,unsupportedBy:[],uniqueUi:[],uniqueOptionIds:[]}}},oi={class:"convert-options"},ii=["textContent"];function ni(t,e,o,l,i,n){const a=m("AutoUI"),c=W("tooltip");return s(),d("div",oi,[z(r("button",{textContent:_(i.advancedView?"Hide advanced options":"Show advanced options"),onClick:e[0]||(e[0]=g=>i.advancedView=!i.advancedView)},null,8,ii),[[c,"Swich between advanced view (all available options) and simple view (most used options)"]]),w(a,{ui:i.general.ui,schema:i.schema,modelValue:i.general.data,expressionContext:"general",advancedView:i.advancedView,showAdvancedButton:!1},null,8,["ui","schema","modelValue","advancedView"]),z(w(a,{ui:i.png.ui,schema:i.schema,modelValue:i.png.data,expressionContext:"png",advancedView:i.advancedView,showAdvancedButton:!1},null,8,["ui","schema","modelValue","advancedView"]),[[I,n.tweakpng]]),w(a,{ui:i.uniqueUi,schema:i.schema,modelValue:i.general.data,expressionContext:"general",advancedView:i.advancedView,showAdvancedButton:!1},null,8,["ui","schema","modelValue","advancedView"])])}var si=v(ti,[["render",ni]]);const ai={name:"Welcome"},li={class:"welcome"},ri=Q('
      Welcome to

      WebP Convert file manager

      To open a folder, click the "+" sign next to the folder name or double click the folder name

      Whats new?

      • You can now adjust conversion settings. The settings start out with the defaults stored in WebP Express (It will however not start out with Stack converter, but instead with your top active working converter - in order to simplify the UI). The options UI are generated from the option definitions exposed be the WebP Convert library. This means that the UI will always be up-to-date. Note that the WebP Express settings is not quite up-to-date. So you will find conversion settings in this interface, which you will not find in WebP Express settings. Also, this interface has a "Auto-limit" option (advanced option), which is used instead of the quality:auto, max-quality and default-quality settings, which are deprecated.

      Whats planned ahead?

      I have plenty of ideas, but no planned priority. Ideas:
      • Trigger bulk conversion on folders / mark for background conversion
      • Stats on folders
      • Display more info on images (dimensions, quality on jpeg and such)
      • Interface to allow adjusting quality on a conversion quickly and compare directly
      • Compare converters side-by-side (visual quality, result size, conversion time)
      To support development, you can buy me a cup of coffee

      ',4),ci=[ri];function di(t,e,o,l,i,n){return s(),d("div",li,ci)}var ui=v(ai,[["render",di]]);const hi={name:"WCFM",components:{SVGs:Ve,ConvertOptions2:si,FileProperties:Zt,FolderProperties:no,Modal:ae,Files:Ge,Splitpanes:se.exports.Splitpanes,Pane:se.exports.Pane,Welcome:ui},methods:{onConvertCloseClick(){console.log("CLRCL"),this.showConvertOptions=!1},onFileSelect(t,e){this.file={path:t,isDir:e}},displayInfo(t){var e=this;L.post("info",{path:t},function(o){e.selectedInfo=o})}},mounted(){var t=this;L.post("get-folder",{path:""},function(e){t.item=e.children[0]})},data(){return{file:null,selectedItem:null,item:null,treeStatusText:"loading file tree...",selectedInfo:{},showConvertOptions:!1}},provide(){return{wcfm:this}}},pi={class:"wcfm",style:{overflow:"hidden"}},mi={class:"pane-content"},fi={key:0,class:"pane-content"};function gi(t,e,o,l,i,n){const a=m("SVGs"),c=m("Files"),g=m("pane"),u=m("FileProperties"),C=m("FolderProperties"),V=m("Welcome"),y=m("splitpanes"),D=m("ConvertOptions2"),O=m("Modal");return s(),d("div",pi,[w(a),w(y,{class:"default-theme",style:{position:"absolute",top:"0",left:"0"}},{default:S(()=>[w(g,{size:"30"},{default:S(()=>[r("div",mi,[r("div",null,[w(c,{item:i.item,statusText:i.treeStatusText,onSelect:n.onFileSelect},null,8,["item","statusText","onSelect"])])])]),_:1}),w(g,{size:"70",style:{"overflow-y":"auto"}},{default:S(()=>[i.file?(s(),d("div",fi,[i.file.isDir?p("",!0):(s(),x(u,{key:0,file:i.file},null,8,["file"])),i.file.isDir?(s(),x(C,{key:1,file:i.file},null,8,["file"])):p("",!0)])):p("",!0),i.file?p("",!0):(s(),x(V,{key:1}))]),_:1})]),_:1}),r("button",{class:"convertOptionsButton",onClick:e[0]||(e[0]=T=>i.showConvertOptions=!0)},"Conversion options"),z(w(O,{title:"Conversion options",closeButtonText:"Ok",width:"600px",height:"95%",maxheight:"650px",alignment:"right",onClose:n.onConvertCloseClick},{default:S(()=>[w(D,{ref:"convertOptions"},null,512)]),_:1},8,["onClose"]),[[I,i.showConvertOptions]])])}var vi=v(hi,[["render",gi]]);var yi={general:[{id:"converter",schema:{title:"Converter",description:"Conversion method. Cwebp and vips are best. the *magick are nearly as good, but only recent versions supports near-lossless. gd is poor, as it does not support any webp options. For full discussion, check the guide",enum:["cwebp","vips","imagick","gmagick","imagemagick","graphicsmagick","wpc","ffmpeg","ewww","gd","stack"],type:["string"],default:"stack"},ui:{component:"select",links:[["Guide","https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/converting/converters.md"]]},sensitive:!1,options:["cwebp","vips","imagick","gmagick","imagemagick","graphicsmagick","wpc","ffmpeg","ewww","gd","stack"]},{id:"encoding",schema:{title:"Encoding",description:'Set encoding for the webp. If you choose "auto", webp-convert will convert to both lossy and lossless and pick the smallest result',enum:["auto","lossy","lossless"],type:["string"],default:"auto"},ui:{component:"select",links:[["Guide","https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#auto-selecting-between-losslesslossy-encoding"]]},sensitive:!1,options:["auto","lossy","lossless"],unsupportedBy:["ewww","gd","stack"]},{id:"quality",schema:{title:"Quality (Lossy)",description:'Quality for lossy encoding. In case you enable "auto-limit", you can consider this property a maximum quality.',"default-png":85,"default-jpeg":75,oneOf:[{type:"number",minimum:0,maximum:100},{type:"string",enum:["auto"]}],type:["integer","string"],default:75},ui:{component:"slider",display:"option('encoding') != 'lossless'"},unsupportedBy:["stack"]},{id:"auto-limit",schema:{title:"Auto-limit",description:`Enable this option to prevent an unnecessarily high quality setting for low quality jpegs. It works by adjusting quality setting down to the quality of the jpeg. Converting ie a jpeg with quality:50 to ie quality:80 does not get you better quality than converting it to quality:80, but it does get you a much bigger file - so you really should enable this option. + +The option is ignored for PNG and never adjusts quality up. + +The feature requires Imagick, ImageMagick or Gmagick in order to detect the quality of the jpeg. + +The option is relative new. Before this option, you could do the same by setting quality to "auto" and specifying a "max-quality" and a "default-quality". These are deprecated now.`,type:["boolean"],default:!0},ui:{component:"checkbox",advanced:!0,links:[["Guide","https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#preventing-unnecessarily-high-quality-setting-for-low-quality-jpegs"]],display:"option('encoding') != 'lossless'"},unsupportedBy:[]},{id:"alpha-quality",schema:{title:"Alpha quality",description:"Quality of alpha channel. Often, there is no need for high quality transparency layer and in some cases you can tweak this all the way down to 10 and save a lot in file size. The option only has effect with lossy encoding, and of course only on images with transparency.",type:["integer"],default:85,minimum:0,maximum:100},ui:{component:"slider",links:[["Guide","https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#alpha-quality"]],display:"(option('encoding') != 'lossless') && (imageType!='jpeg')"},unsupportedBy:["ffmpeg","ewww","gd","stack"]},{id:"near-lossless",schema:{title:'"Near lossless" quality',description:"This option allows you to get impressively better compression for lossless encoding, with minimal impact on visual quality. The range is 0 (maximum preprocessing) to 100 (no preprocessing). Read the guide for more info.",type:["integer"],default:60,minimum:0,maximum:100},ui:{component:"slider",links:[["Guide","https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#near-lossless"]],display:"option('encoding') != 'lossy'"},unsupportedBy:["gmagick","graphicsmagick","ffmpeg","ewww","gd","stack"]},{id:"metadata",schema:{title:"Metadata",description:'Determines which metadata that should be copied over to the webp. Setting it to "all" preserves all metadata, setting it to "none" strips all metadata. *cwebp* can take a comma-separated list of which kinds of metadata that should be copied (ie "exif,icc"). *gd* will always remove all metadata and *ffmpeg* will always keep all metadata. The rest can either strip all or keep all (they will keep all, unless the option is set to *none*)',type:["string"],default:"none"},ui:{component:"multi-select",options:["all","none","exif","icc","xmp"]},sensitive:!1,unsupportedBy:["ffmpeg","gd","stack"]},{id:"method",schema:{title:"Reduction effort (0-6)",description:'Controls the trade off between encoding speed and the compressed file size and quality. Possible values range from 0 to 6. 0 is fastest. 6 results in best quality and compression. PS: The option corresponds to the "method" option in libwebp',type:["integer"],default:6,minimum:0,maximum:6},ui:{component:"slider",advanced:!0},unsupportedBy:["ewww","gd","stack"]},{id:"sharp-yuv",schema:{title:"Sharp YUV",description:"Better RGB->YUV color conversion (sharper and more accurate) at the expense of a little extra conversion time.",type:["boolean"],default:!0},ui:{component:"checkbox",advanced:!0,links:[["Ctrl.blog","https://www.ctrl.blog/entry/webp-sharp-yuv.html"]]},unsupportedBy:["ffmpeg","ewww","gd","stack"]},{id:"auto-filter",schema:{title:"Auto-filter",description:"Turns auto-filter on. This algorithm will spend additional time optimizing the filtering strength to reach a well-balanced quality. Unfortunately, it is extremely expensive in terms of computation. It takes about 5-10 times longer to do a conversion. A 1MB picture which perhaps typically takes about 2 seconds to convert, will takes about 15 seconds to convert with auto-filter. ",type:["boolean"],default:!1},ui:{component:"checkbox",advanced:!0},unsupportedBy:["vips","ffmpeg","ewww","gd","stack"]},{id:"low-memory",schema:{title:"Low memory",description:"Reduce memory usage of lossy encoding at the cost of ~30% longer encoding time and marginally larger output size. Only effective when the *method* option is 3 or more. Read more in [the docs](https://developers.google.com/speed/webp/docs/cwebp)",type:["boolean"],default:!1},ui:{component:"checkbox",advanced:!0,display:"(option('encoding') != 'lossless') && (option('method')>2)"},unsupportedBy:["ffmpeg","ewww","gd","stack"]},{id:"preset",schema:{title:"Preset",description:'Using a preset will set many of the other options to suit a particular type of source material. It even overrides them. It does however not override the quality option. "none" means that no preset will be set',enum:["none","default","photo","picture","drawing","icon","text"],type:["string"],default:"none"},ui:{component:"select",advanced:!0},sensitive:!1,options:["none","default","photo","picture","drawing","icon","text"],unsupportedBy:["ewww","gd","stack"]}],unique:{cwebp:[{id:"cwebp-use-nice",schema:{title:"Use nice",description:"If *use-nice* is set, it will be examined if the *nice* command is available. If it is, the binary is executed using *nice*. This assigns low priority to the process and will save system resources - but result in slower conversion.",type:["boolean"],default:!0},ui:{component:"checkbox",advanced:!0}},{id:"cwebp-try-cwebp",schema:{title:"Try plain cwebp command",description:'If set, the converter will try executing "cwebp -version". In case it succeeds, and the version is higher than those working cwebps found using other methods, the conversion will be done by executing this cwebp.',type:["boolean"],default:!0},ui:{component:"checkbox",advanced:!0}},{id:"cwebp-try-discovering-cwebp",schema:{title:"Try discovering cwebp binary",description:'If set, the converter will try to discover installed cwebp binaries using a "which -a cwebp" command, or in case that fails, a "whereis -b cwebp" command. These commands will find cwebp binaries residing in PATH',type:["boolean"],default:!0},ui:{component:"checkbox",advanced:!0}},{id:"cwebp-try-common-system-paths",schema:{title:"Try locating cwebp in common system paths",description:'If set, the converter will look for a cwebp binaries residing in common system locations such as "/usr/bin/cwebp". If such exist, it is assumed that they are valid cwebp binaries. A version check will be run on the binaries found (they are executed with the "-version" flag. The cwebp with the highest version found using this method and the other enabled methods will be used for the actual conversion.Note: All methods for discovering cwebp binaries are per default enabled. You can save a few microseconds by disabling some, but it is probably not worth it, as your setup will then become less resilient to system changes.',type:["boolean"],default:!0},ui:{component:"checkbox",advanced:!0}},{id:"cwebp-try-supplied-binary-for-os",schema:{title:"Try precompiled cwebp binaries",description:'If set, the converter will try use a precompiled cwebp binary that comes with webp-convert. But only if it has a higher version that those found by other methods. As the library knows the versions of its bundled binaries, no additional time is spent executing them with the "-version" parameter. The binaries are hash-checked before executed. The library btw. comes with several versions of precompiled cwebps because they have different dependencies - some works on some systems and others on others.',type:["boolean"],default:!0},ui:{component:"checkbox",advanced:!0}},{id:"cwebp-skip-these-precompiled-binaries",schema:{title:"Skip these precompiled binaries",description:"",type:["string"],default:""},ui:{component:"multi-select",advanced:!0,options:["cwebp-120-linux-x86-64","cwebp-110-linux-x86-64","cwebp-103-linux-x86-64-static","cwebp-061-linux-x86-64"],display:"option('cwebp-try-supplied-binary-for-os') == true"},sensitive:!1},{id:"cwebp-rel-path-to-precompiled-binaries",schema:{title:"Rel path to precompiled binaries",description:"",type:["string"],default:"./Binaries"},ui:{component:"",advanced:!0,display:"option('cwebp-try-supplied-binary-for-os') == true"},sensitive:!0},{id:"cwebp-command-line-options",schema:{title:"Command line options",description:"",type:["string"],default:""},ui:{component:"input",advanced:!0},sensitive:!1}],vips:[],imagick:[],gmagick:[],imagemagick:[{id:"imagemagick-use-nice",schema:{title:"Use nice",description:"If *use-nice* is set, it will be examined if the *nice* command is available. If it is, the binary is executed using *nice*. This assigns low priority to the process and will save system resources - but result in slower conversion.",type:["boolean"],default:!0},ui:{component:"checkbox",advanced:!0}},{id:"imagemagick-try-common-system-paths",schema:{title:"Try locating ImageMagick in common system paths",description:'If set, the converter will look for a ImageMagick binaries residing in common system locations such as "/usr/bin/convert". If such exist, it is assumed that they are valid ImageMagick binaries. ',type:["boolean"],default:!0},ui:{component:"checkbox",advanced:!0}}],graphicsmagick:[{id:"graphicsmagick-use-nice",schema:{title:"Use nice",description:"If *use-nice* is set, it will be examined if the *nice* command is available. If it is, the binary is executed using *nice*. This assigns low priority to the process and will save system resources - but result in slower conversion.",type:["boolean"],default:!0},ui:{component:"checkbox",advanced:!0}}],wpc:[{id:"wpc-api-key",schema:{title:"API key",description:"",type:["string"],default:""},ui:{component:"password",advanced:!1,display:"option('wpc-api-version') != 0"},sensitive:!0},{id:"wpc-secret",schema:{title:"Secret",description:"",type:["string"],default:""},ui:{component:"password",advanced:!1,display:"option('wpc-api-version') == 0"},sensitive:!0},{id:"wpc-api-url",schema:{title:"API url",description:"URL to connect to",type:["string"],default:""},ui:{component:"password",advanced:!1},sensitive:!0},{id:"wpc-api-version",schema:{title:"API version",description:"",type:["integer"],default:2,minimum:0,maximum:2},ui:{component:"select",advanced:!1,options:["0","1","2"]}},{id:"wpc-crypt-api-key-in-transfer",schema:{title:"Crypt API key in transfer",description:"",type:["boolean"],default:!1},ui:{component:"checkbox",advanced:!0,display:"option('wpc-api-version') >= 1"}}],ffmpeg:[{id:"ffmpeg-use-nice",schema:{title:"Use nice",description:"If *use-nice* is set, it will be examined if the *nice* command is available. If it is, the binary is executed using *nice*. This assigns low priority to the process and will save system resources - but result in slower conversion.",type:["boolean"],default:!0},ui:{component:"checkbox",advanced:!0}}],ewww:[{id:"ewww-api-key",schema:{title:"Ewww API key",description:'ewww API key. If you choose "auto", webp-convert will convert to both lossy and lossless and pick the smallest result',type:["string"],default:""},ui:{component:"password"},sensitive:!0},{id:"ewww-check-key-status-before-converting",schema:{title:"Check key status before converting",description:"If enabled, the api key will be validated (relative inexpensive) before trying to convert. For automatic conversions, you should enable it. Otherwise you run the risk that the same files will be uploaded to ewww cloud service over and over again, in case the key has expired. For manually triggered conversions, you can safely disable the option.",type:["boolean"],default:!0},ui:{component:"checkbox"}}],gd:[],stack:[{id:"stack-converters",schema:{title:"Converters",description:"Converters to try, ordered by priority.",sensitive:!0,type:["array"],default:["cwebp","vips","imagick","gmagick","imagemagick","graphicsmagick","wpc","ffmpeg","ewww","gd"]},ui:{component:"multi-select",options:["cwebp","vips","imagick","gmagick","imagemagick","graphicsmagick","wpc","ffmpeg","ewww","gd"],advanced:!0},sensitive:!0},{id:"stack-shuffle",schema:{title:"Shuffle",description:"Shuffles the converter order on each conversion. Can for example be used to spread out requests on multiple cloud converters",type:["boolean"],default:!1},ui:{component:"checkbox",advanced:!0}}]}};const E=ge(vi);E.use(te);E.use(ve,{defaultHtml:!1});window.wcfmoptions||(window.wcfmoptions={},window.wcfmoptions.poster=function(t,e,o,l){switch(t){case"get-folder":switch(e.path){case"":var i={children:[{name:"/",isDir:!0,nickname:"root"}]};break;case"/":var i={children:[{name:"empty-folder",isDir:!0,isEmpty:!0},{name:"file",isDir:!1,isConverted:!0},{name:"aaa",isDir:!1,isConverted:!0},{name:"test-folder",isDir:!0},{name:"file2",isDir:!1,isConverted:!1}]};break;case"/empty-folder":var i={children:[]};break;case"/test-folder":var i={children:[{name:"banana",isDir:!1},{name:"subfolder",isDir:!0},{name:"apple",isDir:!1}]};break;case"/test-folder/subfolder":var i={children:[{name:"file2",isDir:!1},{name:"file1",isDir:!1}]};break;default:l();return}break;case"conversion-settings":var i={options:yi,defaults:{quality:17,"ewww-api-key":"apilapi","wpc-api-key":"bogulogu",png:{encoding:"lossless",quality:90},converter:"vips"},systemStatus:{converterRequirements:{gd:{extensionLoaded:!1,compiledWithWebP:!0}}}};break;case"info":if(e.path=="/file2")var i={original:{size:100,url:"http://localhost:3000/src/assets/200x100.jpg",mime2:"image/jpeg"},converted:{size:70,url:"http://localhost:3000/src/assets/200x100.jpg",mime:"image/webp"},log:`blah blah blah +\rand *more* blah`};else if(e.path=="/file")var i={original:{size:100,url:"http://localhost:3000/src/assets/dummy2.jpg",mime:"image/jpeg"},log:"blah blah *blah*"};else var i={original:{size:100,url:"http://localhost:3000/src/assets/dummy.jpg",mime:"image/jpeg"},converted:{size:70,url:"http://localhost:3000/src/assets/dummy.jpg",mime:"image/webp"},log:"blah blah *blah*"};break;case"convert":if(e.path=="/file2")var i={success:!1,data:"We pretend file2 errors converting...",log:"Oh no!"};else var i={success:!0,converted:{size:26050,url:"http://we0/wordpress/wp-content/uploads/2021/10/Screenshot_2021-10-04_13-43-11.png.webp",mime:"image/webp"},log:`All is *groovy* +next line`};break;case"delete-converted":if(e.path=="/file2")var i={success:!1,data:"We pretend file2 errors deleting..."};else var i={success:!0};break;default:var i="ok";break}o(i)});E.mount("#webpconvert-filemanager"); diff --git a/lib/wcfm/vendor.fa68d508.js b/lib/wcfm/vendor.fa68d508.js new file mode 100644 index 0000000..7c9bcf8 --- /dev/null +++ b/lib/wcfm/vendor.fa68d508.js @@ -0,0 +1,16 @@ +var Fh=Object.defineProperty;var Rc=Object.getOwnPropertySymbols;var jh=Object.prototype.hasOwnProperty,Vh=Object.prototype.propertyIsEnumerable;var qs=(e,t,n)=>t in e?Fh(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,po=(e,t)=>{for(var n in t||(t={}))jh.call(t,n)&&qs(e,n,t[n]);if(Rc)for(var n of Rc(t))Vh.call(t,n)&&qs(e,n,t[n]);return e};var Sr=(e,t,n)=>(qs(e,typeof t!="symbol"?t+"":t,n),n);function it(e,t){const n=Object.create(null),r=e.split(",");for(let i=0;i!!n[i.toLowerCase()]:i=>!!n[i]}const _s={[1]:"TEXT",[2]:"CLASS",[4]:"STYLE",[8]:"PROPS",[16]:"FULL_PROPS",[32]:"HYDRATE_EVENTS",[64]:"STABLE_FRAGMENT",[128]:"KEYED_FRAGMENT",[256]:"UNKEYED_FRAGMENT",[512]:"NEED_PATCH",[1024]:"DYNAMIC_SLOTS",[2048]:"DEV_ROOT_FRAGMENT",[-1]:"HOISTED",[-2]:"BAIL"},zh={[1]:"STABLE",[2]:"DYNAMIC",[3]:"FORWARDED"},Bh="Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt",Ic=it(Bh),$c=2;function Lc(e,t=0,n=e.length){let r=e.split(/(\r?\n)/);const i=r.filter((a,l)=>l%2==1);r=r.filter((a,l)=>l%2==0);let o=0;const s=[];for(let a=0;a=t){for(let l=a-$c;l<=a+$c||n>o;l++){if(l<0||l>=r.length)continue;const c=l+1;s.push(`${c}${" ".repeat(Math.max(3-String(c).length,0))}| ${r[l]}`);const u=r[l].length,f=i[l]&&i[l].length||0;if(l===a){const p=t-(o-(u+f)),d=Math.max(1,n>o?u-p:n-t);s.push(" | "+" ".repeat(p)+"^".repeat(d))}else if(l>a){if(n>o){const p=Math.max(Math.min(n-o,u),1);s.push(" | "+"^".repeat(p))}o+=u+f}}break}return s.join(` +`)}const Dc="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Fc=it(Dc),Hh=it(Dc+",async,autofocus,autoplay,controls,default,defer,disabled,hidden,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected");function ea(e){return!!e||e===""}const kh=/[>/="'\u0009\u000a\u000c\u0020]/,ta={};function Uh(e){if(ta.hasOwnProperty(e))return ta[e];const t=kh.test(e);return t&&console.error(`unsafe attribute name: ${e}`),ta[e]=!t}const Kh={acceptCharset:"accept-charset",className:"class",htmlFor:"for",httpEquiv:"http-equiv"},jc=it("animation-iteration-count,border-image-outset,border-image-slice,border-image-width,box-flex,box-flex-group,box-ordinal-group,column-count,columns,flex,flex-grow,flex-positive,flex-shrink,flex-negative,flex-order,grid-row,grid-row-end,grid-row-span,grid-row-start,grid-column,grid-column-end,grid-column-span,grid-column-start,font-weight,line-clamp,line-height,opacity,order,orphans,tab-size,widows,z-index,zoom,fill-opacity,flood-opacity,stop-opacity,stroke-dasharray,stroke-dashoffset,stroke-miterlimit,stroke-opacity,stroke-width"),Wh=it("accept,accept-charset,accesskey,action,align,allow,alt,async,autocapitalize,autocomplete,autofocus,autoplay,background,bgcolor,border,buffered,capture,challenge,charset,checked,cite,class,code,codebase,color,cols,colspan,content,contenteditable,contextmenu,controls,coords,crossorigin,csp,data,datetime,decoding,default,defer,dir,dirname,disabled,download,draggable,dropzone,enctype,enterkeyhint,for,form,formaction,formenctype,formmethod,formnovalidate,formtarget,headers,height,hidden,high,href,hreflang,http-equiv,icon,id,importance,integrity,ismap,itemprop,keytype,kind,label,lang,language,loading,list,loop,low,manifest,max,maxlength,minlength,media,min,multiple,muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,preload,radiogroup,readonly,referrerpolicy,rel,required,reversed,rows,rowspan,sandbox,scope,scoped,selected,shape,size,sizes,slot,span,spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,target,title,translate,type,usemap,value,width,wrap"),Yh=it("xmlns,accent-height,accumulate,additive,alignment-baseline,alphabetic,amplitude,arabic-form,ascent,attributeName,attributeType,azimuth,baseFrequency,baseline-shift,baseProfile,bbox,begin,bias,by,calcMode,cap-height,class,clip,clipPathUnits,clip-path,clip-rule,color,color-interpolation,color-interpolation-filters,color-profile,color-rendering,contentScriptType,contentStyleType,crossorigin,cursor,cx,cy,d,decelerate,descent,diffuseConstant,direction,display,divisor,dominant-baseline,dur,dx,dy,edgeMode,elevation,enable-background,end,exponent,fill,fill-opacity,fill-rule,filter,filterRes,filterUnits,flood-color,flood-opacity,font-family,font-size,font-size-adjust,font-stretch,font-style,font-variant,font-weight,format,from,fr,fx,fy,g1,g2,glyph-name,glyph-orientation-horizontal,glyph-orientation-vertical,glyphRef,gradientTransform,gradientUnits,hanging,height,href,hreflang,horiz-adv-x,horiz-origin-x,id,ideographic,image-rendering,in,in2,intercept,k,k1,k2,k3,k4,kernelMatrix,kernelUnitLength,kerning,keyPoints,keySplines,keyTimes,lang,lengthAdjust,letter-spacing,lighting-color,limitingConeAngle,local,marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mask,maskContentUnits,maskUnits,mathematical,max,media,method,min,mode,name,numOctaves,offset,opacity,operator,order,orient,orientation,origin,overflow,overline-position,overline-thickness,panose-1,paint-order,path,pathLength,patternContentUnits,patternTransform,patternUnits,ping,pointer-events,points,pointsAtX,pointsAtY,pointsAtZ,preserveAlpha,preserveAspectRatio,primitiveUnits,r,radius,referrerPolicy,refX,refY,rel,rendering-intent,repeatCount,repeatDur,requiredExtensions,requiredFeatures,restart,result,rotate,rx,ry,scale,seed,shape-rendering,slope,spacing,specularConstant,specularExponent,speed,spreadMethod,startOffset,stdDeviation,stemh,stemv,stitchTiles,stop-color,stop-opacity,strikethrough-position,strikethrough-thickness,string,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,stroke-width,style,surfaceScale,systemLanguage,tabindex,tableValues,target,targetX,targetY,text-anchor,text-decoration,text-rendering,textLength,to,transform,transform-origin,type,u1,u2,underline-position,underline-thickness,unicode,unicode-bidi,unicode-range,units-per-em,v-alphabetic,v-hanging,v-ideographic,v-mathematical,values,vector-effect,version,vert-adv-y,vert-origin-x,vert-origin-y,viewBox,viewTarget,visibility,width,widths,word-spacing,writing-mode,x,x-height,x1,x2,xChannelSelector,xlink:actuate,xlink:arcrole,xlink:href,xlink:role,xlink:show,xlink:title,xlink:type,xml:base,xml:lang,xml:space,y,y1,y2,yChannelSelector,z,zoomAndPan");function Zt(e){if(ue(e)){const t={};for(let n=0;n{if(n){const r=n.split(Gh);r.length>1&&(t[r[0].trim()]=r[1].trim())}}),t}function Zh(e){let t="";if(!e||be(e))return t;for(const n in e){const r=e[n],i=n.startsWith("--")?n:Ot(n);(be(r)||typeof r=="number"&&jc(i))&&(t+=`${i}:${r};`)}return t}function vn(e){let t="";if(be(e))t=e;else if(ue(e))for(let n=0;n]/;function em(e){const t=""+e,n=_h.exec(t);if(!n)return t;let r="",i,o,s=0;for(o=n.index;o||--!>|ln(n,t))}const Jt=e=>e==null?"":ue(e)||je(e)&&(e.toString===sa||!ye(e.toString))?JSON.stringify(e,kc,2):String(e),kc=(e,t)=>t&&t.__v_isRef?kc(e,t.value):Kn(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[r,i])=>(n[`${r} =>`]=i,n),{})}:bn(t)?{[`Set(${t.size})`]:[...t.values()]}:je(t)&&!ue(t)&&!aa(t)?String(t):t,im=["bigInt","optionalChaining","nullishCoalescingOperator"],Ce={},Un=[],pt=()=>{},ni=()=>!1,om=/^on[^a-z]/,yn=e=>om.test(e),ho=e=>e.startsWith("onUpdate:"),Me=Object.assign,ra=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},sm=Object.prototype.hasOwnProperty,xe=(e,t)=>sm.call(e,t),ue=Array.isArray,Kn=e=>ri(e)==="[object Map]",bn=e=>ri(e)==="[object Set]",ia=e=>e instanceof Date,ye=e=>typeof e=="function",be=e=>typeof e=="string",Wn=e=>typeof e=="symbol",je=e=>e!==null&&typeof e=="object",oa=e=>je(e)&&ye(e.then)&&ye(e.catch),sa=Object.prototype.toString,ri=e=>sa.call(e),Uc=e=>ri(e).slice(8,-1),aa=e=>ri(e)==="[object Object]",mo=e=>be(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Sn=it(",key,ref,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),go=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},am=/-(\w)/g,st=go(e=>e.replace(am,(t,n)=>n?n.toUpperCase():"")),lm=/\B([A-Z])/g,Ot=go(e=>e.replace(lm,"-$1").toLowerCase()),En=go(e=>e.charAt(0).toUpperCase()+e.slice(1)),Yn=go(e=>e?`on${En(e)}`:""),Er=(e,t)=>!Object.is(e,t),Xn=(e,t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,value:n})},cn=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let Kc;const Wc=()=>Kc||(Kc=typeof globalThis!="undefined"?globalThis:typeof self!="undefined"?self:typeof window!="undefined"?window:typeof global!="undefined"?global:{});var cm=Object.freeze({__proto__:null,[Symbol.toStringTag]:"Module",EMPTY_ARR:Un,EMPTY_OBJ:Ce,NO:ni,NOOP:pt,PatchFlagNames:_s,babelParserDefaultPlugins:im,camelize:st,capitalize:En,def:ii,escapeHtml:em,escapeHtmlComment:nm,extend:Me,generateCodeFrame:Lc,getGlobalThis:Wc,hasChanged:Er,hasOwn:xe,hyphenate:Ot,includeBooleanAttr:ea,invokeArrayFns:Xn,isArray:ue,isBooleanAttr:Hh,isDate:ia,isFunction:ye,isGloballyWhitelisted:Ic,isHTMLTag:zc,isIntegerKey:mo,isKnownHtmlAttr:Wh,isKnownSvgAttr:Yh,isMap:Kn,isModelListener:ho,isNoUnitNumericStyleProp:jc,isObject:je,isOn:yn,isPlainObject:aa,isPromise:oa,isReservedProp:Sn,isSSRSafeAttrName:Uh,isSVGTag:Bc,isSet:bn,isSpecialBooleanAttr:Fc,isString:be,isSymbol:Wn,isVoidTag:Hc,looseEqual:ln,looseIndexOf:ti,makeMap:it,normalizeClass:vn,normalizeProps:Vc,normalizeStyle:Zt,objectToString:sa,parseStringStyle:na,propsToAttrMap:Kh,remove:ra,slotFlagsText:zh,stringifyStyle:Zh,toDisplayString:Jt,toHandlerKey:Yn,toNumber:cn,toRawType:Uc,toTypeString:ri});let Qt;const vo=[];class la{constructor(t=!1){this.active=!0,this.effects=[],this.cleanups=[],!t&&Qt&&(this.parent=Qt,this.index=(Qt.scopes||(Qt.scopes=[])).push(this)-1)}run(t){if(this.active)try{return this.on(),t()}finally{this.off()}}on(){this.active&&(vo.push(this),Qt=this)}off(){this.active&&(vo.pop(),Qt=vo[vo.length-1])}stop(t){if(this.active){if(this.effects.forEach(n=>n.stop()),this.cleanups.forEach(n=>n()),this.scopes&&this.scopes.forEach(n=>n.stop(!0)),this.parent&&!t){const n=this.parent.scopes.pop();n&&n!==this&&(this.parent.scopes[this.index]=n,n.index=this.index)}this.active=!1}}}function um(e){return new la(e)}function Yc(e,t){t=t||Qt,t&&t.active&&t.effects.push(e)}function fm(){return Qt}function pm(e){Qt&&Qt.cleanups.push(e)}const ca=e=>{const t=new Set(e);return t.w=0,t.n=0,t},Xc=e=>(e.w&On)>0,Gc=e=>(e.n&On)>0,dm=({deps:e})=>{if(e.length)for(let t=0;t{const{deps:t}=e;if(t.length){let n=0;for(let r=0;r0?si[t-1]:void 0}}stop(){this.active&&(Zc(this),this.onStop&&this.onStop(),this.active=!1)}}function Zc(e){const{deps:t}=e;if(t.length){for(let n=0;n{(c==="length"||c>=r)&&a.push(l)});else switch(n!==void 0&&a.push(s.get(n)),t){case"add":ue(e)?mo(n)&&a.push(s.get("length")):(a.push(s.get(Zn)),Kn(e)&&a.push(s.get(pa)));break;case"delete":ue(e)||(a.push(s.get(Zn)),Kn(e)&&a.push(s.get(pa)));break;case"set":Kn(e)&&a.push(s.get(Zn));break}if(a.length===1)a[0]&&ha(a[0]);else{const l=[];for(const c of a)c&&l.push(...c);ha(ca(l))}}function ha(e,t){for(const n of ue(e)?e:[...e])(n!==Gn||n.allowRecurse)&&(n.scheduler?n.scheduler():n.run())}const ym=it("__proto__,__v_isRef,__isVue"),qc=new Set(Object.getOwnPropertyNames(Symbol).map(e=>Symbol[e]).filter(Wn)),bm=yo(),Sm=yo(!1,!0),Em=yo(!0),Om=yo(!0,!0),_c=wm();function wm(){const e={};return["includes","indexOf","lastIndexOf"].forEach(t=>{e[t]=function(...n){const r=Te(this);for(let o=0,s=this.length;o{e[t]=function(...n){Jn();const r=Te(this)[t].apply(this,n);return wn(),r}}),e}function yo(e=!1,t=!1){return function(r,i,o){if(i==="__v_isReactive")return!e;if(i==="__v_isReadonly")return e;if(i==="__v_raw"&&o===(e?t?uu:cu:t?lu:au).get(r))return r;const s=ue(r);if(!e&&s&&xe(_c,i))return Reflect.get(_c,i,o);const a=Reflect.get(r,i,o);return(Wn(i)?qc.has(i):ym(i))||(e||wt(r,"get",i),t)?a:at(a)?!s||!mo(i)?a.value:a:je(a)?e?ga(a):xo(a):a}}const Tm=eu(),Pm=eu(!0);function eu(e=!1){return function(n,r,i,o){let s=n[r];if(!e&&(i=Te(i),s=Te(s),!ue(n)&&at(s)&&!at(i)))return s.value=i,!0;const a=ue(n)&&mo(r)?Number(r)e,bo=e=>Reflect.getPrototypeOf(e);function So(e,t,n=!1,r=!1){e=e.__v_raw;const i=Te(e),o=Te(t);t!==o&&!n&&wt(i,"get",t),!n&&wt(i,"get",o);const{has:s}=bo(i),a=r?ma:n?Sa:li;if(s.call(i,t))return a(e.get(t));if(s.call(i,o))return a(e.get(o));e!==i&&e.get(t)}function Eo(e,t=!1){const n=this.__v_raw,r=Te(n),i=Te(e);return e!==i&&!t&&wt(r,"has",e),!t&&wt(r,"has",i),e===i?n.has(e):n.has(e)||n.has(i)}function Oo(e,t=!1){return e=e.__v_raw,!t&&wt(Te(e),"iterate",Zn),Reflect.get(e,"size",e)}function ru(e){e=Te(e);const t=Te(this);return bo(t).has.call(t,e)||(t.add(e),un(t,"add",e,e)),this}function iu(e,t){t=Te(t);const n=Te(this),{has:r,get:i}=bo(n);let o=r.call(n,e);o||(e=Te(e),o=r.call(n,e));const s=i.call(n,e);return n.set(e,t),o?Er(t,s)&&un(n,"set",e,t):un(n,"add",e,t),this}function ou(e){const t=Te(this),{has:n,get:r}=bo(t);let i=n.call(t,e);i||(e=Te(e),i=n.call(t,e)),r&&r.call(t,e);const o=t.delete(e);return i&&un(t,"delete",e,void 0),o}function su(){const e=Te(this),t=e.size!==0,n=e.clear();return t&&un(e,"clear",void 0,void 0),n}function wo(e,t){return function(r,i){const o=this,s=o.__v_raw,a=Te(s),l=t?ma:e?Sa:li;return!e&&wt(a,"iterate",Zn),s.forEach((c,u)=>r.call(i,l(c),l(u),o))}}function To(e,t,n){return function(...r){const i=this.__v_raw,o=Te(i),s=Kn(o),a=e==="entries"||e===Symbol.iterator&&s,l=e==="keys"&&s,c=i[e](...r),u=n?ma:t?Sa:li;return!t&&wt(o,"iterate",l?pa:Zn),{next(){const{value:f,done:p}=c.next();return p?{value:f,done:p}:{value:a?[u(f[0]),u(f[1])]:u(f),done:p}},[Symbol.iterator](){return this}}}}function Tn(e){return function(...t){return e==="delete"?!1:this}}function Rm(){const e={get(o){return So(this,o)},get size(){return Oo(this)},has:Eo,add:ru,set:iu,delete:ou,clear:su,forEach:wo(!1,!1)},t={get(o){return So(this,o,!1,!0)},get size(){return Oo(this)},has:Eo,add:ru,set:iu,delete:ou,clear:su,forEach:wo(!1,!0)},n={get(o){return So(this,o,!0)},get size(){return Oo(this,!0)},has(o){return Eo.call(this,o,!0)},add:Tn("add"),set:Tn("set"),delete:Tn("delete"),clear:Tn("clear"),forEach:wo(!0,!1)},r={get(o){return So(this,o,!0,!0)},get size(){return Oo(this,!0)},has(o){return Eo.call(this,o,!0)},add:Tn("add"),set:Tn("set"),delete:Tn("delete"),clear:Tn("clear"),forEach:wo(!0,!0)};return["keys","values","entries",Symbol.iterator].forEach(o=>{e[o]=To(o,!1,!1),n[o]=To(o,!0,!1),t[o]=To(o,!1,!0),r[o]=To(o,!0,!0)}),[e,n,t,r]}const[Im,$m,Lm,Dm]=Rm();function Po(e,t){const n=t?e?Dm:Lm:e?$m:Im;return(r,i,o)=>i==="__v_isReactive"?!e:i==="__v_isReadonly"?e:i==="__v_raw"?r:Reflect.get(xe(n,i)&&i in r?n:r,i,o)}const Fm={get:Po(!1,!1)},jm={get:Po(!1,!0)},Vm={get:Po(!0,!1)},zm={get:Po(!0,!0)},au=new WeakMap,lu=new WeakMap,cu=new WeakMap,uu=new WeakMap;function Bm(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function Hm(e){return e.__v_skip||!Object.isExtensible(e)?0:Bm(Uc(e))}function xo(e){return e&&e.__v_isReadonly?e:Co(e,!1,tu,Fm,au)}function fu(e){return Co(e,!1,Am,jm,lu)}function ga(e){return Co(e,!0,nu,Vm,cu)}function km(e){return Co(e,!0,Nm,zm,uu)}function Co(e,t,n,r,i){if(!je(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;const o=i.get(e);if(o)return o;const s=Hm(e);if(s===0)return e;const a=new Proxy(e,s===2?r:n);return i.set(e,a),a}function Qn(e){return va(e)?Qn(e.__v_raw):!!(e&&e.__v_isReactive)}function va(e){return!!(e&&e.__v_isReadonly)}function ya(e){return Qn(e)||va(e)}function Te(e){const t=e&&e.__v_raw;return t?Te(t):e}function ba(e){return ii(e,"__v_skip",!0),e}const li=e=>je(e)?xo(e):e,Sa=e=>je(e)?ga(e):e;function Ea(e){Jc()&&(e=Te(e),e.dep||(e.dep=ca()),Qc(e.dep))}function Mo(e,t){e=Te(e),e.dep&&ha(e.dep)}function at(e){return Boolean(e&&e.__v_isRef===!0)}function Pn(e){return pu(e,!1)}function Um(e){return pu(e,!0)}function pu(e,t){return at(e)?e:new Km(e,t)}class Km{constructor(t,n){this._shallow=n,this.dep=void 0,this.__v_isRef=!0,this._rawValue=n?t:Te(t),this._value=n?t:li(t)}get value(){return Ea(this),this._value}set value(t){t=this._shallow?t:Te(t),Er(t,this._rawValue)&&(this._rawValue=t,this._value=this._shallow?t:li(t),Mo(this))}}function Wm(e){Mo(e)}function du(e){return at(e)?e.value:e}const Ym={get:(e,t,n)=>du(Reflect.get(e,t,n)),set:(e,t,n,r)=>{const i=e[t];return at(i)&&!at(n)?(i.value=n,!0):Reflect.set(e,t,n,r)}};function Oa(e){return Qn(e)?e:new Proxy(e,Ym)}class Xm{constructor(t){this.dep=void 0,this.__v_isRef=!0;const{get:n,set:r}=t(()=>Ea(this),()=>Mo(this));this._get=n,this._set=r}get value(){return this._get()}set value(t){this._set(t)}}function Gm(e){return new Xm(e)}function ci(e){const t=ue(e)?new Array(e.length):{};for(const n in e)t[n]=hu(e,n);return t}class Zm{constructor(t,n){this._object=t,this._key=n,this.__v_isRef=!0}get value(){return this._object[this._key]}set value(t){this._object[this._key]=t}}function hu(e,t){const n=e[t];return at(n)?n:new Zm(e,t)}class Jm{constructor(t,n,r){this._setter=n,this.dep=void 0,this._dirty=!0,this.__v_isRef=!0,this.effect=new ai(t,()=>{this._dirty||(this._dirty=!0,Mo(this))}),this.__v_isReadonly=r}get value(){const t=Te(this);return Ea(t),t._dirty&&(t._dirty=!1,t._value=t.effect.run()),t._value}set value(t){this._setter(t)}}function xn(e,t){let n,r;const i=ye(e);return i?(n=e,r=pt):(n=e.get,r=e.set),new Jm(n,r,i||!r)}Promise.resolve();let ui,mu=[];function gu(e,t){ui=e,ui?(ui.enabled=!0,mu.forEach(({event:n,args:r})=>ui.emit(n,...r)),mu=[]):(t.__VUE_DEVTOOLS_HOOK_REPLAY__=t.__VUE_DEVTOOLS_HOOK_REPLAY__||[]).push(r=>{gu(r,t)})}function Qm(e,t,...n){const r=e.vnode.props||Ce;let i=n;const o=t.startsWith("update:"),s=o&&t.slice(7);if(s&&s in r){const u=`${s==="modelValue"?"model":s}Modifiers`,{number:f,trim:p}=r[u]||Ce;p?i=n.map(d=>d.trim()):f&&(i=n.map(cn))}let a,l=r[a=Yn(t)]||r[a=Yn(st(t))];!l&&o&&(l=r[a=Yn(Ot(t))]),l&&xt(l,e,6,i);const c=r[a+"Once"];if(c){if(!e.emitted)e.emitted={};else if(e.emitted[a])return;e.emitted[a]=!0,xt(c,e,6,i)}}function vu(e,t,n=!1){const r=t.emitsCache,i=r.get(e);if(i!==void 0)return i;const o=e.emits;let s={},a=!1;if(!ye(e)){const l=c=>{const u=vu(c,t,!0);u&&(a=!0,Me(s,u))};!n&&t.mixins.length&&t.mixins.forEach(l),e.extends&&l(e.extends),e.mixins&&e.mixins.forEach(l)}return!o&&!a?(r.set(e,null),null):(ue(o)?o.forEach(l=>s[l]=null):Me(s,o),r.set(e,s),s)}function wa(e,t){return!e||!yn(t)?!1:(t=t.slice(2).replace(/Once$/,""),xe(e,t[0].toLowerCase()+t.slice(1))||xe(e,Ot(t))||xe(e,t))}let Tt=null,Ao=null;function fi(e){const t=Tt;return Tt=e,Ao=e&&e.type.__scopeId||null,t}function yu(e){Ao=e}function bu(){Ao=null}const Su=e=>qt;function qt(e,t=Tt,n){if(!t||e._n)return e;const r=(...i)=>{r._d&&ka(-1);const o=fi(t),s=e(...i);return fi(o),r._d&&ka(1),s};return r._n=!0,r._c=!0,r._d=!0,r}function No(e){const{type:t,vnode:n,proxy:r,withProxy:i,props:o,propsOptions:[s],slots:a,attrs:l,emit:c,render:u,renderCache:f,data:p,setupState:d,ctx:m,inheritAttrs:b}=e;let v,S;const g=fi(e);try{if(n.shapeFlag&4){const E=i||r;v=Pt(u.call(E,E,f,o,d,p,m)),S=l}else{const E=t;v=Pt(E.length>1?E(o,{attrs:l,slots:a,emit:c}):E(o,null)),S=t.props?l:_m(l)}}catch(E){bi.length=0,nr(E,e,1),v=he(ht)}let O=v;if(S&&b!==!1){const E=Object.keys(S),{shapeFlag:$}=O;E.length&&$&(1|6)&&(s&&E.some(ho)&&(S=eg(S,s)),O=An(O,S))}return n.dirs&&(O.dirs=O.dirs?O.dirs.concat(n.dirs):n.dirs),n.transition&&(O.transition=n.transition),v=O,fi(g),v}function qm(e){let t;for(let n=0;n{let t;for(const n in e)(n==="class"||n==="style"||yn(n))&&((t||(t={}))[n]=e[n]);return t},eg=(e,t)=>{const n={};for(const r in e)(!ho(r)||!(r.slice(9)in t))&&(n[r]=e[r]);return n};function tg(e,t,n){const{props:r,children:i,component:o}=e,{props:s,children:a,patchFlag:l}=t,c=o.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&l>=0){if(l&1024)return!0;if(l&16)return r?Eu(r,s,c):!!s;if(l&8){const u=t.dynamicProps;for(let f=0;fe.__isSuspense,rg={name:"Suspense",__isSuspense:!0,process(e,t,n,r,i,o,s,a,l,c){e==null?og(t,n,r,i,o,s,a,l,c):sg(e,t,n,r,i,s,a,l,c)},hydrate:ag,create:Pa,normalize:lg},ig=rg;function pi(e,t){const n=e.props&&e.props[t];ye(n)&&n()}function og(e,t,n,r,i,o,s,a,l){const{p:c,o:{createElement:u}}=l,f=u("div"),p=e.suspense=Pa(e,i,r,t,f,n,o,s,a,l);c(null,p.pendingBranch=e.ssContent,f,null,r,p,o,s),p.deps>0?(pi(e,"onPending"),pi(e,"onFallback"),c(null,e.ssFallback,t,n,r,null,o,s),wr(p,e.ssFallback)):p.resolve()}function sg(e,t,n,r,i,o,s,a,{p:l,um:c,o:{createElement:u}}){const f=t.suspense=e.suspense;f.vnode=t,t.el=e.el;const p=t.ssContent,d=t.ssFallback,{activeBranch:m,pendingBranch:b,isInFallback:v,isHydrating:S}=f;if(b)f.pendingBranch=p,tn(p,b)?(l(b,p,f.hiddenContainer,null,i,f,o,s,a),f.deps<=0?f.resolve():v&&(l(m,d,n,r,i,null,o,s,a),wr(f,d))):(f.pendingId++,S?(f.isHydrating=!1,f.activeBranch=b):c(b,i,f),f.deps=0,f.effects.length=0,f.hiddenContainer=u("div"),v?(l(null,p,f.hiddenContainer,null,i,f,o,s,a),f.deps<=0?f.resolve():(l(m,d,n,r,i,null,o,s,a),wr(f,d))):m&&tn(p,m)?(l(m,p,n,r,i,f,o,s,a),f.resolve(!0)):(l(null,p,f.hiddenContainer,null,i,f,o,s,a),f.deps<=0&&f.resolve()));else if(m&&tn(p,m))l(m,p,n,r,i,f,o,s,a),wr(f,p);else if(pi(t,"onPending"),f.pendingBranch=p,f.pendingId++,l(null,p,f.hiddenContainer,null,i,f,o,s,a),f.deps<=0)f.resolve();else{const{timeout:g,pendingId:O}=f;g>0?setTimeout(()=>{f.pendingId===O&&f.fallback(d)},g):g===0&&f.fallback(d)}}function Pa(e,t,n,r,i,o,s,a,l,c,u=!1){const{p:f,m:p,um:d,n:m,o:{parentNode:b,remove:v}}=c,S=cn(e.props&&e.props.timeout),g={vnode:e,parent:t,parentComponent:n,isSVG:s,container:r,hiddenContainer:i,anchor:o,deps:0,pendingId:0,timeout:typeof S=="number"?S:-1,activeBranch:null,pendingBranch:null,isInFallback:!0,isHydrating:u,isUnmounted:!1,effects:[],resolve(O=!1){const{vnode:E,activeBranch:$,pendingBranch:R,pendingId:T,effects:P,parentComponent:N,container:j}=g;if(g.isHydrating)g.isHydrating=!1;else if(!O){const C=$&&R.transition&&R.transition.mode==="out-in";C&&($.transition.afterLeave=()=>{T===g.pendingId&&p(R,j,w,0)});let{anchor:w}=g;$&&(w=m($),d($,N,g,!0)),C||p(R,j,w,0)}wr(g,R),g.pendingBranch=null,g.isInFallback=!1;let V=g.parent,F=!1;for(;V;){if(V.pendingBranch){V.effects.push(...P),F=!0;break}V=V.parent}F||_a(P),g.effects=[],pi(E,"onResolve")},fallback(O){if(!g.pendingBranch)return;const{vnode:E,activeBranch:$,parentComponent:R,container:T,isSVG:P}=g;pi(E,"onFallback");const N=m($),j=()=>{!g.isInFallback||(f(null,O,T,N,R,null,P,a,l),wr(g,O))},V=O.transition&&O.transition.mode==="out-in";V&&($.transition.afterLeave=j),g.isInFallback=!0,d($,R,null,!0),V||j()},move(O,E,$){g.activeBranch&&p(g.activeBranch,O,E,$),g.container=O},next(){return g.activeBranch&&m(g.activeBranch)},registerDep(O,E){const $=!!g.pendingBranch;$&&g.deps++;const R=O.vnode.el;O.asyncDep.catch(T=>{nr(T,O,0)}).then(T=>{if(O.isUnmounted||g.isUnmounted||g.pendingId!==O.suspenseId)return;O.asyncResolved=!0;const{vnode:P}=O;Ya(O,T,!1),R&&(P.el=R);const N=!R&&O.subTree.el;E(O,P,b(R||O.subTree.el),R?null:m(O.subTree),g,s,l),N&&v(N),Ta(O,P.el),$&&--g.deps==0&&g.resolve()})},unmount(O,E){g.isUnmounted=!0,g.activeBranch&&d(g.activeBranch,n,O,E),g.pendingBranch&&d(g.pendingBranch,n,O,E)}};return g}function ag(e,t,n,r,i,o,s,a,l){const c=t.suspense=Pa(t,r,n,e.parentNode,document.createElement("div"),null,i,o,s,a,!0),u=l(e,c.pendingBranch=t.ssContent,n,c,o,s);return c.deps===0&&c.resolve(),u}function lg(e){const{shapeFlag:t,children:n}=e,r=t&32;e.ssContent=Ou(r?n.default:n),e.ssFallback=r?Ou(n.fallback):he(ht)}function Ou(e){let t;if(ye(e)){const n=Mr&&e._c;n&&(e._d=!1,Ie()),e=e(),n&&(e._d=!0,t=en,nf())}return ue(e)&&(e=qm(e)),e=Pt(e),t&&!e.dynamicChildren&&(e.dynamicChildren=t.filter(n=>n!==e)),e}function wu(e,t){t&&t.pendingBranch?ue(e)?t.effects.push(...e):t.effects.push(e):_a(e)}function wr(e,t){e.activeBranch=t;const{vnode:n,parentComponent:r}=e,i=n.el=t.el;r&&r.subTree===n&&(r.vnode.el=i,Ta(r,i))}function Tu(e,t){if(et){let n=et.provides;const r=et.parent&&et.parent.provides;r===n&&(n=et.provides=Object.create(r)),n[e]=t}}function di(e,t,n=!1){const r=et||Tt;if(r){const i=r.parent==null?r.vnode.appContext&&r.vnode.appContext.provides:r.parent.provides;if(i&&e in i)return i[e];if(arguments.length>1)return n&&ye(t)?t.call(r.proxy):t}}function xa(){const e={isMounted:!1,isLeaving:!1,isUnmounting:!1,leavingVNodes:new Map};return Pr(()=>{e.isMounted=!0}),Lo(()=>{e.isUnmounting=!0}),e}const $t=[Function,Array],cg={name:"BaseTransition",props:{mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:$t,onEnter:$t,onAfterEnter:$t,onEnterCancelled:$t,onBeforeLeave:$t,onLeave:$t,onAfterLeave:$t,onLeaveCancelled:$t,onBeforeAppear:$t,onAppear:$t,onAfterAppear:$t,onAppearCancelled:$t},setup(e,{slots:t}){const n=In(),r=xa();let i;return()=>{const o=t.default&&Ro(t.default(),!0);if(!o||!o.length)return;const s=Te(e),{mode:a}=s,l=o[0];if(r.isLeaving)return Ma(l);const c=xu(l);if(!c)return Ma(l);const u=Tr(c,s,r,n);qn(c,u);const f=n.subTree,p=f&&xu(f);let d=!1;const{getTransitionKey:m}=c.type;if(m){const b=m();i===void 0?i=b:b!==i&&(i=b,d=!0)}if(p&&p.type!==ht&&(!tn(c,p)||d)){const b=Tr(p,s,r,n);if(qn(p,b),a==="out-in")return r.isLeaving=!0,b.afterLeave=()=>{r.isLeaving=!1,n.update()},Ma(l);a==="in-out"&&c.type!==ht&&(b.delayLeave=(v,S,g)=>{const O=Pu(r,p);O[String(p.key)]=p,v._leaveCb=()=>{S(),v._leaveCb=void 0,delete u.delayedLeave},u.delayedLeave=g})}return l}}},Ca=cg;function Pu(e,t){const{leavingVNodes:n}=e;let r=n.get(t.type);return r||(r=Object.create(null),n.set(t.type,r)),r}function Tr(e,t,n,r){const{appear:i,mode:o,persisted:s=!1,onBeforeEnter:a,onEnter:l,onAfterEnter:c,onEnterCancelled:u,onBeforeLeave:f,onLeave:p,onAfterLeave:d,onLeaveCancelled:m,onBeforeAppear:b,onAppear:v,onAfterAppear:S,onAppearCancelled:g}=t,O=String(e.key),E=Pu(n,e),$=(T,P)=>{T&&xt(T,r,9,P)},R={mode:o,persisted:s,beforeEnter(T){let P=a;if(!n.isMounted)if(i)P=b||a;else return;T._leaveCb&&T._leaveCb(!0);const N=E[O];N&&tn(e,N)&&N.el._leaveCb&&N.el._leaveCb(),$(P,[T])},enter(T){let P=l,N=c,j=u;if(!n.isMounted)if(i)P=v||l,N=S||c,j=g||u;else return;let V=!1;const F=T._enterCb=C=>{V||(V=!0,C?$(j,[T]):$(N,[T]),R.delayedLeave&&R.delayedLeave(),T._enterCb=void 0)};P?(P(T,F),P.length<=1&&F()):F()},leave(T,P){const N=String(e.key);if(T._enterCb&&T._enterCb(!0),n.isUnmounting)return P();$(f,[T]);let j=!1;const V=T._leaveCb=F=>{j||(j=!0,P(),F?$(m,[T]):$(d,[T]),T._leaveCb=void 0,E[N]===e&&delete E[N])};E[N]=e,p?(p(T,V),p.length<=1&&V()):V()},clone(T){return Tr(T,t,n,r)}};return R}function Ma(e){if(mi(e))return e=An(e),e.children=null,e}function xu(e){return mi(e)?e.children?e.children[0]:void 0:e}function qn(e,t){e.shapeFlag&6&&e.component?qn(e.component.subTree,t):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Ro(e,t=!1){let n=[],r=0;for(let i=0;i1)for(let i=0;i!!e.type.__asyncLoader;function ug(e){ye(e)&&(e={loader:e});const{loader:t,loadingComponent:n,errorComponent:r,delay:i=200,timeout:o,suspensible:s=!0,onError:a}=e;let l=null,c,u=0;const f=()=>(u++,l=null,p()),p=()=>{let d;return l||(d=l=t().catch(m=>{if(m=m instanceof Error?m:new Error(String(m)),a)return new Promise((b,v)=>{a(m,()=>b(f()),()=>v(m),u+1)});throw m}).then(m=>d!==l&&l?l:(m&&(m.__esModule||m[Symbol.toStringTag]==="Module")&&(m=m.default),c=m,m)))};return Aa({name:"AsyncComponentWrapper",__asyncLoader:p,get __asyncResolved(){return c},setup(){const d=et;if(c)return()=>Na(c,d);const m=g=>{l=null,nr(g,d,13,!r)};if(s&&d.suspense||Oi)return p().then(g=>()=>Na(g,d)).catch(g=>(m(g),()=>r?he(r,{error:g}):null));const b=Pn(!1),v=Pn(),S=Pn(!!i);return i&&setTimeout(()=>{S.value=!1},i),o!=null&&setTimeout(()=>{if(!b.value&&!v.value){const g=new Error(`Async component timed out after ${o}ms.`);m(g),v.value=g}},o),p().then(()=>{b.value=!0,d.parent&&mi(d.parent.vnode)&&qa(d.parent.update)}).catch(g=>{m(g),v.value=g}),()=>{if(b.value&&c)return Na(c,d);if(v.value&&r)return he(r,{error:v.value});if(n&&!S.value)return he(n)}}})}function Na(e,{vnode:{ref:t,props:n,children:r}}){const i=he(e,n,r);return i.ref=t,i}const mi=e=>e.type.__isKeepAlive,fg={name:"KeepAlive",__isKeepAlive:!0,props:{include:[String,RegExp,Array],exclude:[String,RegExp,Array],max:[String,Number]},setup(e,{slots:t}){const n=In(),r=n.ctx;if(!r.renderer)return t.default;const i=new Map,o=new Set;let s=null;const a=n.suspense,{renderer:{p:l,m:c,um:u,o:{createElement:f}}}=r,p=f("div");r.activate=(g,O,E,$,R)=>{const T=g.component;c(g,O,E,0,a),l(T.vnode,g,O,E,T,a,$,g.slotScopeIds,R),ot(()=>{T.isDeactivated=!1,T.a&&Xn(T.a);const P=g.props&&g.props.onVnodeMounted;P&&St(P,T.parent,g)},a)},r.deactivate=g=>{const O=g.component;c(g,p,null,1,a),ot(()=>{O.da&&Xn(O.da);const E=g.props&&g.props.onVnodeUnmounted;E&&St(E,O.parent,g),O.isDeactivated=!0},a)};function d(g){Ra(g),u(g,n,a)}function m(g){i.forEach((O,E)=>{const $=Wo(O.type);$&&(!g||!g($))&&b(E)})}function b(g){const O=i.get(g);!s||O.type!==s.type?d(O):s&&Ra(s),i.delete(g),o.delete(g)}lt(()=>[e.include,e.exclude],([g,O])=>{g&&m(E=>gi(g,E)),O&&m(E=>!gi(O,E))},{flush:"post",deep:!0});let v=null;const S=()=>{v!=null&&i.set(v,Ia(n.subTree))};return Pr(S),$o(S),Lo(()=>{i.forEach(g=>{const{subTree:O,suspense:E}=n,$=Ia(O);if(g.type===$.type){Ra($);const R=$.component.da;R&&ot(R,E);return}d(g)})}),()=>{if(v=null,!t.default)return null;const g=t.default(),O=g[0];if(g.length>1)return s=null,g;if(!Mn(O)||!(O.shapeFlag&4)&&!(O.shapeFlag&128))return s=null,O;let E=Ia(O);const $=E.type,R=Wo(hi(E)?E.type.__asyncResolved||{}:$),{include:T,exclude:P,max:N}=e;if(T&&(!R||!gi(T,R))||P&&R&&gi(P,R))return s=E,O;const j=E.key==null?$:E.key,V=i.get(j);return E.el&&(E=An(E),O.shapeFlag&128&&(O.ssContent=E)),v=j,V?(E.el=V.el,E.component=V.component,E.transition&&qn(E,E.transition),E.shapeFlag|=512,o.delete(j),o.add(j)):(o.add(j),N&&o.size>parseInt(N,10)&&b(o.values().next().value)),E.shapeFlag|=256,s=E,O}}},pg=fg;function gi(e,t){return ue(e)?e.some(n=>gi(n,t)):be(e)?e.split(",").indexOf(t)>-1:e.test?e.test(t):!1}function Cu(e,t){Au(e,"a",t)}function Mu(e,t){Au(e,"da",t)}function Au(e,t,n=et){const r=e.__wdc||(e.__wdc=()=>{let i=n;for(;i;){if(i.isDeactivated)return;i=i.parent}e()});if(Io(t,r,n),n){let i=n.parent;for(;i&&i.parent;)mi(i.parent.vnode)&&dg(r,t,n,i),i=i.parent}}function dg(e,t,n,r){const i=Io(t,e,r,!0);vi(()=>{ra(r[t],i)},n)}function Ra(e){let t=e.shapeFlag;t&256&&(t-=256),t&512&&(t-=512),e.shapeFlag=t}function Ia(e){return e.shapeFlag&128?e.ssContent:e}function Io(e,t,n=et,r=!1){if(n){const i=n[e]||(n[e]=[]),o=t.__weh||(t.__weh=(...s)=>{if(n.isUnmounted)return;Jn(),$n(n);const a=xt(t,n,e,s);return Ln(),wn(),a});return r?i.unshift(o):i.push(o),o}}const fn=e=>(t,n=et)=>(!Oi||e==="sp")&&Io(e,t,n),Nu=fn("bm"),Pr=fn("m"),Ru=fn("bu"),$o=fn("u"),Lo=fn("bum"),vi=fn("um"),Iu=fn("sp"),$u=fn("rtg"),Lu=fn("rtc");function Du(e,t=et){Io("ec",e,t)}let $a=!0;function hg(e){const t=Vu(e),n=e.proxy,r=e.ctx;$a=!1,t.beforeCreate&&Fu(t.beforeCreate,e,"bc");const{data:i,computed:o,methods:s,watch:a,provide:l,inject:c,created:u,beforeMount:f,mounted:p,beforeUpdate:d,updated:m,activated:b,deactivated:v,beforeDestroy:S,beforeUnmount:g,destroyed:O,unmounted:E,render:$,renderTracked:R,renderTriggered:T,errorCaptured:P,serverPrefetch:N,expose:j,inheritAttrs:V,components:F,directives:C,filters:w}=t;if(c&&mg(c,r,null,e.appContext.config.unwrapInjectedRef),s)for(const z in s){const B=s[z];ye(B)&&(r[z]=B.bind(n))}if(i){const z=i.call(n,n);je(z)&&(e.data=xo(z))}if($a=!0,o)for(const z in o){const B=o[z],K=ye(B)?B.bind(n,n):ye(B.get)?B.get.bind(n,n):pt,G=!ye(B)&&ye(B.set)?B.set.bind(n):pt,de=xn({get:K,set:G});Object.defineProperty(r,z,{enumerable:!0,configurable:!0,get:()=>de.value,set:ce=>de.value=ce})}if(a)for(const z in a)ju(a[z],r,n,z);if(l){const z=ye(l)?l.call(n):l;Reflect.ownKeys(z).forEach(B=>{Tu(B,z[B])})}u&&Fu(u,e,"c");function L(z,B){ue(B)?B.forEach(K=>z(K.bind(n))):B&&z(B.bind(n))}if(L(Nu,f),L(Pr,p),L(Ru,d),L($o,m),L(Cu,b),L(Mu,v),L(Du,P),L(Lu,R),L($u,T),L(Lo,g),L(vi,E),L(Iu,N),ue(j))if(j.length){const z=e.exposed||(e.exposed={});j.forEach(B=>{Object.defineProperty(z,B,{get:()=>n[B],set:K=>n[B]=K})})}else e.exposed||(e.exposed={});$&&e.render===pt&&(e.render=$),V!=null&&(e.inheritAttrs=V),F&&(e.components=F),C&&(e.directives=C)}function mg(e,t,n=pt,r=!1){ue(e)&&(e=La(e));for(const i in e){const o=e[i];let s;je(o)?"default"in o?s=di(o.from||i,o.default,!0):s=di(o.from||i):s=di(o),at(s)&&r?Object.defineProperty(t,i,{enumerable:!0,configurable:!0,get:()=>s.value,set:a=>s.value=a}):t[i]=s}}function Fu(e,t,n){xt(ue(e)?e.map(r=>r.bind(t.proxy)):e.bind(t.proxy),t,n)}function ju(e,t,n,r){const i=r.includes(".")?Of(n,r):()=>n[r];if(be(e)){const o=t[e];ye(o)&<(i,o)}else if(ye(e))lt(i,e.bind(n));else if(je(e))if(ue(e))e.forEach(o=>ju(o,t,n,r));else{const o=ye(e.handler)?e.handler.bind(n):t[e.handler];ye(o)&<(i,o,e)}}function Vu(e){const t=e.type,{mixins:n,extends:r}=t,{mixins:i,optionsCache:o,config:{optionMergeStrategies:s}}=e.appContext,a=o.get(t);let l;return a?l=a:!i.length&&!n&&!r?l=t:(l={},i.length&&i.forEach(c=>Do(l,c,s,!0)),Do(l,t,s)),o.set(t,l),l}function Do(e,t,n,r=!1){const{mixins:i,extends:o}=t;o&&Do(e,o,n,!0),i&&i.forEach(s=>Do(e,s,n,!0));for(const s in t)if(!(r&&s==="expose")){const a=gg[s]||n&&n[s];e[s]=a?a(e[s],t[s]):t[s]}return e}const gg={data:zu,props:_n,emits:_n,methods:_n,computed:_n,beforeCreate:dt,created:dt,beforeMount:dt,mounted:dt,beforeUpdate:dt,updated:dt,beforeDestroy:dt,beforeUnmount:dt,destroyed:dt,unmounted:dt,activated:dt,deactivated:dt,errorCaptured:dt,serverPrefetch:dt,components:_n,directives:_n,watch:yg,provide:zu,inject:vg};function zu(e,t){return t?e?function(){return Me(ye(e)?e.call(this,this):e,ye(t)?t.call(this,this):t)}:t:e}function vg(e,t){return _n(La(e),La(t))}function La(e){if(ue(e)){const t={};for(let n=0;n0)&&!(s&16)){if(s&8){const u=e.vnode.dynamicProps;for(let f=0;f{l=!0;const[p,d]=Hu(f,t,!0);Me(s,p),d&&a.push(...d)};!n&&t.mixins.length&&t.mixins.forEach(u),e.extends&&u(e.extends),e.mixins&&e.mixins.forEach(u)}if(!o&&!l)return r.set(e,Un),Un;if(ue(o))for(let u=0;u-1,d[1]=b<0||m-1||xe(d,"default"))&&a.push(f)}}}const c=[s,a];return r.set(e,c),c}function ku(e){return e[0]!=="$"}function Uu(e){const t=e&&e.toString().match(/^\s*function (\w+)/);return t?t[1]:e===null?"null":""}function Ku(e,t){return Uu(e)===Uu(t)}function Wu(e,t){return ue(t)?t.findIndex(n=>Ku(n,e)):ye(t)&&Ku(t,e)?0:-1}const Yu=e=>e[0]==="_"||e==="$stable",Fa=e=>ue(e)?e.map(Pt):[Pt(e)],Eg=(e,t,n)=>{const r=qt((...i)=>Fa(t(...i)),n);return r._c=!1,r},Xu=(e,t,n)=>{const r=e._ctx;for(const i in e){if(Yu(i))continue;const o=e[i];if(ye(o))t[i]=Eg(i,o,r);else if(o!=null){const s=Fa(o);t[i]=()=>s}}},Gu=(e,t)=>{const n=Fa(t);e.slots.default=()=>n},Og=(e,t)=>{if(e.vnode.shapeFlag&32){const n=t._;n?(e.slots=Te(t),ii(t,"_",n)):Xu(t,e.slots={})}else e.slots={},t&&Gu(e,t);ii(e.slots,zo,1)},wg=(e,t,n)=>{const{vnode:r,slots:i}=e;let o=!0,s=Ce;if(r.shapeFlag&32){const a=t._;a?n&&a===1?o=!1:(Me(i,t),!n&&a===1&&delete i._):(o=!t.$stable,Xu(t,i)),s=t}else t&&(Gu(e,t),s={default:1});if(o)for(const a in i)!Yu(a)&&!(a in s)&&delete i[a]};function xr(e,t){const n=Tt;if(n===null)return e;const r=n.proxy,i=e.dirs||(e.dirs=[]);for(let o=0;o/svg/.test(e.namespaceURI)&&e.tagName!=="foreignObject",ja=e=>e.nodeType===8;function xg(e){const{mt:t,p:n,o:{patchProp:r,nextSibling:i,parentNode:o,remove:s,insert:a,createComment:l}}=e,c=(v,S)=>{if(!S.hasChildNodes()){n(null,v,S),Go();return}Cn=!1,u(S.firstChild,v,null,null,null),Go(),Cn&&console.error("Hydration completed but contains mismatches.")},u=(v,S,g,O,E,$=!1)=>{const R=ja(v)&&v.data==="[",T=()=>m(v,S,g,O,E,R),{type:P,ref:N,shapeFlag:j}=S,V=v.nodeType;S.el=v;let F=null;switch(P){case Cr:V!==3?F=T():(v.data!==S.children&&(Cn=!0,v.data=S.children),F=i(v));break;case ht:V!==8||R?F=T():F=i(v);break;case tr:if(V!==1)F=T();else{F=v;const C=!S.children.length;for(let w=0;w{$=$||!!S.dynamicChildren;const{type:R,props:T,patchFlag:P,shapeFlag:N,dirs:j}=S,V=R==="input"&&j||R==="option";if(V||P!==-1){if(j&&_t(S,null,g,"created"),T)if(V||!$||P&(16|32))for(const C in T)(V&&C.endsWith("value")||yn(C)&&!Sn(C))&&r(v,C,null,T[C],!1,void 0,g);else T.onClick&&r(v,"onClick",null,T.onClick,!1,void 0,g);let F;if((F=T&&T.onVnodeBeforeMount)&&St(F,g,S),j&&_t(S,null,g,"beforeMount"),((F=T&&T.onVnodeMounted)||j)&&wu(()=>{F&&St(F,g,S),j&&_t(S,null,g,"mounted")},O),N&16&&!(T&&(T.innerHTML||T.textContent))){let C=p(v.firstChild,S,v,g,O,E,$);for(;C;){Cn=!0;const w=C;C=C.nextSibling,s(w)}}else N&8&&v.textContent!==S.children&&(Cn=!0,v.textContent=S.children)}return v.nextSibling},p=(v,S,g,O,E,$,R)=>{R=R||!!S.dynamicChildren;const T=S.children,P=T.length;for(let N=0;N{const{slotScopeIds:R}=S;R&&(E=E?E.concat(R):R);const T=o(v),P=p(i(v),S,T,g,O,E,$);return P&&ja(P)&&P.data==="]"?i(S.anchor=P):(Cn=!0,a(S.anchor=l("]"),T,P),P)},m=(v,S,g,O,E,$)=>{if(Cn=!0,S.el=null,$){const P=b(v);for(;;){const N=i(v);if(N&&N!==P)s(N);else break}}const R=i(v),T=o(v);return s(v),n(null,S,T,R,g,O,Fo(T),E),R},b=v=>{let S=0;for(;v;)if(v=i(v),v&&ja(v)&&(v.data==="["&&S++,v.data==="]")){if(S===0)return i(v);S--}return v};return[c,u]}const ot=wu;function Ju(e){return qu(e)}function Qu(e){return qu(e,xg)}function qu(e,t){const n=Wc();n.__VUE__=!0;const{insert:r,remove:i,patchProp:o,createElement:s,createText:a,createComment:l,setText:c,setElementText:u,parentNode:f,nextSibling:p,setScopeId:d=pt,cloneNode:m,insertStaticContent:b}=e,v=(x,I,k,X=null,W=null,q=null,ne=!1,_=null,ee=!!I.dynamicChildren)=>{if(x===I)return;x&&!tn(x,I)&&(X=ft(x),ve(x,W,q,!0),x=null),I.patchFlag===-2&&(ee=!1,I.dynamicChildren=null);const{type:Q,ref:fe,shapeFlag:le}=I;switch(Q){case Cr:S(x,I,k,X);break;case ht:g(x,I,k,X);break;case tr:x==null&&O(I,k,X,ne);break;case Ye:C(x,I,k,X,W,q,ne,_,ee);break;default:le&1?R(x,I,k,X,W,q,ne,_,ee):le&6?w(x,I,k,X,W,q,ne,_,ee):(le&64||le&128)&&Q.process(x,I,k,X,W,q,ne,_,ee,Ve)}fe!=null&&W&&jo(fe,x&&x.ref,q,I||x,!I)},S=(x,I,k,X)=>{if(x==null)r(I.el=a(I.children),k,X);else{const W=I.el=x.el;I.children!==x.children&&c(W,I.children)}},g=(x,I,k,X)=>{x==null?r(I.el=l(I.children||""),k,X):I.el=x.el},O=(x,I,k,X)=>{[x.el,x.anchor]=b(x.children,I,k,X)},E=({el:x,anchor:I},k,X)=>{let W;for(;x&&x!==I;)W=p(x),r(x,k,X),x=W;r(I,k,X)},$=({el:x,anchor:I})=>{let k;for(;x&&x!==I;)k=p(x),i(x),x=k;i(I)},R=(x,I,k,X,W,q,ne,_,ee)=>{ne=ne||I.type==="svg",x==null?T(I,k,X,W,q,ne,_,ee):j(x,I,W,q,ne,_,ee)},T=(x,I,k,X,W,q,ne,_)=>{let ee,Q;const{type:fe,props:le,shapeFlag:pe,transition:y,patchFlag:h,dirs:U}=x;if(x.el&&m!==void 0&&h===-1)ee=x.el=m(x.el);else{if(ee=x.el=s(x.type,q,le&&le.is,le),pe&8?u(ee,x.children):pe&16&&N(x.children,ee,null,X,W,q&&fe!=="foreignObject",ne,_),U&&_t(x,null,X,"created"),le){for(const Z in le)Z!=="value"&&!Sn(Z)&&o(ee,Z,null,le[Z],q,x.children,X,W,Qe);"value"in le&&o(ee,"value",null,le.value),(Q=le.onVnodeBeforeMount)&&St(Q,X,x)}P(ee,x,x.scopeId,ne,X)}U&&_t(x,null,X,"beforeMount");const Y=(!W||W&&!W.pendingBranch)&&y&&!y.persisted;Y&&y.beforeEnter(ee),r(ee,I,k),((Q=le&&le.onVnodeMounted)||Y||U)&&ot(()=>{Q&&St(Q,X,x),Y&&y.enter(ee),U&&_t(x,null,X,"mounted")},W)},P=(x,I,k,X,W)=>{if(k&&d(x,k),X)for(let q=0;q{for(let Q=ee;Q{const _=I.el=x.el;let{patchFlag:ee,dynamicChildren:Q,dirs:fe}=I;ee|=x.patchFlag&16;const le=x.props||Ce,pe=I.props||Ce;let y;(y=pe.onVnodeBeforeUpdate)&&St(y,k,I,x),fe&&_t(I,x,k,"beforeUpdate");const h=W&&I.type!=="foreignObject";if(Q?V(x.dynamicChildren,Q,_,k,X,h,q):ne||K(x,I,_,null,k,X,h,q,!1),ee>0){if(ee&16)F(_,I,le,pe,k,X,W);else if(ee&2&&le.class!==pe.class&&o(_,"class",null,pe.class,W),ee&4&&o(_,"style",le.style,pe.style,W),ee&8){const U=I.dynamicProps;for(let Y=0;Y{y&&St(y,k,I,x),fe&&_t(I,x,k,"updated")},X)},V=(x,I,k,X,W,q,ne)=>{for(let _=0;_{if(k!==X){for(const _ in X){if(Sn(_))continue;const ee=X[_],Q=k[_];ee!==Q&&_!=="value"&&o(x,_,Q,ee,ne,I.children,W,q,Qe)}if(k!==Ce)for(const _ in k)!Sn(_)&&!(_ in X)&&o(x,_,k[_],null,ne,I.children,W,q,Qe);"value"in X&&o(x,"value",k.value,X.value)}},C=(x,I,k,X,W,q,ne,_,ee)=>{const Q=I.el=x?x.el:a(""),fe=I.anchor=x?x.anchor:a("");let{patchFlag:le,dynamicChildren:pe,slotScopeIds:y}=I;y&&(_=_?_.concat(y):y),x==null?(r(Q,k,X),r(fe,k,X),N(I.children,k,fe,W,q,ne,_,ee)):le>0&&le&64&&pe&&x.dynamicChildren?(V(x.dynamicChildren,pe,k,W,q,ne,_),(I.key!=null||W&&I===W.subTree)&&Va(x,I,!0)):K(x,I,k,fe,W,q,ne,_,ee)},w=(x,I,k,X,W,q,ne,_,ee)=>{I.slotScopeIds=_,x==null?I.shapeFlag&512?W.ctx.activate(I,k,X,ne,ee):M(I,k,X,W,q,ne,ee):L(x,I,ee)},M=(x,I,k,X,W,q,ne)=>{const _=x.component=lf(x,X,W);if(mi(x)&&(_.ctx.renderer=Ve),uf(_),_.asyncDep){if(W&&W.registerDep(_,z),!x.el){const ee=_.subTree=he(ht);g(null,ee,I,k)}return}z(_,x,I,k,W,q,ne)},L=(x,I,k)=>{const X=I.component=x.component;if(tg(x,I,k))if(X.asyncDep&&!X.asyncResolved){B(X,I,k);return}else X.next=I,nv(X.update),X.update();else I.component=x.component,I.el=x.el,X.vnode=I},z=(x,I,k,X,W,q,ne)=>{const _=()=>{if(x.isMounted){let{next:fe,bu:le,u:pe,parent:y,vnode:h}=x,U=fe,Y;ee.allowRecurse=!1,fe?(fe.el=h.el,B(x,fe,ne)):fe=h,le&&Xn(le),(Y=fe.props&&fe.props.onVnodeBeforeUpdate)&&St(Y,y,fe,h),ee.allowRecurse=!0;const Z=No(x),me=x.subTree;x.subTree=Z,v(me,Z,f(me.el),ft(me),x,W,q),fe.el=Z.el,U===null&&Ta(x,Z.el),pe&&ot(pe,W),(Y=fe.props&&fe.props.onVnodeUpdated)&&ot(()=>St(Y,y,fe,h),W)}else{let fe;const{el:le,props:pe}=I,{bm:y,m:h,parent:U}=x,Y=hi(I);if(ee.allowRecurse=!1,y&&Xn(y),!Y&&(fe=pe&&pe.onVnodeBeforeMount)&&St(fe,U,I),ee.allowRecurse=!0,le&&vt){const Z=()=>{x.subTree=No(x),vt(le,x.subTree,x,W,null)};Y?I.type.__asyncLoader().then(()=>!x.isUnmounted&&Z()):Z()}else{const Z=x.subTree=No(x);v(null,Z,k,X,x,W,q),I.el=Z.el}if(h&&ot(h,W),!Y&&(fe=pe&&pe.onVnodeMounted)){const Z=I;ot(()=>St(fe,U,Z),W)}I.shapeFlag&256&&x.a&&ot(x.a,W),x.isMounted=!0,I=k=X=null}},ee=new ai(_,()=>qa(x.update),x.scope),Q=x.update=ee.run.bind(ee);Q.id=x.uid,ee.allowRecurse=Q.allowRecurse=!0,Q()},B=(x,I,k)=>{I.component=x;const X=x.vnode.props;x.vnode=I,x.next=null,Sg(x,I.props,X,k),wg(x,I.children,k),Jn(),el(void 0,x.update),wn()},K=(x,I,k,X,W,q,ne,_,ee=!1)=>{const Q=x&&x.children,fe=x?x.shapeFlag:0,le=I.children,{patchFlag:pe,shapeFlag:y}=I;if(pe>0){if(pe&128){de(Q,le,k,X,W,q,ne,_,ee);return}else if(pe&256){G(Q,le,k,X,W,q,ne,_,ee);return}}y&8?(fe&16&&Qe(Q,W,q),le!==Q&&u(k,le)):fe&16?y&16?de(Q,le,k,X,W,q,ne,_,ee):Qe(Q,W,q,!0):(fe&8&&u(k,""),y&16&&N(le,k,X,W,q,ne,_,ee))},G=(x,I,k,X,W,q,ne,_,ee)=>{x=x||Un,I=I||Un;const Q=x.length,fe=I.length,le=Math.min(Q,fe);let pe;for(pe=0;pefe?Qe(x,W,q,!0,!1,le):N(I,k,X,W,q,ne,_,ee,le)},de=(x,I,k,X,W,q,ne,_,ee)=>{let Q=0;const fe=I.length;let le=x.length-1,pe=fe-1;for(;Q<=le&&Q<=pe;){const y=x[Q],h=I[Q]=ee?Rn(I[Q]):Pt(I[Q]);if(tn(y,h))v(y,h,k,null,W,q,ne,_,ee);else break;Q++}for(;Q<=le&&Q<=pe;){const y=x[le],h=I[pe]=ee?Rn(I[pe]):Pt(I[pe]);if(tn(y,h))v(y,h,k,null,W,q,ne,_,ee);else break;le--,pe--}if(Q>le){if(Q<=pe){const y=pe+1,h=ype)for(;Q<=le;)ve(x[Q],W,q,!0),Q++;else{const y=Q,h=Q,U=new Map;for(Q=h;Q<=pe;Q++){const te=I[Q]=ee?Rn(I[Q]):Pt(I[Q]);te.key!=null&&U.set(te.key,Q)}let Y,Z=0;const me=pe-h+1;let Oe=!1,Fe=0;const Le=new Array(me);for(Q=0;Q=me){ve(te,W,q,!0);continue}let oe;if(te.key!=null)oe=U.get(te.key);else for(Y=h;Y<=pe;Y++)if(Le[Y-h]===0&&tn(te,I[Y])){oe=Y;break}oe===void 0?ve(te,W,q,!0):(Le[oe-h]=Q+1,oe>=Fe?Fe=oe:Oe=!0,v(te,I[oe],k,null,W,q,ne,_,ee),Z++)}const ge=Oe?Cg(Le):Un;for(Y=ge.length-1,Q=me-1;Q>=0;Q--){const te=h+Q,oe=I[te],ae=te+1{const{el:q,type:ne,transition:_,children:ee,shapeFlag:Q}=x;if(Q&6){ce(x.component.subTree,I,k,X);return}if(Q&128){x.suspense.move(I,k,X);return}if(Q&64){ne.move(x,I,k,Ve);return}if(ne===Ye){r(q,I,k);for(let le=0;le_.enter(q),W);else{const{leave:le,delayLeave:pe,afterLeave:y}=_,h=()=>r(q,I,k),U=()=>{le(q,()=>{h(),y&&y()})};pe?pe(q,h,U):U()}else r(q,I,k)},ve=(x,I,k,X=!1,W=!1)=>{const{type:q,props:ne,ref:_,children:ee,dynamicChildren:Q,shapeFlag:fe,patchFlag:le,dirs:pe}=x;if(_!=null&&jo(_,null,k,x,!0),fe&256){I.ctx.deactivate(x);return}const y=fe&1&&pe,h=!hi(x);let U;if(h&&(U=ne&&ne.onVnodeBeforeUnmount)&&St(U,I,x),fe&6)Je(x.component,k,X);else{if(fe&128){x.suspense.unmount(k,X);return}y&&_t(x,null,I,"beforeUnmount"),fe&64?x.type.remove(x,I,k,W,Ve,X):Q&&(q!==Ye||le>0&&le&64)?Qe(Q,I,k,!1,!0):(q===Ye&&le&(128|256)||!W&&fe&16)&&Qe(ee,I,k),X&&Se(x)}(h&&(U=ne&&ne.onVnodeUnmounted)||y)&&ot(()=>{U&&St(U,I,x),y&&_t(x,null,I,"unmounted")},k)},Se=x=>{const{type:I,el:k,anchor:X,transition:W}=x;if(I===Ye){Re(k,X);return}if(I===tr){$(x);return}const q=()=>{i(k),W&&!W.persisted&&W.afterLeave&&W.afterLeave()};if(x.shapeFlag&1&&W&&!W.persisted){const{leave:ne,delayLeave:_}=W,ee=()=>ne(k,q);_?_(x.el,q,ee):ee()}else q()},Re=(x,I)=>{let k;for(;x!==I;)k=p(x),i(x),x=k;i(I)},Je=(x,I,k)=>{const{bum:X,scope:W,update:q,subTree:ne,um:_}=x;X&&Xn(X),W.stop(),q&&(q.active=!1,ve(ne,x,I,k)),_&&ot(_,I),ot(()=>{x.isUnmounted=!0},I),I&&I.pendingBranch&&!I.isUnmounted&&x.asyncDep&&!x.asyncResolved&&x.suspenseId===I.pendingId&&(I.deps--,I.deps===0&&I.resolve())},Qe=(x,I,k,X=!1,W=!1,q=0)=>{for(let ne=q;nex.shapeFlag&6?ft(x.component.subTree):x.shapeFlag&128?x.suspense.next():p(x.anchor||x.el),kt=(x,I,k)=>{x==null?I._vnode&&ve(I._vnode,null,null,!0):v(I._vnode||null,x,I,null,null,null,k),Go(),I._vnode=x},Ve={p:v,um:ve,m:ce,r:Se,mt:M,mc:N,pc:K,pbc:V,n:ft,o:e};let ze,vt;return t&&([ze,vt]=t(Ve)),{render:kt,hydrate:ze,createApp:Pg(kt,ze)}}function jo(e,t,n,r,i=!1){if(ue(e)){e.forEach((p,d)=>jo(p,t&&(ue(t)?t[d]:t),n,r,i));return}if(hi(r)&&!i)return;const o=r.shapeFlag&4?Ga(r.component)||r.component.proxy:r.el,s=i?null:o,{i:a,r:l}=e,c=t&&t.r,u=a.refs===Ce?a.refs={}:a.refs,f=a.setupState;if(c!=null&&c!==l&&(be(c)?(u[c]=null,xe(f,c)&&(f[c]=null)):at(c)&&(c.value=null)),be(l)){const p=()=>{u[l]=s,xe(f,l)&&(f[l]=s)};s?(p.id=-1,ot(p,n)):p()}else if(at(l)){const p=()=>{l.value=s};s?(p.id=-1,ot(p,n)):p()}else ye(l)&&nn(l,a,12,[s,u])}function St(e,t,n,r=null){xt(e,t,7,[n,r])}function Va(e,t,n=!1){const r=e.children,i=t.children;if(ue(r)&&ue(i))for(let o=0;o>1,e[n[a]]0&&(t[r]=n[o-1]),n[o]=r)}}for(o=n.length,s=n[o-1];o-- >0;)n[o]=s,s=t[s];return n}const Mg=e=>e.__isTeleport,yi=e=>e&&(e.disabled||e.disabled===""),_u=e=>typeof SVGElement!="undefined"&&e instanceof SVGElement,za=(e,t)=>{const n=e&&e.to;return be(n)?t?t(n):null:n},Ag={__isTeleport:!0,process(e,t,n,r,i,o,s,a,l,c){const{mc:u,pc:f,pbc:p,o:{insert:d,querySelector:m,createText:b,createComment:v}}=c,S=yi(t.props);let{shapeFlag:g,children:O,dynamicChildren:E}=t;if(e==null){const $=t.el=b(""),R=t.anchor=b("");d($,n,r),d(R,n,r);const T=t.target=za(t.props,m),P=t.targetAnchor=b("");T&&(d(P,T),s=s||_u(T));const N=(j,V)=>{g&16&&u(O,j,V,i,o,s,a,l)};S?N(n,R):T&&N(T,P)}else{t.el=e.el;const $=t.anchor=e.anchor,R=t.target=e.target,T=t.targetAnchor=e.targetAnchor,P=yi(e.props),N=P?n:R,j=P?$:T;if(s=s||_u(R),E?(p(e.dynamicChildren,E,N,i,o,s,a),Va(e,t,!0)):l||f(e,t,N,j,i,o,s,a,!1),S)P||Vo(t,n,$,c,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){const V=t.target=za(t.props,m);V&&Vo(t,V,null,c,0)}else P&&Vo(t,R,T,c,1)}},remove(e,t,n,r,{um:i,o:{remove:o}},s){const{shapeFlag:a,children:l,anchor:c,targetAnchor:u,target:f,props:p}=e;if(f&&o(u),(s||!yi(p))&&(o(c),a&16))for(let d=0;d0?en||Un:null,nf(),Mr>0&&en&&en.push(e),e}function Si(e,t,n,r,i,o){return rf(Ho(e,t,n,r,i,o,!0))}function ke(e,t,n,r,i){return rf(he(e,t,n,r,i,!0))}function Mn(e){return e?e.__v_isVNode===!0:!1}function tn(e,t){return e.type===t.type&&e.key===t.key}function Dg(e){}const zo="__vInternal",of=({key:e})=>e!=null?e:null,Bo=({ref:e})=>e!=null?be(e)||at(e)||ye(e)?{i:Tt,r:e}:e:null;function Ho(e,t=null,n=null,r=0,i=null,o=e===Ye?0:1,s=!1,a=!1){const l={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&of(t),ref:t&&Bo(t),scopeId:Ao,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetAnchor:null,staticCount:0,shapeFlag:o,patchFlag:r,dynamicProps:i,dynamicChildren:null,appContext:null};return a?(Ua(l,n),o&128&&e.normalize(l)):n&&(l.shapeFlag|=be(n)?8:16),Mr>0&&!s&&en&&(l.patchFlag>0||o&6)&&l.patchFlag!==32&&en.push(l),l}const he=Fg;function Fg(e,t=null,n=null,r=0,i=null,o=!1){if((!e||e===ef)&&(e=ht),Mn(e)){const a=An(e,t,!0);return n&&Ua(a,n),a}if(Zg(e)&&(e=e.__vccOpts),t){t=sf(t);let{class:a,style:l}=t;a&&!be(a)&&(t.class=vn(a)),je(l)&&(ya(l)&&!ue(l)&&(l=Me({},l)),t.style=Zt(l))}const s=be(e)?1:ng(e)?128:Mg(e)?64:je(e)?4:ye(e)?2:0;return Ho(e,t,n,r,i,s,o,!0)}function sf(e){return e?ya(e)||zo in e?Me({},e):e:null}function An(e,t,n=!1){const{props:r,ref:i,patchFlag:o,children:s}=e,a=t?Ei(r||{},t):r;return{__v_isVNode:!0,__v_skip:!0,type:e.type,props:a,key:a&&of(a),ref:t&&t.ref?n&&i?ue(i)?i.concat(Bo(t)):[i,Bo(t)]:Bo(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:s,target:e.target,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==Ye?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:e.transition,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&An(e.ssContent),ssFallback:e.ssFallback&&An(e.ssFallback),el:e.el,anchor:e.anchor}}function Nn(e=" ",t=0){return he(Cr,null,e,t)}function jg(e,t){const n=he(tr,null,e);return n.staticCount=t,n}function Lt(e="",t=!1){return t?(Ie(),ke(ht,null,e)):he(ht,null,e)}function Pt(e){return e==null||typeof e=="boolean"?he(ht):ue(e)?he(Ye,null,e.slice()):typeof e=="object"?Rn(e):he(Cr,null,String(e))}function Rn(e){return e.el===null||e.memo?e:An(e)}function Ua(e,t){let n=0;const{shapeFlag:r}=e;if(t==null)t=null;else if(ue(t))n=16;else if(typeof t=="object")if(r&(1|64)){const i=t.default;i&&(i._c&&(i._d=!1),Ua(e,i()),i._c&&(i._d=!0));return}else{n=32;const i=t._;!i&&!(zo in t)?t._ctx=Tt:i===3&&Tt&&(Tt.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else ye(t)?(t={default:t,_ctx:Tt},n=32):(t=String(t),r&64?(n=16,t=[Nn(t)]):n=8);e.children=t,e.shapeFlag|=n}function Ei(...e){const t={};for(let n=0;nt(s,a,void 0,o&&o[a]));else{const s=Object.keys(e);i=new Array(s.length);for(let a=0,l=s.length;aMn(t)?!(t.type===ht||t.type===Ye&&!af(t.children)):!0)?e:null}function zg(e){const t={};for(const n in e)t[Yn(n)]=e[n];return t}const Ka=e=>e?cf(e)?Ga(e)||e.proxy:Ka(e.parent):null,Uo=Me(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Ka(e.parent),$root:e=>Ka(e.root),$emit:e=>e.emit,$options:e=>Vu(e),$forceUpdate:e=>()=>qa(e.update),$nextTick:e=>Xo.bind(e.proxy),$watch:e=>sv.bind(e)}),Wa={get({_:e},t){const{ctx:n,setupState:r,data:i,props:o,accessCache:s,type:a,appContext:l}=e;let c;if(t[0]!=="$"){const d=s[t];if(d!==void 0)switch(d){case 0:return r[t];case 1:return i[t];case 3:return n[t];case 2:return o[t]}else{if(r!==Ce&&xe(r,t))return s[t]=0,r[t];if(i!==Ce&&xe(i,t))return s[t]=1,i[t];if((c=e.propsOptions[0])&&xe(c,t))return s[t]=2,o[t];if(n!==Ce&&xe(n,t))return s[t]=3,n[t];$a&&(s[t]=4)}}const u=Uo[t];let f,p;if(u)return t==="$attrs"&&wt(e,"get",t),u(e);if((f=a.__cssModules)&&(f=f[t]))return f;if(n!==Ce&&xe(n,t))return s[t]=3,n[t];if(p=l.config.globalProperties,xe(p,t))return p[t]},set({_:e},t,n){const{data:r,setupState:i,ctx:o}=e;if(i!==Ce&&xe(i,t))i[t]=n;else if(r!==Ce&&xe(r,t))r[t]=n;else if(xe(e.props,t))return!1;return t[0]==="$"&&t.slice(1)in e?!1:(o[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:r,appContext:i,propsOptions:o}},s){let a;return n[s]!==void 0||e!==Ce&&xe(e,s)||t!==Ce&&xe(t,s)||(a=o[0])&&xe(a,s)||xe(r,s)||xe(Uo,s)||xe(i.config.globalProperties,s)}},Bg=Me({},Wa,{get(e,t){if(t!==Symbol.unscopables)return Wa.get(e,t,e)},has(e,t){return t[0]!=="_"&&!Ic(t)}}),Hg=Zu();let kg=0;function lf(e,t,n){const r=e.type,i=(t?t.appContext:e.appContext)||Hg,o={uid:kg++,vnode:e,type:r,parent:t,appContext:i,root:null,next:null,subTree:null,update:null,scope:new la(!0),render:null,proxy:null,exposed:null,exposeProxy:null,withProxy:null,provides:t?t.provides:Object.create(i.provides),accessCache:null,renderCache:[],components:null,directives:null,propsOptions:Hu(r,i),emitsOptions:vu(r,i),emit:null,emitted:null,propsDefaults:Ce,inheritAttrs:r.inheritAttrs,ctx:Ce,data:Ce,props:Ce,attrs:Ce,slots:Ce,refs:Ce,setupState:Ce,setupContext:null,suspense:n,suspenseId:n?n.pendingId:0,asyncDep:null,asyncResolved:!1,isMounted:!1,isUnmounted:!1,isDeactivated:!1,bc:null,c:null,bm:null,m:null,bu:null,u:null,um:null,bum:null,da:null,a:null,rtg:null,rtc:null,ec:null,sp:null};return o.ctx={_:o},o.root=t?t.root:o,o.emit=Qm.bind(null,o),e.ce&&e.ce(o),o}let et=null;const In=()=>et||Tt,$n=e=>{et=e,e.scope.on()},Ln=()=>{et&&et.scope.off(),et=null};function cf(e){return e.vnode.shapeFlag&4}let Oi=!1;function uf(e,t=!1){Oi=t;const{props:n,children:r}=e.vnode,i=cf(e);bg(e,n,i,t),Og(e,r);const o=i?Ug(e,t):void 0;return Oi=!1,o}function Ug(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=ba(new Proxy(e.ctx,Wa));const{setup:r}=n;if(r){const i=e.setupContext=r.length>1?pf(e):null;$n(e),Jn();const o=nn(r,e,0,[e.props,i]);if(wn(),Ln(),oa(o)){if(o.then(Ln,Ln),t)return o.then(s=>{Ya(e,s,t)}).catch(s=>{nr(s,e,0)});e.asyncDep=o}else Ya(e,o,t)}else ff(e,t)}function Ya(e,t,n){ye(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:je(t)&&(e.setupState=Oa(t)),ff(e,n)}let Ko,Xa;function Kg(e){Ko=e,Xa=t=>{t.render._rc&&(t.withProxy=new Proxy(t.ctx,Bg))}}const Wg=()=>!Ko;function ff(e,t,n){const r=e.type;if(!e.render){if(!t&&Ko&&!r.render){const i=r.template;if(i){const{isCustomElement:o,compilerOptions:s}=e.appContext.config,{delimiters:a,compilerOptions:l}=r,c=Me(Me({isCustomElement:o,delimiters:a},s),l);r.render=Ko(i,c)}}e.render=r.render||pt,Xa&&Xa(e)}$n(e),Jn(),hg(e),wn(),Ln()}function Yg(e){return new Proxy(e.attrs,{get(t,n){return wt(e,"get","$attrs"),t[n]}})}function pf(e){const t=r=>{e.exposed=r||{}};let n;return{get attrs(){return n||(n=Yg(e))},slots:e.slots,emit:e.emit,expose:t}}function Ga(e){if(e.exposed)return e.exposeProxy||(e.exposeProxy=new Proxy(Oa(ba(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in Uo)return Uo[n](e)}}))}const Xg=/(?:^|[-_])(\w)/g,Gg=e=>e.replace(Xg,t=>t.toUpperCase()).replace(/[-_]/g,"");function Wo(e){return ye(e)&&e.displayName||e.name}function df(e,t,n=!1){let r=Wo(t);if(!r&&t.__file){const i=t.__file.match(/([^/\\]+)\.\w+$/);i&&(r=i[1])}if(!r&&e&&e.parent){const i=o=>{for(const s in o)if(o[s]===t)return s};r=i(e.components||e.parent.type.components)||i(e.appContext.components)}return r?Gg(r):n?"App":"Anonymous"}function Zg(e){return ye(e)&&"__vccOpts"in e}const wi=[];function hf(e,...t){Jn();const n=wi.length?wi[wi.length-1].component:null,r=n&&n.appContext.config.warnHandler,i=Jg();if(r)nn(r,n,11,[e+t.join(""),n&&n.proxy,i.map(({vnode:o})=>`at <${df(n,o.type)}>`).join(` +`),i]);else{const o=[`[Vue warn]: ${e}`,...t];i.length&&o.push(` +`,...Qg(i)),console.warn(...o)}wn()}function Jg(){let e=wi[wi.length-1];if(!e)return[];const t=[];for(;e;){const n=t[0];n&&n.vnode===e?n.recurseCount++:t.push({vnode:e,recurseCount:0});const r=e.component&&e.component.parent;e=r&&r.vnode}return t}function Qg(e){const t=[];return e.forEach((n,r)=>{t.push(...r===0?[]:[` +`],...qg(n))}),t}function qg({vnode:e,recurseCount:t}){const n=t>0?`... (${t} recursive calls)`:"",r=e.component?e.component.parent==null:!1,i=` at <${df(e.component,e.type,r)}`,o=">"+n;return e.props?[i,..._g(e.props),o]:[i+o]}function _g(e){const t=[],n=Object.keys(e);return n.slice(0,3).forEach(r=>{t.push(...mf(r,e[r]))}),n.length>3&&t.push(" ..."),t}function mf(e,t,n){return be(t)?(t=JSON.stringify(t),n?t:[`${e}=${t}`]):typeof t=="number"||typeof t=="boolean"||t==null?n?t:[`${e}=${t}`]:at(t)?(t=mf(e,Te(t.value),!0),n?t:[`${e}=Ref<`,t,">"]):ye(t)?[`${e}=fn${t.name?`<${t.name}>`:""}`]:(t=Te(t),n?t:[`${e}=`,t])}function nn(e,t,n,r){let i;try{i=r?e(...r):e()}catch(o){nr(o,t,n)}return i}function xt(e,t,n,r){if(ye(e)){const o=nn(e,t,n,r);return o&&oa(o)&&o.catch(s=>{nr(s,t,n)}),o}const i=[];for(let o=0;o>>1;Ci(Ct[r])pn&&Ct.splice(t,1)}function yf(e,t,n,r){ue(e)?n.push(...e):(!t||!t.includes(e,e.allowRecurse?r+1:r))&&n.push(e),vf()}function rv(e){yf(e,Pi,Ti,Ar)}function _a(e){yf(e,Dn,xi,Nr)}function el(e,t=null){if(Ti.length){for(Qa=t,Pi=[...new Set(Ti)],Ti.length=0,Ar=0;ArCi(n)-Ci(r)),Nr=0;Nre.id==null?1/0:e.id;function bf(e){Za=!1,Yo=!0,el(e),Ct.sort((n,r)=>Ci(n)-Ci(r));const t=pt;try{for(pn=0;pne.value,c=!!e._shallow):Qn(e)?(l=()=>e,r=!0):ue(e)?(u=!0,c=e.some(Qn),l=()=>e.map(S=>{if(at(S))return S.value;if(Qn(S))return rr(S);if(ye(S))return nn(S,a,2)})):ye(e)?t?l=()=>nn(e,a,2):l=()=>{if(!(a&&a.isUnmounted))return f&&f(),xt(e,a,3,[p])}:l=pt,t&&r){const S=l;l=()=>rr(S())}let f,p=S=>{f=v.onStop=()=>{nn(S,a,4)}};if(Oi)return p=pt,t?n&&xt(t,a,3,[l(),u?[]:void 0,p]):l(),pt;let d=u?[]:Ef;const m=()=>{if(!!v.active)if(t){const S=v.run();(r||c||(u?S.some((g,O)=>Er(g,d[O])):Er(S,d)))&&(f&&f(),xt(t,a,3,[S,d===Ef?void 0:d,p]),d=S)}else v.run()};m.allowRecurse=!!t;let b;i==="sync"?b=m:i==="post"?b=()=>ot(m,a&&a.suspense):b=()=>{!a||a.isMounted?rv(m):m()};const v=new ai(l,b);return t?n?m():d=v.run():i==="post"?ot(v.run.bind(v),a&&a.suspense):v.run(),()=>{v.stop(),a&&a.scope&&ra(a.scope.effects,v)}}function sv(e,t,n){const r=this.proxy,i=be(e)?e.includes(".")?Of(r,e):()=>r[e]:e.bind(r,r);let o;ye(t)?o=t:(o=t.handler,n=t);const s=et;$n(this);const a=Mi(i,o.bind(r),n);return s?$n(s):Ln(),a}function Of(e,t){const n=t.split(".");return()=>{let r=e;for(let i=0;i{rr(n,t)});else if(aa(e))for(const n in e)rr(e[n],t);return e}const wf=e=>typeof e=="function",av=e=>e!==null&&typeof e=="object",lv=e=>av(e)&&wf(e.then)&&wf(e.catch);function cv(){return null}function uv(){return null}function fv(e){}function pv(e,t){return null}function dv(){return Tf().slots}function hv(){return Tf().attrs}function Tf(){const e=In();return e.setupContext||(e.setupContext=pf(e))}function mv(e,t){for(const n in t){const r=e[n];r?r.default=t[n]:r===null&&(e[n]={default:t[n]})}return e}function gv(e){const t=In();let n=e();return Ln(),lv(n)&&(n=n.catch(r=>{throw $n(t),r})),[n,()=>$n(t)]}function tl(e,t,n){const r=arguments.length;return r===2?je(t)&&!ue(t)?Mn(t)?he(e,null,[t]):he(e,t):he(e,null,t):(r>3?n=Array.prototype.slice.call(arguments,2):r===3&&Mn(n)&&(n=[n]),he(e,t,n))}const Pf=Symbol(""),vv=()=>{{const e=di(Pf);return e||hf("Server rendering context not provided. Make sure to only call useSSRContext() conditionally in the server build."),e}};function yv(){}function bv(e,t,n,r){const i=n[r];if(i&&xf(i,e))return i;const o=t();return o.memo=e.slice(),n[r]=o}function xf(e,t){const n=e.memo;if(n.length!=t.length)return!1;for(let r=0;r0&&en&&en.push(e),!0}const Cf="3.2.19",Sv={createComponentInstance:lf,setupComponent:uf,renderComponentRoot:No,setCurrentRenderingInstance:fi,isVNode:Mn,normalizeVNode:Pt},Ev=Sv,Ov=null,wv=null,Tv="http://www.w3.org/2000/svg",Rr=typeof document!="undefined"?document:null,Mf=new Map,Pv={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{const i=t?Rr.createElementNS(Tv,e):Rr.createElement(e,n?{is:n}:void 0);return e==="select"&&r&&r.multiple!=null&&i.setAttribute("multiple",r.multiple),i},createText:e=>Rr.createTextNode(e),createComment:e=>Rr.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Rr.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},cloneNode(e){const t=e.cloneNode(!0);return"_value"in e&&(t._value=e._value),t},insertStaticContent(e,t,n,r){const i=n?n.previousSibling:t.lastChild;let o=Mf.get(e);if(!o){const s=Rr.createElement("template");if(s.innerHTML=r?`${e}`:e,o=s.content,r){const a=o.firstChild;for(;a.firstChild;)o.appendChild(a.firstChild);o.removeChild(a)}Mf.set(e,o)}return t.insertBefore(o.cloneNode(!0),n),[i?i.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}};function xv(e,t,n){const r=e._vtc;r&&(t=(t?[t,...r]:[...r]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}function Cv(e,t,n){const r=e.style,i=r.display;if(!n)e.removeAttribute("style");else if(be(n))t!==n&&(r.cssText=n);else{for(const o in n)nl(r,o,n[o]);if(t&&!be(t))for(const o in t)n[o]==null&&nl(r,o,"")}"_vod"in e&&(r.display=i)}const Af=/\s*!important$/;function nl(e,t,n){if(ue(n))n.forEach(r=>nl(e,t,r));else if(t.startsWith("--"))e.setProperty(t,n);else{const r=Mv(e,t);Af.test(n)?e.setProperty(Ot(r),n.replace(Af,""),"important"):e[r]=n}}const Nf=["Webkit","Moz","ms"],rl={};function Mv(e,t){const n=rl[t];if(n)return n;let r=st(t);if(r!=="filter"&&r in e)return rl[t]=r;r=En(r);for(let i=0;idocument.createEvent("Event").timeStamp&&(Zo=()=>performance.now());const e=navigator.userAgent.match(/firefox\/(\d+)/i);If=!!(e&&Number(e[1])<=53)}let il=0;const Rv=Promise.resolve(),Iv=()=>{il=0},$v=()=>il||(Rv.then(Iv),il=Zo());function dn(e,t,n,r){e.addEventListener(t,n,r)}function Lv(e,t,n,r){e.removeEventListener(t,n,r)}function Dv(e,t,n,r,i=null){const o=e._vei||(e._vei={}),s=o[t];if(r&&s)s.value=r;else{const[a,l]=Fv(t);if(r){const c=o[t]=jv(r,i);dn(e,a,c,l)}else s&&(Lv(e,a,s,l),o[t]=void 0)}}const $f=/(?:Once|Passive|Capture)$/;function Fv(e){let t;if($f.test(e)){t={};let n;for(;n=e.match($f);)e=e.slice(0,e.length-n[0].length),t[n[0].toLowerCase()]=!0}return[Ot(e.slice(2)),t]}function jv(e,t){const n=r=>{const i=r.timeStamp||Zo();(If||i>=n.attached-1)&&xt(Vv(r,n.value),t,5,[r])};return n.value=e,n.attached=$v(),n}function Vv(e,t){if(ue(t)){const n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(r=>i=>!i._stopped&&r(i))}else return t}const Lf=/^on[a-z]/,zv=(e,t,n,r,i=!1,o,s,a,l)=>{t==="class"?xv(e,r,i):t==="style"?Cv(e,n,r):yn(t)?ho(t)||Dv(e,t,n,r,s):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Bv(e,t,r,i))?Nv(e,t,r,o,s,a,l):(t==="true-value"?e._trueValue=r:t==="false-value"&&(e._falseValue=r),Av(e,t,r,i))};function Bv(e,t,n,r){return r?!!(t==="innerHTML"||t==="textContent"||t in e&&Lf.test(t)&&ye(n)):t==="spellcheck"||t==="draggable"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA"||Lf.test(t)&&be(n)?!1:t in e}function Df(e,t){const n=Aa(e);class r extends Jo{constructor(o){super(n,o,t)}}return r.def=n,r}const Hv=e=>Df(e,rp),kv=typeof HTMLElement!="undefined"?HTMLElement:class{};class Jo extends kv{constructor(t,n={},r){super();this._def=t,this._props=n,this._instance=null,this._connected=!1,this._resolved=!1,this._numberProps=null,this.shadowRoot&&r?r(this._createVNode(),this.shadowRoot):this.attachShadow({mode:"open"});for(let i=0;i{for(const o of i)this._setAttr(o.attributeName)}).observe(this,{attributes:!0})}connectedCallback(){this._connected=!0,this._instance||(this._resolveDef(),this._update())}disconnectedCallback(){this._connected=!1,Xo(()=>{this._connected||(cl(null,this.shadowRoot),this._instance=null)})}_resolveDef(){if(this._resolved)return;const t=r=>{this._resolved=!0;const{props:i,styles:o}=r,s=!ue(i),a=i?s?Object.keys(i):i:[];let l;if(s)for(const c in this._props){const u=i[c];(u===Number||u&&u.type===Number)&&(this._props[c]=cn(this._props[c]),(l||(l=Object.create(null)))[c]=!0)}l&&(this._numberProps=l,this._update());for(const c of Object.keys(this))c[0]!=="_"&&this._setProp(c,this[c]);for(const c of a.map(st))Object.defineProperty(this,c,{get(){return this._getProp(c)},set(u){this._setProp(c,u)}});this._applyStyles(o)},n=this._def.__asyncLoader;n?n().then(t):t(this._def)}_setAttr(t){let n=this.getAttribute(t);this._numberProps&&this._numberProps[t]&&(n=cn(n)),this._setProp(st(t),n,!1)}_getProp(t){return this._props[t]}_setProp(t,n,r=!0){n!==this._props[t]&&(this._props[t]=n,this._instance&&this._update(),r&&(n===!0?this.setAttribute(Ot(t),""):typeof n=="string"||typeof n=="number"?this.setAttribute(Ot(t),n+""):n||this.removeAttribute(Ot(t))))}_update(){cl(this._createVNode(),this.shadowRoot)}_createVNode(){const t=he(this._def,Me({},this._props));return this._instance||(t.ce=n=>{this._instance=n,n.isCE=!0,n.emit=(i,...o)=>{this.dispatchEvent(new CustomEvent(i,{detail:o}))};let r=this;for(;r=r&&(r.parentNode||r.host);)if(r instanceof Jo){n.parent=r._instance;break}}),t}_applyStyles(t){t&&t.forEach(n=>{const r=document.createElement("style");r.textContent=n,this.shadowRoot.appendChild(r)})}}function Uv(e="$style"){{const t=In();if(!t)return Ce;const n=t.type.__cssModules;if(!n)return Ce;const r=n[e];return r||Ce}}function Kv(e){const t=In();if(!t)return;const n=()=>ol(t.subTree,e(t.proxy));Sf(n),Pr(()=>{const r=new MutationObserver(n);r.observe(t.subTree.el.parentNode,{childList:!0}),vi(()=>r.disconnect())})}function ol(e,t){if(e.shapeFlag&128){const n=e.suspense;e=n.activeBranch,n.pendingBranch&&!n.isHydrating&&n.effects.push(()=>{ol(n.activeBranch,t)})}for(;e.component;)e=e.component.subTree;if(e.shapeFlag&1&&e.el)Ff(e.el,t);else if(e.type===Ye)e.children.forEach(n=>ol(n,t));else if(e.type===tr){let{el:n,anchor:r}=e;for(;n&&(Ff(n,t),n!==r);)n=n.nextSibling}}function Ff(e,t){if(e.nodeType===1){const n=e.style;for(const r in t)n.setProperty(`--${r}`,t[r])}}const Fn="transition",Ai="animation",Ni=(e,{slots:t})=>tl(Ca,zf(e),t);Ni.displayName="Transition";const jf={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},Wv=Ni.props=Me({},Ca.props,jf),ir=(e,t=[])=>{ue(e)?e.forEach(n=>n(...t)):e&&e(...t)},Vf=e=>e?ue(e)?e.some(t=>t.length>1):e.length>1:!1;function zf(e){const t={};for(const F in e)F in jf||(t[F]=e[F]);if(e.css===!1)return t;const{name:n="v",type:r,duration:i,enterFromClass:o=`${n}-enter-from`,enterActiveClass:s=`${n}-enter-active`,enterToClass:a=`${n}-enter-to`,appearFromClass:l=o,appearActiveClass:c=s,appearToClass:u=a,leaveFromClass:f=`${n}-leave-from`,leaveActiveClass:p=`${n}-leave-active`,leaveToClass:d=`${n}-leave-to`}=e,m=Yv(i),b=m&&m[0],v=m&&m[1],{onBeforeEnter:S,onEnter:g,onEnterCancelled:O,onLeave:E,onLeaveCancelled:$,onBeforeAppear:R=S,onAppear:T=g,onAppearCancelled:P=O}=t,N=(F,C,w)=>{or(F,C?u:a),or(F,C?c:s),w&&w()},j=(F,C)=>{or(F,d),or(F,p),C&&C()},V=F=>(C,w)=>{const M=F?T:g,L=()=>N(C,F,w);ir(M,[C,L]),Bf(()=>{or(C,F?l:o),hn(C,F?u:a),Vf(M)||Hf(C,r,b,L)})};return Me(t,{onBeforeEnter(F){ir(S,[F]),hn(F,o),hn(F,s)},onBeforeAppear(F){ir(R,[F]),hn(F,l),hn(F,c)},onEnter:V(!1),onAppear:V(!0),onLeave(F,C){const w=()=>j(F,C);hn(F,f),Wf(),hn(F,p),Bf(()=>{or(F,f),hn(F,d),Vf(E)||Hf(F,r,v,w)}),ir(E,[F,w])},onEnterCancelled(F){N(F,!1),ir(O,[F])},onAppearCancelled(F){N(F,!0),ir(P,[F])},onLeaveCancelled(F){j(F),ir($,[F])}})}function Yv(e){if(e==null)return null;if(je(e))return[sl(e.enter),sl(e.leave)];{const t=sl(e);return[t,t]}}function sl(e){return cn(e)}function hn(e,t){t.split(/\s+/).forEach(n=>n&&e.classList.add(n)),(e._vtc||(e._vtc=new Set)).add(t)}function or(e,t){t.split(/\s+/).forEach(r=>r&&e.classList.remove(r));const{_vtc:n}=e;n&&(n.delete(t),n.size||(e._vtc=void 0))}function Bf(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let Xv=0;function Hf(e,t,n,r){const i=e._endId=++Xv,o=()=>{i===e._endId&&r()};if(n)return setTimeout(o,n);const{type:s,timeout:a,propCount:l}=kf(e,t);if(!s)return r();const c=s+"end";let u=0;const f=()=>{e.removeEventListener(c,p),o()},p=d=>{d.target===e&&++u>=l&&f()};setTimeout(()=>{u(n[m]||"").split(", "),i=r(Fn+"Delay"),o=r(Fn+"Duration"),s=Uf(i,o),a=r(Ai+"Delay"),l=r(Ai+"Duration"),c=Uf(a,l);let u=null,f=0,p=0;t===Fn?s>0&&(u=Fn,f=s,p=o.length):t===Ai?c>0&&(u=Ai,f=c,p=l.length):(f=Math.max(s,c),u=f>0?s>c?Fn:Ai:null,p=u?u===Fn?o.length:l.length:0);const d=u===Fn&&/\b(transform|all)(,|$)/.test(n[Fn+"Property"]);return{type:u,timeout:f,propCount:p,hasTransform:d}}function Uf(e,t){for(;e.lengthKf(n)+Kf(e[r])))}function Kf(e){return Number(e.slice(0,-1).replace(",","."))*1e3}function Wf(){return document.body.offsetHeight}const Yf=new WeakMap,Xf=new WeakMap,Gv={name:"TransitionGroup",props:Me({},Wv,{tag:String,moveClass:String}),setup(e,{slots:t}){const n=In(),r=xa();let i,o;return $o(()=>{if(!i.length)return;const s=e.moveClass||`${e.name||"v"}-move`;if(!_v(i[0].el,n.vnode.el,s))return;i.forEach(Jv),i.forEach(Qv);const a=i.filter(qv);Wf(),a.forEach(l=>{const c=l.el,u=c.style;hn(c,s),u.transform=u.webkitTransform=u.transitionDuration="";const f=c._moveCb=p=>{p&&p.target!==c||(!p||/transform$/.test(p.propertyName))&&(c.removeEventListener("transitionend",f),c._moveCb=null,or(c,s))};c.addEventListener("transitionend",f)})}),()=>{const s=Te(e),a=zf(s);let l=s.tag||Ye;i=o,o=t.default?Ro(t.default()):[];for(let c=0;c{s.split(/\s+/).forEach(a=>a&&r.classList.remove(a))}),n.split(/\s+/).forEach(s=>s&&r.classList.add(s)),r.style.display="none";const i=t.nodeType===1?t:t.parentNode;i.appendChild(r);const{hasTransform:o}=kf(r);return i.removeChild(r),o}const jn=e=>{const t=e.props["onUpdate:modelValue"];return ue(t)?n=>Xn(t,n):t};function ey(e){e.target.composing=!0}function Gf(e){const t=e.target;t.composing&&(t.composing=!1,ty(t,"input"))}function ty(e,t){const n=document.createEvent("HTMLEvents");n.initEvent(t,!0,!0),e.dispatchEvent(n)}const Qo={created(e,{modifiers:{lazy:t,trim:n,number:r}},i){e._assign=jn(i);const o=r||i.props&&i.props.type==="number";dn(e,t?"change":"input",s=>{if(s.target.composing)return;let a=e.value;n?a=a.trim():o&&(a=cn(a)),e._assign(a)}),n&&dn(e,"change",()=>{e.value=e.value.trim()}),t||(dn(e,"compositionstart",ey),dn(e,"compositionend",Gf),dn(e,"change",Gf))},mounted(e,{value:t}){e.value=t==null?"":t},beforeUpdate(e,{value:t,modifiers:{lazy:n,trim:r,number:i}},o){if(e._assign=jn(o),e.composing||document.activeElement===e&&(n||r&&e.value.trim()===t||(i||e.type==="number")&&cn(e.value)===t))return;const s=t==null?"":t;e.value!==s&&(e.value=s)}},al={deep:!0,created(e,t,n){e._assign=jn(n),dn(e,"change",()=>{const r=e._modelValue,i=Ir(e),o=e.checked,s=e._assign;if(ue(r)){const a=ti(r,i),l=a!==-1;if(o&&!l)s(r.concat(i));else if(!o&&l){const c=[...r];c.splice(a,1),s(c)}}else if(bn(r)){const a=new Set(r);o?a.add(i):a.delete(i),s(a)}else s(qf(e,o))})},mounted:Zf,beforeUpdate(e,t,n){e._assign=jn(n),Zf(e,t,n)}};function Zf(e,{value:t,oldValue:n},r){e._modelValue=t,ue(t)?e.checked=ti(t,r.props.value)>-1:bn(t)?e.checked=t.has(r.props.value):t!==n&&(e.checked=ln(t,qf(e,!0)))}const ll={created(e,{value:t},n){e.checked=ln(t,n.props.value),e._assign=jn(n),dn(e,"change",()=>{e._assign(Ir(e))})},beforeUpdate(e,{value:t,oldValue:n},r){e._assign=jn(r),t!==n&&(e.checked=ln(t,r.props.value))}},Jf={deep:!0,created(e,{value:t,modifiers:{number:n}},r){const i=bn(t);dn(e,"change",()=>{const o=Array.prototype.filter.call(e.options,s=>s.selected).map(s=>n?cn(Ir(s)):Ir(s));e._assign(e.multiple?i?new Set(o):o:o[0])}),e._assign=jn(r)},mounted(e,{value:t}){Qf(e,t)},beforeUpdate(e,t,n){e._assign=jn(n)},updated(e,{value:t}){Qf(e,t)}};function Qf(e,t){const n=e.multiple;if(!(n&&!ue(t)&&!bn(t))){for(let r=0,i=e.options.length;r-1:o.selected=t.has(s);else if(ln(Ir(o),t)){e.selectedIndex!==r&&(e.selectedIndex=r);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Ir(e){return"_value"in e?e._value:e.value}function qf(e,t){const n=t?"_trueValue":"_falseValue";return n in e?e[n]:t}const ny={created(e,t,n){qo(e,t,n,null,"created")},mounted(e,t,n){qo(e,t,n,null,"mounted")},beforeUpdate(e,t,n,r){qo(e,t,n,r,"beforeUpdate")},updated(e,t,n,r){qo(e,t,n,r,"updated")}};function qo(e,t,n,r,i){let o;switch(e.tagName){case"SELECT":o=Jf;break;case"TEXTAREA":o=Qo;break;default:switch(n.props&&n.props.type){case"checkbox":o=al;break;case"radio":o=ll;break;default:o=Qo}}const s=o[i];s&&s(e,t,n,r)}function ry(){Qo.getSSRProps=({value:e})=>({value:e}),ll.getSSRProps=({value:e},t)=>{if(t.props&&ln(t.props.value,e))return{checked:!0}},al.getSSRProps=({value:e},t)=>{if(ue(e)){if(t.props&&ti(e,t.props.value)>-1)return{checked:!0}}else if(bn(e)){if(t.props&&e.has(t.props.value))return{checked:!0}}else if(e)return{checked:!0}}}const iy=["ctrl","shift","alt","meta"],oy={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>iy.some(n=>e[`${n}Key`]&&!t.includes(n))},Xe=(e,t)=>(n,...r)=>{for(let i=0;in=>{if(!("key"in n))return;const r=Ot(n.key);if(t.some(i=>i===r||sy[i]===r))return e(n)},sr={beforeMount(e,{value:t},{transition:n}){e._vod=e.style.display==="none"?"":e.style.display,n&&t?n.beforeEnter(e):Ri(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:r}){!t!=!n&&(r?t?(r.beforeEnter(e),Ri(e,!0),r.enter(e)):r.leave(e,()=>{Ri(e,!1)}):Ri(e,t))},beforeUnmount(e,{value:t}){Ri(e,t)}};function Ri(e,t){e.style.display=t?e._vod:"none"}function ay(){sr.getSSRProps=({value:e})=>{if(!e)return{style:{display:"none"}}}}const _f=Me({patchProp:zv},Pv);let Ii,ep=!1;function tp(){return Ii||(Ii=Ju(_f))}function np(){return Ii=ep?Ii:Qu(_f),ep=!0,Ii}const cl=(...e)=>{tp().render(...e)},rp=(...e)=>{np().hydrate(...e)},ip=(...e)=>{const t=tp().createApp(...e),{mount:n}=t;return t.mount=r=>{const i=op(r);if(!i)return;const o=t._component;!ye(o)&&!o.render&&!o.template&&(o.template=i.innerHTML),i.innerHTML="";const s=n(i,!1,i instanceof SVGElement);return i instanceof Element&&(i.removeAttribute("v-cloak"),i.setAttribute("data-v-app","")),s},t},ly=(...e)=>{const t=np().createApp(...e),{mount:n}=t;return t.mount=r=>{const i=op(r);if(i)return n(i,!0,i instanceof SVGElement)},t};function op(e){return be(e)?document.querySelector(e):e}let sp=!1;const cy=()=>{sp||(sp=!0,ry(),ay())};var uy=Object.freeze({__proto__:null,[Symbol.toStringTag]:"Module",Transition:Ni,TransitionGroup:Zv,VueElement:Jo,createApp:ip,createSSRApp:ly,defineCustomElement:Df,defineSSRCustomElement:Hv,hydrate:rp,initDirectivesForSSR:cy,render:cl,useCssModule:Uv,useCssVars:Kv,vModelCheckbox:al,vModelDynamic:ny,vModelRadio:ll,vModelSelect:Jf,vModelText:Qo,vShow:sr,withKeys:Dt,withModifiers:Xe,EffectScope:la,ReactiveEffect:ai,computed:xn,customRef:Gm,effect:mm,effectScope:um,getCurrentScope:fm,isProxy:ya,isReactive:Qn,isReadonly:va,isRef:at,markRaw:ba,onScopeDispose:pm,proxyRefs:Oa,reactive:xo,readonly:ga,ref:Pn,shallowReactive:fu,shallowReadonly:km,shallowRef:Um,stop:gm,toRaw:Te,toRef:hu,toRefs:ci,triggerRef:Wm,unref:du,camelize:st,capitalize:En,normalizeClass:vn,normalizeProps:Vc,normalizeStyle:Zt,toDisplayString:Jt,toHandlerKey:Yn,BaseTransition:Ca,Comment:ht,Fragment:Ye,KeepAlive:pg,Static:tr,Suspense:ig,Teleport:Rg,Text:Cr,callWithAsyncErrorHandling:xt,callWithErrorHandling:nn,cloneVNode:An,compatUtils:wv,createBlock:ke,createCommentVNode:Lt,createElementBlock:Si,createElementVNode:Ho,createHydrationRenderer:Qu,createRenderer:Ju,createSlots:Vg,createStaticVNode:jg,createTextVNode:Nn,createVNode:he,defineAsyncComponent:ug,defineComponent:Aa,defineEmits:uv,defineExpose:fv,defineProps:cv,get devtools(){return ui},getCurrentInstance:In,getTransitionRawChildren:Ro,guardReactiveProps:sf,h:tl,handleError:nr,initCustomFormatter:yv,inject:di,isMemoSame:xf,isRuntimeOnly:Wg,isVNode:Mn,mergeDefaults:mv,mergeProps:Ei,nextTick:Xo,onActivated:Cu,onBeforeMount:Nu,onBeforeUnmount:Lo,onBeforeUpdate:Ru,onDeactivated:Mu,onErrorCaptured:Du,onMounted:Pr,onRenderTracked:Lu,onRenderTriggered:$u,onServerPrefetch:Iu,onUnmounted:vi,onUpdated:$o,openBlock:Ie,popScopeId:bu,provide:Tu,pushScopeId:yu,queuePostFlushCb:_a,registerRuntimeCompiler:Kg,renderList:ko,renderSlot:qe,resolveComponent:er,resolveDirective:Lg,resolveDynamicComponent:$g,resolveFilter:Ov,resolveTransitionHooks:Tr,setBlockTracking:ka,setDevtoolsHook:gu,setTransitionHooks:qn,ssrContextKey:Pf,ssrUtils:Ev,toHandlers:zg,transformVNodeArgs:Dg,useAttrs:hv,useSSRContext:vv,useSlots:dv,useTransitionState:xa,version:Cf,warn:hf,watch:lt,watchEffect:iv,watchPostEffect:Sf,watchSyncEffect:ov,withAsyncContext:gv,withCtx:qt,withDefaults:pv,withDirectives:xr,withMemo:bv,withScopeId:Su});function ul(e){if(e.__esModule)return e;var t=Object.defineProperty({},"__esModule",{value:!0});return Object.keys(e).forEach(function(n){var r=Object.getOwnPropertyDescriptor(e,n);Object.defineProperty(t,n,r.get?r:{enumerable:!0,get:function(){return e[n]}})}),t}var fy={exports:{}},ap={exports:{}},lp={};function fl(e){throw e}function cp(e){}function Be(e,t,n,r){const i=e,o=new SyntaxError(String(i));return o.code=e,o.loc=t,o}const $r=Symbol(""),Lr=Symbol(""),_o=Symbol(""),$i=Symbol(""),pl=Symbol(""),Vn=Symbol(""),dl=Symbol(""),hl=Symbol(""),es=Symbol(""),ts=Symbol(""),Dr=Symbol(""),ns=Symbol(""),ml=Symbol(""),rs=Symbol(""),Li=Symbol(""),is=Symbol(""),os=Symbol(""),ss=Symbol(""),as=Symbol(""),gl=Symbol(""),vl=Symbol(""),Di=Symbol(""),Fi=Symbol(""),ls=Symbol(""),cs=Symbol(""),Fr=Symbol(""),jr=Symbol(""),us=Symbol(""),fs=Symbol(""),up=Symbol(""),ps=Symbol(""),ji=Symbol(""),fp=Symbol(""),pp=Symbol(""),ds=Symbol(""),dp=Symbol(""),hp=Symbol(""),hs=Symbol(""),yl=Symbol(""),mn={[$r]:"Fragment",[Lr]:"Teleport",[_o]:"Suspense",[$i]:"KeepAlive",[pl]:"BaseTransition",[Vn]:"openBlock",[dl]:"createBlock",[hl]:"createElementBlock",[es]:"createVNode",[ts]:"createElementVNode",[Dr]:"createCommentVNode",[ns]:"createTextVNode",[ml]:"createStaticVNode",[rs]:"resolveComponent",[Li]:"resolveDynamicComponent",[is]:"resolveDirective",[os]:"resolveFilter",[ss]:"withDirectives",[as]:"renderList",[gl]:"renderSlot",[vl]:"createSlots",[Di]:"toDisplayString",[Fi]:"mergeProps",[ls]:"normalizeClass",[cs]:"normalizeStyle",[Fr]:"normalizeProps",[jr]:"guardReactiveProps",[us]:"toHandlers",[fs]:"camelize",[up]:"capitalize",[ps]:"toHandlerKey",[ji]:"setBlockTracking",[fp]:"pushScopeId",[pp]:"popScopeId",[ds]:"withCtx",[dp]:"unref",[hp]:"isRef",[hs]:"withMemo",[yl]:"isMemoSame"};function mp(e){Object.getOwnPropertySymbols(e).forEach(t=>{mn[t]=e[t]})}const tt={source:"",start:{line:1,column:1,offset:0},end:{line:1,column:1,offset:0}};function gp(e,t=tt){return{type:0,children:e,helpers:[],components:[],directives:[],hoists:[],imports:[],cached:0,temps:0,codegenNode:void 0,loc:t}}function Vr(e,t,n,r,i,o,s,a=!1,l=!1,c=!1,u=tt){return e&&(a?(e.helper(Vn),e.helper(ur(e.inSSR,c))):e.helper(cr(e.inSSR,c)),s&&e.helper(ss)),{type:13,tag:t,props:n,children:r,patchFlag:i,dynamicProps:o,directives:s,isBlock:a,disableTracking:l,isComponent:c,loc:u}}function zr(e,t=tt){return{type:17,loc:t,elements:e}}function Mt(e,t=tt){return{type:15,loc:t,properties:e}}function Ge(e,t){return{type:16,loc:tt,key:be(e)?Ee(e,!0):e,value:t}}function Ee(e,t=!1,n=tt,r=0){return{type:4,loc:n,content:e,isStatic:t,constType:t?3:r}}function py(e,t){return{type:5,loc:t,content:be(e)?Ee(e,!1,t):e}}function Kt(e,t=tt){return{type:8,loc:t,children:e}}function Ze(e,t=[],n=tt){return{type:14,loc:n,callee:e,arguments:t}}function ar(e,t=void 0,n=!1,r=!1,i=tt){return{type:18,params:e,returns:t,newline:n,isSlot:r,loc:i}}function ms(e,t,n,r=!0){return{type:19,test:e,consequent:t,alternate:n,newline:r,loc:tt}}function vp(e,t,n=!1){return{type:20,index:e,value:t,isVNode:n,loc:tt}}function yp(e){return{type:21,body:e,loc:tt}}function dy(e){return{type:22,elements:e,loc:tt}}function hy(e,t,n){return{type:23,test:e,consequent:t,alternate:n,loc:tt}}function my(e,t){return{type:24,left:e,right:t,loc:tt}}function gy(e){return{type:25,expressions:e,loc:tt}}function vy(e){return{type:26,returns:e,loc:tt}}const mt=e=>e.type===4&&e.isStatic,lr=(e,t)=>e===t||e===Ot(t);function bl(e){if(lr(e,"Teleport"))return Lr;if(lr(e,"Suspense"))return _o;if(lr(e,"KeepAlive"))return $i;if(lr(e,"BaseTransition"))return pl}const yy=/^\d|[^\$\w]/,Vi=e=>!yy.test(e),by=/[A-Za-z_$\xA0-\uFFFF]/,Sy=/[\.\?\w$\xA0-\uFFFF]/,Ey=/\s+[.[]\s*|\s*[.[]\s+/g,bp=e=>{e=e.trim().replace(Ey,s=>s.trim());let t=0,n=[],r=0,i=0,o=null;for(let s=0;st.type===7&&t.name==="bind"&&(!t.arg||t.arg.type!==4||!t.arg.isStatic))}function gs(e){return e.type===5||e.type===2}function Ol(e){return e.type===7&&e.name==="slot"}function Hr(e){return e.type===1&&e.tagType===3}function ki(e){return e.type===1&&e.tagType===2}function cr(e,t){return e||t?es:ts}function ur(e,t){return e||t?dl:hl}const Ty=new Set([Fr,jr]);function Ep(e,t=[]){if(e&&!be(e)&&e.type===14){const n=e.callee;if(!be(n)&&Ty.has(n))return Ep(e.arguments[0],t.concat(e))}return[e,t]}function Ui(e,t,n){let r,o=e.type===13?e.props:e.arguments[2],s=[],a;if(o&&!be(o)&&o.type===14){const l=Ep(o);o=l[0],s=l[1],a=s[s.length-1]}if(o==null||be(o))r=Mt([t]);else if(o.type===14){const l=o.arguments[0];!be(l)&&l.type===15?l.properties.unshift(t):o.callee===us?r=Ze(n.helper(Fi),[Mt([t]),o]):o.arguments.unshift(Mt([t])),!r&&(r=o)}else if(o.type===15){let l=!1;if(t.key.type===4){const c=t.key.content;l=o.properties.some(u=>u.key.type===4&&u.key.content===c)}l||o.properties.unshift(t),r=o}else r=Ze(n.helper(Fi),[Mt([t]),o]),a&&a.callee===jr&&(a=s[s.length-2]);e.type===13?a?a.arguments[0]=r:e.props=r:a?a.arguments[0]=r:e.arguments[2]=r}function kr(e,t){return`_${t}_${e.replace(/[^\w]/g,(n,r)=>n==="-"?"_":e.charCodeAt(r).toString())}`}function Wt(e,t){if(!e||Object.keys(t).length===0)return!1;switch(e.type){case 1:for(let n=0;nWt(n,t));case 11:return Wt(e.source,t)?!0:e.children.some(n=>Wt(n,t));case 9:return e.branches.some(n=>Wt(n,t));case 10:return Wt(e.condition,t)?!0:e.children.some(n=>Wt(n,t));case 4:return!e.isStatic&&Vi(e.content)&&!!t[e.content];case 8:return e.children.some(n=>je(n)&&Wt(n,t));case 5:case 12:return Wt(e.content,t);case 2:case 3:return!1;default:return!1}}function Op(e){return e.type===14&&e.callee===hs?e.arguments[1].returns:e}function vs(e,{helper:t,removeHelper:n,inSSR:r}){e.isBlock||(e.isBlock=!0,n(cr(r,e.isComponent)),t(Vn),t(ur(r,e.isComponent)))}const Py={COMPILER_IS_ON_ELEMENT:{message:'Platform-native elements with "is" prop will no longer be treated as components in Vue 3 unless the "is" value is explicitly prefixed with "vue:".',link:"https://v3.vuejs.org/guide/migration/custom-elements-interop.html"},COMPILER_V_BIND_SYNC:{message:e=>`.sync modifier for v-bind has been removed. Use v-model with argument instead. \`v-bind:${e}.sync\` should be changed to \`v-model:${e}\`.`,link:"https://v3.vuejs.org/guide/migration/v-model.html"},COMPILER_V_BIND_PROP:{message:".prop modifier for v-bind has been removed and no longer necessary. Vue 3 will automatically set a binding as DOM property when appropriate."},COMPILER_V_BIND_OBJECT_ORDER:{message:'v-bind="obj" usage is now order sensitive and behaves like JavaScript object spread: it will now overwrite an existing non-mergeable attribute that appears before v-bind in the case of conflict. To retain 2.x behavior, move v-bind to make it the first attribute. You can also suppress this warning if the usage is intended.',link:"https://v3.vuejs.org/guide/migration/v-bind.html"},COMPILER_V_ON_NATIVE:{message:".native modifier for v-on has been removed as is no longer necessary.",link:"https://v3.vuejs.org/guide/migration/v-on-native-modifier-removed.html"},COMPILER_V_IF_V_FOR_PRECEDENCE:{message:"v-if / v-for precedence when used on the same element has changed in Vue 3: v-if now takes higher precedence and will no longer have access to v-for scope variables. It is best to avoid the ambiguity with