PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / 3.3.1
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder v3.3.1
3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 2.10.1 All 138 releases
bit-form / includes / Core / Util / Translation / TranslationManager.php

TranslationManager.php in Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder 3.3.1, at includes/Core/Util/Translation/TranslationManager.php

372 lines 11.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace BitCode\BitForm\Core\Util\Translation;
4
5 use BitCode\BitForm\Core\Form\FormManager;
6 use BitCode\BitForm\Core\Util\EscapingHelper;
7 use BitCode\BitForm\Core\Util\Translation\Contract\StringRegistrationProviderInterface;
8 use BitCode\BitForm\Core\Util\Translation\Contract\TranslationProviderInterface;
9
10 /**
11 * Lazy entry point for multilingual support.
12 *
13 * Detection runs at most once per request from Hooks::init_classes(): the first
14 * available provider (WPML > Polylang > TranslatePress, extensible via the
15 * `bitform_translation_providers` filter) is wired to the public
16 * `bitform_translate_form_string` / `bitform_current_language` filters. With no
17 * multilingual plugin active nothing is hooked and rendering is unchanged.
18 *
19 * Hooks only on a frontend render and on Bit Form's own frontend AJAX actions.
20 * Every other admin-ajax request must see source-language content: the builder
21 * saves back what it reads, so a translation reaching it overwrites the source.
22 *
23 * No provider exists for GTranslate (translates rendered HTML, no PHP API) or
24 * Loco Translate (gettext catalog only, not user-authored DB content).
25 */
26 final class TranslationManager
27 {
28 /**
29 * Frontend AJAX actions, Free and Pro. Pro names are plain strings, so no Pro
30 * code needs to be present. Extend via `bitform_translation_frontend_ajax_actions`.
31 */
32 public const FRONTEND_AJAX_ACTIONS = [
33 'bitforms_submit_form',
34 'bitforms_entry_update',
35 'bitforms_update_form_entry',
36 'bitforms_before_submit_validate',
37 'bitforms_trigger_workflow',
38 'bitforms_onload_added_field_and_property',
39 'bitforms_send_email_otp',
40 'bitforms_verify_email_otp',
41 'bitforms_save_partial_form_progress',
42 ];
43
44 /**
45 * Active provider, false when none available, null before detection.
46 *
47 * @var TranslationProviderInterface|false|null
48 */
49 private static $provider = null;
50
51 /**
52 * Contexts already booted this request; the Request::Check() branches overlap.
53 *
54 * @var array<string,bool>
55 */
56 private static $booted = [];
57
58 /**
59 * Memoized `bitform_translation_enabled`.
60 *
61 * @var bool|null
62 */
63 private static $enabled = null;
64
65 /**
66 * Sanitized language slug from the AJAX payload, '' when absent/invalid.
67 *
68 * @var string|null
69 */
70 private static $requestLang = null;
71
72 /**
73 * Per-form translation-enabled memo.
74 *
75 * @var array<int,bool>
76 */
77 private static $formEnabledCache = [];
78
79 /**
80 * Entry point from Hooks::init_classes().
81 *
82 * @param string $context 'frontend' | 'ajax' | 'admin'
83 */
84 public static function boot($context)
85 {
86 if (isset(self::$booted[$context])) {
87 return;
88 }
89 self::$booted[$context] = true;
90
91 $provider = self::provider();
92 if (!$provider || !self::isEnabled()) {
93 return;
94 }
95
96 if ('admin' === $context) {
97 // Also true on admin-ajax, which is where the form-save listener registers.
98 // HTML-layer providers have no string store and skip this entirely.
99 if ($provider instanceof StringRegistrationProviderInterface) {
100 $provider->onAdminInit();
101 }
102 return;
103 }
104
105 // admin-ajax satisfies Request::Check('frontend') too; the 'ajax' context owns it.
106 if ('frontend' === $context && wp_doing_ajax()) {
107 return;
108 }
109
110 if ('ajax' === $context) {
111 if (!self::isFrontendAjaxRequest()) {
112 return;
113 }
114 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only language hint, re-validated by the provider against its own language list.
115 $rawLang = isset($_REQUEST['bf_lang']) ? wp_unslash($_REQUEST['bf_lang']) : '';
116 self::$requestLang = self::sanitizeLang($rawLang);
117 $provider->onAjaxInit(self::$requestLang);
118 }
119
120 add_filter('bitform_current_language', [self::class, 'filterCurrentLanguage'], 10, 2);
121 // HTML-layer plugins translate the rendered page themselves, so hooking the
122 // string filter there double-translates. Their blind spot is AJAX JSON.
123 if ('ajax' === $context || !$provider->translatesRenderedHtml()) {
124 add_filter('bitform_translate_form_string', [self::class, 'filterTranslate'], 10, 3);
125 }
126 }
127
128 /**
129 * @return bool whether the current AJAX action is one of Bit Form's frontend endpoints
130 */
131 public static function isFrontendAjaxRequest()
132 {
133 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- action name only, used to scope a read-only display filter.
134 $action = isset($_REQUEST['action']) && is_string($_REQUEST['action']) ? sanitize_key(wp_unslash($_REQUEST['action'])) : '';
135 if ('' === $action) {
136 return false;
137 }
138 $allowed = apply_filters('bitform_translation_frontend_ajax_actions', self::FRONTEND_AJAX_ACTIONS);
139 return is_array($allowed) && in_array($action, $allowed, true);
140 }
141
142 /**
143 * @return bool memoized `bitform_translation_enabled`
144 */
145 private static function isEnabled()
146 {
147 if (null === self::$enabled) {
148 self::$enabled = (bool) apply_filters('bitform_translation_enabled', true);
149 }
150 return self::$enabled;
151 }
152
153 /**
154 * Detects and memoizes the active provider (once per request).
155 *
156 * @return TranslationProviderInterface|false
157 */
158 public static function provider()
159 {
160 if (null === self::$provider) {
161 self::$provider = false;
162 $classes = apply_filters('bitform_translation_providers', [
163 Provider\WpmlProvider::class,
164 Provider\PolylangProvider::class,
165 Provider\TranslatePressProvider::class,
166 ]);
167 foreach ((array) $classes as $class) {
168 if (!is_string($class)) {
169 continue;
170 }
171 // Built-ins answer from ProviderProbe, keeping their files off the
172 // autoloader on the vast majority of sites. Third-party classes have no
173 // probe and are loaded and asked directly.
174 if (false === ProviderProbe::isActive($class)) {
175 continue;
176 }
177 if (
178 class_exists($class)
179 && is_subclass_of($class, TranslationProviderInterface::class)
180 && $class::isAvailable()
181 ) {
182 self::$provider = new $class();
183 break;
184 }
185 }
186 }
187 return self::$provider;
188 }
189
190 /**
191 * `bitform_translate_form_string` delegate: enforces the per-form opt-out.
192 *
193 * @param mixed $string
194 * @param mixed $context
195 * @param mixed $formId
196 *
197 * @return mixed
198 */
199 public static function filterTranslate($string, $context = '', $formId = 0)
200 {
201 $provider = self::provider();
202 if (!$provider || !is_string($string) || '' === $string) {
203 return $string;
204 }
205 if (!self::isFormTranslationEnabled($formId)) {
206 return $string;
207 }
208 $translated = $provider->translate($string, $context, $formId);
209 if (!is_string($translated) || $translated === $string) {
210 return $string;
211 }
212 return self::guardTranslation($translated, $string, (string) $context);
213 }
214
215 /**
216 * Source strings are authored under `manage_bitform`, their translations are
217 * not (WPML ships a Translator role). Constrains the contexts that reach
218 * privileged sinks; everything else passes through.
219 *
220 * @param string $translated
221 * @param string $source
222 * @param string $context
223 *
224 * @return string
225 */
226 private static function guardTranslation($translated, $source, $context)
227 {
228 if (0 === strpos($context, 'redirect-url-')) {
229 return self::guardRedirect($translated, $source);
230 }
231 // A subject is plain text and ends up in a mail header.
232 if (0 === strpos($context, 'mail-sub-')) {
233 return sanitize_text_field($translated);
234 }
235 // Message HTML is injected client-side into .msg-content; mail body HTML is
236 // delivered to the admin and the submitter.
237 $isHtmlSink = 0 === strpos($context, 'msg-content-') || 0 === strpos($context, 'mail-body-');
238 if ($isHtmlSink && apply_filters('bitform_translation_sanitize_html', true, $context)) {
239 return wp_kses($translated, self::translatedAllowedHtml());
240 }
241 return $translated;
242 }
243
244 /**
245 * The renderer's allowlist minus <script>, which it carries only for a
246 * server-emitted Turnstile block.
247 *
248 * @return array<string,array<string,bool>>
249 */
250 private static function translatedAllowedHtml()
251 {
252 $allowed = EscapingHelper::getAllowedHtmlTags();
253 unset($allowed['script'], $allowed['style']);
254 return $allowed;
255 }
256
257 /**
258 * A translated redirect localizes the path, it does not leave the site.
259 * Off-host targets fall back to the source.
260 *
261 * @param string $translated
262 * @param string $source
263 *
264 * @return string
265 */
266 private static function guardRedirect($translated, $source)
267 {
268 $host = wp_parse_url($translated, PHP_URL_HOST);
269 if (empty($host)) {
270 return $translated; // relative — same site
271 }
272 $allowed = array_filter([
273 wp_parse_url($source, PHP_URL_HOST),
274 wp_parse_url(home_url(), PHP_URL_HOST),
275 ]);
276 $allowed = apply_filters('bitform_translation_allowed_redirect_hosts', $allowed, $source);
277 if (!is_array($allowed)) {
278 return $source;
279 }
280 return in_array(strtolower($host), array_map('strtolower', array_filter($allowed, 'is_string')), true) ? $translated : $source;
281 }
282
283 /**
284 * `bitform_current_language` delegate.
285 *
286 * @param mixed $lang
287 * @param mixed $formId
288 *
289 * @return mixed
290 */
291 public static function filterCurrentLanguage($lang = '', $formId = 0)
292 {
293 $provider = self::provider();
294 if (!$provider) {
295 return $lang;
296 }
297 $resolved = (string) $provider->getCurrentLanguage();
298 return '' !== $resolved ? $resolved : $lang;
299 }
300
301 /**
302 * Per-form opt-out: form_content->additional->settings->translation->disabled,
303 * overridable via the `bitform_form_translation_enabled` filter.
304 *
305 * @param mixed $formId
306 * @param string $rawContent already-loaded form_content JSON, skips the FormManager lookup
307 *
308 * @return bool
309 */
310 public static function isFormTranslationEnabled($formId, $rawContent = null)
311 {
312 $formId = (int) $formId;
313 if ($formId <= 0) {
314 return true;
315 }
316 if (!isset(self::$formEnabledCache[$formId])) {
317 $enabled = true;
318 if (is_string($rawContent)) {
319 $raw = $rawContent;
320 } else {
321 $formManager = FormManager::getInstance($formId);
322 $raw = $formManager->isExist() ? $formManager->getFieldsContent() : '';
323 }
324 // Cheap pre-check: forms without a translation settings block skip the decode.
325 if (is_string($raw) && false !== strpos($raw, '"translation"')) {
326 $decoded = json_decode($raw);
327 if (is_object($decoded) && !empty($decoded->additional->settings->translation->disabled)) {
328 $enabled = false;
329 }
330 }
331 self::$formEnabledCache[$formId] = (bool) apply_filters('bitform_form_translation_enabled', $enabled, $formId);
332 }
333 return self::$formEnabledCache[$formId];
334 }
335
336 /**
337 * @return string sanitized bf_lang from the AJAX payload, '' if none
338 */
339 public static function getRequestLang()
340 {
341 return null === self::$requestLang ? '' : self::$requestLang;
342 }
343
344 /**
345 * Accepts slugs like "en", "pt-br", "zh_CN"; anything else becomes ''.
346 * Providers re-validate against their own language list where they have one.
347 *
348 * @param mixed $lang
349 *
350 * @return string
351 */
352 public static function sanitizeLang($lang)
353 {
354 if (!is_string($lang) || '' === $lang || strlen($lang) > 20) {
355 return '';
356 }
357 return preg_match('/^[a-z]{2,3}([_-][A-Za-z0-9]{2,10}){0,2}$/', $lang) ? $lang : '';
358 }
359
360 /**
361 * Test hook: clears all memoized state.
362 */
363 public static function resetForTesting()
364 {
365 self::$provider = null;
366 self::$requestLang = null;
367 self::$formEnabledCache = [];
368 self::$booted = [];
369 self::$enabled = null;
370 }
371 }
372