PluginProbe ʕ •ᴥ•ʔ
Kirki – Freeform Page Builder, Website Builder & Customizer / trunk
Kirki – Freeform Page Builder, Website Builder & Customizer vtrunk
6.3.0 6.2.5 6.2.4 6.2.3 6.2.2 6.2.1 6.2.0 6.1.1 6.1.0 6.0.14 6.0.13 6.0.12 6.0.11 6.0.10 6.0.9 6.0.8 6.0.7 6.0.6 6.0.5 6.0.4 6.0.3 6.0.2 6.0.1 3.1.3 3.1.4 3.1.5 3.1.6 3.1.7 3.1.8 3.1.9 4.0.19 4.0.20 4.0.21 4.0.22 4.0.23 4.0.24 4.1 4.2.0 5.0.0 5.1.0 5.1.1 5.2.0 5.2.1 5.2.2 5.2.3 6.0.0 trunk 3.0.40 3.0.41 3.0.42 3.0.43 3.0.44 3.0.45 3.1.0 3.1.1 3.1.2
kirki / app / Services / FormSubmissionService.php
kirki / app / Services Last commit date
AppsService.php 1 month ago CollaborationCommentService.php 8 hours ago CollaborationService.php 1 month ago CollectionItemService.php 8 hours ago CollectionService.php 1 month ago ContentManagerTemplateBinder.php 1 month ago ContentManagerTemplateService.php 1 month ago EditorService.php 1 month ago FontService.php 2 weeks ago FormSubmissionService.php 8 hours ago GlobalDataService.php 1 month ago MediaService.php 1 month ago PageService.php 1 month ago PageSettingsService.php 1 month ago PostService.php 1 month ago UtilityPageService.php 1 month ago
FormSubmissionService.php
378 lines
1 <?php
2
3 namespace Kirki\App\Services;
4
5 use function Kirki\Framework\app;
6 use function Kirki\Framework\user;
7
8 defined('ABSPATH') || exit;
9
10 use Exception;
11 use Kirki\App\Constants\Form\FormFieldTypes;
12 use Kirki\App\DTO\Form\FormConfigDTO;
13 use Kirki\App\FormActions\FormActionDispatcher;
14 use Kirki\App\Models\Form;
15 use Kirki\App\Models\FormData;
16 use Kirki\App\Supports\ActionHooks;
17 use Kirki\App\Supports\Form\FormFieldRulesBuilder;
18 use Kirki\App\Supports\Recaptcha;
19 use Kirki\App\Supports\Session;
20 use Kirki\Framework\Exceptions\ValidationException;
21 use Kirki\Framework\Http\Response;
22 use Kirki\Framework\Validation\Validator;
23
24 /**
25 * Orchestrates a front-end form submission.
26 */
27 class FormSubmissionService
28 {
29 /**
30 * @var FormActionDispatcher
31 */
32 protected $actions;
33
34 public function __construct()
35 {
36 $this->actions = app(FormActionDispatcher::class); // @todo: resolve using DI once the router starts using container
37 }
38
39 /**
40 * Handle a form submission.
41 *
42 * @param array $params The full request payload.
43 * @return bool Whether every configured action (email/webhook/...) succeeded.
44 *
45 * @throws ValidationException When field validation fails.
46 * @throws Exception When the form metadata/configuration is invalid, a
47 * submission limit has been reached, or the submission
48 * could not be saved.
49 */
50 public function handle(array $params)
51 {
52
53 ['form_id' => $form_id, 'post_id' => $post_id] = $this->parse_form_metadata($params['_kirki_form'] ?? '');
54
55 Recaptcha::verify($params, $form_id);
56
57 $form_config = $this->load_form_config($form_id, $post_id);
58
59 $form_data = $this->extract_form_data($params, $form_config->fields);
60
61
62 $form_data = $this->validate_fields($form_data, $form_config->fields);
63
64 $form = $this->save_form($form_id, $post_id, $form_config);
65 $submitter_id = $this->resolve_submitter_id();
66
67 $this->enforce_submission_limits($form->id, $submitter_id, $form_config);
68
69 $this->save_submission($form->id, $form_data, $form_config, $submitter_id);
70
71 $actions_succeeded = $this->actions->dispatch($form_data, $form_config);
72
73 if (!$actions_succeeded) {
74 throw new Exception(esc_html__('One or more form actions failed. Please try again.', 'kirki'), (int) Response::INTERNAL_SERVER_ERROR);
75 }
76
77 ActionHooks::kirki_form_submitted($form_data, $form_config);
78
79 return true;
80 }
81
82 /**
83 * Extract only the valid form field data from the request payload.
84 *
85 * Filters out internal submission metadata keys, ignores file-type fields
86 * (since file uploads are no longer supported), and strips any unconfigured keys.
87 *
88 * @param array $params The full request payload.
89 * @param array $fields Form field configuration.
90 * @return array The cleaned form data.
91 */
92 protected function extract_form_data(array $params, array $fields = [])
93 {
94 $raw_data = $params;
95
96 if (empty($fields)) {
97 return $raw_data;
98 }
99
100 $form_data = [];
101
102 foreach ($fields as $name => $field) {
103 if (($field['type'] ?? null) === FormFieldTypes::FILE) {
104 continue;
105 }
106
107 if (array_key_exists($name, $raw_data)) {
108 $form_data[$name] = $raw_data[$name];
109 }
110 }
111
112 return $form_data;
113 }
114
115 /**
116 * Parse and verify the base64 form metadata token.
117 *
118 * The token is signed with `wp_hash()` at render time, so an attacker
119 * cannot mint tokens for arbitrary form/post combinations.
120 *
121 * @param mixed $form_meta_data_base64 Base64 encoded form metadata.
122 * @return array{form_id: string|null, post_id: string|null}
123 */
124 protected function parse_form_metadata($form_meta_data_base64)
125 {
126 if (!is_string($form_meta_data_base64)) {
127 return ['form_id' => null, 'post_id' => null];
128 }
129
130 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
131 $form_meta_data = explode('|', base64_decode(base64_decode($form_meta_data_base64)));
132
133 if (count($form_meta_data) < 3) {
134 return ['form_id' => null, 'post_id' => null];
135 }
136
137 $form_id = $form_meta_data[0];
138 $post_id = $form_meta_data[1];
139 $signature = $form_meta_data[2];
140
141 $expected = wp_hash($form_id . '|' . $post_id);
142
143 if (!hash_equals($expected, (string) $signature)) {
144 return ['form_id' => null, 'post_id' => null];
145 }
146
147 return [
148 'form_id' => $form_id ?: null,
149 'post_id' => $post_id ?: null,
150 ];
151 }
152
153 /**
154 * Resolve the stored form configuration for a submission.
155 *
156 * @param string|null $form_id The form element id.
157 * @param string|null $post_id The id of the post the form is rendered on.
158 * @return FormConfigDTO The form configuration.
159 *
160 * @throws Exception When the metadata or configuration is invalid.
161 */
162 protected function load_form_config($form_id, $post_id)
163 {
164 if (!isset($form_id, $post_id)) {
165 throw new Exception(esc_html__('Form data is invalid!', 'kirki'), (int) Response::BAD_REQUEST);
166 }
167
168 $form_config = Session::get($form_id);
169
170 if (!is_array($form_config)) {
171 throw new Exception(esc_html__('Form config not found', 'kirki'), (int) Response::BAD_REQUEST);
172 }
173
174 return FormConfigDTO::from_array($form_config);
175 }
176
177 /**
178 * Validate the submission against the configured fields.
179 *
180 * @param array $form_data The submission data.
181 * @param array $fields The field configuration.
182 * @return array The validated form data.
183 *
184 * @throws ValidationException When validation fails.
185 */
186 protected function validate_fields(array $form_data, array $fields)
187 {
188 // phpcs:ignore WordPress.Security.NonceVerification.Missing
189 $data = FormFieldRulesBuilder::data_for_validation($form_data, $fields);
190
191 Validator::make($data, FormFieldRulesBuilder::rules($fields))->validate(); //@todo: not all data are coming
192
193 return $data;
194 }
195
196 /**
197 * Find or create the stored form record, keeping its name in sync.
198 *
199 * @param string $form_id The form element id.
200 * @param string $post_id The id of the post the form is rendered on.
201 * @param FormConfigDTO $form_config The form configuration.
202 * @return Form
203 */
204 protected function save_form($form_id, $post_id, FormConfigDTO $form_config)
205 {
206 $form_name = $form_config->name;
207
208 $saved = Form::update_or_create([
209 'post_id' => (int) $post_id,
210 'form_ele_id' => $form_id,
211 ], [
212 'name' => $form_name,
213 ]);
214
215 return $saved;
216 }
217
218 /**
219 * Resolve a stable identifier for the submitter.
220 *
221 * Used both for the per-submitter entry limit and stored alongside the
222 * submission. Derived from the network peer address (`REMOTE_ADDR`) rather
223 * than the client-supplied `kirki_session_id` cookie, which a submitter can
224 * rotate on every request to reset their entry count. Proxy headers
225 * (`X-Forwarded-For` et al.) are deliberately not trusted here since they are
226 * attacker-controlled in the absence of a vetted reverse proxy. Falls back to
227 * the cookie session id only when no peer address is available (e.g. CLI).
228 *
229 * @return string
230 */
231 protected function resolve_submitter_id()
232 {
233 $ip = isset($_SERVER['REMOTE_ADDR'])
234 ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']))
235 : '';
236
237 if ($ip === '') {
238 return Session::get_session_id();
239 }
240
241 return 'ip_' . hash('sha256', $ip);
242 }
243
244 /**
245 * Reject the submission if it hits a configured entry or response limit.
246 *
247 * @param int $form_id The stored form id.
248 * @param string $session_id The submitter identifier (see resolve_submitter_id()).
249 * @param FormConfigDTO $form_config The form configuration.
250 * @return void
251 *
252 * @throws Exception When a limit has been reached.
253 */
254 protected function enforce_submission_limits($form_id, $session_id, FormConfigDTO $form_config)
255 {
256 $max_entry = $form_config->maxEntry;
257 $entry_limit = !empty($max_entry['restricted']) ? (int) $max_entry['value'] : null;
258
259 if ($this->entry_limit_reached($form_id, $session_id, $entry_limit)) {
260 throw new Exception(esc_html__('You have reached the maximum number of submissions allowed.', 'kirki'), (int) Response::TOO_MANY_REQUESTS);
261 }
262
263 $response_limit = $form_config->responseLimit;
264 $response_limit = !empty($response_limit['restricted']) ? (int) $response_limit['value'] : null;
265
266 if ($this->response_limit_reached($form_id, $response_limit)) {
267 throw new Exception(esc_html__('This form is no longer accepting submissions.', 'kirki'), (int) Response::TOO_MANY_REQUESTS);
268 }
269 }
270
271 /**
272 * Whether the current session has reached the per-session entry limit.
273 *
274 * @param int $form_id The stored form id.
275 * @param string $session_id The submitter identifier (see resolve_submitter_id()).
276 * @param int|null $limit The entry limit.
277 * @return bool
278 */
279 protected function entry_limit_reached($form_id, $session_id, $limit)
280 {
281 if ($limit === null) {
282 return false;
283 }
284
285 $count = FormData::where('form_id', $form_id)
286 ->where('session_id', $session_id)
287 ->distinct()
288 ->count('timestamp');
289
290 return $count >= intval($limit);
291 }
292
293 /**
294 * Whether the form has reached its total response limit.
295 *
296 * @param int $form_id The stored form id.
297 * @param int|null $limit The response limit.
298 * @return bool
299 */
300 protected function response_limit_reached($form_id, $limit)
301 {
302 if ($limit === null) {
303 return false;
304 }
305
306 $count = FormData::where('form_id', $form_id)
307 ->distinct()
308 ->count('timestamp');
309
310 return $count >= intval($limit);
311 }
312
313
314
315 /**
316 * Persist the submission, honouring the form's saveData preference.
317 *
318 * @param int $form_id Stored form id.
319 * @param array $form_data Form data.
320 * @param FormConfigDTO $form_config Form configuration.
321 * @param string $session_id Kirki session id.
322 * @return void
323 *
324 * @throws Exception When saveData is enabled but the submission could not be stored.
325 */
326 protected function save_submission($form_id, $form_data, FormConfigDTO $form_config, $session_id)
327 {
328 if (!$form_config->saveData) {
329 return;
330 }
331
332 if (!$this->insert_form_data($form_data, $form_id, $form_config->fields, $session_id)) {
333 throw new Exception(esc_html__('Failed to save your submission. Please try again.', 'kirki'), (int) Response::INTERNAL_SERVER_ERROR);
334 }
335 }
336
337 /**
338 * Insert submission data — one row per field, grouped by timestamp + session.
339 *
340 * @param array $form_data Form data.
341 * @param int $form_id Stored form id.
342 * @param array $form_data_types Field configuration (for input_type).
343 * @param string $session_id Kirki session id.
344 * @return bool
345 */
346 protected function insert_form_data($form_data, $form_id, $form_data_types, $session_id)
347 {
348 if (empty($form_data)) {
349 return false;
350 }
351
352 $timestamp = time();
353 $user_id = user()->get_id();
354
355 $rows = [];
356
357 foreach ($form_data as $name => $value) {
358 $type = isset($form_data_types[$name]['type']) ? $form_data_types[$name]['type'] : 'text';
359
360 if (is_array($value)) {
361 $value = maybe_serialize($value);
362 }
363
364 $rows[] = [
365 'form_id' => $form_id,
366 'user_id' => $user_id,
367 'session_id' => $session_id,
368 'timestamp' => $timestamp,
369 'input_key' => (string) $name,
370 'input_value' => $value,
371 'input_type' => (string) $type,
372 ];
373 }
374
375 return FormData::insert($rows);
376 }
377 }
378