$field Normalised field definition.
* @param mixed $raw_value Raw value pulled from $_POST.
*
* @return mixed Sanitized value.
*/
public function sanitize_field( array $field, $raw_value ) {
$callback = isset( $field['sanitize_callback'] ) && is_callable( $field['sanitize_callback'] )
? $field['sanitize_callback']
: null;
if ( $callback !== null ) {
try {
return call_user_func( $callback, $raw_value );
} catch ( Throwable $e ) {
$field_id = isset( $field['id'] ) && is_string( $field['id'] ) ? $field['id'] : '(unknown)';
error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- intentional error trail for addon authors.
sprintf(
'[Albert Settings] sanitize_callback for field "%s" threw: %s',
$field_id,
$e->getMessage()
)
);
return array_key_exists( 'default', $field ) ? $field['default'] : null;
}
}
$type = isset( $field['type'] ) && is_string( $field['type'] ) ? $field['type'] : 'text';
switch ( $type ) {
case 'url':
$url = is_scalar( $raw_value ) ? esc_url_raw( (string) $raw_value ) : '';
return rtrim( $url, '/' );
case 'number':
$attributes = isset( $field['attributes'] ) && is_array( $field['attributes'] ) ? $field['attributes'] : [];
$step = $attributes['step'] ?? null;
$is_decimal = is_string( $step ) && strpos( $step, '.' ) !== false;
$has_min = isset( $field['min'] ) && is_numeric( $field['min'] );
$has_min = $has_min || ( isset( $attributes['min'] ) && is_numeric( $attributes['min'] ) );
if ( $is_decimal ) {
$decimal = is_scalar( $raw_value ) ? (float) $raw_value : 0.0;
// The integer branch below keeps its absint() floor when no
// `min` is declared. Without the same floor here, a decimal
// field declaring only a `max` stored an unbounded negative
// silently — the one shape of number field where a missing
// lower bound was not caught by anything.
if ( ! $has_min && $decimal < 0.0 ) {
$decimal = 0.0;
}
return $this->clamp( $field, $decimal );
}
// absint() takes the *absolute* value, so -5 arrives as 5 — a
// floor of 0 would never see the negative it was declared to
// catch, and somebody who typed -5 gets 5 with no explanation.
// A declared `min` is the authority on the lower bound, so cast
// plainly and let the clamp do its job. Without one, absint()
// stays: it is what has kept these fields non-negative since
// 1.1.0 and nothing should start storing negatives now.
if ( ! is_scalar( $raw_value ) ) {
return $this->clamp( $field, 0 );
}
return $this->clamp( $field, $has_min ? (int) $raw_value : absint( $raw_value ) );
case 'textarea':
return is_scalar( $raw_value ) ? sanitize_textarea_field( (string) $raw_value ) : '';
// Radio cards are a select in everything that matters here: a closed
// set of values, one chosen. They validate against the same option
// keys rather than falling through to the text branch, where any
// value POSTed for a field with no explicit sanitize_callback would
// be stored as-is.
case 'select':
case 'radio-cards':
$options = isset( $field['options'] ) && is_array( $field['options'] ) ? $field['options'] : [];
$value = is_scalar( $raw_value ) ? (string) $raw_value : '';
if ( array_key_exists( $value, $options ) ) {
return $value;
}
return array_key_exists( 'default', $field ) ? $field['default'] : '';
case 'checkbox':
return $raw_value === '1' || $raw_value === 1 || $raw_value === true;
case 'custom':
// Custom fields without a sanitize_callback are skipped at the save layer; if we
// somehow arrive here, return the default rather than persisting unsanitized data.
return array_key_exists( 'default', $field ) ? $field['default'] : null;
case 'text':
default:
return is_scalar( $raw_value ) ? sanitize_text_field( (string) $raw_value ) : '';
}
}
/**
* Hold a number to the range its own field declares.
*
* `min` and `max` used to reach the `` and stop there, so the bound
* was a hint to the browser and nothing else: a field declaring
* `max => 3650` accepted 99999 from a crafted POST, and — since the option
* is registered — from any `update_option()` call as well. Clamping here
* makes one declaration drive both the attribute and the stored value,
* which is the point of declaring it.
*
* Clamping is also *said*, not done quietly. Rewriting somebody's 99999 to
* 3650 without a word leaves them looking at a number they did not type and
* no way to know why. This is the behaviour Free's upload limit already had
* as a one-off; here it becomes what every bounded number field does.
*
* Idempotent, which matters because a registered option is sanitised twice
* on save — once by the settings screen and again inside `update_option()`.
* The second pass sees an in-range value and says nothing.
*
* @since 1.4.0
*
* @param array $field Normalised field definition.
* @param int|float $value Coerced numeric value.
*
* @return int|float
*/
private function clamp( array $field, $value ) {
$attributes = isset( $field['attributes'] ) && is_array( $field['attributes'] ) ? $field['attributes'] : [];
// Top level wins; `attributes` is where these lived before 1.4.0 and
// keeps working, so a field declaring them either way is bounded.
$min = $field['min'] ?? ( $attributes['min'] ?? null );
$max = $field['max'] ?? ( $attributes['max'] ?? null );
$clamped = $value;
if ( is_numeric( $min ) && $clamped < $min ) {
$clamped = $min + 0;
}
if ( is_numeric( $max ) && $clamped > $max ) {
$clamped = $max + 0;
}
if ( $clamped === $value ) {
return $value;
}
$this->report_clamp( $field, $value, $clamped );
// Match the type the branch above produced: `$min + 0` on a string
// attribute yields an int or float by PHP's own rules, which is what
// is wanted, but an int field must not come back as a float.
return is_float( $value ) ? (float) $clamped : (int) $clamped;
}
/**
* Tell the person saving that their value was changed, and to what.
*
* Guarded on the function rather than assumed: `add_settings_error()` lives
* in wp-admin, and a registered option's sanitiser can run from anywhere
* `update_option()` is called. There is nobody reading a settings screen in
* those contexts anyway.
*
* @since 1.4.0
*
* @param array $field Normalised field definition.
* @param int|float $value What arrived.
* @param int|float $clamped What will be stored.
*
* @return void
*/
private function report_clamp( array $field, $value, $clamped ): void {
if ( ! function_exists( 'add_settings_error' ) ) {
return;
}
$label = isset( $field['label'] ) && is_string( $field['label'] ) && $field['label'] !== ''
? $field['label']
: ( isset( $field['id'] ) && is_string( $field['id'] ) ? $field['id'] : '' );
$id = isset( $field['id'] ) && is_string( $field['id'] ) ? $field['id'] : 'field';
add_settings_error(
'albert_settings',
$id . '_clamped',
sprintf(
/* translators: 1: field label, 2: the value that was entered, 3: the value saved instead */
__( '%1$s: %2$s is outside the allowed range, so %3$s was saved instead.', 'albert-ai-butler' ),
$label,
$value,
$clamped
),
'warning'
);
}
}