Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
58.82% covered (warning)
58.82%
50 / 85
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageValidationTrait
58.82% covered (warning)
58.82%
50 / 85
40.00% covered (danger)
40.00%
2 / 5
77.89
0.00% covered (danger)
0.00%
0 / 1
 addImageValidationRules
72.73% covered (warning)
72.73%
48 / 66
0.00% covered (danger)
0.00%
0 / 1
19.56
 validateFileExtension
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 validateNotDangerousExtension
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 addRequiredImageValidation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addOptionalImageValidation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3
4namespace App\Model\Behavior;
5
6use Cake\Validation\Validator;
7
8/**
9 * ImageValidationTrait
10 *
11 * Provides reusable image validation rules for models that handle image uploads.
12 * This trait consolidates common image validation logic that was previously
13 * duplicated across multiple table classes (Images, Articles, Users, Tags).
14 *
15 * Usage:
16 * ```php
17 * use App\Model\Behavior\ImageValidationTrait;
18 *
19 * class ImagesTable extends Table
20 * {
21 *     use ImageValidationTrait;
22 *
23 *     public function validationCreate(Validator $validator): Validator
24 *     {
25 *         return $this->addImageValidationRules($validator, 'image', true);
26 *     }
27 * }
28 * ```
29 */
30trait ImageValidationTrait
31{
32    /**
33     * Allowed file extensions for image uploads (security measure)
34     *
35     * @var array<string>
36     */
37    private array $allowedImageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
38
39    /**
40     * Dangerous file extensions that should never be allowed
41     *
42     * @var array<string>
43     */
44    private array $dangerousExtensions = [
45        'php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar',
46        'exe', 'sh', 'bash', 'bat', 'cmd', 'com',
47        'js', 'jsp', 'asp', 'aspx', 'cgi', 'pl', 'py', 'rb',
48        'htaccess', 'htpasswd',
49    ];
50
51    /**
52     * Add standard image validation rules to a validator
53     *
54     * @param \Cake\Validation\Validator $validator The validator instance
55     * @param string $field The field name to validate (default: 'image')
56     * @param bool $required Whether the image field is required (default: false)
57     * @param array $options Additional options for customization
58     * @return \Cake\Validation\Validator
59     */
60    public function addImageValidationRules(
61        Validator $validator,
62        string $field = 'image',
63        bool $required = false,
64        array $options = [],
65    ): Validator {
66        // Default configuration
67        $defaults = [
68            'allowedMimeTypes' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
69            'allowedExtensions' => $this->allowedImageExtensions,
70            'maxFileSize' => '10MB',
71            'messages' => [
72                'mimeType' => __('Please upload only images (jpeg, png, gif, webp).'),
73                'fileSize' => __('Image must be less than 10MB.'),
74                'required' => __('An image file is required.'),
75                'extension' => __('Invalid file extension. Only jpg, jpeg, png, gif, webp allowed.'),
76                'dangerous' => __('This file type is not allowed for security reasons.'),
77            ],
78        ];
79
80        // Deep merge options with defaults (messages array needs special handling)
81        $config = array_merge($defaults, $options);
82        if (isset($options['messages'])) {
83            $config['messages'] = array_merge($defaults['messages'], $options['messages']);
84        }
85
86        // Handle required validation
87        if ($required) {
88            $validator
89                ->requirePresence($field, 'create')
90                ->notEmptyFile($field, $config['messages']['required']);
91        } else {
92            $validator->allowEmptyFile($field);
93        }
94
95        // Add file-specific validation rules
96        return $validator->add($field, [
97            'mimeType' => [
98                'rule' => ['mimeType', $config['allowedMimeTypes']],
99                'message' => $config['messages']['mimeType'],
100                'on' => function ($context) use ($field) {
101                    // Only validate mime type if file was uploaded successfully
102                    return !empty($context['data'][$field])
103                        && is_object($context['data'][$field])
104                        && method_exists($context['data'][$field], 'getError')
105                        && $context['data'][$field]->getError() === UPLOAD_ERR_OK;
106                },
107            ],
108            'fileSize' => [
109                'rule' => ['fileSize', '<=', $config['maxFileSize']],
110                'message' => $config['messages']['fileSize'],
111                'on' => function ($context) use ($field) {
112                    // Only validate file size if file was uploaded successfully
113                    return !empty($context['data'][$field])
114                        && is_object($context['data'][$field])
115                        && method_exists($context['data'][$field], 'getError')
116                        && $context['data'][$field]->getError() === UPLOAD_ERR_OK;
117                },
118            ],
119            'safeExtension' => [
120                'rule' => function ($value) use ($config) {
121                    return $this->validateFileExtension($value, $config['allowedExtensions']);
122                },
123                'message' => $config['messages']['extension'],
124                'on' => function ($context) use ($field) {
125                    return !empty($context['data'][$field])
126                        && is_object($context['data'][$field])
127                        && method_exists($context['data'][$field], 'getError')
128                        && $context['data'][$field]->getError() === UPLOAD_ERR_OK;
129                },
130            ],
131            'notDangerous' => [
132                'rule' => function ($value) {
133                    return $this->validateNotDangerousExtension($value);
134                },
135                'message' => $config['messages']['dangerous'],
136                'on' => function ($context) use ($field) {
137                    return !empty($context['data'][$field])
138                        && is_object($context['data'][$field])
139                        && method_exists($context['data'][$field], 'getError')
140                        && $context['data'][$field]->getError() === UPLOAD_ERR_OK;
141                },
142            ],
143        ]);
144    }
145
146    /**
147     * Validate that file extension is in the allowed list
148     *
149     * @param mixed $value The uploaded file object
150     * @param array $allowedExtensions List of allowed extensions
151     * @return bool
152     */
153    private function validateFileExtension(mixed $value, array $allowedExtensions): bool
154    {
155        if (!is_object($value) || !method_exists($value, 'getClientFilename')) {
156            return false;
157        }
158
159        $filename = $value->getClientFilename();
160        if (empty($filename)) {
161            return false;
162        }
163
164        $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
165
166        return in_array($extension, $allowedExtensions, true);
167    }
168
169    /**
170     * Validate that file does not have a dangerous extension
171     * Checks all extensions in the filename (e.g., shell.php.jpg)
172     *
173     * @param mixed $value The uploaded file object
174     * @return bool True if safe, false if dangerous
175     */
176    private function validateNotDangerousExtension(mixed $value): bool
177    {
178        if (!is_object($value) || !method_exists($value, 'getClientFilename')) {
179            return false;
180        }
181
182        $filename = $value->getClientFilename();
183        if (empty($filename)) {
184            return false;
185        }
186
187        // Check all parts of the filename for dangerous extensions
188        // This catches tricks like "shell.php.jpg" or "file.phtml.png"
189        $parts = explode('.', strtolower($filename));
190        foreach ($parts as $part) {
191            if (in_array($part, $this->dangerousExtensions, true)) {
192                return false;
193            }
194        }
195
196        return true;
197    }
198
199    /**
200     * Add image validation rules for create operations (required)
201     *
202     * @param \Cake\Validation\Validator $validator The validator instance
203     * @param string $field The field name to validate (default: 'image')
204     * @param array $options Additional options for customization
205     * @return \Cake\Validation\Validator
206     */
207    public function addRequiredImageValidation(
208        Validator $validator,
209        string $field = 'image',
210        array $options = [],
211    ): Validator {
212        return $this->addImageValidationRules($validator, $field, true, $options);
213    }
214
215    /**
216     * Add image validation rules for update operations (optional)
217     *
218     * @param \Cake\Validation\Validator $validator The validator instance
219     * @param string $field The field name to validate (default: 'image')
220     * @param array $options Additional options for customization
221     * @return \Cake\Validation\Validator
222     */
223    public function addOptionalImageValidation(
224        Validator $validator,
225        string $field = 'image',
226        array $options = [],
227    ): Validator {
228        return $this->addImageValidationRules($validator, $field, false, $options);
229    }
230}