Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.01% covered (success)
97.01%
65 / 67
95.00% covered (success)
95.00%
19 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Form
97.01% covered (success)
97.01%
65 / 67
95.00% covered (success)
95.00%
19 / 20
43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 generateCsrf
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 csrfSessionName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 csrfName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateCsrf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addFields
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 required
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRequired
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 addValidator
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 process
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 bind
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 value
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 values
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setValues
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addValues
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addError
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validate
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
11
 error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Dynart\Micro;
4
5/**
6 * Represents a form
7 * @package Dynart\Micro
8 */
9class Form {
10
11    /**
12     * Stores the name of the form
13     */
14    protected string $name = 'form';
15
16    /**
17     * Is this form uses CSRF?
18     */
19    protected bool $csrf = true;
20
21    /**
22     * Holds the fields
23     */
24    protected array $fields = [];
25
26    /**
27     * A list of the required field names
28     */
29    protected array $required = [];
30
31    /**
32     * The values of the fields in [name => value] format
33     */
34    protected array $values = [];
35
36    /**
37     * The error messages of the fields in [name => message] format
38     */
39    protected array $errors = [];
40
41    /**
42     * Validators for the fields in [name => [validator1, validator2]] format
43     */
44    protected array $validators = [];
45
46    protected Session $session;
47
48    protected Request $request;
49
50    /**
51     * Creates the form with given name and `$csrf` value
52     *
53     * @param Request $request The HTTP request
54     * @param Session $session The session used for the CSRF check
55     * @param string $name The name of the form, can be an empty string (usually for filter forms)
56     * @param bool $csrf Is the form should use a CSRF field and validate it on `process()`?
57     */
58    public function __construct(Request $request, Session $session, string $name = 'form', bool $csrf = true) {
59        $this->request = $request;
60        $this->session = $session;
61        $this->name = $name;
62        $this->csrf = $csrf;
63    }
64
65    /**
66     * If the `$csrf` is true, generates a CSRF field and a CSRF value in the session
67     * @throws MicroException If it couldn't gather sufficient entropy for random_bytes
68     */
69    public function generateCsrf(): void {
70        if (!$this->csrf) {
71            return;
72        }
73        try {
74            $value = bin2hex(random_bytes(128));
75        } catch (\Exception $e) {
76            throw new MicroException("Couldn't gather sufficient entropy");
77        }
78        $this->addFields([$this->csrfName() => ['type' => 'hidden']]);
79        $this->setValues([$this->csrfName() => $value]);
80        $this->session->set($this->csrfSessionName(), $value);
81    }
82
83    /**
84     * Returns with the CSRF session name
85     */
86    public function csrfSessionName(): string {
87        return 'form.'.$this->name.'.csrf';
88    }
89
90    /**
91     * Returns with the CSRF field name
92     */
93    public function csrfName(): string {
94        return '_csrf';
95    }
96
97    /**
98     * Returns true if the CSRF session value equals with the CSRF field value
99     */
100    public function validateCsrf(): bool {
101        return !$this->csrf || $this->session->get($this->csrfSessionName()) == $this->value($this->csrfName());
102    }
103
104    /**
105     * Returns the name of this form
106     */
107    public function name(): string {
108        return $this->name;
109    }
110
111    /**
112     * Returns the fields of this form in [name => [field_data]] format
113     */
114    public function fields(): array {
115        return $this->fields;
116    }
117
118    /**
119     * Adds fields to the form (merges them with the existing ones)
120     *
121     * @param array $fields The fields in [name => [field_data]] format
122     * @param bool $required Is this field required to be filled out?
123     */
124    public function addFields(array $fields, bool $required = true): void {
125        $this->fields = array_merge($this->fields, $fields);
126        if ($required) {
127            $this->required = array_merge($this->required, array_keys($fields));
128        }
129    }
130
131    /**
132     * Returns if a field must be filled or not
133     * @param string $name
134     * @return bool
135     */
136    public function required(string $name): bool {
137        return in_array($name, $this->required);
138    }
139
140    /**
141     * Sets a field to be required or not
142     *
143     * @param string $name The name of the field
144     * @param bool $required Is it required?
145     */
146    public function setRequired(string $name, bool $required): void {
147        if ($required) {
148            if (!in_array($name, $this->required)) {
149                $this->required[] = $name;
150            }
151        } else {
152            $this->required = array_diff($this->required, [$name]);
153        }
154    }
155
156    /**
157     * Adds a validator for a field
158     *
159     * @param string $name The name of the field
160     * @param Validator $validator The validator
161     */
162    public function addValidator(string $name, Validator $validator): void {
163        if (!isset($this->validators[$name])) {
164            $this->validators[$name] = [];
165        }
166        $this->validators[$name][] = $validator;
167        $validator->setForm($this);
168    }
169
170    /**
171     * Processes a form if the request method is `$httpMethod`, adds the CSRF field if `$csrf` is true
172     *
173     * @param string $httpMethod The required HTTP method
174     * @return bool Returns true if the form is valid
175     */
176    public function process(string $httpMethod = 'POST'): bool {
177        $result = false;
178        if ($this->request->httpMethod() == $httpMethod) {
179            $this->bind();
180            $result = $this->validate();
181        }
182        $this->generateCsrf();
183        return $result;
184    }
185
186    /**
187     * Binds the request values to the field values
188     *
189     * If the form has a name it will use the `form_name[]` value from the request,
190     * otherwise: one field name one request parameter name.
191     */
192    public function bind(): void {
193        if ($this->name) {
194            $this->values = $this->request->get($this->name, []);
195        } else {
196            foreach ($this->fields as $name => $field) {
197                $this->values[$name] = $this->request->get($name);
198            }
199        }
200    }
201
202    /**
203     * Returns a value for a field
204     * @param string $name The name of the field
205     * @param bool $escape Should the value to be escaped for an HTML attribute?
206     * @return null|string The value of the field
207     */
208    public function value(string $name, bool $escape = false): ?string {
209        $value = null;
210        if (array_key_exists($name, $this->values)) {
211            $value = $this->values[$name];
212            if ($escape) {
213                $value = htmlspecialchars($value, ENT_QUOTES);
214            }
215        }
216        return $value;
217    }
218
219    /**
220     * Returns with the values for the fields in [name => value] form
221     * @return array
222     */
223    public function values(): array {
224        return $this->values;
225    }
226
227    /**
228     * Sets the values for the fields (clears the previous ones)
229     * @param array $values
230     */
231    public function setValues(array $values): void {
232        $this->values = $values;
233    }
234
235    /**
236     * Adds the values for the fields (merges them with the existing ones)
237     * @param array $values
238     */
239    public function addValues(array $values): void {
240        $this->values = array_merge($this->values, $values);
241    }
242
243    /**
244     * Adds an error to the form itself
245     * @param string $error
246     */
247    public function addError(string $error): void {
248        if (!isset($this->errors['_form'])) {
249            $this->errors['_form'] = [];
250        }
251        $this->errors['_form'][] = $error;
252    }
253
254    /**
255     * Runs the validators per field if the field is required or has value
256     *
257     * If one validator fails for a field the other validators will NOT run for that field.
258     *
259     * @return bool The form validation was successful?
260     */
261    public function validate(): bool {
262        if (!$this->validateCsrf()) {
263            $this->addError('CSRF token is invalid.');
264        }
265        foreach (array_keys($this->fields) as $name) {
266            if ($this->required($name) && !$this->value($name)) {
267                $this->errors[$name] = 'Required.'; // TODO: Translation
268            }
269        }
270        foreach ($this->validators as $name => $validators) {
271            if (isset($this->errors[$name])) {
272                continue;
273            }
274            if (!$this->value($name) && !$this->required($name)) {
275                continue;
276            }
277            foreach ($validators as $validator) {
278                if (!$validator->validate($this->value($name))) {
279                    $this->errors[$name] = $validator->message();
280                    break;
281                }
282            }
283        }
284        return empty($this->errors);
285    }
286
287    /**
288     * Returns an error message for a field
289     *
290     * @param string $name The field name
291     * @return string|null The error message or null
292     */
293    public function error(string $name): ?string {
294        return $this->errors[$name] ?? null;
295    }
296
297}