PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.9
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.9
4.1.0 4.0.9 4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / gutenberg / form / class-form-file-handler.php
superb-blocks / src / gutenberg / form Last commit date
class-form-access-control.php 2 weeks ago class-form-captcha-handler.php 2 weeks ago class-form-controller.php 2 weeks ago class-form-email-config-check.php 2 weeks ago class-form-email-handler.php 2 weeks ago class-form-encryption.php 2 weeks ago class-form-exporter.php 2 weeks ago class-form-field-validator.php 2 weeks ago class-form-file-handler.php 2 weeks ago class-form-google-auth.php 2 weeks ago class-form-integration-handler.php 2 weeks ago class-form-math-parser.php 2 weeks ago class-form-permissions.php 2 weeks ago class-form-registry.php 2 weeks ago class-form-settings.php 2 weeks ago class-form-submission-cpt.php 2 weeks ago class-form-submission-handler.php 2 weeks ago
class-form-file-handler.php
817 lines
1 <?php
2
3 namespace SuperbAddons\Gutenberg\Form;
4
5 defined('ABSPATH') || exit();
6
7 class FormFileHandler
8 {
9 const UPLOAD_SUBDIR = 'superb-addons-forms';
10 const UPLOAD_DIR_TOKEN_OPTION = 'superbaddons_form_upload_dir_token';
11
12 /**
13 * Name of the upload directory for form files: UPLOAD_SUBDIR plus a
14 * random per-site token, e.g. "superb-addons-forms-k2m9q4p1n8x7c3v5".
15 *
16 * @return string
17 */
18 public static function GetUploadSubdir()
19 {
20 $token = get_option(self::UPLOAD_DIR_TOKEN_OPTION);
21 // Regenerate when missing or not a plain token, so a tampered option
22 // value can never become a path segment.
23 if (!is_string($token) || !preg_match('/^[a-z0-9]{8,64}$/', $token)) {
24 $token = self::MintUploadDirToken();
25 self::MigrateLegacyUploadDir(self::UPLOAD_SUBDIR . '-' . $token);
26 }
27 return self::UPLOAD_SUBDIR . '-' . $token;
28 }
29
30 /**
31 * Store a fresh upload directory token, or adopt the one a concurrent
32 * request stored first.
33 *
34 * @return string Valid token, stored except in the pathological case
35 * where an invalid value is being re-written concurrently.
36 */
37 private static function MintUploadDirToken()
38 {
39 $token = strtolower(wp_generate_password(16, false, false));
40 for ($attempt = 0; $attempt < 2; $attempt++) {
41 if (add_option(self::UPLOAD_DIR_TOKEN_OPTION, $token, '', 'yes')) {
42 return $token;
43 }
44 $stored = get_option(self::UPLOAD_DIR_TOKEN_OPTION);
45 if (is_string($stored) && preg_match('/^[a-z0-9]{8,64}$/', $stored)) {
46 return $stored;
47 }
48 delete_option(self::UPLOAD_DIR_TOKEN_OPTION);
49 }
50 return $token;
51 }
52
53 private static function MigrateLegacyUploadDir($target_subdir)
54 {
55 $upload_dir = wp_upload_dir();
56 $legacy_base = $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR;
57 $target_base = $upload_dir['basedir'] . '/' . $target_subdir;
58 // A freshly minted random directory cannot pre-exist; if it somehow
59 // does (concurrent mint already moved the tree), there is nothing to do
60 if (!is_dir($legacy_base) || is_dir($target_base)) {
61 return;
62 }
63
64 global $wp_filesystem;
65 if (empty($wp_filesystem)) {
66 require_once(ABSPATH . 'wp-admin/includes/file.php');
67 WP_Filesystem();
68 }
69 if (!empty($wp_filesystem)) {
70 $wp_filesystem->move($legacy_base, $target_base);
71 }
72 }
73
74 /**
75 * Resolve a stored absolute file path
76 *
77 * @param string $path Stored absolute path
78 * @return string Resolved path, '' when the input is not a usable string
79 */
80 public static function ResolveStoredPath($path)
81 {
82 if (!is_string($path) || $path === '') {
83 return '';
84 }
85 if (file_exists($path)) {
86 return $path;
87 }
88 $upload_dir = wp_upload_dir();
89 $legacy_base = $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR . '/';
90 if (strpos($path, $legacy_base) === 0) {
91 $candidate = $upload_dir['basedir'] . '/' . self::GetUploadSubdir() . '/' . substr($path, strlen($legacy_base));
92 if (file_exists($candidate)) {
93 return $candidate;
94 }
95 }
96 return $path;
97 }
98
99 /**
100 * Master list of file types available to form file upload fields.
101 * Feeds both the editor's Accepted File Types picker and server-side
102 * upload validation, so the two cannot drift apart.
103 *
104 * Extensions on the HasDangerousExtension deny-list are stripped from
105 * the filtered result unconditionally, so the filter cannot be used to
106 * allow executable or server-interpreted uploads.
107 *
108 * @return array Entries of array('ext' => '.mp4', 'label' => 'MP4', 'mime' => 'video/mp4')
109 */
110 public static function GetAllowedFileTypes()
111 {
112 $types = array(
113 // Images. SVG is excluded because it is XML and can host inline scripts.
114 array('ext' => '.jpg', 'label' => 'JPG', 'mime' => 'image/jpeg'),
115 array('ext' => '.jpeg', 'label' => 'JPEG', 'mime' => 'image/jpeg'),
116 array('ext' => '.png', 'label' => 'PNG', 'mime' => 'image/png'),
117 array('ext' => '.gif', 'label' => 'GIF', 'mime' => 'image/gif'),
118 array('ext' => '.webp', 'label' => 'WebP', 'mime' => 'image/webp'),
119 // Documents
120 array('ext' => '.pdf', 'label' => 'PDF', 'mime' => 'application/pdf'),
121 array('ext' => '.doc', 'label' => 'DOC', 'mime' => 'application/msword'),
122 array('ext' => '.docx', 'label' => 'DOCX', 'mime' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
123 array('ext' => '.txt', 'label' => 'TXT', 'mime' => 'text/plain'),
124 // Spreadsheets
125 array('ext' => '.xls', 'label' => 'XLS', 'mime' => 'application/vnd.ms-excel'),
126 array('ext' => '.xlsx', 'label' => 'XLSX', 'mime' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
127 array('ext' => '.csv', 'label' => 'CSV', 'mime' => 'text/csv'),
128 // Archives
129 array('ext' => '.zip', 'label' => 'ZIP', 'mime' => 'application/zip'),
130 // Video
131 array('ext' => '.mp4', 'label' => 'MP4', 'mime' => 'video/mp4'),
132 array('ext' => '.m4v', 'label' => 'M4V', 'mime' => 'video/mp4'),
133 array('ext' => '.mov', 'label' => 'MOV', 'mime' => 'video/quicktime'),
134 array('ext' => '.webm', 'label' => 'WebM', 'mime' => 'video/webm'),
135 array('ext' => '.avi', 'label' => 'AVI', 'mime' => 'video/avi'),
136 array('ext' => '.mkv', 'label' => 'MKV', 'mime' => 'video/x-matroska'),
137 array('ext' => '.mpg', 'label' => 'MPG', 'mime' => 'video/mpeg'),
138 array('ext' => '.wmv', 'label' => 'WMV', 'mime' => 'video/x-ms-wmv'),
139 // Audio
140 array('ext' => '.mp3', 'label' => 'MP3', 'mime' => 'audio/mpeg'),
141 array('ext' => '.m4a', 'label' => 'M4A', 'mime' => 'audio/mpeg'),
142 array('ext' => '.wav', 'label' => 'WAV', 'mime' => 'audio/wav'),
143 array('ext' => '.ogg', 'label' => 'OGG', 'mime' => 'audio/ogg'),
144 array('ext' => '.flac', 'label' => 'FLAC', 'mime' => 'audio/flac'),
145 );
146
147 /**
148 * Filters the file types visitors can upload through form file upload fields.
149 *
150 * Each entry needs an 'ext' (extension with leading dot), 'label' (shown in
151 * the editor's Accepted File Types picker), and 'mime' (enforced during
152 * upload validation). Entries whose extension is on the dangerous-extension
153 * deny-list (php, html, svg, exe, ...) are discarded. Types WordPress does
154 * not allow by default do not need a separate upload_mimes filter; this
155 * list is authoritative for form uploads.
156 *
157 * @since 4.0.9
158 *
159 * @param array $types Entries of array('ext' => ..., 'label' => ..., 'mime' => ...).
160 */
161 $types = apply_filters('superbaddons_form_allowed_file_types', $types);
162
163 if (!is_array($types)) {
164 return array();
165 }
166
167 $sanitized = array();
168 $seen = array();
169 foreach ($types as $type) {
170 if (!is_array($type) || empty($type['ext']) || !is_string($type['ext']) || empty($type['mime']) || !is_string($type['mime'])) {
171 continue;
172 }
173 $ext = strtolower(trim($type['ext']));
174 if (strpos($ext, '.') !== 0) {
175 $ext = '.' . $ext;
176 }
177 // Single alphanumeric segment only — multi-segment entries like
178 // .tar.gz can never match pathinfo(PATHINFO_EXTENSION) in validation
179 if (!preg_match('/^\.[a-z0-9]+$/', $ext)) {
180 continue;
181 }
182 // Deny-list wins over the filter
183 if (self::HasDangerousExtension('file' . $ext)) {
184 continue;
185 }
186 if (isset($seen[$ext])) {
187 continue;
188 }
189 $seen[$ext] = true;
190 $sanitized[] = array(
191 'ext' => $ext,
192 'label' => isset($type['label']) && is_string($type['label']) && $type['label'] !== '' ? $type['label'] : strtoupper(substr($ext, 1)),
193 'mime' => $type['mime'],
194 );
195 }
196 return $sanitized;
197 }
198
199 /**
200 * Default accepted extensions for file fields whose server-side config
201 * carries no explicit accept list. Gutenberg omits attributes that still
202 * equal their block.json defaults when serializing, so a file field whose
203 * settings were never touched reaches the server without fileSettings at
204 * all; this list makes validation enforce exactly the types the editor UI
205 * displays for that state. Must be kept in sync with the fileSettings.accept
206 * default in /form-field/block.json.
207 *
208 * @return array Extensions with leading dot
209 */
210 public static function GetDefaultAccept()
211 {
212 return array('.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.doc', '.docx', '.txt', '.xls', '.xlsx', '.csv');
213 }
214
215 /**
216 * Extension => MIME map derived from GetAllowedFileTypes, in the format
217 * wp_check_filetype() and wp_handle_upload() expect.
218 *
219 * @return array e.g. array('mp4' => 'video/mp4', ...)
220 */
221 public static function GetAllowedMimes()
222 {
223 $mimes = array();
224 foreach (self::GetAllowedFileTypes() as $type) {
225 $mimes[substr($type['ext'], 1)] = $type['mime'];
226 }
227 return $mimes;
228 }
229
230 /**
231 * Validate uploaded files for a field against its config.
232 * Called by FormFieldValidator before files are processed.
233 *
234 * @param array $field_config Field configuration from server-side config
235 * @param string $default_required_message Form-wide message for empty required fields, '' for the localized default
236 * @return string Error message, empty if valid
237 */
238 public static function ValidateFiles($field_config, $default_required_message = '')
239 {
240 $field_id = isset($field_config['fieldId']) ? $field_config['fieldId'] : '';
241 $required = !empty($field_config['required']);
242 $fs = isset($field_config['fileSettings']) && is_array($field_config['fileSettings']) ? $field_config['fileSettings'] : array();
243 $max_file_size = isset($fs['maxFileSize']) ? floatval($fs['maxFileSize']) : 5;
244 $multiple = !empty($fs['multiple']);
245 $max_files = isset($fs['maxFiles']) ? intval($fs['maxFiles']) : 5;
246 // Like the other fileSettings keys above, a missing accept list means
247 // the field was left at its block.json defaults (default-valued
248 // attributes are omitted from serialized markup), so enforce the
249 // default list the editor UI shows rather than the wider master list.
250 // A present-but-empty list (legacy content saved before the editor
251 // refused to empty the picker) gets the same defaults.
252 $accept = isset($fs['accept']) && is_array($fs['accept']) && !empty($fs['accept']) ? $fs['accept'] : self::GetDefaultAccept();
253
254 // Check if files were submitted for this field
255 $files = self::GetUploadedFiles($field_id);
256
257 if (empty($files)) {
258 // Conditional logic: if field has active rules, skip required check
259 // (handled by FormFieldValidator before calling us, but guard here too)
260 if ($required) {
261 $logic = isset($field_config['conditionalLogic']) ? $field_config['conditionalLogic'] : null;
262 if ($logic && isset($logic['ruleGroups']) && is_array($logic['ruleGroups'])) {
263 foreach ($logic['ruleGroups'] as $group) {
264 if (isset($group['conditions']) && is_array($group['conditions'])) {
265 foreach ($group['conditions'] as $cond) {
266 if (!empty($cond['field'])) {
267 return '';
268 }
269 }
270 }
271 }
272 }
273 return FormFieldValidator::GetRequiredMessage($field_config, $default_required_message);
274 }
275 return '';
276 }
277
278 // Validate file count
279 if (!$multiple && count($files) > 1) {
280 return __('Only one file is allowed.', 'superb-blocks');
281 }
282 if ($multiple && count($files) > $max_files) {
283 /* translators: %d: maximum number of files allowed for this field */
284 return sprintf(__('Maximum %d files allowed.', 'superb-blocks'), $max_files);
285 }
286
287 // Validate each file
288 $max_bytes = $max_file_size * 1024 * 1024;
289 $allowed_mimes = self::GetAllowedMimes();
290 foreach ($files as $file) {
291 // Check for upload errors
292 if (!empty($file['error']) && intval($file['error']) !== UPLOAD_ERR_OK) {
293 $upload_error = intval($file['error']);
294 if ($upload_error === UPLOAD_ERR_INI_SIZE || $upload_error === UPLOAD_ERR_FORM_SIZE) {
295 return __('File exceeds the maximum upload size for this site.', 'superb-blocks');
296 }
297 return __('File upload failed.', 'superb-blocks');
298 }
299
300 // Unconditional deny-list: reject dangerous extensions regardless of the
301 // per-field accept whitelist. Catches misconfigured accept[] entries and
302 // double-extension filenames (e.g. shell.php.jpg) before any further check.
303 if (!empty($file['name']) && self::HasDangerousExtension($file['name'])) {
304 return __('File type is not allowed.', 'superb-blocks');
305 }
306
307 // Validate size
308 if (isset($file['size']) && $file['size'] > $max_bytes) {
309 /* translators: %s: maximum file size in megabytes */
310 return sprintf(__('File size exceeds %sMB.', 'superb-blocks'), $max_file_size);
311 }
312
313 // Validate extension against the master list and the field whitelist
314 if (!empty($file['name'])) {
315 $ext = '.' . strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
316
317 // Master list: extension must be a known allowed type
318 // (filterable via superbaddons_form_allowed_file_types)
319 if (!isset($allowed_mimes[substr($ext, 1)])) {
320 return __('File type is not allowed.', 'superb-blocks');
321 }
322
323 if (!empty($accept)) {
324 $allowed = false;
325 foreach ($accept as $accepted) {
326 // Accept list entries are like ".jpg", ".pdf", etc.
327 if (strtolower(trim($accepted)) === $ext) {
328 $allowed = true;
329 break;
330 }
331 }
332 if (!$allowed) {
333 return __('File type is not allowed.', 'superb-blocks');
334 }
335 }
336 }
337
338 // Additional MIME validation using WordPress, restricted to the master list
339 if (!empty($file['tmp_name']) && !empty($file['name'])) {
340 $wp_filetype = wp_check_filetype($file['name'], $allowed_mimes);
341 if (empty($wp_filetype['ext']) || empty($wp_filetype['type'])) {
342 return __('File type is not allowed.', 'superb-blocks');
343 }
344 }
345 }
346
347 return '';
348 }
349
350 /**
351 * Process and store uploaded files for a submission.
352 *
353 * @param array $form_fields_config Array of field config arrays
354 * @return array fieldId => array of file metadata arrays
355 */
356 public static function ProcessUploads($form_fields_config)
357 {
358 // Nonce verified upstream by FormController::SubmitCallback before this method runs; presence check only, no value processed here.
359 // phpcs:ignore WordPress.Security.NonceVerification.Missing
360 if (empty($_FILES['files'])) {
361 return array();
362 }
363
364 require_once(ABSPATH . 'wp-admin/includes/file.php');
365
366 $result = array();
367
368 // Build lookup for file field configs
369 $file_field_configs = array();
370 foreach ($form_fields_config as $fc) {
371 $ftype = isset($fc['fieldType']) ? $fc['fieldType'] : '';
372 $fid = isset($fc['fieldId']) ? $fc['fieldId'] : '';
373 if ($ftype === 'file' && $fid !== '') {
374 $file_field_configs[$fid] = $fc;
375 }
376 }
377
378 $allowed_mimes = self::GetAllowedMimes();
379 $upload_subdir = self::GetUploadSubdir();
380
381 foreach ($file_field_configs as $field_id => $config) {
382 $files = self::GetUploadedFiles($field_id);
383 if (empty($files)) {
384 continue;
385 }
386
387 $field_files = array();
388 foreach ($files as $file) {
389 if (empty($file['tmp_name']) || intval($file['error']) !== UPLOAD_ERR_OK) {
390 continue;
391 }
392
393 // Hook into upload_dir to redirect to our protected directory
394 $dir_filter = function ($dirs) use ($upload_subdir) {
395 $subdir = '/' . $upload_subdir . $dirs['subdir'];
396 $dirs['subdir'] = $subdir;
397 $dirs['path'] = $dirs['basedir'] . $subdir;
398 $dirs['url'] = $dirs['baseurl'] . $subdir;
399 return $dirs;
400 };
401 add_filter('upload_dir', $dir_filter);
402
403 // Ensure protected directory exists
404 self::EnsureUploadDir();
405
406 $uploaded = wp_handle_upload($file, array(
407 'test_form' => false,
408 'action' => 'superb_form_upload',
409 'mimes' => $allowed_mimes,
410 // Random per-file suffix: makes stored URLs unguessable on
411 // servers where the directory deny rules do not apply
412 // (Nginx), and removes wp_unique_filename()'s
413 // check-then-move race where two concurrent submissions
414 // uploading the same filename could silently overwrite
415 // each other. The original name is kept in the submission
416 // metadata and restored on download via Content-Disposition.
417 'unique_filename_callback' => array(__CLASS__, 'GenerateStoredFilename'),
418 ));
419
420 remove_filter('upload_dir', $dir_filter);
421
422 if (!empty($uploaded['file'])) {
423 $field_files[] = array(
424 'name' => sanitize_file_name($file['name']),
425 'path' => $uploaded['file'],
426 'url' => isset($uploaded['url']) ? $uploaded['url'] : '',
427 'type' => isset($uploaded['type']) ? $uploaded['type'] : '',
428 'size' => $file['size'],
429 );
430 }
431 }
432
433 if (!empty($field_files)) {
434 $result[$field_id] = $field_files;
435
436 /**
437 * Fires after files are uploaded for a form field.
438 *
439 * @param string $field_id The field ID.
440 * @param array $field_files Array of file metadata.
441 * @param array $config Field configuration.
442 */
443 do_action('superbaddons_form_after_upload', $field_id, $field_files, $config);
444 }
445 }
446
447 return $result;
448 }
449
450 /**
451 * unique_filename_callback for wp_handle_upload: append a random suffix
452 * to the stored filename. wp_unique_filename passes $name as the full
453 * sanitized basename including the extension, and $ext as '.pdf' style.
454 *
455 * @param string $dir Target directory
456 * @param string $name Sanitized basename including extension
457 * @param string $ext Extension with leading dot, '' when none
458 * @return string
459 */
460 public static function GenerateStoredFilename($dir, $name, $ext)
461 {
462 $base = $name;
463 if ($ext !== '' && substr($name, strlen($name) - strlen($ext)) === $ext) {
464 $base = substr($name, 0, strlen($name) - strlen($ext));
465 }
466 if ($base === '') {
467 $base = 'file';
468 }
469
470 // Keep the stored name inside every real-world limit: 255 bytes per
471 // path component on ext4/XFS/NTFS, ~143 bytes on eCryptfs, and
472 // Windows' 260-character full-path cap. Neither sanitize_file_name()
473 // nor wp_unique_filename() truncates, and an overlong name would make
474 // the move inside wp_handle_upload fail after validation has already
475 // passed. 48 chars is at most 192 bytes of UTF-8;
476 $base = mb_substr($base, 0, 48);
477
478 $attempts = 0;
479 do {
480 $suffix = strtolower(wp_generate_password(12, false, false));
481 $filename = $base . '-' . $suffix . $ext;
482 $attempts++;
483 } while ($attempts < 3 && file_exists(trailingslashit($dir) . $filename));
484
485 return $filename;
486 }
487
488 /**
489 * Delete all files associated with a submission's field data.
490 *
491 * @param array $fields Submission fields (field_id => value)
492 */
493 public static function DeleteSubmissionFiles($fields)
494 {
495 if (!is_array($fields)) {
496 return;
497 }
498
499 foreach ($fields as $value) {
500 // File fields store an array of file metadata
501 if (!is_array($value)) {
502 continue;
503 }
504
505 foreach ($value as $file) {
506 if (!is_array($file) || !isset($file['path'])) {
507 continue;
508 }
509 $path = self::ResolveStoredPath($file['path']);
510 if ($path !== '' && file_exists($path)) {
511 wp_delete_file($path);
512 }
513 }
514 }
515 }
516
517 /**
518 * Delete the plugin's form upload directories entirely: all remaining
519 * files (including any orphaned by crashed requests), the
520 * .htaccess/web.config/index.php scaffolding, and the empty year/month
521 * subdirectories. Called from plugin reset after every stored submission
522 * has been deleted; both the current tokenized directory and the legacy
523 * unhashed directory are removed. Reads the token option directly so no
524 * new token is minted when none exists.
525 */
526 public static function DeleteUploadDirectories()
527 {
528 global $wp_filesystem;
529 if (empty($wp_filesystem)) {
530 require_once(ABSPATH . 'wp-admin/includes/file.php');
531 WP_Filesystem();
532 }
533 if (empty($wp_filesystem)) {
534 return;
535 }
536
537 $upload_dir = wp_upload_dir();
538 $bases = array($upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR);
539 $token = get_option(self::UPLOAD_DIR_TOKEN_OPTION);
540 if (is_string($token) && preg_match('/^[a-z0-9]{8,64}$/', $token)) {
541 $bases[] = $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR . '-' . $token;
542 }
543
544 foreach ($bases as $base) {
545 if (is_dir($base)) {
546 $wp_filesystem->delete($base, true);
547 }
548 }
549 }
550
551 /**
552 * Serve a file from the protected upload directory.
553 * Streams the file with appropriate headers and exits.
554 *
555 * @param string $file_path Absolute path to the file
556 * @param string $original_name Original file name for download
557 * @param string $mime_type MIME type
558 */
559 public static function ServeFile($file_path, $original_name, $mime_type)
560 {
561 // Resolve the current directory before touching the stored path: on
562 // the very first touch this mints the token and migrates the legacy
563 // directory, which would otherwise move the file out from under a
564 // path resolved earlier in this request.
565 $upload_dir = wp_upload_dir();
566 $current_base = $upload_dir['basedir'] . '/' . self::GetUploadSubdir();
567
568 // Stored paths may predate the randomized upload directory
569 $file_path = self::ResolveStoredPath($file_path);
570 if ($file_path === '' || !file_exists($file_path) || !is_readable($file_path)) {
571 return new \WP_REST_Response(array(
572 'success' => false,
573 'message' => __('File not found.', 'superb-blocks'),
574 ), 404);
575 }
576
577 // Ensure file is within one of our upload directories directly under
578 // the uploads basedir: the unhashed directory for files stored before
579 // the randomized directory token was introduced, or any tokenized
580 // variant. Accepting the token pattern instead of only the current
581 // token keeps files servable even if the token option is ever lost
582 // and re-minted, which would otherwise strand every earlier upload
583 // under the retired directory. Paths come exclusively from submission
584 // meta, so this does not widen what a request can reach. Segment-wise
585 // comparison keeps the check exact, so a sibling directory that
586 // merely starts with the base name can never pass.
587 $allowed = false;
588 $real_path = realpath($file_path);
589 $real_uploads = realpath($upload_dir['basedir']);
590 if ($real_path !== false && $real_uploads !== false && strpos($real_path, $real_uploads . DIRECTORY_SEPARATOR) === 0) {
591 $segments = explode(DIRECTORY_SEPARATOR, substr($real_path, strlen($real_uploads) + 1));
592 if (count($segments) > 1 && preg_match('/^' . preg_quote(self::UPLOAD_SUBDIR, '/') . '(-[a-z0-9]{8,64})?$/', $segments[0])) {
593 $allowed = true;
594 }
595 }
596
597 if (!$allowed) {
598 return new \WP_REST_Response(array(
599 'success' => false,
600 'message' => __('Access denied.', 'superb-blocks'),
601 ), 403);
602 }
603
604 // A concurrent request performing the one-time legacy-directory
605 // migration can move the file between the checks above and the
606 // stream below. Re-resolve and re-confine (the migrated location can
607 // only be the current randomized directory) instead of streaming a
608 // dead path.
609 if (!file_exists($file_path)) {
610 $file_path = self::ResolveStoredPath($file_path);
611 $real_path = $file_path !== '' ? realpath($file_path) : false;
612 $real_base = realpath($current_base);
613 if ($real_path === false || $real_base === false || strpos($real_path, $real_base . DIRECTORY_SEPARATOR) !== 0) {
614 return new \WP_REST_Response(array(
615 'success' => false,
616 'message' => __('File not found.', 'superb-blocks'),
617 ), 404);
618 }
619 }
620
621 $safe_name = str_replace(array('"', "\r", "\n"), '', sanitize_file_name($original_name));
622 header('Content-Type: ' . $mime_type);
623 header('X-Content-Type-Options: nosniff');
624 header('Content-Disposition: attachment; filename="' . $safe_name . '"');
625 header('Content-Length: ' . filesize($file_path));
626 header('Cache-Control: no-store, no-cache, must-revalidate');
627
628 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
629 readfile($file_path);
630 // Plain exit, not wp_die(): the die handler appends an HTML error
631 // page skeleton after the streamed bytes.
632 exit;
633 }
634
635 /**
636 * Check if a filename has a dangerous extension in any dot-separated segment.
637 * Catches single (shell.php), double-extension (shell.php.jpg), and dotfile
638 * (.htaccess) cases. Server-config and active-content extensions are denied
639 * because the upload subdir is web-reachable on IIS/Nginx where .htaccess is
640 * ignored, so a stored .html or .svg could host an XSS payload even though
641 * wp_handle_upload would not save a .php file under an unauthenticated request.
642 *
643 * @param string $filename
644 * @return bool
645 */
646 private static function HasDangerousExtension($filename)
647 {
648 static $deny = array(
649 // PHP and PHP-handler variants
650 'php',
651 'php3',
652 'php4',
653 'php5',
654 'php7',
655 'php8',
656 'phtml',
657 'pht',
658 'phar',
659 'phps',
660 // Other server-side scripting
661 'cgi',
662 'pl',
663 'py',
664 'rb',
665 'jsp',
666 'jspx',
667 'asp',
668 'aspx',
669 'cer',
670 'cfm',
671 'shtml',
672 // Executables and shells
673 'exe',
674 'msi',
675 'sh',
676 'bat',
677 'cmd',
678 'com',
679 'vb',
680 'vbs',
681 'wsh',
682 // Server config
683 'htaccess',
684 'htpasswd',
685 'ini',
686 'env',
687 // Active web content (inline XSS if directly accessible)
688 'html',
689 'htm',
690 'xhtml',
691 'svg',
692 'svgz',
693 'js',
694 'mjs',
695 'xml',
696 );
697
698 $parts = explode('.', strtolower($filename));
699 array_shift($parts); // skip basename, only inspect dot-segments
700 foreach ($parts as $part) {
701 if ($part !== '' && in_array($part, $deny, true)) {
702 return true;
703 }
704 }
705 return false;
706 }
707
708 /**
709 * Get uploaded files for a specific field ID from $_FILES.
710 * Normalizes the PHP $_FILES array for multiple files.
711 *
712 * @param string $field_id
713 * @return array Array of file arrays (name, type, tmp_name, error, size)
714 */
715 private static function GetUploadedFiles($field_id)
716 {
717 // Nonce verified upstream by FormController::SubmitCallback before any caller of this method runs.
718 // PHP guarantees the parallel name/type/tmp_name/error/size keys exist together when $_FILES['files']['name'][$field_id] is set, so checking the others would be redundant.
719 // Raw $_FILES values are consumed downstream by wp_handle_upload() (which applies WP's standard upload sanitization) and sanitize_file_name() at storage time; running sanitize_text_field on tmp_name/size/error here would corrupt the values wp_handle_upload expects.
720 // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
721 if (empty($_FILES['files']) || !isset($_FILES['files']['name'][$field_id])) {
722 return array();
723 }
724
725 $files = array();
726 $names = $_FILES['files']['name'][$field_id];
727 $types = $_FILES['files']['type'][$field_id];
728 $tmp_names = $_FILES['files']['tmp_name'][$field_id];
729 $errors = $_FILES['files']['error'][$field_id];
730 $sizes = $_FILES['files']['size'][$field_id];
731 // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
732
733 // Normalize: could be a single file or array of files
734 if (is_array($names)) {
735 for ($i = 0; $i < count($names); $i++) {
736 if (empty($names[$i])) {
737 continue;
738 }
739 $files[] = array(
740 'name' => $names[$i],
741 'type' => $types[$i],
742 'tmp_name' => $tmp_names[$i],
743 'error' => $errors[$i],
744 'size' => $sizes[$i],
745 );
746 }
747 } else {
748 if (!empty($names)) {
749 $files[] = array(
750 'name' => $names,
751 'type' => $types,
752 'tmp_name' => $tmp_names,
753 'error' => $errors,
754 'size' => $sizes,
755 );
756 }
757 }
758
759 return $files;
760 }
761
762 /**
763 * Ensure the protected upload directory exists with .htaccess and index.php.
764 */
765 private static function EnsureUploadDir()
766 {
767 $upload_dir = wp_upload_dir();
768 $base_path = $upload_dir['basedir'] . '/' . self::GetUploadSubdir();
769
770 // Create base directory if needed
771 if (!is_dir($base_path)) {
772 wp_mkdir_p($base_path);
773 }
774
775 // Use WP_Filesystem for file writes (required by plugin review guidelines)
776 global $wp_filesystem;
777 if (empty($wp_filesystem)) {
778 require_once(ABSPATH . 'wp-admin/includes/file.php');
779 WP_Filesystem();
780 }
781
782 // WP_Filesystem() can fail to initialize (e.g. a non-direct method
783 // needing credentials that are not stored); this runs during visitor
784 // form submissions, so skip the scaffolding instead of fataling on a
785 // null object. FS_CHMOD_FILE is also only defined after successful
786 // initialization. The deny rules are defense-in-depth on top of the
787 // randomized directory name and filenames.
788 if (!empty($wp_filesystem)) {
789 // Create .htaccess to deny direct access (Apache)
790 $htaccess = $base_path . '/.htaccess';
791 if (!file_exists($htaccess)) {
792 $wp_filesystem->put_contents($htaccess, "# Deny direct access to uploaded form files\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n", FS_CHMOD_FILE);
793 }
794
795 // Create web.config to deny direct access (IIS). accessPolicy="None"
796 // strips Read/Script/Execute so any request to this dir returns 403,
797 // mirroring the Apache "Require all denied" posture above.
798 $webconfig = $base_path . '/web.config';
799 if (!file_exists($webconfig)) {
800 $wp_filesystem->put_contents($webconfig, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<configuration>\n <system.webServer>\n <handlers accessPolicy=\"None\" />\n </system.webServer>\n</configuration>\n", FS_CHMOD_FILE);
801 }
802
803 // Create index.php to prevent directory listing
804 $index = $base_path . '/index.php';
805 if (!file_exists($index)) {
806 $wp_filesystem->put_contents($index, "<?php\n// Silence is golden.\n", FS_CHMOD_FILE);
807 }
808 }
809
810 // Also protect the current year/month subdirectory
811 $current_path = $upload_dir['path'];
812 if (strpos($current_path, $base_path) === 0 && !is_dir($current_path)) {
813 wp_mkdir_p($current_path);
814 }
815 }
816 }
817