PluginProbe ʕ •ᴥ•ʔ
Kirki – Freeform Page Builder, Website Builder & Customizer / 6.2.5
Kirki – Freeform Page Builder, Website Builder & Customizer v6.2.5
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 2 months ago CollaborationService.php 1 month ago CollectionItemService.php 3 weeks ago CollectionService.php 1 month ago ContentManagerTemplateBinder.php 1 month ago ContentManagerTemplateService.php 1 month ago EditorService.php 2 months ago FontService.php 2 weeks ago FormSubmissionService.php 1 week ago GlobalDataService.php 1 month ago MediaService.php 2 months ago PageService.php 1 month ago PageSettingsService.php 2 months ago PostService.php 1 month ago UtilityPageService.php 1 month ago
FormSubmissionService.php
352 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 $session_id = Session::get_session_id(); // todo: need to fix this for user ip
66
67 $this->enforce_submission_limits($form->id, $session_id, $form_config);
68
69 $this->save_submission($form->id, $form_data, $form_config, $session_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 * Reject the submission if it hits a configured entry or response limit.
220 *
221 * @param int $form_id The stored form id.
222 * @param string $session_id The Kirki session id (cookie-identified, see Session::get_session_id()).
223 * @param FormConfigDTO $form_config The form configuration.
224 * @return void
225 *
226 * @throws Exception When a limit has been reached.
227 */
228 protected function enforce_submission_limits($form_id, $session_id, FormConfigDTO $form_config)
229 {
230 $max_entry = $form_config->maxEntry;
231 $entry_limit = !empty($max_entry['restricted']) ? (int) $max_entry['value'] : null;
232
233 if ($this->entry_limit_reached($form_id, $session_id, $entry_limit)) {
234 throw new Exception(esc_html__('You have reached the maximum number of submissions allowed.', 'kirki'), (int) Response::TOO_MANY_REQUESTS);
235 }
236
237 $response_limit = $form_config->responseLimit;
238 $response_limit = !empty($response_limit['restricted']) ? (int) $response_limit['value'] : null;
239
240 if ($this->response_limit_reached($form_id, $response_limit)) {
241 throw new Exception(esc_html__('This form is no longer accepting submissions.', 'kirki'), (int) Response::TOO_MANY_REQUESTS);
242 }
243 }
244
245 /**
246 * Whether the current session has reached the per-session entry limit.
247 *
248 * @param int $form_id The stored form id.
249 * @param string $session_id The Kirki session id (cookie-identified, see Session::get_session_id()).
250 * @param int|null $limit The entry limit.
251 * @return bool
252 */
253 protected function entry_limit_reached($form_id, $session_id, $limit)
254 {
255 if ($limit === null) {
256 return false;
257 }
258
259 $count = FormData::where('form_id', $form_id)
260 ->where('session_id', $session_id)
261 ->distinct()
262 ->count('timestamp');
263
264 return $count >= intval($limit);
265 }
266
267 /**
268 * Whether the form has reached its total response limit.
269 *
270 * @param int $form_id The stored form id.
271 * @param int|null $limit The response limit.
272 * @return bool
273 */
274 protected function response_limit_reached($form_id, $limit)
275 {
276 if ($limit === null) {
277 return false;
278 }
279
280 $count = FormData::where('form_id', $form_id)
281 ->distinct()
282 ->count('timestamp');
283
284 return $count >= intval($limit);
285 }
286
287
288
289 /**
290 * Persist the submission, honouring the form's saveData preference.
291 *
292 * @param int $form_id Stored form id.
293 * @param array $form_data Form data.
294 * @param FormConfigDTO $form_config Form configuration.
295 * @param string $session_id Kirki session id.
296 * @return void
297 *
298 * @throws Exception When saveData is enabled but the submission could not be stored.
299 */
300 protected function save_submission($form_id, $form_data, FormConfigDTO $form_config, $session_id)
301 {
302 if (!$form_config->saveData) {
303 return;
304 }
305
306 if (!$this->insert_form_data($form_data, $form_id, $form_config->fields, $session_id)) {
307 throw new Exception(esc_html__('Failed to save your submission. Please try again.', 'kirki'), (int) Response::INTERNAL_SERVER_ERROR);
308 }
309 }
310
311 /**
312 * Insert submission data — one row per field, grouped by timestamp + session.
313 *
314 * @param array $form_data Form data.
315 * @param int $form_id Stored form id.
316 * @param array $form_data_types Field configuration (for input_type).
317 * @param string $session_id Kirki session id.
318 * @return bool
319 */
320 protected function insert_form_data($form_data, $form_id, $form_data_types, $session_id)
321 {
322 if (empty($form_data)) {
323 return false;
324 }
325
326 $timestamp = time();
327 $user_id = user()->get_id();
328
329 $rows = [];
330
331 foreach ($form_data as $name => $value) {
332 $type = isset($form_data_types[$name]['type']) ? $form_data_types[$name]['type'] : 'text';
333
334 if (is_array($value)) {
335 $value = maybe_serialize($value);
336 }
337
338 $rows[] = [
339 'form_id' => $form_id,
340 'user_id' => $user_id,
341 'session_id' => $session_id,
342 'timestamp' => $timestamp,
343 'input_key' => (string) $name,
344 'input_value' => $value,
345 'input_type' => (string) $type,
346 ];
347 }
348
349 return FormData::insert($rows);
350 }
351 }
352