messages = [ 'required' => isset($messages['required']) && $messages['required'] !== '' ? (string) $messages['required'] : __('This field is required.', 'zadex'), 'invalid' => isset($messages['invalid']) && $messages['invalid'] !== '' ? (string) $messages['invalid'] : __('Please enter a valid value.', 'zadex'), ]; } /** * @param array> $fields normalized (Fields::normalize) * @param array $values submitted `fields[...]` * @param array>> $files normalized uploads per field id * @return array{values: array>, errors: array, files: array>>} */ public function validate(array $fields, array $values, array $files = []): array { $clean = []; $errors = []; $uploads = []; foreach (Fields::inputs($fields) as $id => $field) { $raw = $values[$id] ?? null; if ($field['type'] === 'upload') { $list = $files[$id] ?? []; $error = $this->check_files($field, $list); if ($error !== '') { $errors[$id] = $error; } elseif ($list) { $uploads[$id] = $list; } $clean[$id] = []; continue; } [$value, $error] = $this->check($field, $raw); $clean[$id] = $value; if ($error !== '') { $errors[$id] = $error; } } return ['values' => $clean, 'errors' => $errors, 'files' => $uploads]; } /** * @param array $field * @param mixed $raw * @return array{0: string|array, 1: string} [clean value, error message] */ public function check(array $field, $raw): array { $type = (string) $field['type']; $required = !empty($field['required']); $options = array_map(static fn(array $o): string => (string) $o['value'], (array) ($field['options'] ?? [])); if ($type === 'checkbox') { $list = is_array($raw) ? $raw : ($raw === null || $raw === '' ? [] : [$raw]); $picked = []; foreach ($list as $v) { if (!is_scalar($v)) { return [[], $this->messages['invalid']]; } $v = (string) $v; if (!in_array($v, $options, true)) { return [[], $this->messages['invalid']]; } $picked[] = $v; } $picked = array_values(array_unique($picked)); if ($required && !$picked) { return [[], $this->messages['required']]; } return [$picked, '']; } if ($raw !== null && !is_scalar($raw)) { return ['', $this->messages['invalid']]; } $value = $raw === null ? '' : (string) $raw; if ($type === 'acceptance') { $on = in_array(strtolower(trim($value)), ['on', '1', 'yes', 'true'], true); if ($required && !$on) { return ['', $this->messages['required']]; } return [$on ? 'on' : '', '']; } if ($type === 'textarea') { $value = function_exists('sanitize_textarea_field') ? sanitize_textarea_field($value) : trim(wp_strip_all_tags($value)); } elseif ($type === 'password') { $value = trim(str_replace("\0", '', $value)); } else { $value = sanitize_text_field($value); } $value = trim($value); if ($value === '') { return ['', $required ? $this->messages['required'] : '']; } $max_len = $type === 'textarea' ? self::MAX_TEXTAREA : self::MAX_TEXT; if (mb_strlen($value) > $max_len) { return [$value, $this->messages['invalid']]; } switch ($type) { case 'email': $email = sanitize_email($value); return is_email($email) ? [$email, ''] : [$value, $this->messages['invalid']]; case 'tel': return preg_match('/^[0-9+().\s\/\-]{3,40}$/', $value) ? [$value, ''] : [$value, $this->messages['invalid']]; case 'number': if (!is_numeric($value)) { return [$value, $this->messages['invalid']]; } $n = (float) $value; if (is_numeric($field['min'] ?? '') && $n < (float) $field['min']) { return [$value, $this->messages['invalid']]; } if (is_numeric($field['max'] ?? '') && $n > (float) $field['max']) { return [$value, $this->messages['invalid']]; } return [$value, '']; case 'date': if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m) || !checkdate((int) $m[2], (int) $m[3], (int) $m[1])) { return [$value, $this->messages['invalid']]; } return $this->range($value, $field); case 'time': if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/', $value)) { return [$value, $this->messages['invalid']]; } return $this->range($value, $field); case 'select': case 'radio': return in_array($value, $options, true) ? [$value, ''] : [$value, $this->messages['invalid']]; default: return [$value, '']; } } /** * ISO dates and 24h times compare correctly as strings. * * @param array $field * @return array{0: string, 1: string} */ private function range(string $value, array $field): array { $min = (string) ($field['min'] ?? ''); $max = (string) ($field['max'] ?? ''); if (($min !== '' && strcmp($value, $min) < 0) || ($max !== '' && strcmp($value, $max) > 0)) { return [$value, $this->messages['invalid']]; } return [$value, '']; } /** * @param array $field * @param array> $files */ public function check_files(array $field, array $files): string { $files = array_values(array_filter($files, static fn($f): bool => is_array($f) && (int) ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE)); if (!$files) { return !empty($field['required']) ? $this->messages['required'] : ''; } if (count($files) > 1 && empty($field['multiple'])) { return $this->messages['invalid']; } if (count($files) > 10) { return $this->messages['invalid']; } $max = (float) ($field['max_size'] ?? 5) * 1024 * 1024; foreach ($files as $file) { if ((int) ($file['error'] ?? 0) !== UPLOAD_ERR_OK || (int) ($file['size'] ?? 0) <= 0 || (int) $file['size'] > $max) { return $this->messages['invalid']; } if (!self::accepts((string) ($file['name'] ?? ''), (string) ($file['type'] ?? ''), (string) ($field['accept'] ?? ''))) { return $this->messages['invalid']; } } return ''; } /** `accept` list check: `.pdf`, `image/*`, `application/pdf`. Empty = WordPress' allowed types decide. */ public static function accepts(string $name, string $type, string $accept): bool { $list = array_values(array_filter(array_map(static fn($s): string => strtolower(trim($s)), explode(',', $accept)))); if (!$list) { return true; } $name = strtolower($name); $type = strtolower($type); foreach ($list as $a) { if ($a[0] === '.' && str_ends_with($name, $a)) { return true; } if (str_ends_with($a, '/*') && str_starts_with($type, substr($a, 0, -1))) { return true; } if ($a === $type) { return true; } } return false; } }