diff --git a/common/Gruntfile.ts b/common/Gruntfile.ts index 70e7904..ed9fc22 100755 --- a/common/Gruntfile.ts +++ b/common/Gruntfile.ts @@ -148,7 +148,7 @@ function applyDefaultRunnerConfiguration(grunt: IGrunt) { * Unfortunately the fix / PR is merged but not yet released. Due to the fact that the below also works for us, it's ok. */ grunt.registerTask("i18n:prepare:wp", () => - grunt.file.expand([`${pkg.slug ? "src/public/dev" : "dev"}/*.js`, "!**/*vendor~*"]).forEach((file) => { + grunt.file.expand([`${pkg.slug ? "src/public/dev" : "dev"}/*.js`, "!**/*vendor-*"]).forEach((file) => { const content = readFileSync(file, { encoding: "UTF-8" }); const regex = /(Object\([A-Za-z0-9_]+__WEBPACK_IMPORTED_MODULE[A-Za-z0-9_]+\[)\/\* ?(__|_n|_x|_nx) \*\/ "[A-Za-z0-9_]"/gm; const target = `${dirname(file)}/i18n-dir/`; diff --git a/packages/utils/languages/backend/utils-de_DE.mo b/packages/utils/languages/backend/utils-de_DE.mo new file mode 100644 index 0000000..7c3c44f Binary files /dev/null and b/packages/utils/languages/backend/utils-de_DE.mo differ diff --git a/packages/utils/languages/backend/utils-de_DE.po b/packages/utils/languages/backend/utils-de_DE.po new file mode 100644 index 0000000..c9fc194 --- /dev/null +++ b/packages/utils/languages/backend/utils-de_DE.po @@ -0,0 +1,44 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"POT-Creation-Date: n/a\n" +"PO-Revision-Date: 2020-07-13 09:37+0200\n" +"X-Generator: Poedit 2.2.4\n" +"Last-Translator: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: de_DE\n" + +#. translators: +#: Service.php:81 +msgid "" +"One or more WordPress plugins tried to call the WordPress REST API, which " +"failed. Most likely a security plugin%s or a web server configuration " +"disabled the REST API. Please make sure that the following REST API " +"namespaces are reachable to use your plugin without problems:" +msgstr "" +"Ein oder mehrere WordPress-Plugins versuchten, die WordPress REST API " +"aufzurufen, was fehlschlug. Höchstwahrscheinlich hat ein Sicherheits-" +"Plugin%s oder eine Webserver-Konfiguration die REST-API deaktiviert. " +"Bitte stelle sicher, dass die folgenden REST-API-Namensräume erreichbar " +"sind, um das jeweilige Plugin ohne Probleme nutzen zu können:" + +#. translators: +#: Service.php:89 +msgid "" +"What is the WordPress REST API and how to enable it? %1$sLearn more%2$s." +msgstr "" +"Was ist die WordPress REST API und wie kann sie aktiviert werden? " +"%1$sKlicke hier, um mehr darüber zu erfahren%2$s." + +#: Service.php:92 +msgid "" +"https://devowl.io/knowledge-base/i-only-see-a-loading-spinner-what-can-i-" +"do/" +msgstr "" +"https://devowl.io/knowledge-base/i-only-see-a-loading-spinner-what-can-i-" +"do/" diff --git a/packages/utils/languages/backend/utils.pot b/packages/utils/languages/backend/utils.pot index 6842057..712280c 100644 --- a/packages/utils/languages/backend/utils.pot +++ b/packages/utils/languages/backend/utils.pot @@ -10,3 +10,17 @@ msgstr "" "POT-Creation-Date: n/a\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.4.0\n" + +#. translators: +#: Service.php:81 +msgid "One or more WordPress plugins tried to call the WordPress REST API, which failed. Most likely a security plugin%s or a web server configuration disabled the REST API. Please make sure that the following REST API namespaces are reachable to use your plugin without problems:" +msgstr "" + +#. translators: +#: Service.php:89 +msgid "What is the WordPress REST API and how to enable it? %1$sLearn more%2$s." +msgstr "" + +#: Service.php:92 +msgid "https://devowl.io/knowledge-base/i-only-see-a-loading-spinner-what-can-i-do/" +msgstr "" diff --git a/packages/utils/lib/factory/ajax/commonRequest.tsx b/packages/utils/lib/factory/ajax/commonRequest.tsx index ef963a0..6f1d366 100755 --- a/packages/utils/lib/factory/ajax/commonRequest.tsx +++ b/packages/utils/lib/factory/ajax/commonRequest.tsx @@ -38,9 +38,9 @@ async function commonRequest< // Use global parameter (see https://developer.wordpress.org/rest-api/using-the-rest-api/global-parameters/) if (WP_REST_API_USE_GLOBAL_METHOD && location.method && location.method !== RouteHttpVerb.GET) { - settings.method = "POST"; + settings.method = RouteHttpVerb.POST; } else { - settings.method = "GET"; + settings.method = RouteHttpVerb.GET; } // Request with GET/HEAD method cannot have body @@ -69,11 +69,15 @@ async function commonRequest< if (!result.ok) { let responseJSON = undefined; try { - responseJSON = await result.json(); + responseJSON = await parseResult(apiUrlBuilt, result); } catch (e) { // Silence is golden. } + // Set this request as failing so the endpoint is probably corrupt (see `corrupRestApi.tsx`) + settings.method === RouteHttpVerb.GET && + (window.detectCorrupRestApiFailed = (window.detectCorrupRestApiFailed || 0) + 1); + const resultAny = result as any; resultAny.responseJSON = responseJSON; throw resultAny; diff --git a/packages/utils/lib/factory/ajax/corruptRestApi.tsx b/packages/utils/lib/factory/ajax/corruptRestApi.tsx new file mode 100644 index 0000000..ce9a1b3 --- /dev/null +++ b/packages/utils/lib/factory/ajax/corruptRestApi.tsx @@ -0,0 +1,42 @@ +declare global { + interface Window { + /** + * This number indicates the failed `GET` requests for all REST API calls. + * See also `commonRequest.tsx`. + */ + detectCorrupRestApiFailed: number; + } +} + +const WAIT_TO_TEST = 10000; + +const NOTICE_ID = "notice-corrupt-rest-api"; + +/** + * Register a new endpoint which needs to resolve to a valid JSON result. In this way we + * can detect a corrupt REST API namespace e. g. it is blocked through a security plugin. + */ +function handleCorrupRestApi(resolve: Record Promise>, forceRerequest = false) { + // Initially set + window.detectCorrupRestApiFailed = window.detectCorrupRestApiFailed || 0; + + setTimeout(async () => { + const notice = document.getElementById(NOTICE_ID); + + // Only in backend and when a corrupt REST API detected + if (notice && (window.detectCorrupRestApiFailed > 0 || forceRerequest)) { + for (const namespace of Object.keys(resolve)) { + try { + await resolve[namespace](); + } catch (e) { + notice.style.display = "block"; + const li = document.createElement("li"); + li.innerHTML = `- ${namespace}`; + notice.childNodes[1].appendChild(li); + } + } + } + }, WAIT_TO_TEST); +} + +export { handleCorrupRestApi }; diff --git a/packages/utils/lib/factory/ajax/index.tsx b/packages/utils/lib/factory/ajax/index.tsx index ff2c1e7..00e46fb 100755 --- a/packages/utils/lib/factory/ajax/index.tsx +++ b/packages/utils/lib/factory/ajax/index.tsx @@ -3,3 +3,4 @@ export * from "./commonRequest"; export * from "./createRequestFactory"; export * from "./routeHttpVerbEnum"; export * from "./parseResult"; +export * from "./corruptRestApi"; diff --git a/packages/utils/src/Core.php b/packages/utils/src/Core.php index ae4978e..6f30594 100755 --- a/packages/utils/src/Core.php +++ b/packages/utils/src/Core.php @@ -52,6 +52,7 @@ protected function construct() { add_action('plugins_loaded', [$this, 'updateDbCheck']); add_action('init', [$this, 'init']); add_action('rest_api_init', [$this->getService(), 'rest_api_init']); + add_action('admin_notices', [$this->getService(), 'admin_notices']); // Localize the plugin and package itself $this->getPluginClassInstance(PluginReceiver::$PLUGIN_CLASS_LOCALIZATION)->hooks(); diff --git a/packages/utils/src/PackageLocalization.php b/packages/utils/src/PackageLocalization.php index b60172c..7bf464d 100755 --- a/packages/utils/src/PackageLocalization.php +++ b/packages/utils/src/PackageLocalization.php @@ -38,14 +38,14 @@ protected function __construct($rootSlug, $packageDir) { protected function override($locale) { switch ($locale) { // Put your overrides here! - // case 'de_AT': - // case 'de_CH': - // case 'de_CH_informal': - // case 'de_DE_formal': - // return 'de_DE'; - // break; - // default: - // break; + case 'de_AT': + case 'de_CH': + case 'de_CH_informal': + case 'de_DE_formal': + return 'de_DE'; + break; + default: + break; } return $locale; } diff --git a/packages/utils/src/Service.php b/packages/utils/src/Service.php index cda2bac..f164cbf 100755 --- a/packages/utils/src/Service.php +++ b/packages/utils/src/Service.php @@ -13,6 +13,25 @@ class Service { private $core; + const NOTICE_CORRUPT_REST_API_ID = 'notice-corrupt-rest-api'; + + const SECURITY_PLUGINS_BLOCKING_REST_API = [ + 'better-wp-security', + 'all-in-one-wp-security-and-firewall', + 'sucuri-scanner', + 'anti-spam', + 'wp-cerber', + 'wp-simple-firewall', + 'wp-hide-security-enhancer', + 'bulletproof-security', + 'disable-json-api', + 'ninjafirewall', + 'hide-my-wp', + 'perfmatters', + 'swift-performance', + 'clearfy' + ]; + /** * C'tor. * @@ -51,6 +70,66 @@ public function getCore() { return $this->core; } + /** + * Show a notice for `corruptRestApi.tsx`. + */ + public function admin_notices() { + if (!isset($GLOBALS[self::NOTICE_CORRUPT_REST_API_ID])) { + $GLOBALS[self::NOTICE_CORRUPT_REST_API_ID] = true; + $securityPlugins = $this->getSecurityPlugins(); + echo sprintf( + '', + sprintf( + // translators: + __( + 'One or more WordPress plugins tried to call the WordPress REST API, which failed. Most likely a security plugin%s or a web server configuration disabled the REST API. Please make sure that the following REST API namespaces are reachable to use your plugin without problems:', + 'devowl-wp-utils' + ), + count($securityPlugins) > 0 ? ' (' . join(', ', $securityPlugins) . ')' : '' + ), + sprintf( + // translators: + __('What is the WordPress REST API and how to enable it? %1$sLearn more%2$s.', 'devowl-wp-utils'), + '', + '' + ) + ); + } + } + + /** + * Get all active security plugins which can limit the WP REST API. + * + * @return string[] + */ + public function getSecurityPlugins() { + $result = []; + $plugins = get_option('active_plugins'); + + // @codeCoverageIgnoreStart + if (!defined('PHPUNIT_FILE')) { + require_once ABSPATH . '/wp-admin/includes/plugin.php'; + } + // @codeCoverageIgnoreEnd + + foreach ($plugins as $pluginFile) { + foreach (self::SECURITY_PLUGINS_BLOCKING_REST_API as $slug) { + if (strpos($pluginFile, $slug, 0) === 0) { + $result[] = get_plugin_data(constant('WP_PLUGIN_DIR') . '/' . $pluginFile)['Name']; + } + } + } + + return $result; + } + /** * Get the wp-json URL for a defined REST service. * diff --git a/packages/utils/test/jest/factory/ajax/commonRequest.test.tsx b/packages/utils/test/jest/factory/ajax/commonRequest.test.tsx index 1ab717a..0c109f1 100755 --- a/packages/utils/test/jest/factory/ajax/commonRequest.test.tsx +++ b/packages/utils/test/jest/factory/ajax/commonRequest.test.tsx @@ -194,7 +194,7 @@ describe("commonRequest", () => { }); it("should throw error when response is not ok", async () => { - const method = RouteHttpVerb.POST; + const method = RouteHttpVerb.GET; const opts = produce(baseOpts, (draft) => { (draft.location as any).method = method; }); @@ -204,14 +204,13 @@ describe("commonRequest", () => { createUrlMock(); deepMerge.mockImplementation((): any => ({})); deepMerge.all.mockImplementation((): any => ({})); - const mockJson = jest.fn().mockReturnValue({}); - jest.spyOn(window, "fetch").mockImplementationOnce(() => ({ ok: false, json: mockJson } as any)); + jest.spyOn(window, "fetch").mockImplementationOnce(() => ({ ok: false } as any)); await expect(commonRequest(opts)).rejects.toEqual({ ok: false, - json: expect.any(Function), responseJSON: {} }); - expect(mockJson).toHaveBeenCalled(); + expect(parseResult).toHaveBeenCalled(); + expect((global as any).detectCorrupRestApiFailed).toBe(1); }); }); diff --git a/packages/utils/test/jest/factory/ajax/corrupRestApi.test.tsx b/packages/utils/test/jest/factory/ajax/corrupRestApi.test.tsx new file mode 100644 index 0000000..1c55f35 --- /dev/null +++ b/packages/utils/test/jest/factory/ajax/corrupRestApi.test.tsx @@ -0,0 +1,124 @@ +import { handleCorrupRestApi } from "../../../../lib/factory/ajax/corruptRestApi"; + +describe("handleCorrupRestApi", () => { + jest.useFakeTimers(); + + it("should not request anything when no notice is available", () => { + (global as any).detectCorrupRestApiFailed = undefined; + + const mockResolver = jest.fn(); + handleCorrupRestApi({ + "wp/v1": mockResolver + }); + + expect((global as any).detectCorrupRestApiFailed).toBe(0); + jest.runAllTimers(); + expect(mockResolver).not.toHaveBeenCalled(); + }); + + it("should request when notice is available", () => { + (global as any).detectCorrupRestApiFailed = 1; + + const noticeElement = { + style: { + display: "none" + }, + childNodes: [ + null, + { + appendChild: jest.fn() + } + ] + }; + const liElement = { + innerHTML: "" + }; + const spyGetNotice = jest.spyOn((global as any).document, "getElementById").mockReturnValueOnce(noticeElement); + const spyCreateLi = jest.spyOn((global as any).document, "createElement"); + + const mockResolver = jest.fn(); + handleCorrupRestApi({ + "wp/v1": mockResolver + }); + + expect(mockResolver).not.toHaveBeenCalled(); + jest.runAllTimers(); + expect(spyGetNotice).toHaveBeenCalledWith("notice-corrupt-rest-api"); + expect(mockResolver).toHaveBeenCalled(); + expect(noticeElement.style.display).toBe("none"); + expect(noticeElement.childNodes[1].appendChild).not.toHaveBeenCalled(); + expect(spyCreateLi).not.toHaveBeenCalled(); + expect(liElement.innerHTML).toBe(""); + }); + + it("should request when forced", () => { + (global as any).detectCorrupRestApiFailed = 0; + + const noticeElement = { + style: { + display: "none" + }, + childNodes: [ + null, + { + appendChild: jest.fn() + } + ] + }; + const liElement = { + innerHTML: "" + }; + const spyGetNotice = jest.spyOn((global as any).document, "getElementById").mockReturnValueOnce(noticeElement); + const spyCreateLi = jest.spyOn((global as any).document, "createElement"); + + const mockResolver = jest.fn(); + handleCorrupRestApi( + { + "wp/v1": mockResolver + }, + true + ); + + expect(mockResolver).not.toHaveBeenCalled(); + jest.runAllTimers(); + expect(spyGetNotice).toHaveBeenCalledWith("notice-corrupt-rest-api"); + expect(mockResolver).toHaveBeenCalled(); + expect(noticeElement.style.display).toBe("none"); + expect(noticeElement.childNodes[1].appendChild).not.toHaveBeenCalled(); + expect(spyCreateLi).not.toHaveBeenCalled(); + expect(liElement.innerHTML).toBe(""); + }); + + it("should show notice when an error occured", () => { + (global as any).detectCorrupRestApiFailed = 1; + + const noticeElement = { + style: { + display: "none" + }, + childNodes: [ + null, + { + appendChild: jest.fn() + } + ] + }; + const liElement = { + innerHTML: "" + }; + jest.spyOn((global as any).document, "getElementById").mockReturnValueOnce(noticeElement); + const spyCreateLi = jest.spyOn((global as any).document, "createElement").mockReturnValueOnce(liElement); + + const mockResolver = jest.fn().mockImplementation(() => { + throw "Some error occured during request"; + }); + handleCorrupRestApi({ + "wp/v1": mockResolver + }); + + jest.runAllTimers(); + expect(noticeElement.style.display).toBe("block"); + expect(noticeElement.childNodes[1].appendChild).toHaveBeenCalledWith(expect.any(Object)); + expect(spyCreateLi).toHaveBeenCalledWith("li"); + }); +}); diff --git a/packages/utils/test/phpunit/CoreTest.php b/packages/utils/test/phpunit/CoreTest.php index 6a03b02..7789f69 100755 --- a/packages/utils/test/phpunit/CoreTest.php +++ b/packages/utils/test/phpunit/CoreTest.php @@ -67,6 +67,7 @@ public function testConstruct() { WP_Mock::expectActionAdded('plugins_loaded', [$this->core, 'updateDbCheck']); WP_Mock::expectActionAdded('init', [$this->core, 'init']); WP_Mock::expectActionAdded('rest_api_init', [$mockService, 'rest_api_init']); + WP_Mock::expectActionAdded('admin_notices', [$mockService, 'admin_notices']); /** @var MockInterface|LocalizationImpl */ $mockLocalization = Mockery::mock(LocalizationImpl::class); diff --git a/packages/utils/test/phpunit/PackageLocalizationTest.php b/packages/utils/test/phpunit/PackageLocalizationTest.php index 90db598..307c83e 100755 --- a/packages/utils/test/phpunit/PackageLocalizationTest.php +++ b/packages/utils/test/phpunit/PackageLocalizationTest.php @@ -39,6 +39,22 @@ public function testOverride() { $this->assertEquals($should, $actual); } + public function testOverrideDeFormal() { + $locale = 'de_DE_formal'; + $should = 'de_DE'; + + $this->packageLocalization + ->shouldAllowMockingProtectedMethods() + ->shouldReceive('override') + ->passthru(); + + $method = new ReflectionMethod(PackageLocalization::class, 'override'); + $method->setAccessible(true); + $actual = $method->invoke($this->packageLocalization, $locale); + + $this->assertEquals($should, $actual); + } + public function testGetPackageInfoBackend() { $rootSlug = PHPUNIT_ROOT_SLUG; $package = 'utils'; diff --git a/packages/utils/test/phpunit/ServiceTest.php b/packages/utils/test/phpunit/ServiceTest.php index 065147c..85ab12a 100755 --- a/packages/utils/test/phpunit/ServiceTest.php +++ b/packages/utils/test/phpunit/ServiceTest.php @@ -49,6 +49,51 @@ public function testRoutePlugin() { $this->addToAssertionCount(1); } + public function testAdminNotices() { + $this->service->shouldReceive('admin_notices')->passthru(); + + $this->service + ->shouldReceive('getSecurityPlugins') + ->once() + ->andReturn([]); + + WP_Mock::userFunction('__', ['return' => '__']); + + $this->expectOutputString( + '' + ); + + $this->service->admin_notices(); + + // Only output once + $this->service->admin_notices(); + } + + public function testGetSecurityPlugins() { + $should = ['Hide My WP']; + $this->service->shouldReceive('getSecurityPlugins')->passthru(); + + WP_Mock::userFunction('get_option', [ + 'times' => 1, + 'args' => ['active_plugins'], + 'return' => ['phpunit-plugin/index.php', 'hide-my-wp/index.php'] + ]); + + redefine('constant', function ($arg) { + return $arg; + }); + + WP_Mock::userFunction('get_plugin_data', [ + 'times' => 1, + 'args' => ['WP_PLUGIN_DIR/hide-my-wp/index.php'], + 'return' => ['Name' => 'Hide My WP'] + ]); + + $actual = $this->service->getSecurityPlugins(); + + $this->assertEquals($actual, $should); + } + public function testGetUrl() { $should = 'http://localhost/wp-json/test/v1/';