PluginProbe ʕ •ᴥ•ʔ
Kirki – Freeform Page Builder, Website Builder & Customizer / 6.2.1
Kirki – Freeform Page Builder, Website Builder & Customizer v6.2.1
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 2 weeks ago CollaborationCommentService.php 1 month ago CollaborationService.php 1 week ago CollectionItemService.php 1 month ago CollectionService.php 1 week ago ContentManagerTemplateBinder.php 1 week ago ContentManagerTemplateService.php 1 week ago EditorService.php 1 month ago FontService.php 2 weeks ago FormSubmissionService.php 3 days ago GlobalDataService.php 2 weeks ago MediaService.php 1 month ago PageService.php 1 week ago PageSettingsService.php 1 month ago PostService.php 1 week ago UtilityPageService.php 1 week ago
FormSubmissionService.php
392 lines
1 <?php
2
3 namespace Kirki\App\Services;
4
5 use Kirki\Framework\Filesystem\UploadedFile;
6 use function Kirki\Framework\app;
7 use function Kirki\Framework\user;
8
9 defined('ABSPATH') || exit;
10
11 use Exception;
12 use Kirki\App\FormActions\FormActionDispatcher;
13 use Kirki\App\DTO\Form\FormConfigDTO;
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 use WP_Error;
24
25 /**
26 * Orchestrates a front-end form submission.
27 */
28 class FormSubmissionService
29 {
30 /**
31 * Request keys that are submission metadata, not form field data.
32 *
33 * @var array
34 */
35 protected const ADDITIONAL_KEYS = [
36 '_kirki_form',
37 '_wpnonce',
38 '_wp_http_referer',
39 'g-recaptcha-token',
40 'g-recaptcha-response',
41 ];
42
43 /**
44 * @var FormActionDispatcher
45 */
46 protected $actions;
47
48 public function __construct()
49 {
50 $this->actions = app(FormActionDispatcher::class); // @todo: resolve using DI once the router starts using container
51 }
52
53 /**
54 * Handle a form submission.
55 *
56 * @param array $params The full request payload.
57 * @param array UploadedFile[] the file array of our own file class
58 * @return bool Whether every configured action (email/webhook/...) succeeded.
59 *
60 * @throws ValidationException When field validation fails.
61 * @throws Exception When the form metadata/configuration is invalid, a
62 * submission limit has been reached, or the submission
63 * could not be saved.
64 */
65 public function handle(array $params, array $files = [])
66 {
67 Recaptcha::verify($params['g-recaptcha-token'] ?? null);
68
69 $form_data = $this->extract_form_data($params);
70 ['form_id' => $form_id, 'post_id' => $post_id] = $this->parse_form_metadata($params['_kirki_form'] ?? '');
71 $form_config = $this->load_form_config($form_id, $post_id);
72
73 $form_data = $this->validate_fields($form_data, $form_config->fields, $files);
74
75 $form = $this->save_form($form_id, $post_id, $form_config);
76 $session_id = Session::get_session_id(); // todo: need to fix this for user ip
77
78 $this->enforce_submission_limits($form->id, $session_id, $form_config);
79
80 $form_data = $this->process_file_uploads($form_data);
81 $this->save_submission($form->id, $form_data, $form_config, $session_id);
82
83 $actions_succeeded = $this->actions->dispatch($form_data, $form_config);
84
85 if (!$actions_succeeded) {
86 throw new Exception(esc_html__('One or more form actions failed. Please try again.', 'kirki'), (int) Response::INTERNAL_SERVER_ERROR);
87 }
88
89 ActionHooks::kirki_form_submitted($form_data, $form_config);
90
91 return true;
92 }
93
94 /**
95 * Extract only the form data from the request payload.
96 *
97 * @param array $params The full request payload.
98 * @return array The form data without additional keys.
99 */
100 protected function extract_form_data(array $params)
101 {
102 $form_data = $params;
103
104 foreach (static::ADDITIONAL_KEYS as $key) {
105 unset($form_data[$key]);
106 }
107
108 return $form_data;
109 }
110
111 /**
112 * Parse and verify the base64 form metadata token.
113 *
114 * The token is signed with `wp_hash()` at render time, so an attacker
115 * cannot mint tokens for arbitrary form/post combinations.
116 *
117 * @param mixed $form_meta_data_base64 Base64 encoded form metadata.
118 * @return array{form_id: string|null, post_id: string|null}
119 */
120 protected function parse_form_metadata($form_meta_data_base64)
121 {
122 if (!is_string($form_meta_data_base64)) {
123 return ['form_id' => null, 'post_id' => null];
124 }
125
126 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
127 $form_meta_data = explode('|', base64_decode(base64_decode($form_meta_data_base64)));
128
129 if (count($form_meta_data) < 3) {
130 return ['form_id' => null, 'post_id' => null];
131 }
132
133 $form_id = $form_meta_data[0];
134 $post_id = $form_meta_data[1];
135 $signature = $form_meta_data[2];
136
137 $expected = wp_hash($form_id . '|' . $post_id);
138
139 if (!hash_equals($expected, (string) $signature)) {
140 return ['form_id' => null, 'post_id' => null];
141 }
142
143 return [
144 'form_id' => $form_id ?: null,
145 'post_id' => $post_id ?: null,
146 ];
147 }
148
149 /**
150 * Resolve the stored form configuration for a submission.
151 *
152 * @param string|null $form_id The form element id.
153 * @param string|null $post_id The id of the post the form is rendered on.
154 * @return FormConfigDTO The form configuration.
155 *
156 * @throws Exception When the metadata or configuration is invalid.
157 */
158 protected function load_form_config($form_id, $post_id)
159 {
160 if (!isset($form_id, $post_id)) {
161 throw new Exception(esc_html__('Form data is invalid!', 'kirki'), (int) Response::BAD_REQUEST);
162 }
163
164 $form_config = Session::get($form_id);
165
166 if (!is_array($form_config)) {
167 throw new Exception(esc_html__('Form config not found', 'kirki'), (int) Response::BAD_REQUEST);
168 }
169
170 return FormConfigDTO::from_array($form_config);
171 }
172
173 /**
174 * Validate the submission against the configured fields.
175 *
176 * @param array $form_data The submission data.
177 * @param array $fields The field configuration.
178 * @param UploadedFile[] $files The file array of our own file class.
179 * @return array The validated form data.
180 *
181 * @throws ValidationException When validation fails.
182 */
183 protected function validate_fields(array $form_data, array $fields, array $files = [])
184 {
185 // phpcs:ignore WordPress.Security.NonceVerification.Missing
186 $data = FormFieldRulesBuilder::data_for_validation($form_data, $fields, $files);
187
188 Validator::make($data, FormFieldRulesBuilder::rules($fields))->validate(); //@todo: not all data are coming
189
190 return $data;
191 }
192
193 /**
194 * Find or create the stored form record, keeping its name in sync.
195 *
196 * @param string $form_id The form element id.
197 * @param string $post_id The id of the post the form is rendered on.
198 * @param FormConfigDTO $form_config The form configuration.
199 * @return Form
200 */
201 protected function save_form($form_id, $post_id, FormConfigDTO $form_config)
202 {
203 $form_name = $form_config->name;
204
205 $saved = Form::update_or_create([
206 'post_id' => (int) $post_id,
207 'form_ele_id' => $form_id,
208 ], [
209 'name' => $form_name,
210 ]);
211
212 return $saved;
213 }
214
215 /**
216 * Reject the submission if it hits a configured entry or response limit.
217 *
218 * @param int $form_id The stored form id.
219 * @param string $session_id The Kirki session id (cookie-identified, see Session::get_session_id()).
220 * @param FormConfigDTO $form_config The form configuration.
221 * @return void
222 *
223 * @throws Exception When a limit has been reached.
224 */
225 protected function enforce_submission_limits($form_id, $session_id, FormConfigDTO $form_config)
226 {
227 $max_entry = $form_config->maxEntry;
228 $entry_limit = !empty($max_entry['restricted']) ? (int) $max_entry['value'] : null;
229
230 if ($this->entry_limit_reached($form_id, $session_id, $entry_limit)) {
231 throw new Exception(esc_html__('You have reached the maximum number of submissions allowed.', 'kirki'), (int) Response::TOO_MANY_REQUESTS);
232 }
233
234 $response_limit = $form_config->responseLimit;
235 $response_limit = !empty($response_limit['restricted']) ? (int) $response_limit['value'] : null;
236
237 if ($this->response_limit_reached($form_id, $response_limit)) {
238 throw new Exception(esc_html__('This form is no longer accepting submissions.', 'kirki'), (int) Response::TOO_MANY_REQUESTS);
239 }
240 }
241
242 /**
243 * Whether the current session has reached the per-session entry limit.
244 *
245 * @param int $form_id The stored form id.
246 * @param string $session_id The Kirki session id (cookie-identified, see Session::get_session_id()).
247 * @param int|null $limit The entry limit.
248 * @return bool
249 */
250 protected function entry_limit_reached($form_id, $session_id, $limit)
251 {
252 if ($limit === null) {
253 return false;
254 }
255
256 $count = FormData::where('form_id', $form_id)
257 ->where('session_id', $session_id)
258 ->distinct()
259 ->count('timestamp');
260
261 return $count >= intval($limit);
262 }
263
264 /**
265 * Whether the form has reached its total response limit.
266 *
267 * @param int $form_id The stored form id.
268 * @param int|null $limit The response limit.
269 * @return bool
270 */
271 protected function response_limit_reached($form_id, $limit)
272 {
273 if ($limit === null) {
274 return false;
275 }
276
277 $count = FormData::where('form_id', $form_id)
278 ->distinct()
279 ->count('timestamp');
280
281 return $count >= intval($limit);
282 }
283
284 /**
285 * Process file uploads from the submission.
286 *
287 * By the time this runs, validate_fields() has already rejected any file
288 * with an invalid type or size, so this only has to persist the upload.
289 *
290 * @param array $form_data Form data array.
291 * @return array Modified form data with attachment IDs.
292 *
293 * @throws Exception When file upload fails.
294 */
295 protected function process_file_uploads($form_data)
296 {
297 foreach ($form_data as $name => $data) {
298 if (!$data instanceof UploadedFile) {
299 continue;
300 }
301
302 $result = $this->upload_file_to_media($name);
303
304 if (is_wp_error($result)) {
305 throw new Exception($result->get_error_message(), Response::INTERNAL_SERVER_ERROR);
306 }
307
308 $form_data[$name] = $result;
309 }
310
311 return $form_data;
312 }
313
314 /**
315 * Upload a file to the WordPress media library.
316 *
317 * @param string $name File input name.
318 * @return int|WP_Error Attachment ID or error.
319 */
320 protected function upload_file_to_media($name)
321 {
322 require_once ABSPATH . 'wp-admin/includes/image.php';
323 require_once ABSPATH . 'wp-admin/includes/file.php';
324 require_once ABSPATH . 'wp-admin/includes/media.php';
325
326 return media_handle_upload($name, 0);
327 }
328
329 /**
330 * Persist the submission, honouring the form's saveData preference.
331 *
332 * @param int $form_id Stored form id.
333 * @param array $form_data Form data.
334 * @param FormConfigDTO $form_config Form configuration.
335 * @param string $session_id Kirki session id.
336 * @return void
337 *
338 * @throws Exception When saveData is enabled but the submission could not be stored.
339 */
340 protected function save_submission($form_id, $form_data, FormConfigDTO $form_config, $session_id)
341 {
342 if (!$form_config->saveData) {
343 return;
344 }
345
346 if (!$this->insert_form_data($form_data, $form_id, $form_config->fields, $session_id)) {
347 throw new Exception(esc_html__('Failed to save your submission. Please try again.', 'kirki'), (int) Response::INTERNAL_SERVER_ERROR);
348 }
349 }
350
351 /**
352 * Insert submission data — one row per field, grouped by timestamp + session.
353 *
354 * @param array $form_data Form data.
355 * @param int $form_id Stored form id.
356 * @param array $form_data_types Field configuration (for input_type).
357 * @param string $session_id Kirki session id.
358 * @return bool
359 */
360 protected function insert_form_data($form_data, $form_id, $form_data_types, $session_id)
361 {
362 if (empty($form_data)) {
363 return false;
364 }
365
366 $timestamp = time();
367 $user_id = user()->get_id();
368
369 $rows = [];
370
371 foreach ($form_data as $name => $value) {
372 $type = isset($form_data_types[$name]['type']) ? $form_data_types[$name]['type'] : 'text';
373
374 if (is_array($value)) {
375 $value = maybe_serialize($value);
376 }
377
378 $rows[] = [
379 'form_id' => $form_id,
380 'user_id' => $user_id,
381 'session_id' => $session_id,
382 'timestamp' => $timestamp,
383 'input_key' => (string) $name,
384 'input_value' => $value,
385 'input_type' => (string) $type,
386 ];
387 }
388
389 return FormData::insert($rows);
390 }
391 }
392