PluginProbe ʕ •ᴥ•ʔ
Kirki – Freeform Page Builder, Website Builder & Customizer / 6.2.0
Kirki – Freeform Page Builder, Website Builder & Customizer v6.2.0
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 1 week 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
374 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 decode the base64 form metadata token.
113 *
114 * @param mixed $form_meta_data_base64 Base64 encoded form metadata.
115 * @return array{form_id: string|null, post_id: string|null}
116 */
117 protected function parse_form_metadata($form_meta_data_base64)
118 {
119 if (!is_string($form_meta_data_base64)) {
120 return ['form_id' => null, 'post_id' => null];
121 }
122
123 $form_meta_data = explode('|', base64_decode(base64_decode($form_meta_data_base64)));
124
125 return [
126 'form_id' => $form_meta_data[0] ?: null,
127 'post_id' => $form_meta_data[1] ?: null,
128 ];
129 }
130
131 /**
132 * Resolve the stored form configuration for a submission.
133 *
134 * @param string|null $form_id The form element id.
135 * @param string|null $post_id The id of the post the form is rendered on.
136 * @return FormConfigDTO The form configuration.
137 *
138 * @throws Exception When the metadata or configuration is invalid.
139 */
140 protected function load_form_config($form_id, $post_id)
141 {
142 if (!isset($form_id, $post_id)) {
143 throw new Exception(esc_html__('Form data is invalid!', 'kirki'), (int) Response::BAD_REQUEST);
144 }
145
146 $form_config = Session::get($form_id);
147
148 if (!is_array($form_config)) {
149 throw new Exception(esc_html__('Form config not found', 'kirki'), (int) Response::BAD_REQUEST);
150 }
151
152 return FormConfigDTO::from_array($form_config);
153 }
154
155 /**
156 * Validate the submission against the configured fields.
157 *
158 * @param array $form_data The submission data.
159 * @param array $fields The field configuration.
160 * @param UploadedFile[] $files The file array of our own file class.
161 * @return array The validated form data.
162 *
163 * @throws ValidationException When validation fails.
164 */
165 protected function validate_fields(array $form_data, array $fields, array $files = [])
166 {
167 // phpcs:ignore WordPress.Security.NonceVerification.Missing
168 $data = FormFieldRulesBuilder::data_for_validation($form_data, $fields, $files);
169
170 Validator::make($data, FormFieldRulesBuilder::rules($fields))->validate(); //@todo: not all data are coming
171
172 return $data;
173 }
174
175 /**
176 * Find or create the stored form record, keeping its name in sync.
177 *
178 * @param string $form_id The form element id.
179 * @param string $post_id The id of the post the form is rendered on.
180 * @param FormConfigDTO $form_config The form configuration.
181 * @return Form
182 */
183 protected function save_form($form_id, $post_id, FormConfigDTO $form_config)
184 {
185 $form_name = $form_config->name;
186
187 $saved = Form::update_or_create([
188 'post_id' => (int) $post_id,
189 'form_ele_id' => $form_id,
190 ], [
191 'name' => $form_name,
192 ]);
193
194 return $saved;
195 }
196
197 /**
198 * Reject the submission if it hits a configured entry or response limit.
199 *
200 * @param int $form_id The stored form id.
201 * @param string $session_id The Kirki session id (cookie-identified, see Session::get_session_id()).
202 * @param FormConfigDTO $form_config The form configuration.
203 * @return void
204 *
205 * @throws Exception When a limit has been reached.
206 */
207 protected function enforce_submission_limits($form_id, $session_id, FormConfigDTO $form_config)
208 {
209 $max_entry = $form_config->maxEntry;
210 $entry_limit = !empty($max_entry['restricted']) ? (int) $max_entry['value'] : null;
211
212 if ($this->entry_limit_reached($form_id, $session_id, $entry_limit)) {
213 throw new Exception(esc_html__('You have reached the maximum number of submissions allowed.', 'kirki'), (int) Response::TOO_MANY_REQUESTS);
214 }
215
216 $response_limit = $form_config->responseLimit;
217 $response_limit = !empty($response_limit['restricted']) ? (int) $response_limit['value'] : null;
218
219 if ($this->response_limit_reached($form_id, $response_limit)) {
220 throw new Exception(esc_html__('This form is no longer accepting submissions.', 'kirki'), (int) Response::TOO_MANY_REQUESTS);
221 }
222 }
223
224 /**
225 * Whether the current session has reached the per-session entry limit.
226 *
227 * @param int $form_id The stored form id.
228 * @param string $session_id The Kirki session id (cookie-identified, see Session::get_session_id()).
229 * @param int|null $limit The entry limit.
230 * @return bool
231 */
232 protected function entry_limit_reached($form_id, $session_id, $limit)
233 {
234 if ($limit === null) {
235 return false;
236 }
237
238 $count = FormData::where('form_id', $form_id)
239 ->where('session_id', $session_id)
240 ->distinct()
241 ->count('timestamp');
242
243 return $count >= intval($limit);
244 }
245
246 /**
247 * Whether the form has reached its total response limit.
248 *
249 * @param int $form_id The stored form id.
250 * @param int|null $limit The response limit.
251 * @return bool
252 */
253 protected function response_limit_reached($form_id, $limit)
254 {
255 if ($limit === null) {
256 return false;
257 }
258
259 $count = FormData::where('form_id', $form_id)
260 ->distinct()
261 ->count('timestamp');
262
263 return $count >= intval($limit);
264 }
265
266 /**
267 * Process file uploads from the submission.
268 *
269 * By the time this runs, validate_fields() has already rejected any file
270 * with an invalid type or size, so this only has to persist the upload.
271 *
272 * @param array $form_data Form data array.
273 * @return array Modified form data with attachment IDs.
274 *
275 * @throws Exception When file upload fails.
276 */
277 protected function process_file_uploads($form_data)
278 {
279 foreach ($form_data as $name => $data) {
280 if (!$data instanceof UploadedFile) {
281 continue;
282 }
283
284 $result = $this->upload_file_to_media($name);
285
286 if (is_wp_error($result)) {
287 throw new Exception($result->get_error_message(), Response::INTERNAL_SERVER_ERROR);
288 }
289
290 $form_data[$name] = $result;
291 }
292
293 return $form_data;
294 }
295
296 /**
297 * Upload a file to the WordPress media library.
298 *
299 * @param string $name File input name.
300 * @return int|WP_Error Attachment ID or error.
301 */
302 protected function upload_file_to_media($name)
303 {
304 require_once ABSPATH . 'wp-admin/includes/image.php';
305 require_once ABSPATH . 'wp-admin/includes/file.php';
306 require_once ABSPATH . 'wp-admin/includes/media.php';
307
308 return media_handle_upload($name, 0);
309 }
310
311 /**
312 * Persist the submission, honouring the form's saveData preference.
313 *
314 * @param int $form_id Stored form id.
315 * @param array $form_data Form data.
316 * @param FormConfigDTO $form_config Form configuration.
317 * @param string $session_id Kirki session id.
318 * @return void
319 *
320 * @throws Exception When saveData is enabled but the submission could not be stored.
321 */
322 protected function save_submission($form_id, $form_data, FormConfigDTO $form_config, $session_id)
323 {
324 if (!$form_config->saveData) {
325 return;
326 }
327
328 if (!$this->insert_form_data($form_data, $form_id, $form_config->fields, $session_id)) {
329 throw new Exception(esc_html__('Failed to save your submission. Please try again.', 'kirki'), (int) Response::INTERNAL_SERVER_ERROR);
330 }
331 }
332
333 /**
334 * Insert submission data — one row per field, grouped by timestamp + session.
335 *
336 * @param array $form_data Form data.
337 * @param int $form_id Stored form id.
338 * @param array $form_data_types Field configuration (for input_type).
339 * @param string $session_id Kirki session id.
340 * @return bool
341 */
342 protected function insert_form_data($form_data, $form_id, $form_data_types, $session_id)
343 {
344 if (empty($form_data)) {
345 return false;
346 }
347
348 $timestamp = time();
349 $user_id = user()->get_id();
350
351 $rows = [];
352
353 foreach ($form_data as $name => $value) {
354 $type = isset($form_data_types[$name]['type']) ? $form_data_types[$name]['type'] : 'text';
355
356 if (is_array($value)) {
357 $value = maybe_serialize($value);
358 }
359
360 $rows[] = [
361 'form_id' => $form_id,
362 'user_id' => $user_id,
363 'session_id' => $session_id,
364 'timestamp' => $timestamp,
365 'input_key' => (string) $name,
366 'input_value' => $value,
367 'input_type' => (string) $type,
368 ];
369 }
370
371 return FormData::insert($rows);
372 }
373 }
374