PluginProbe ʕ •ᴥ•ʔ
Kirki – Freeform Page Builder, Website Builder & Customizer / 6.2.2
Kirki – Freeform Page Builder, Website Builder & Customizer v6.2.2
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 3 weeks ago CollaborationCommentService.php 1 month ago CollaborationService.php 2 weeks ago CollectionItemService.php 4 days ago CollectionService.php 2 weeks ago ContentManagerTemplateBinder.php 2 weeks ago ContentManagerTemplateService.php 2 weeks ago EditorService.php 1 month ago FontService.php 3 weeks ago FormSubmissionService.php 4 days ago GlobalDataService.php 3 weeks ago MediaService.php 1 month ago PageService.php 2 weeks ago PageSettingsService.php 1 month ago PostService.php 2 weeks ago UtilityPageService.php 2 weeks ago
FormSubmissionService.php
349 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 Recaptcha::verify($params['g-recaptcha-token'] ?? null);
53
54 ['form_id' => $form_id, 'post_id' => $post_id] = $this->parse_form_metadata($params['_kirki_form'] ?? '');
55 $form_config = $this->load_form_config($form_id, $post_id);
56
57 $form_data = $this->extract_form_data($params, $form_config->fields);
58
59 $form_data = $this->validate_fields($form_data, $form_config->fields);
60
61 $form = $this->save_form($form_id, $post_id, $form_config);
62 $session_id = Session::get_session_id(); // todo: need to fix this for user ip
63
64 $this->enforce_submission_limits($form->id, $session_id, $form_config);
65
66 $this->save_submission($form->id, $form_data, $form_config, $session_id);
67
68 $actions_succeeded = $this->actions->dispatch($form_data, $form_config);
69
70 if (!$actions_succeeded) {
71 throw new Exception(esc_html__('One or more form actions failed. Please try again.', 'kirki'), (int) Response::INTERNAL_SERVER_ERROR);
72 }
73
74 ActionHooks::kirki_form_submitted($form_data, $form_config);
75
76 return true;
77 }
78
79 /**
80 * Extract only the valid form field data from the request payload.
81 *
82 * Filters out internal submission metadata keys, ignores file-type fields
83 * (since file uploads are no longer supported), and strips any unconfigured keys.
84 *
85 * @param array $params The full request payload.
86 * @param array $fields Form field configuration.
87 * @return array The cleaned form data.
88 */
89 protected function extract_form_data(array $params, array $fields = [])
90 {
91 $raw_data = $params;
92
93 if (empty($fields)) {
94 return $raw_data;
95 }
96
97 $form_data = [];
98
99 foreach ($fields as $name => $field) {
100 if (($field['type'] ?? null) === FormFieldTypes::FILE) {
101 continue;
102 }
103
104 if (array_key_exists($name, $raw_data)) {
105 $form_data[$name] = $raw_data[$name];
106 }
107 }
108
109 return $form_data;
110 }
111
112 /**
113 * Parse and verify the base64 form metadata token.
114 *
115 * The token is signed with `wp_hash()` at render time, so an attacker
116 * cannot mint tokens for arbitrary form/post combinations.
117 *
118 * @param mixed $form_meta_data_base64 Base64 encoded form metadata.
119 * @return array{form_id: string|null, post_id: string|null}
120 */
121 protected function parse_form_metadata($form_meta_data_base64)
122 {
123 if (!is_string($form_meta_data_base64)) {
124 return ['form_id' => null, 'post_id' => null];
125 }
126
127 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
128 $form_meta_data = explode('|', base64_decode(base64_decode($form_meta_data_base64)));
129
130 if (count($form_meta_data) < 3) {
131 return ['form_id' => null, 'post_id' => null];
132 }
133
134 $form_id = $form_meta_data[0];
135 $post_id = $form_meta_data[1];
136 $signature = $form_meta_data[2];
137
138 $expected = wp_hash($form_id . '|' . $post_id);
139
140 if (!hash_equals($expected, (string) $signature)) {
141 return ['form_id' => null, 'post_id' => null];
142 }
143
144 return [
145 'form_id' => $form_id ?: null,
146 'post_id' => $post_id ?: null,
147 ];
148 }
149
150 /**
151 * Resolve the stored form configuration for a submission.
152 *
153 * @param string|null $form_id The form element id.
154 * @param string|null $post_id The id of the post the form is rendered on.
155 * @return FormConfigDTO The form configuration.
156 *
157 * @throws Exception When the metadata or configuration is invalid.
158 */
159 protected function load_form_config($form_id, $post_id)
160 {
161 if (!isset($form_id, $post_id)) {
162 throw new Exception(esc_html__('Form data is invalid!', 'kirki'), (int) Response::BAD_REQUEST);
163 }
164
165 $form_config = Session::get($form_id);
166
167 if (!is_array($form_config)) {
168 throw new Exception(esc_html__('Form config not found', 'kirki'), (int) Response::BAD_REQUEST);
169 }
170
171 return FormConfigDTO::from_array($form_config);
172 }
173
174 /**
175 * Validate the submission against the configured fields.
176 *
177 * @param array $form_data The submission data.
178 * @param array $fields The field configuration.
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)
184 {
185 // phpcs:ignore WordPress.Security.NonceVerification.Missing
186 $data = FormFieldRulesBuilder::data_for_validation($form_data, $fields);
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
286 /**
287 * Persist the submission, honouring the form's saveData preference.
288 *
289 * @param int $form_id Stored form id.
290 * @param array $form_data Form data.
291 * @param FormConfigDTO $form_config Form configuration.
292 * @param string $session_id Kirki session id.
293 * @return void
294 *
295 * @throws Exception When saveData is enabled but the submission could not be stored.
296 */
297 protected function save_submission($form_id, $form_data, FormConfigDTO $form_config, $session_id)
298 {
299 if (!$form_config->saveData) {
300 return;
301 }
302
303 if (!$this->insert_form_data($form_data, $form_id, $form_config->fields, $session_id)) {
304 throw new Exception(esc_html__('Failed to save your submission. Please try again.', 'kirki'), (int) Response::INTERNAL_SERVER_ERROR);
305 }
306 }
307
308 /**
309 * Insert submission data — one row per field, grouped by timestamp + session.
310 *
311 * @param array $form_data Form data.
312 * @param int $form_id Stored form id.
313 * @param array $form_data_types Field configuration (for input_type).
314 * @param string $session_id Kirki session id.
315 * @return bool
316 */
317 protected function insert_form_data($form_data, $form_id, $form_data_types, $session_id)
318 {
319 if (empty($form_data)) {
320 return false;
321 }
322
323 $timestamp = time();
324 $user_id = user()->get_id();
325
326 $rows = [];
327
328 foreach ($form_data as $name => $value) {
329 $type = isset($form_data_types[$name]['type']) ? $form_data_types[$name]['type'] : 'text';
330
331 if (is_array($value)) {
332 $value = maybe_serialize($value);
333 }
334
335 $rows[] = [
336 'form_id' => $form_id,
337 'user_id' => $user_id,
338 'session_id' => $session_id,
339 'timestamp' => $timestamp,
340 'input_key' => (string) $name,
341 'input_value' => $value,
342 'input_type' => (string) $type,
343 ];
344 }
345
346 return FormData::insert($rows);
347 }
348 }
349