| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentForm\App\Services\FormBuilder; |
| 4 |
|
| 5 |
defined('ABSPATH') || die; |
| 6 |
|
| 7 |
/** |
| 8 |
* Decides the autocomplete attribute a field renders. |
| 9 |
* |
| 10 |
* A token tells the browser what a field is for, so it can autofill it. Only |
| 11 |
* the names in the HTML spec's autofill table work; anything else is ignored |
| 12 |
* by browsers, so an unrecognised value is dropped rather than rendered. |
| 13 |
* |
| 14 |
* Every rendered field passes through normalizeAttributes(), which asks: |
| 15 |
* |
| 16 |
* 1. Did the form owner pick a value in the editor? Theirs wins. |
| 17 |
* 2. If not, is the input type unambiguous? Only email, url and tel are. |
| 18 |
* 3. Is the result still a real token? Checked here because a form saved by |
| 19 |
* an admin skips the save-side check (see Updater::sanitizeFields). |
| 20 |
* |
| 21 |
* 'none' is the editor's "render no attribute" choice. It is stored so the |
| 22 |
* choice survives a save, and dropped at step 3 so no attribute is emitted. |
| 23 |
* |
| 24 |
* @see https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill |
| 25 |
*/ |
| 26 |
class AutocompleteTokens |
| 27 |
{ |
| 28 |
// The only input types whose purpose is unambiguous. |
| 29 |
protected static $typeDefaults = [ |
| 30 |
'email' => 'email', |
| 31 |
'url' => 'url', |
| 32 |
'tel' => 'tel', |
| 33 |
]; |
| 34 |
|
| 35 |
protected static $nameDefaults = [ |
| 36 |
'first_name' => 'given-name', |
| 37 |
'middle_name' => 'additional-name', |
| 38 |
'last_name' => 'family-name', |
| 39 |
]; |
| 40 |
|
| 41 |
// Address autofill is opt-in ('on' on the parent field): a form can carry |
| 42 |
// two address blocks and the Places widget binds address_line_1, so these |
| 43 |
// would be wrong more often than right as a default. |
| 44 |
protected static $addressDefaults = [ |
| 45 |
'address_line_1' => 'address-line1', |
| 46 |
'address_line_2' => 'address-line2', |
| 47 |
'city' => 'address-level2', |
| 48 |
'state' => 'address-level1', |
| 49 |
'zip' => 'postal-code', |
| 50 |
'country' => 'country', |
| 51 |
]; |
| 52 |
|
| 53 |
// cc-*, transaction-*, one-time-code and current-password are excluded: |
| 54 |
// card details live in the gateway iframe, so those tokens could only ever |
| 55 |
// autofill a secret into a plain input stored with the submission. |
| 56 |
protected static $allowed = [ |
| 57 |
'name', 'honorific-prefix', 'given-name', 'additional-name', 'family-name', |
| 58 |
'honorific-suffix', 'nickname', |
| 59 |
'email', 'tel', 'tel-country-code', 'tel-national', 'tel-area-code', |
| 60 |
'tel-local', 'tel-local-prefix', 'tel-local-suffix', 'tel-extension', |
| 61 |
'impp', 'url', 'photo', |
| 62 |
'street-address', 'address-line1', 'address-line2', 'address-line3', |
| 63 |
'address-level4', 'address-level3', 'address-level2', 'address-level1', |
| 64 |
'country', 'country-name', 'postal-code', |
| 65 |
'organization', 'organization-title', |
| 66 |
'username', 'new-password', |
| 67 |
'bday', 'bday-day', 'bday-month', 'bday-year', 'sex', 'language', |
| 68 |
]; |
| 69 |
|
| 70 |
// Values that say whether to autofill without naming what the field is for, |
| 71 |
// so they apply to a whole field or composite block: the spec's on/off, plus |
| 72 |
// our own 'none' sentinel meaning "render no attribute at all". |
| 73 |
protected static $wholeField = ['on', 'off', 'none']; |
| 74 |
|
| 75 |
// The single boundary every rendered field passes through. |
| 76 |
public static function normalizeAttributes($attributes) |
| 77 |
{ |
| 78 |
// A non-array can arrive here: attributes pass through public filters |
| 79 |
// (fluentform/before_render_item) before reaching buildAttributes(). |
| 80 |
if (!is_array($attributes)) { |
| 81 |
return $attributes; |
| 82 |
} |
| 83 |
|
| 84 |
if (!array_key_exists('autocomplete', $attributes)) { |
| 85 |
return $attributes; |
| 86 |
} |
| 87 |
|
| 88 |
$token = static::sanitize($attributes['autocomplete']); |
| 89 |
|
| 90 |
if ('' === $token) { |
| 91 |
$type = strtolower((string) (isset($attributes['type']) ? $attributes['type'] : '')); |
| 92 |
$token = static::resolveDefault( |
| 93 |
isset(static::$typeDefaults[$type]) ? static::$typeDefaults[$type] : '', |
| 94 |
['input_type' => $type, 'sub_field' => ''] |
| 95 |
); |
| 96 |
} |
| 97 |
|
| 98 |
if ('' === $token || 'none' === $token) { |
| 99 |
unset($attributes['autocomplete']); |
| 100 |
|
| 101 |
return $attributes; |
| 102 |
} |
| 103 |
|
| 104 |
$attributes['autocomplete'] = $token; |
| 105 |
|
| 106 |
return $attributes; |
| 107 |
} |
| 108 |
|
| 109 |
// One control covers every sub-field, so only a whole-field choice applies -- |
| 110 |
// and it overrides anything authored on a child. |
| 111 |
public static function forSubField($name, $parentChoice = '', $childChoice = '') |
| 112 |
{ |
| 113 |
// Both entry points must normalise identically: the save-side pass is |
| 114 |
// skipped for unfiltered_html users, so ' OFF ' can arrive here raw. |
| 115 |
$parentHasKey = null !== $parentChoice; |
| 116 |
$parentChoice = static::sanitize($parentChoice); |
| 117 |
$childChoice = static::sanitize($childChoice); |
| 118 |
|
| 119 |
if (in_array($parentChoice, static::$wholeField, true)) { |
| 120 |
return 'on' === $parentChoice && isset(static::$addressDefaults[$name]) |
| 121 |
? static::resolveDefault(static::$addressDefaults[$name], ['input_type' => '', 'sub_field' => $name]) |
| 122 |
: $parentChoice; |
| 123 |
} |
| 124 |
|
| 125 |
if ($childChoice) { |
| 126 |
return $childChoice; |
| 127 |
} |
| 128 |
|
| 129 |
return isset(static::$addressDefaults[$name]) || !$parentHasKey |
| 130 |
? '' |
| 131 |
: static::resolveDefault(isset(static::$nameDefaults[$name]) ? static::$nameDefaults[$name] : '', ['input_type' => '', 'sub_field' => $name]); |
| 132 |
} |
| 133 |
|
| 134 |
// A site can rewrite any default through the filter, so whatever comes back |
| 135 |
// is validated before it is allowed near the markup. |
| 136 |
protected static function resolveDefault($default, $context) |
| 137 |
{ |
| 138 |
/** |
| 139 |
* @param string $token Derived token for a field left on Automatic, '' when none applies. |
| 140 |
* @param array $context ['input_type' => e.g. 'email', 'sub_field' => e.g. 'first_name']. |
| 141 |
*/ |
| 142 |
return static::sanitize(apply_filters('fluentform/autocomplete_token', $default, $context)); |
| 143 |
} |
| 144 |
|
| 145 |
public static function sanitize($value) |
| 146 |
{ |
| 147 |
if (!is_scalar($value)) { |
| 148 |
return ''; |
| 149 |
} |
| 150 |
|
| 151 |
$value = preg_replace('/\s+/', ' ', strtolower(trim((string) $value))); |
| 152 |
|
| 153 |
if (in_array($value, static::$wholeField, true)) { |
| 154 |
return $value; |
| 155 |
} |
| 156 |
|
| 157 |
$parts = explode(' ', $value); |
| 158 |
$fieldName = array_pop($parts); |
| 159 |
|
| 160 |
// Filterable so a site can re-admit a spec token excluded here on policy |
| 161 |
// (current-password, one-time-code) without patching the class. |
| 162 |
$allowed = (array) apply_filters('fluentform/autocomplete_allowed_tokens', static::$allowed); |
| 163 |
|
| 164 |
if (!in_array($fieldName, $allowed, true)) { |
| 165 |
return ''; |
| 166 |
} |
| 167 |
|
| 168 |
$prefix = static::validPrefix($parts, $fieldName); |
| 169 |
|
| 170 |
return null === $prefix ? '' : trim($prefix . ' ' . $fieldName); |
| 171 |
} |
| 172 |
|
| 173 |
// Optional section, then an address scope, then a contact scope — in that |
| 174 |
// order, and a contact scope only on a contact field. Null means invalid. |
| 175 |
protected static function validPrefix(array $parts, $fieldName) |
| 176 |
{ |
| 177 |
$prefix = []; |
| 178 |
|
| 179 |
if ($parts && preg_match('/^section-[a-z0-9_-]{1,32}$/', $parts[0])) { |
| 180 |
$prefix[] = array_shift($parts); |
| 181 |
} |
| 182 |
|
| 183 |
if ($parts && in_array($parts[0], ['shipping', 'billing'], true)) { |
| 184 |
$prefix[] = array_shift($parts); |
| 185 |
} |
| 186 |
|
| 187 |
if ($parts && in_array($parts[0], ['home', 'work', 'mobile', 'fax', 'pager'], true)) { |
| 188 |
if (0 !== strpos($fieldName, 'tel') && !in_array($fieldName, ['email', 'impp'], true)) { |
| 189 |
return null; |
| 190 |
} |
| 191 |
|
| 192 |
$prefix[] = array_shift($parts); |
| 193 |
} |
| 194 |
|
| 195 |
return $parts ? null : implode(' ', $prefix); |
| 196 |
} |
| 197 |
|
| 198 |
public static function editorOptions() |
| 199 |
{ |
| 200 |
$options = static::baseEditorOptions(); |
| 201 |
|
| 202 |
foreach (static::$allowed as $token) { |
| 203 |
$options[] = ['value' => $token, 'label' => $token]; |
| 204 |
} |
| 205 |
|
| 206 |
return $options; |
| 207 |
} |
| 208 |
|
| 209 |
// Address adds an opt-in choice the other composites do not need. |
| 210 |
public static function addressEditorOptions() |
| 211 |
{ |
| 212 |
$options = static::baseEditorOptions(); |
| 213 |
|
| 214 |
array_splice($options, 1, 0, [ |
| 215 |
['value' => 'on', 'label' => __('On (autofill this address)', 'fluentform')], |
| 216 |
]); |
| 217 |
|
| 218 |
return $options; |
| 219 |
} |
| 220 |
|
| 221 |
// The choices every control starts with, and all a composite can offer -- |
| 222 |
// see forSubField(). Note this is not $wholeField: the editor shows |
| 223 |
// 'Automatic' (an empty stored value) and does not offer a bare 'on'. |
| 224 |
public static function baseEditorOptions() |
| 225 |
{ |
| 226 |
return [ |
| 227 |
['value' => '', 'label' => __('Automatic', 'fluentform')], |
| 228 |
['value' => 'off', 'label' => 'off'], |
| 229 |
['value' => 'none', 'label' => __('None', 'fluentform')], |
| 230 |
]; |
| 231 |
} |
| 232 |
} |
| 233 |
|