diff --git a/.gitignore b/.gitignore index 9b87a28..5676717 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ .phpunit.result.cache # Folders -dist node_modules tests/coverage vendor diff --git a/README.md b/README.md index e0d4127..cf7627c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Requires at least: 6.0 Requires PHP: 8.0 Stable tag: 1.1.0 Tags: deepl, translating, secure -Tested up to: 6.7.1 +Tested up to: 6.8.5 This plugin registers secure API endpoints that allow you to request translations directly from DeepL without exposing your DeepL API-key. @@ -22,21 +22,39 @@ When providing translations to website visitors, you can configure which languag Each object that is translated will store its cached translation in the `wp_postmeta` table within the database. This caching mechanism ensures that translations are efficiently reused, reducing unnecessary API requests to DeepL and saving costs. -- **Serving Cached Translations:** If a cached translation is newer than the `post_modified` date of the object, the cached version is served. -- **Fetching New Translations:** When the `post_modified` date of the object is more recent than the cached translation, a new translation is fetched from DeepL. Once retrieved, this translation is immediately cached for future use. +- **Serving Cached Translations:** If a cached translation is newer than the `post_modified` date of the object, the cached version is served. +- **Fetching New Translations:** When the `post_modified` date of the object is more recent than the cached translation, a new translation is fetched from DeepL. Once retrieved, this translation is immediately cached for future use. +- **Cache Authorization:** Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability will still return a translation from DeepL, but the result will not be stored in the cache. This approach minimizes the number of API calls to DeepL, ensuring translations are kept up to date only when necessary. +### Admin: Cache Metabox + +A metabox labeled **Yard DeepL** is displayed on the edit screen of supported post types (default: `page`). It provides two options: + +- **Disable translation cache:** When checked, the cache is bypassed for this object. Useful for posts with dynamic content that should always be translated fresh. +- **Clear translation cache:** When checked and the post is saved, all cached translations for this object are deleted. + +### Admin: Translation Cache Column + +A **Translation cache** column is added to the post list screen of all supported post types. Its purpose is to notify editors which posts should be cached as soon as possible to avoid unnecessary DeepL API costs. + +- **Green badge per language** (e.g. `NL`, `EN-US`): a valid, up-to-date cached translation exists for that language. Visitor requests are served from cache at no cost. +- **Grey badge with a count per language** (e.g. `EN-US 42`): no cache exists for that language and visitors have triggered that many live DeepL API calls. Hovering the badge shows the full count as a tooltip. These posts are the most urgent to cache. +- **—**: the post has never been requested for translation, or caching is disabled for this post. + +The uncached call count is only incremented for visitor requests (users without cache-write capability) and is automatically reset per language once a cache entry is stored. Posts with the **Disable translation cache** option enabled are excluded from the column entirely. + ## External Services This plugin connects to the DeepL API to provide translations for content. -- **Service:** DeepL API () -- **Purpose:** To translate text from one language to another based on the provided target language. -- **Data Sent:** Text content for translation, the target language code, and the DeepL API key (handled securely and never exposed to users). -- **Conditions:** Data is sent when a request for translation is initiated. -- **Privacy Policy:** [DeepL Privacy Policy](https://www.deepl.com/privacy) -- **Terms of Service:** [DeepL Terms of Service](https://www.deepl.com/pro-license) +- **Service:** DeepL API () +- **Purpose:** To translate text from one language to another based on the provided target language. +- **Data Sent:** Text content for translation, the target language code, and the DeepL API key (handled securely and never exposed to users). +- **Conditions:** Data is sent when a request for translation is initiated. +- **Privacy Policy:** [DeepL Privacy Policy](https://www.deepl.com/privacy) +- **Terms of Service:** [DeepL Terms of Service](https://www.deepl.com/pro-license) ## Installation @@ -62,12 +80,20 @@ If you need to work with development dependencies, follow these steps: The API endpoints registered by this plugin are secured using a WordPress nonce. The nonce is passed to the front-end using the `wp_localize_script` function and is stored in a global JavaScript object `ydpl` which contains the following properties: -- `ydpl_translate_post_id`: The ID of the post to be translated. -- `ydpl_rest_translate_url`: The URL of the API endpoint for translation requests. -- `ydpl_supported_languages`: The list of languages supported for translation. -- `ydpl_api_request_nonce`: The nonce used for API validation. +- `ydpl_translate_post_id`: The ID of the post to be translated. +- `ydpl_rest_translate_url`: The URL of the API endpoint for translation requests. +- `ydpl_supported_languages`: The list of languages supported for translation. +- `ydpl_api_request_nonce`: The nonce used for API validation. + +When making requests to the API, ensure that the nonce is included in the request headers. The header should be named `X-WP-Nonce`, and it should contain a nonce created with the `wp_rest` action (available via `ydpl.ydpl_api_request_nonce` on the front-end). + +#### Rate Limiting -When making requests to the API, ensure that the nonce is included in the request headers. The header should be named `nonce`, and it should contain the value of `ydpl_api_request_nonce`. +To prevent abuse, unauthenticated requests (or requests from users without the cache capability) are limited to **3 requests per 60 seconds** per IP address. Authenticated users with the required cache capability (default: `edit_posts`) are exempt from this rate limit. + +#### Cache Capability + +Only users with the `edit_posts` capability can trigger cache creation. This can be customized using the `yard::deepl/cache_capability` filter (see [Filters](#filters)). #### Example @@ -75,41 +101,78 @@ When making requests to the API, ensure that the nonce is included in the reques ```javascript var xhr = new XMLHttpRequest(); -xhr.open('POST', ydpl.ydpl_rest_translate_url, true); +xhr.open( 'POST', ydpl.ydpl_rest_translate_url, true ); // Set request headers -xhr.setRequestHeader('Content-Type', 'application/json'); -xhr.setRequestHeader('nonce', ydpl.ydpl_api_request_nonce); +xhr.setRequestHeader( 'Content-Type', 'application/json' ); +xhr.setRequestHeader( 'X-WP-Nonce', ydpl.ydpl_api_request_nonce ); // Handle response xhr.onreadystatechange = function () { - if (xhr.readyState === 4 && xhr.status === 200) { - console.log('Translation:', JSON.parse(xhr.responseText)); - } else if (xhr.readyState === 4) { - console.error('Error:', xhr.statusText); - } + if ( xhr.readyState === 4 && xhr.status === 200 ) { + console.log( 'Translation:', JSON.parse( xhr.responseText ) ); + } else if ( xhr.readyState === 4 ) { + console.error( 'Error:', xhr.statusText ); + } }; // Prepare and send the request body -var data = JSON.stringify({ - text: ["Look another test"], - target_lang: "DE" -}); +var data = JSON.stringify( { + text: [ 'Look another test' ], + target_lang: 'DE', +} ); -xhr.send(data); +xhr.send( data ); ``` ##### Response ```javascript [ - { - "text": "Look another test!", - "translation": "Sehen Sie sich einen weiteren Test an!" - } -] + { + text: 'Look another test!', + translation: 'Sehen Sie sich einen weiteren Test an!', + }, +]; ``` +## Filters + +| Filter | Default | Description | +| -------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `yard::deepl/cache_capability` | `'edit_posts'` | The WordPress capability required to create cache entries. Users without this capability receive translations but results are not cached. | +| `yard::deepl/cache_metabox_post_types` | `['page']` | Post types on which the Yard DeepL cache metabox is displayed. | +| ~~`yard::deepl/disable_cache_metabox_post_types`~~ | — | **Deprecated.** Use `yard::deepl/cache_metabox_post_types` instead. | + +## Changelog + +### NEXT (unreleased) + +- Add: same-origin check for REST API requests +- Add: rate limiting for unauthenticated / low-privilege requests (3 req / 60 s per IP) +- Add: cache creation restricted to users with `edit_posts` capability (configurable via filter) +- Add: `yard::deepl/cache_capability` filter +- Add: Translation cache column on post list screens showing cached languages and per-language uncached call counts, to help editors prioritise which posts to cache +- Change: deprecated `yard::deepl/disable_cache_metabox_post_types` in favour of `yard::deepl/cache_metabox_post_types` +- Change: nonce validation now also accepts the standard `wp_rest` nonce action as a fallback + +### 1.1.0 (Jan 31, 2025) + +- Add: disable DeepL translation cache metabox +- Change: use init hook in plugin bootstrap construct, fixes translations for WordPress 6.7 + +### 1.0.2 (Jan 08, 2025) + +- Change: update all occurrences of 'deepl' to 'DeepL' for consistency + +### 1.0.1 (Jan 07, 2025) + +- Change: processed corrections + +### 1.0.0 (Oct 18, 2024) + +- Init: first release! + ## About us [![banner](https://raw.githubusercontent.com/yardinternet/.github/refs/heads/main/profile/assets/small-banner-github.svg)](https://www.yard.nl/werken-bij/) diff --git a/assets/css/admin-columns.css b/assets/css/admin-columns.css new file mode 100644 index 0000000..c8949b2 --- /dev/null +++ b/assets/css/admin-columns.css @@ -0,0 +1,37 @@ +.ydpl-cache-badges { + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.ydpl-cache-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + line-height: 1; + padding: 3px 6px; + border-radius: 3px; + font-weight: 600; + letter-spacing: 0.03em; +} + +.ydpl-cache-badge--cached { + background: #00a32a; + color: #fff; +} + +.ydpl-cache-badge--uncached { + background: #f0f0f1; + color: #3c434a; +} + +.ydpl-badge-count { + font-weight: 400; + font-size: 9px; + opacity: 0.7; +} + +.ydpl-cache-status--none { + color: #8c8f94; +} diff --git a/assets/css/editor.css b/assets/css/editor.css new file mode 100644 index 0000000..c9c9af6 --- /dev/null +++ b/assets/css/editor.css @@ -0,0 +1,7 @@ +.ydpl-metabox-row:not( :first-child ) { + margin-top: 1rem; +} + +.ydpl-metabox-row > p { + font-weight: 500; +} diff --git a/dist/admin-columns.asset.php b/dist/admin-columns.asset.php new file mode 100644 index 0000000..f74de63 --- /dev/null +++ b/dist/admin-columns.asset.php @@ -0,0 +1 @@ + array(), 'version' => '1d877b8f32d5c2ebba6e'); diff --git a/dist/admin-columns.css b/dist/admin-columns.css new file mode 100644 index 0000000..18ae8fd --- /dev/null +++ b/dist/admin-columns.css @@ -0,0 +1 @@ +.ydpl-cache-badges{display:flex;flex-wrap:wrap;gap:3px}.ydpl-cache-badge{align-items:center;border-radius:3px;display:inline-flex;font-size:10px;font-weight:600;gap:4px;letter-spacing:.03em;line-height:1;padding:3px 6px}.ydpl-cache-badge--cached{background:#00a32a;color:#fff}.ydpl-cache-badge--uncached{background:#f0f0f1;color:#3c434a}.ydpl-badge-count{font-size:9px;font-weight:400;opacity:.7}.ydpl-cache-status--none{color:#8c8f94} diff --git a/dist/admin-columns.js b/dist/admin-columns.js new file mode 100644 index 0000000..e69de29 diff --git a/dist/editor.asset.php b/dist/editor.asset.php new file mode 100644 index 0000000..c31ee19 --- /dev/null +++ b/dist/editor.asset.php @@ -0,0 +1 @@ + array(), 'version' => '045a517d31b6755cceb1'); diff --git a/dist/editor.css b/dist/editor.css new file mode 100644 index 0000000..5f390b4 --- /dev/null +++ b/dist/editor.css @@ -0,0 +1 @@ +.ydpl-metabox-row:not(:first-child){margin-top:1rem}.ydpl-metabox-row>p{font-weight:500} diff --git a/dist/editor.js b/dist/editor.js new file mode 100644 index 0000000..e69de29 diff --git a/languages/yard-deepl-nl_NL.mo b/languages/yard-deepl-nl_NL.mo index fd6f7bb..633fbe3 100644 Binary files a/languages/yard-deepl-nl_NL.mo and b/languages/yard-deepl-nl_NL.mo differ diff --git a/languages/yard-deepl-nl_NL.po b/languages/yard-deepl-nl_NL.po index 1832649..047fa8d 100644 --- a/languages/yard-deepl-nl_NL.po +++ b/languages/yard-deepl-nl_NL.po @@ -1,71 +1,39 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" +"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/yard-deepl\n" "Last-Translator: \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: 2024-09-06T17:21:36+00:00\n" -"PO-Revision-Date: 2024-09-06T17:21:36+00:00\n" +"POT-Creation-Date: 2026-05-21T12:17:13+00:00\n" +"PO-Revision-Date: 2026-05-27T14:36:10+00:00\n" "Language: \n" #. Plugin Name of the plugin +#: yard-deepl.php:8 +#: src/Providers/MetaBoxServiceProvider.php:35 #: src/Providers/SettingsServiceProvider.php:44 #: src/Providers/SettingsServiceProvider.php:45 -#: src/Providers/MetaboxServiceProvider.php:34 msgid "Yard DeepL" msgstr "Yard DeepL" #. Description of the plugin +#: yard-deepl.php:9 msgid "This plugin registers secure API endpoints that allow you to request translations directly from DeepL without exposing your Deepl API-key" msgstr "Deze plugin registreert beveiligde API-eindpunten waarmee je vertalingen direct bij DeepL kunt opvragen zonder je DeepL API-sleutel bloot te stellen aan de buitenwereld" #. Author of the plugin -msgid "Yard | Digital" -msgstr "Yard | Digital" +#: yard-deepl.php:11 +msgid "Yard | Digital Agency" +msgstr "Yard | Digital Agency" #. Author URI of the plugin +#: yard-deepl.php:12 msgid "https://www.yard.nl" msgstr "https://www.yard.nl" -#: src/Providers/MetaboxServiceProvider.php:51 -msgid "Disable translation cache when this object contains dynamic content." -msgstr "Schakel de vertalingscache uit wanneer dit object dynamische inhoud bevat." - -#: src/Providers/MetaboxServiceProvider.php:76 -msgid "Disable translation cache?" -msgstr "Vertalingscache uitschakelen?" - -#: src/Providers/SettingsServiceProvider.php:66 -msgid "Settings" -msgstr "Instellingen" - -#: src/Providers/SettingsServiceProvider.php:73 -msgid "Deepl API key" -msgstr "Deepl API-sleutel" - -#: src/Providers/SettingsServiceProvider.php:82 -msgid "Deepl supported languages" -msgstr "Deepl ondersteunde talen" - -#: src/Providers/SettingsServiceProvider.php:98 -msgid "Parameter object_id mandatory" -msgstr "Parameter object_id verplicht" - -#: src/Views/admin/settings-page.php:15 -msgid "Save" -msgstr "Opslaan" - -#: src/Views/admin/partials/settings/settings-description-general.php:10 -msgid "website" -msgstr "website" - -#: src/Views/admin/partials/settings/settings-description-general.php:16 -msgid "To query the Deepl API, an API key is required. You can find more information on their %s." -msgstr "Om de Deepl API te bevragen, is een API-sleutel vereist. Meer informatie vind je op hun %s." - #: config/php-di.php:25 msgid "Arabic" msgstr "Arabisch" @@ -197,3 +165,69 @@ msgstr "Chinees (vereenvoudigd)" #: config/php-di.php:153 msgid "Chinese (Traditional)" msgstr "Chinees (traditioneel)" + +#: src/Providers/MetaBoxServiceProvider.php:72 +msgid "Disable translation cache when this object contains dynamic content." +msgstr "Schakel de vertalingscache uit wanneer dit object dynamische inhoud bevat." + +#: src/Providers/MetaBoxServiceProvider.php:75 +msgid "Disable translation cache?" +msgstr "Vertalingscache uitschakelen?" + +#: src/Providers/MetaBoxServiceProvider.php:78 +msgid "Clear cached translations on save." +msgstr "Verwijder vertaalcache bij opslaan." + +#: src/Providers/MetaBoxServiceProvider.php:81 +msgid "Clear translation cache?" +msgstr "Vertaalcache wissen?" + +#: src/Providers/SettingsServiceProvider.php:69 +msgid "Settings" +msgstr "Instellingen" + +#: src/Providers/SettingsServiceProvider.php:76 +msgid "Deepl API key" +msgstr "Deepl API-sleutel" + +#: src/Providers/SettingsServiceProvider.php:85 +msgid "Deepl supported languages" +msgstr "Deepl ondersteunde talen" + +#: src/Providers/SettingsServiceProvider.php:101 +msgid "Parameter object_id mandatory" +msgstr "Parameter object_id verplicht" + +#. Translators: %s is the URL to the Deepl API documentation page. +#: src/Views/admin/partials/settings/settings-description-general.php:10 +msgid "website" +msgstr "website" + +#. Translators: %s is the link to the Deepl API documentation website. +#: src/Views/admin/partials/settings/settings-description-general.php:16 +#, php-format +msgid "To query the Deepl API, an API key is required. You can find more information on their %s." +msgstr "Om de Deepl API te bevragen, is een API-sleutel vereist. Meer informatie vind je op hun %s." + +#: src/Providers/AdminColumnsServiceProvider.php:53 +msgid "Translation cache" +msgstr "Vertaalcache" + +#: src/Providers/AdminColumnsServiceProvider.php:70 +msgid "No languages configured" +msgstr "Geen talen geconfigureerd" + +#: src/Providers/AdminColumnsServiceProvider.php:105 +msgid "No cached translations" +msgstr "Geen gecachede vertalingen" + +#. Translators: %d is the number of uncached DeepL API calls made for this post and language. +#: src/Providers/AdminColumnsServiceProvider.php:94 +msgid "%d uncached call" +msgid_plural "%d uncached calls" +msgstr[0] "%d aanroep zonder cache" +msgstr[1] "%d aanroepen zonder cache" + +#: src/Views/admin/settings-page.php:15 +msgid "Save" +msgstr "Opslaan" diff --git a/languages/yard-deepl.pot b/languages/yard-deepl.pot index aacc228..ff8ff77 100644 --- a/languages/yard-deepl.pot +++ b/languages/yard-deepl.pot @@ -1,74 +1,42 @@ -# Copyright (C) 2024 Yard | Digital +# Copyright (C) 2026 Yard | Digital Agency # This file is distributed under the GPLv2 or later. msgid "" msgstr "" -"Project-Id-Version: Yard DeepL 0.0.1\n" +"Project-Id-Version: Yard DeepL 1.1.0\n" "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/yard-deepl\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2024-09-06T17:29:33+00:00\n" +"POT-Creation-Date: 2026-05-27T14:36:10+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"X-Generator: WP-CLI 2.9.0\n" +"X-Generator: WP-CLI 2.12.0\n" "X-Domain: yard-deepl\n" #. Plugin Name of the plugin +#: yard-deepl.php:8 +#: src/Providers/MetaBoxServiceProvider.php:35 #: src/Providers/SettingsServiceProvider.php:44 #: src/Providers/SettingsServiceProvider.php:45 -#: src/Providers/MetaboxServiceProvider.php:34 msgid "Yard DeepL" msgstr "" #. Description of the plugin -msgid "This plugin registers secure API endpoints that allows you to request translations directly from DeepL" +#: yard-deepl.php:9 +msgid "This plugin registers secure API endpoints that allow you to request translations directly from DeepL without exposing your Deepl API-key" msgstr "" #. Author of the plugin -msgid "Yard | Digital" +#: yard-deepl.php:11 +msgid "Yard | Digital Agency" msgstr "" #. Author URI of the plugin +#: yard-deepl.php:12 msgid "https://www.yard.nl" msgstr "" -#: src/Providers/MetaboxServiceProvider.php:51 -msgid "Disable translation cache when this object contains dynamic content." -msgstr "" - -#: src/Providers/MetaboxServiceProvider.php:76 -msgid "Disable translation cache?" -msgstr "" - -#: src/Providers/SettingsServiceProvider.php:66 -msgid "Settings" -msgstr "" - -#: src/Providers/SettingsServiceProvider.php:73 -msgid "Deepl API key" -msgstr "" - -#: src/Providers/SettingsServiceProvider.php:82 -msgid "Deepl supported languages" -msgstr "" - -#: src/Providers/SettingsServiceProvider.php:98 -msgid "Parameter object_id mandatory" -msgstr "" - -#: src/Views/admin/settings-page.php:15 -msgid "Save" -msgstr "" - -#: src/Views/admin/partials/settings/settings-description-general.php:10 -msgid "website" -msgstr "" - -#: src/Views/admin/partials/settings/settings-description-general.php:16 -msgid "To query the Deepl API, an API key is required. You can find more information on their %s." -msgstr "" - #: config/php-di.php:25 msgid "Arabic" msgstr "" @@ -200,3 +168,69 @@ msgstr "" #: config/php-di.php:153 msgid "Chinese (Traditional)" msgstr "" + +#: src/Providers/MetaBoxServiceProvider.php:72 +msgid "Disable translation cache when this object contains dynamic content." +msgstr "" + +#: src/Providers/MetaBoxServiceProvider.php:75 +msgid "Disable translation cache?" +msgstr "" + +#: src/Providers/MetaBoxServiceProvider.php:78 +msgid "Clear cached translations on save." +msgstr "" + +#: src/Providers/MetaBoxServiceProvider.php:81 +msgid "Clear translation cache?" +msgstr "" + +#: src/Providers/SettingsServiceProvider.php:69 +msgid "Settings" +msgstr "" + +#: src/Providers/SettingsServiceProvider.php:76 +msgid "Deepl API key" +msgstr "" + +#: src/Providers/SettingsServiceProvider.php:85 +msgid "Deepl supported languages" +msgstr "" + +#: src/Providers/SettingsServiceProvider.php:101 +msgid "Parameter object_id mandatory" +msgstr "" + +#. Translators: %s is the URL to the Deepl API documentation page. +#: src/Views/admin/partials/settings/settings-description-general.php:10 +msgid "website" +msgstr "" + +#. Translators: %s is the link to the Deepl API documentation website. +#: src/Views/admin/partials/settings/settings-description-general.php:16 +#, php-format +msgid "To query the Deepl API, an API key is required. You can find more information on their %s." +msgstr "" + +#: src/Providers/AdminColumnsServiceProvider.php:53 +msgid "Translation cache" +msgstr "" + +#: src/Providers/AdminColumnsServiceProvider.php:70 +msgid "No languages configured" +msgstr "" + +#: src/Providers/AdminColumnsServiceProvider.php:105 +msgid "No cached translations" +msgstr "" + +#. Translators: %d is the number of uncached DeepL API calls made for this post and language. +#: src/Providers/AdminColumnsServiceProvider.php:94 +msgid "%d uncached call" +msgid_plural "%d uncached calls" +msgstr[0] "" +msgstr[1] "" + +#: src/Views/admin/settings-page.php:15 +msgid "Save" +msgstr "" diff --git a/package-lock.json b/package-lock.json index 9c459ae..e03f17e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "yard-deepl", "version": "1.0.0", "devDependencies": { "@wordpress/prettier-config": "^2.16.0", @@ -29885,7 +29886,9 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, - "requires": {} + "requires": { + "ajv": "^8.0.0" + } }, "ajv-keywords": { "version": "5.1.0", diff --git a/readme.txt b/readme.txt index 8b53d2a..efd0cf0 100644 --- a/readme.txt +++ b/readme.txt @@ -8,7 +8,7 @@ Requires at least: 6.0 Requires PHP: 8.0 Stable tag: 1.1.0 Tags: deepl, translating, secure -Tested up to: 6.7.1 +Tested up to: 6.8.5 This plugin registers secure API endpoints that allow you to request translations directly from DeepL without exposing your DeepL API-key. @@ -22,9 +22,27 @@ Each object that is translated will store its cached translation in the `wp_post * Serving Cached Translations: If a cached translation is newer than the `post_modified` date of the object, the cached version is served. * Fetching New Translations: When the `post_modified` date of the object is more recent than the cached translation, a new translation is fetched from DeepL. Once retrieved, this translation is immediately cached for future use. +* Cache Authorization: Only logged-in users with the `edit_posts` capability (or a custom capability configured via the `yard::deepl/cache_capability` filter) are permitted to create new cache entries. Requests from users without this capability will still return a translation from DeepL, but the result will not be stored in the cache. This approach minimizes the number of API calls to DeepL, ensuring translations are kept up to date only when necessary. += Admin: Cache Metabox = + +A metabox labeled Yard DeepL is displayed on the edit screen of supported post types (default: page). It provides two options: + +* Disable translation cache: When checked, the cache is bypassed for this object. Useful for posts with dynamic content that should always be translated fresh. +* Clear translation cache: When checked and the post is saved, all cached translations for this object are deleted. + += Admin: Translation Cache Column = + +A Translation cache column is added to the post list screen of all supported post types. Its purpose is to notify editors which posts should be cached as soon as possible to avoid unnecessary DeepL API costs. + +* Green badge per language (e.g. NL, EN-US): a valid, up-to-date cached translation exists for that language. Visitor requests are served from cache at no cost. +* Grey badge with a count per language (e.g. EN-US 42): no cache exists for that language and visitors have triggered that many live DeepL API calls. Hovering the badge shows the full count as a tooltip. These posts are the most urgent to cache. +* —: the post has never been requested for translation, or caching is disabled for this post. + +The uncached call count is only incremented for visitor requests (users without cache-write capability) and is automatically reset per language once a cache entry is stored. Posts with the Disable translation cache option enabled are excluded from the column entirely. + == External Services == This plugin connects to the DeepL API to provide translations for content. @@ -43,7 +61,7 @@ This plugin connects to the DeepL API to provide translations for content. == Usage == -## Security += Security = The API endpoints registered by this plugin are secured using a WordPress nonce. The nonce is passed to the front-end using the `wp_localize_script` function and is stored in a global JavaScript object `ydpl` which contains the following properties: @@ -52,18 +70,22 @@ The API endpoints registered by this plugin are secured using a WordPress nonce. * `ydpl_supported_languages`: The list of languages supported for translation. * `ydpl_api_request_nonce`: The nonce used for API validation. -When making requests to the API, ensure that the nonce is included in the request headers. The header should be named `nonce`, and it should contain the value of `ydpl_api_request_nonce`. +When making requests to the API, ensure that the nonce is included in the request headers. The header should be named `X-WP-Nonce`, and it should contain a nonce created with the `wp_rest` action (available via `ydpl.ydpl_api_request_nonce` on the front-end). -## Example +Rate limiting: Unauthenticated requests (or requests from users without the cache capability) are limited to 3 requests per 60 seconds per IP address. Authenticated users with the required cache capability (default: edit_posts) are exempt from this rate limit. -### Request +Cache capability: Only users with the `edit_posts` capability can trigger cache creation. This can be customized using the `yard::deepl/cache_capability` filter. + += Example = + +Request: var xhr = new XMLHttpRequest(); xhr.open('POST', ydpl.ydpl_rest_translate_url, true); // Set request headers xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.setRequestHeader('nonce', ydpl.ydpl_api_request_nonce); + xhr.setRequestHeader('X-WP-Nonce', ydpl.ydpl_api_request_nonce); // Handle response xhr.onreadystatechange = function () { @@ -82,7 +104,7 @@ When making requests to the API, ensure that the nonce is included in the reques xhr.send(data); -### Response +Response: [ { @@ -91,8 +113,24 @@ When making requests to the API, ensure that the nonce is included in the reques } ] +== Filters == + +* `yard::deepl/cache_capability` (default: `edit_posts`) — The WordPress capability required to create cache entries. Users without this capability receive translations but results are not cached. +* `yard::deepl/cache_metabox_post_types` (default: `['page']`) — Post types on which the Yard DeepL cache metabox is displayed. +* `yard::deepl/disable_cache_metabox_post_types` — Deprecated. Use `yard::deepl/cache_metabox_post_types` instead. + == Changelog == += NEXT: unreleased = + +* Add: same-origin check for REST API requests +* Add: rate limiting for unauthenticated / low-privilege requests (3 req / 60 s per IP) +* Add: cache creation restricted to users with `edit_posts` capability (configurable via filter) +* Add: `yard::deepl/cache_capability` filter +* Add: Translation cache column on post list screens showing cached languages and per-language uncached call counts, to help editors prioritise which posts to cache +* Change: deprecated `yard::deepl/disable_cache_metabox_post_types` in favour of `yard::deepl/cache_metabox_post_types` +* Change: nonce validation now also accepts the standard `wp_rest` nonce action as a fallback + = 1.1.0: Jan 31, 2025 = * Add: disable DeepL translation cache metabox diff --git a/src/Bootstrap.php b/src/Bootstrap.php index 20abea2..1c0cfaf 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -9,6 +9,7 @@ exit; } +use YDPL\Providers\AdminColumnsServiceProvider; use YDPL\Providers\AssetsServiceProvider; use YDPL\Providers\MetaBoxServiceProvider; use YDPL\Providers\RestAPIServiceProvider; @@ -71,6 +72,7 @@ protected function get_providers(): array new SettingsServiceProvider(), new RestAPIServiceProvider(), new MetaBoxServiceProvider(), + new AdminColumnsServiceProvider(), ); } diff --git a/src/Controllers/RestAPIController.php b/src/Controllers/RestAPIController.php index 0e804c9..2b913b5 100644 --- a/src/Controllers/RestAPIController.php +++ b/src/Controllers/RestAPIController.php @@ -12,6 +12,7 @@ use Exception; use WP_REST_Request; use WP_REST_Response; +use YDPL\Exceptions\ObjectNotFoundException; use YDPL\Services\TranslationService; use YDPL\Singletons\SiteOptionsSingleton; use YDPL\Traits\ErrorLog; @@ -21,6 +22,9 @@ */ class RestAPIController { + protected const RATE_LIMIT = 3; + protected const RATE_LIMIT_TIME_WINDOW_IN_SECONDS = 60; + use ErrorLog; protected TranslationService $service; @@ -37,24 +41,38 @@ public function __construct() */ public function handle_translate_request( WP_REST_Request $request ): WP_REST_Response { - $text = $request->get_param( 'text' ); - $target_lang = $request->get_param( 'target_lang' ); - $object_id = $request->get_param( 'object_id' ); + $text = (array) ( $request->get_param( 'text' ) ?? array() ); + $target_lang = (string) ( $request->get_param( 'target_lang' ) ?? '' ); + $object_id = (int) ( $request->get_param( 'object_id' ) ?? 0 ); + $origin = (string) ( $request->get_header( 'origin' ) ?? '' ); + + if ( 0 < strlen( $origin ) && ! $this->is_same_origin( $origin ) ) { + return $this->set_failure_response( 403, 'Invalid origin. Origin does not match the site URL.' ); + } - // Are required by Deepl. - if ( empty( $text ) || empty( $target_lang ) ) { - return $this->set_failure_response( 400, 'Invalid input parameters.' ); + $user_has_cache_capability = current_user_can( apply_filters( 'yard::deepl/cache_capability', 'edit_posts' ) ); + + if ( 0 < $object_id ) { + try { + $cached_translation = $this->service->get_cached_translation( $object_id, $target_lang ) ?? array(); + } catch ( ObjectNotFoundException $e ) { + return $this->set_failure_response( 404, 'Object not found.' ); + } + } else { + $cached_translation = null; } - // Is required when configured as such in the plugin settings. - if ( $this->options->rest_api_param_object_id_is_mandatory() && empty( $object_id ) ) { - return $this->set_failure_response( 400, 'Invalid input parameters.' ); + // Apply rate limit check if object ID is absent or translation is not cached when an object ID is present. + if ( ! $cached_translation ) { + if ( $this->is_rate_limit_exceeded() && ! $user_has_cache_capability ) { + return $this->set_failure_response( 429, 'Rate limit exceeded.' ); + } } try { - $translation = $this->service->handle_translation( (int) $object_id, $text, $target_lang ); + $translation = $this->service->handle_translation( $object_id, $text, $target_lang, $user_has_cache_capability, $cached_translation ); - if ( empty( $translation ) ) { + if ( array() === $translation ) { throw new Exception( 'Failed to translate text.', 500 ); } } catch ( Exception $e ) { @@ -68,6 +86,75 @@ public function handle_translate_request( WP_REST_Request $request ): WP_REST_Re ); } + /** + * @since NEXT + */ + protected function is_rate_limit_exceeded(): bool + { + $client_ip = $this->get_client_ip(); + + if ( '' === $client_ip ) { + return true; + } + + $transient_key = 'ydpl_rate_limit_' . hash_hmac( 'sha256', $client_ip, SECURE_AUTH_KEY ); + $request_count = (int) ( get_transient( $transient_key ) ?: 0 ); + + if ( self::RATE_LIMIT <= $request_count ) { + return true; + } + + set_transient( $transient_key, $request_count + 1, self::RATE_LIMIT_TIME_WINDOW_IN_SECONDS ); + + return false; + } + + /** + * @since NEXT + */ + protected function get_client_ip(): string + { + $remote_address = $_SERVER['REMOTE_ADDR'] ?? ''; + + if ( filter_var( $remote_address, FILTER_VALIDATE_IP ) === false ) { + return ''; + } + + return $remote_address; + } + + /** + * @since NEXT + */ + protected function is_same_origin( string $origin ): bool + { + $home = wp_parse_url( home_url() ); + $parsed = wp_parse_url( $origin ); + + if ( ! $home || ! $parsed ) { + return false; + } + + $home_scheme = $home['scheme'] ?? ''; + $parsed_scheme = $parsed['scheme'] ?? ''; + + return $home_scheme === $parsed_scheme + && ( $home['host'] ?? '' ) === ( $parsed['host'] ?? '' ) + && $this->normalize_port( $home['port'] ?? null, $home_scheme ) === $this->normalize_port( $parsed['port'] ?? null, $parsed_scheme ); + } + + /** + * @since NEXT + */ + protected function normalize_port( ?int $port, string $scheme ): int + { + if ( null !== $port ) { + return $port; + } + + return 'https' === $scheme ? 443 : 80; + } + /** * @since 0.0.1 */ diff --git a/src/Providers/AdminColumnsServiceProvider.php b/src/Providers/AdminColumnsServiceProvider.php new file mode 100644 index 0000000..5348a8b --- /dev/null +++ b/src/Providers/AdminColumnsServiceProvider.php @@ -0,0 +1,136 @@ +repository = new TranslationRepository(); + } + + /** + * @since NEXT + */ + public function register(): void + { + foreach ( $this->get_cache_metabox_post_types() as $post_type ) { + add_filter( "manage_{$post_type}_posts_columns", array( $this, 'add_column' ) ); + add_action( "manage_{$post_type}_posts_custom_column", array( $this, 'render_column' ), 10, 2 ); + } + + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); + } + + /** + * @since NEXT + */ + public function add_column( array $columns ): array + { + $columns['ydpl_translation_cache'] = __( 'Translation cache', 'yard-deepl' ); + + return $columns; + } + + /** + * @since NEXT + */ + public function render_column( string $column, int $post_id ): void + { + if ( 'ydpl_translation_cache' !== $column ) { + return; + } + + $configured_languages = ydpl_resolve_from_container( 'ydpl.site_options' )->configured_supported_languages(); + + if ( array() === $configured_languages ) { + echo ''; + + return; + } + + $column_data = $this->repository->get_column_data( $post_id, $configured_languages ); + $cached_langs = $column_data['cached_languages']; + $uncached_counts = $column_data['uncached_request_counts']; + + $badges = ''; + + foreach ( $configured_languages as $lang ) { + if ( in_array( $lang, $cached_langs, true ) ) { + $badges .= sprintf( + '%s', + esc_html( $lang ) + ); + } elseif ( isset( $uncached_counts[ $lang ] ) ) { + $count = $uncached_counts[ $lang ]; + $badges .= sprintf( + '%s %s', + esc_attr( + sprintf( + /* translators: %d: number of uncached DeepL API calls made for this post and language */ + _n( '%d uncached call', '%d uncached calls', $count, 'yard-deepl' ), + $count + ) + ), + esc_html( $lang ), + esc_html( number_format_i18n( $count ) ) + ); + } + } + + if ( '' === $badges ) { + echo ''; + + return; + } + + echo '
' . $badges . '
'; + } + + /** + * @since NEXT + */ + public function enqueue_styles( string $hook_suffix ): void + { + if ( 'edit.php' !== $hook_suffix ) { + return; + } + + $screen = get_current_screen(); + + if ( ! $screen || ! in_array( $screen->post_type, $this->get_cache_metabox_post_types(), true ) ) { + return; + } + + $path = ydpl_asset_path( 'admin-columns.asset.php' ); + $script_asset = file_exists( $path ) ? require $path : array( + 'dependencies' => array(), + 'version' => round( microtime( true ) ), + ); + + wp_enqueue_style( 'ydpl-admin-columns', ydpl_asset_url( 'admin-columns.css' ), $script_asset['dependencies'] ?? array(), $script_asset['version'] ); + } +} diff --git a/src/Providers/AssetsServiceProvider.php b/src/Providers/AssetsServiceProvider.php index 19f8459..8c3e621 100644 --- a/src/Providers/AssetsServiceProvider.php +++ b/src/Providers/AssetsServiceProvider.php @@ -22,6 +22,7 @@ class AssetsServiceProvider implements ServiceProviderInterface public function register(): void { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); } /** @@ -29,25 +30,27 @@ public function register(): void */ public function enqueue_assets(): void { - $path = ydpl_asset_url( 'main.asset.php' ); + $path = ydpl_asset_path( 'main.asset.php' ); $script_asset = file_exists( $path ) ? require $path : array( 'dependencies' => array(), 'version' => round( microtime( true ) ), ); - - wp_enqueue_script( 'ydpl-main', ydpl_asset_url( 'main.js' ), array( 'jquery' ), $script_asset['version'], true ); + wp_enqueue_script( 'ydpl-main', ydpl_asset_url( 'main.js' ), $script_asset['dependencies'] ?? array(), $script_asset['version'], true ); wp_localize_script( 'ydpl-main', 'ydpl', array( - 'ydpl_translate_post_id' => get_the_ID() ?: 0, + 'ydpl_translate_post_id' => get_the_ID() ?: get_queried_object_id() ?: 0, 'ydpl_rest_translate_url' => esc_url_raw( rest_url( YDPL_API_NAMESPACE . '/translate' ) ), 'ydpl_supported_languages' => $this->format_selected_supported_languages(), - 'ydpl_api_request_nonce' => wp_create_nonce( YDPL_NONCE_REST_NAME ), + 'ydpl_api_request_nonce' => wp_create_nonce( 'wp_rest' ), ) ); } + /** + * @since 0.0.1 + */ private function format_selected_supported_languages(): array { $supported_languages = ydpl_resolve_from_container( 'ydpl.supported_target.languages' ); @@ -62,4 +65,22 @@ function ( $supported_language ) use ( $configured_supported_languages ) { return array_values( $filtered ); } + + /** + * @since NEXT + */ + public function enqueue_admin_assets( string $hook_suffix ): void + { + if ( ! in_array( $hook_suffix, array( 'post.php', 'post-new.php' ), true ) ) { + return; + } + + $path = ydpl_asset_path( 'editor.asset.php' ); + $script_asset = file_exists( $path ) ? require $path : array( + 'dependencies' => array(), + 'version' => round( microtime( true ) ), + ); + + wp_enqueue_style( 'ydpl-editor', ydpl_asset_url( 'editor.css' ), $script_asset['dependencies'] ?? array(), $script_asset['version'] ); + } } diff --git a/src/Providers/MetaBoxServiceProvider.php b/src/Providers/MetaBoxServiceProvider.php index e00b406..1ac4c0a 100644 --- a/src/Providers/MetaBoxServiceProvider.php +++ b/src/Providers/MetaBoxServiceProvider.php @@ -12,28 +12,32 @@ } use YDPL\Contracts\ServiceProviderInterface; +use YDPL\Repositories\TranslationRepository; +use YDPL\Traits\CachePostTypesTrait; /** * @since 1.1.0 */ class MetaBoxServiceProvider implements ServiceProviderInterface { + use CachePostTypesTrait; + public function register(): void { - add_action( 'add_meta_boxes', array( $this, 'register_meta_box' ), 999 ); - add_action( 'save_post', array( $this, 'save_metabox_values' ), 999 ); + add_action( 'add_meta_boxes', array( $this, 'register_meta_boxes' ), 999 ); + add_action( 'save_post', array( $this, 'handle_saved_metabox_values' ), 999 ); } /** * @since 1.1.0 */ - public function register_meta_box(): void + public function register_meta_boxes(): void { add_meta_box( 'yard-deepl', - __( 'Yard Deepl', 'yard-deepl' ), - array( $this, 'render_meta_box' ), - apply_filters( 'yard::deepl/disable_cache_metabox_post_types', array( 'page' ) ), + __( 'Yard DeepL', 'yard-deepl' ), + array( $this, 'render_meta_boxes' ), + $this->get_cache_metabox_post_types(), 'side', 'high' ); @@ -42,16 +46,11 @@ public function register_meta_box(): void /** * @since 1.1.0 */ - public function render_meta_box( WP_Post $post ): void + public function render_meta_boxes( WP_Post $post ): void { $this->security_nonce_field(); - $html = sprintf( - '

%s

', - esc_html__( 'Disable translation cache when this object contains dynamic content.', 'yard-deepl' ) - ); - - $html = $this->disable_translation_cache_metabox( $html, $post ); + $html = $this->translation_cache_metaboxes( $post ); echo $html; } @@ -67,47 +66,85 @@ private function security_nonce_field(): void /** * @since 1.1.0 */ - private function disable_translation_cache_metabox( string $html, WP_Post $post ): string + private function translation_cache_metaboxes( WP_Post $post ): string { - $is_disabled = get_post_meta( $post->ID, 'ydpl_disable_deepl_translation_cache', true ); + $cache_is_disabled = get_post_meta( $post->ID, 'ydpl_disable_deepl_translation_cache', true ); - $html .= '