PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.1.0
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.1.0
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 1 week 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 1 week 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
815 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 * @param array $types Entries of array('ext' => ..., 'label' => ..., 'mime' => ...).
158 */
159 $types = apply_filters('superbaddons_form_allowed_file_types', $types);
160
161 if (!is_array($types)) {
162 return array();
163 }
164
165 $sanitized = array();
166 $seen = array();
167 foreach ($types as $type) {
168 if (!is_array($type) || empty($type['ext']) || !is_string($type['ext']) || empty($type['mime']) || !is_string($type['mime'])) {
169 continue;
170 }
171 $ext = strtolower(trim($type['ext']));
172 if (strpos($ext, '.') !== 0) {
173 $ext = '.' . $ext;
174 }
175 // Single alphanumeric segment only — multi-segment entries like
176 // .tar.gz can never match pathinfo(PATHINFO_EXTENSION) in validation
177 if (!preg_match('/^\.[a-z0-9]+$/', $ext)) {
178 continue;
179 }
180 // Deny-list wins over the filter
181 if (self::HasDangerousExtension('file' . $ext)) {
182 continue;
183 }
184 if (isset($seen[$ext])) {
185 continue;
186 }
187 $seen[$ext] = true;
188 $sanitized[] = array(
189 'ext' => $ext,
190 'label' => isset($type['label']) && is_string($type['label']) && $type['label'] !== '' ? $type['label'] : strtoupper(substr($ext, 1)),
191 'mime' => $type['mime'],
192 );
193 }
194 return $sanitized;
195 }
196
197 /**
198 * Default accepted extensions for file fields whose server-side config
199 * carries no explicit accept list. Gutenberg omits attributes that still
200 * equal their block.json defaults when serializing, so a file field whose
201 * settings were never touched reaches the server without fileSettings at
202 * all; this list makes validation enforce exactly the types the editor UI
203 * displays for that state. Must be kept in sync with the fileSettings.accept
204 * default in /form-field/block.json.
205 *
206 * @return array Extensions with leading dot
207 */
208 public static function GetDefaultAccept()
209 {
210 return array('.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.doc', '.docx', '.txt', '.xls', '.xlsx', '.csv');
211 }
212
213 /**
214 * Extension => MIME map derived from GetAllowedFileTypes, in the format
215 * wp_check_filetype() and wp_handle_upload() expect.
216 *
217 * @return array e.g. array('mp4' => 'video/mp4', ...)
218 */
219 public static function GetAllowedMimes()
220 {
221 $mimes = array();
222 foreach (self::GetAllowedFileTypes() as $type) {
223 $mimes[substr($type['ext'], 1)] = $type['mime'];
224 }
225 return $mimes;
226 }
227
228 /**
229 * Validate uploaded files for a field against its config.
230 * Called by FormFieldValidator before files are processed.
231 *
232 * @param array $field_config Field configuration from server-side config
233 * @param string $default_required_message Form-wide message for empty required fields, '' for the localized default
234 * @return string Error message, empty if valid
235 */
236 public static function ValidateFiles($field_config, $default_required_message = '')
237 {
238 $field_id = isset($field_config['fieldId']) ? $field_config['fieldId'] : '';
239 $required = !empty($field_config['required']);
240 $fs = isset($field_config['fileSettings']) && is_array($field_config['fileSettings']) ? $field_config['fileSettings'] : array();
241 $max_file_size = isset($fs['maxFileSize']) ? floatval($fs['maxFileSize']) : 5;
242 $multiple = !empty($fs['multiple']);
243 $max_files = isset($fs['maxFiles']) ? intval($fs['maxFiles']) : 5;
244 // Like the other fileSettings keys above, a missing accept list means
245 // the field was left at its block.json defaults (default-valued
246 // attributes are omitted from serialized markup), so enforce the
247 // default list the editor UI shows rather than the wider master list.
248 // A present-but-empty list (legacy content saved before the editor
249 // refused to empty the picker) gets the same defaults.
250 $accept = isset($fs['accept']) && is_array($fs['accept']) && !empty($fs['accept']) ? $fs['accept'] : self::GetDefaultAccept();
251
252 // Check if files were submitted for this field
253 $files = self::GetUploadedFiles($field_id);
254
255 if (empty($files)) {
256 // Conditional logic: if field has active rules, skip required check
257 // (handled by FormFieldValidator before calling us, but guard here too)
258 if ($required) {
259 $logic = isset($field_config['conditionalLogic']) ? $field_config['conditionalLogic'] : null;
260 if ($logic && isset($logic['ruleGroups']) && is_array($logic['ruleGroups'])) {
261 foreach ($logic['ruleGroups'] as $group) {
262 if (isset($group['conditions']) && is_array($group['conditions'])) {
263 foreach ($group['conditions'] as $cond) {
264 if (!empty($cond['field'])) {
265 return '';
266 }
267 }
268 }
269 }
270 }
271 return FormFieldValidator::GetRequiredMessage($field_config, $default_required_message);
272 }
273 return '';
274 }
275
276 // Validate file count
277 if (!$multiple && count($files) > 1) {
278 return __('Only one file is allowed.', 'superb-blocks');
279 }
280 if ($multiple && count($files) > $max_files) {
281 /* translators: %d: maximum number of files allowed for this field */
282 return sprintf(__('Maximum %d files allowed.', 'superb-blocks'), $max_files);
283 }
284
285 // Validate each file
286 $max_bytes = $max_file_size * 1024 * 1024;
287 $allowed_mimes = self::GetAllowedMimes();
288 foreach ($files as $file) {
289 // Check for upload errors
290 if (!empty($file['error']) && intval($file['error']) !== UPLOAD_ERR_OK) {
291 $upload_error = intval($file['error']);
292 if ($upload_error === UPLOAD_ERR_INI_SIZE || $upload_error === UPLOAD_ERR_FORM_SIZE) {
293 return __('File exceeds the maximum upload size for this site.', 'superb-blocks');
294 }
295 return __('File upload failed.', 'superb-blocks');
296 }
297
298 // Unconditional deny-list: reject dangerous extensions regardless of the
299 // per-field accept whitelist. Catches misconfigured accept[] entries and
300 // double-extension filenames (e.g. shell.php.jpg) before any further check.
301 if (!empty($file['name']) && self::HasDangerousExtension($file['name'])) {
302 return __('File type is not allowed.', 'superb-blocks');
303 }
304
305 // Validate size
306 if (isset($file['size']) && $file['size'] > $max_bytes) {
307 /* translators: %s: maximum file size in megabytes */
308 return sprintf(__('File size exceeds %sMB.', 'superb-blocks'), $max_file_size);
309 }
310
311 // Validate extension against the master list and the field whitelist
312 if (!empty($file['name'])) {
313 $ext = '.' . strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
314
315 // Master list: extension must be a known allowed type
316 // (filterable via superbaddons_form_allowed_file_types)
317 if (!isset($allowed_mimes[substr($ext, 1)])) {
318 return __('File type is not allowed.', 'superb-blocks');
319 }
320
321 if (!empty($accept)) {
322 $allowed = false;
323 foreach ($accept as $accepted) {
324 // Accept list entries are like ".jpg", ".pdf", etc.
325 if (strtolower(trim($accepted)) === $ext) {
326 $allowed = true;
327 break;
328 }
329 }
330 if (!$allowed) {
331 return __('File type is not allowed.', 'superb-blocks');
332 }
333 }
334 }
335
336 // Additional MIME validation using WordPress, restricted to the master list
337 if (!empty($file['tmp_name']) && !empty($file['name'])) {
338 $wp_filetype = wp_check_filetype($file['name'], $allowed_mimes);
339 if (empty($wp_filetype['ext']) || empty($wp_filetype['type'])) {
340 return __('File type is not allowed.', 'superb-blocks');
341 }
342 }
343 }
344
345 return '';
346 }
347
348 /**
349 * Process and store uploaded files for a submission.
350 *
351 * @param array $form_fields_config Array of field config arrays
352 * @return array fieldId => array of file metadata arrays
353 */
354 public static function ProcessUploads($form_fields_config)
355 {
356 // Nonce verified upstream by FormController::SubmitCallback before this method runs; presence check only, no value processed here.
357 // phpcs:ignore WordPress.Security.NonceVerification.Missing
358 if (empty($_FILES['files'])) {
359 return array();
360 }
361
362 require_once(ABSPATH . 'wp-admin/includes/file.php');
363
364 $result = array();
365
366 // Build lookup for file field configs
367 $file_field_configs = array();
368 foreach ($form_fields_config as $fc) {
369 $ftype = isset($fc['fieldType']) ? $fc['fieldType'] : '';
370 $fid = isset($fc['fieldId']) ? $fc['fieldId'] : '';
371 if ($ftype === 'file' && $fid !== '') {
372 $file_field_configs[$fid] = $fc;
373 }
374 }
375
376 $allowed_mimes = self::GetAllowedMimes();
377 $upload_subdir = self::GetUploadSubdir();
378
379 foreach ($file_field_configs as $field_id => $config) {
380 $files = self::GetUploadedFiles($field_id);
381 if (empty($files)) {
382 continue;
383 }
384
385 $field_files = array();
386 foreach ($files as $file) {
387 if (empty($file['tmp_name']) || intval($file['error']) !== UPLOAD_ERR_OK) {
388 continue;
389 }
390
391 // Hook into upload_dir to redirect to our protected directory
392 $dir_filter = function ($dirs) use ($upload_subdir) {
393 $subdir = '/' . $upload_subdir . $dirs['subdir'];
394 $dirs['subdir'] = $subdir;
395 $dirs['path'] = $dirs['basedir'] . $subdir;
396 $dirs['url'] = $dirs['baseurl'] . $subdir;
397 return $dirs;
398 };
399 add_filter('upload_dir', $dir_filter);
400
401 // Ensure protected directory exists
402 self::EnsureUploadDir();
403
404 $uploaded = wp_handle_upload($file, array(
405 'test_form' => false,
406 'action' => 'superb_form_upload',
407 'mimes' => $allowed_mimes,
408 // Random per-file suffix: makes stored URLs unguessable on
409 // servers where the directory deny rules do not apply
410 // (Nginx), and removes wp_unique_filename()'s
411 // check-then-move race where two concurrent submissions
412 // uploading the same filename could silently overwrite
413 // each other. The original name is kept in the submission
414 // metadata and restored on download via Content-Disposition.
415 'unique_filename_callback' => array(__CLASS__, 'GenerateStoredFilename'),
416 ));
417
418 remove_filter('upload_dir', $dir_filter);
419
420 if (!empty($uploaded['file'])) {
421 $field_files[] = array(
422 'name' => sanitize_file_name($file['name']),
423 'path' => $uploaded['file'],
424 'url' => isset($uploaded['url']) ? $uploaded['url'] : '',
425 'type' => isset($uploaded['type']) ? $uploaded['type'] : '',
426 'size' => $file['size'],
427 );
428 }
429 }
430
431 if (!empty($field_files)) {
432 $result[$field_id] = $field_files;
433
434 /**
435 * Fires after files are uploaded for a form field.
436 *
437 * @param string $field_id The field ID.
438 * @param array $field_files Array of file metadata.
439 * @param array $config Field configuration.
440 */
441 do_action('superbaddons_form_after_upload', $field_id, $field_files, $config);
442 }
443 }
444
445 return $result;
446 }
447
448 /**
449 * unique_filename_callback for wp_handle_upload: append a random suffix
450 * to the stored filename. wp_unique_filename passes $name as the full
451 * sanitized basename including the extension, and $ext as '.pdf' style.
452 *
453 * @param string $dir Target directory
454 * @param string $name Sanitized basename including extension
455 * @param string $ext Extension with leading dot, '' when none
456 * @return string
457 */
458 public static function GenerateStoredFilename($dir, $name, $ext)
459 {
460 $base = $name;
461 if ($ext !== '' && substr($name, strlen($name) - strlen($ext)) === $ext) {
462 $base = substr($name, 0, strlen($name) - strlen($ext));
463 }
464 if ($base === '') {
465 $base = 'file';
466 }
467
468 // Keep the stored name inside every real-world limit: 255 bytes per
469 // path component on ext4/XFS/NTFS, ~143 bytes on eCryptfs, and
470 // Windows' 260-character full-path cap. Neither sanitize_file_name()
471 // nor wp_unique_filename() truncates, and an overlong name would make
472 // the move inside wp_handle_upload fail after validation has already
473 // passed. 48 chars is at most 192 bytes of UTF-8;
474 $base = mb_substr($base, 0, 48);
475
476 $attempts = 0;
477 do {
478 $suffix = strtolower(wp_generate_password(12, false, false));
479 $filename = $base . '-' . $suffix . $ext;
480 $attempts++;
481 } while ($attempts < 3 && file_exists(trailingslashit($dir) . $filename));
482
483 return $filename;
484 }
485
486 /**
487 * Delete all files associated with a submission's field data.
488 *
489 * @param array $fields Submission fields (field_id => value)
490 */
491 public static function DeleteSubmissionFiles($fields)
492 {
493 if (!is_array($fields)) {
494 return;
495 }
496
497 foreach ($fields as $value) {
498 // File fields store an array of file metadata
499 if (!is_array($value)) {
500 continue;
501 }
502
503 foreach ($value as $file) {
504 if (!is_array($file) || !isset($file['path'])) {
505 continue;
506 }
507 $path = self::ResolveStoredPath($file['path']);
508 if ($path !== '' && file_exists($path)) {
509 wp_delete_file($path);
510 }
511 }
512 }
513 }
514
515 /**
516 * Delete the plugin's form upload directories entirely: all remaining
517 * files (including any orphaned by crashed requests), the
518 * .htaccess/web.config/index.php scaffolding, and the empty year/month
519 * subdirectories. Called from plugin reset after every stored submission
520 * has been deleted; both the current tokenized directory and the legacy
521 * unhashed directory are removed. Reads the token option directly so no
522 * new token is minted when none exists.
523 */
524 public static function DeleteUploadDirectories()
525 {
526 global $wp_filesystem;
527 if (empty($wp_filesystem)) {
528 require_once(ABSPATH . 'wp-admin/includes/file.php');
529 WP_Filesystem();
530 }
531 if (empty($wp_filesystem)) {
532 return;
533 }
534
535 $upload_dir = wp_upload_dir();
536 $bases = array($upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR);
537 $token = get_option(self::UPLOAD_DIR_TOKEN_OPTION);
538 if (is_string($token) && preg_match('/^[a-z0-9]{8,64}$/', $token)) {
539 $bases[] = $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR . '-' . $token;
540 }
541
542 foreach ($bases as $base) {
543 if (is_dir($base)) {
544 $wp_filesystem->delete($base, true);
545 }
546 }
547 }
548
549 /**
550 * Serve a file from the protected upload directory.
551 * Streams the file with appropriate headers and exits.
552 *
553 * @param string $file_path Absolute path to the file
554 * @param string $original_name Original file name for download
555 * @param string $mime_type MIME type
556 */
557 public static function ServeFile($file_path, $original_name, $mime_type)
558 {
559 // Resolve the current directory before touching the stored path: on
560 // the very first touch this mints the token and migrates the legacy
561 // directory, which would otherwise move the file out from under a
562 // path resolved earlier in this request.
563 $upload_dir = wp_upload_dir();
564 $current_base = $upload_dir['basedir'] . '/' . self::GetUploadSubdir();
565
566 // Stored paths may predate the randomized upload directory
567 $file_path = self::ResolveStoredPath($file_path);
568 if ($file_path === '' || !file_exists($file_path) || !is_readable($file_path)) {
569 return new \WP_REST_Response(array(
570 'success' => false,
571 'message' => __('File not found.', 'superb-blocks'),
572 ), 404);
573 }
574
575 // Ensure file is within one of our upload directories directly under
576 // the uploads basedir: the unhashed directory for files stored before
577 // the randomized directory token was introduced, or any tokenized
578 // variant. Accepting the token pattern instead of only the current
579 // token keeps files servable even if the token option is ever lost
580 // and re-minted, which would otherwise strand every earlier upload
581 // under the retired directory. Paths come exclusively from submission
582 // meta, so this does not widen what a request can reach. Segment-wise
583 // comparison keeps the check exact, so a sibling directory that
584 // merely starts with the base name can never pass.
585 $allowed = false;
586 $real_path = realpath($file_path);
587 $real_uploads = realpath($upload_dir['basedir']);
588 if ($real_path !== false && $real_uploads !== false && strpos($real_path, $real_uploads . DIRECTORY_SEPARATOR) === 0) {
589 $segments = explode(DIRECTORY_SEPARATOR, substr($real_path, strlen($real_uploads) + 1));
590 if (count($segments) > 1 && preg_match('/^' . preg_quote(self::UPLOAD_SUBDIR, '/') . '(-[a-z0-9]{8,64})?$/', $segments[0])) {
591 $allowed = true;
592 }
593 }
594
595 if (!$allowed) {
596 return new \WP_REST_Response(array(
597 'success' => false,
598 'message' => __('Access denied.', 'superb-blocks'),
599 ), 403);
600 }
601
602 // A concurrent request performing the one-time legacy-directory
603 // migration can move the file between the checks above and the
604 // stream below. Re-resolve and re-confine (the migrated location can
605 // only be the current randomized directory) instead of streaming a
606 // dead path.
607 if (!file_exists($file_path)) {
608 $file_path = self::ResolveStoredPath($file_path);
609 $real_path = $file_path !== '' ? realpath($file_path) : false;
610 $real_base = realpath($current_base);
611 if ($real_path === false || $real_base === false || strpos($real_path, $real_base . DIRECTORY_SEPARATOR) !== 0) {
612 return new \WP_REST_Response(array(
613 'success' => false,
614 'message' => __('File not found.', 'superb-blocks'),
615 ), 404);
616 }
617 }
618
619 $safe_name = str_replace(array('"', "\r", "\n"), '', sanitize_file_name($original_name));
620 header('Content-Type: ' . $mime_type);
621 header('X-Content-Type-Options: nosniff');
622 header('Content-Disposition: attachment; filename="' . $safe_name . '"');
623 header('Content-Length: ' . filesize($file_path));
624 header('Cache-Control: no-store, no-cache, must-revalidate');
625
626 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
627 readfile($file_path);
628 // Plain exit, not wp_die(): the die handler appends an HTML error
629 // page skeleton after the streamed bytes.
630 exit;
631 }
632
633 /**
634 * Check if a filename has a dangerous extension in any dot-separated segment.
635 * Catches single (shell.php), double-extension (shell.php.jpg), and dotfile
636 * (.htaccess) cases. Server-config and active-content extensions are denied
637 * because the upload subdir is web-reachable on IIS/Nginx where .htaccess is
638 * ignored, so a stored .html or .svg could host an XSS payload even though
639 * wp_handle_upload would not save a .php file under an unauthenticated request.
640 *
641 * @param string $filename
642 * @return bool
643 */
644 private static function HasDangerousExtension($filename)
645 {
646 static $deny = array(
647 // PHP and PHP-handler variants
648 'php',
649 'php3',
650 'php4',
651 'php5',
652 'php7',
653 'php8',
654 'phtml',
655 'pht',
656 'phar',
657 'phps',
658 // Other server-side scripting
659 'cgi',
660 'pl',
661 'py',
662 'rb',
663 'jsp',
664 'jspx',
665 'asp',
666 'aspx',
667 'cer',
668 'cfm',
669 'shtml',
670 // Executables and shells
671 'exe',
672 'msi',
673 'sh',
674 'bat',
675 'cmd',
676 'com',
677 'vb',
678 'vbs',
679 'wsh',
680 // Server config
681 'htaccess',
682 'htpasswd',
683 'ini',
684 'env',
685 // Active web content (inline XSS if directly accessible)
686 'html',
687 'htm',
688 'xhtml',
689 'svg',
690 'svgz',
691 'js',
692 'mjs',
693 'xml',
694 );
695
696 $parts = explode('.', strtolower($filename));
697 array_shift($parts); // skip basename, only inspect dot-segments
698 foreach ($parts as $part) {
699 if ($part !== '' && in_array($part, $deny, true)) {
700 return true;
701 }
702 }
703 return false;
704 }
705
706 /**
707 * Get uploaded files for a specific field ID from $_FILES.
708 * Normalizes the PHP $_FILES array for multiple files.
709 *
710 * @param string $field_id
711 * @return array Array of file arrays (name, type, tmp_name, error, size)
712 */
713 private static function GetUploadedFiles($field_id)
714 {
715 // Nonce verified upstream by FormController::SubmitCallback before any caller of this method runs.
716 // 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.
717 // 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.
718 // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
719 if (empty($_FILES['files']) || !isset($_FILES['files']['name'][$field_id])) {
720 return array();
721 }
722
723 $files = array();
724 $names = $_FILES['files']['name'][$field_id];
725 $types = $_FILES['files']['type'][$field_id];
726 $tmp_names = $_FILES['files']['tmp_name'][$field_id];
727 $errors = $_FILES['files']['error'][$field_id];
728 $sizes = $_FILES['files']['size'][$field_id];
729 // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
730
731 // Normalize: could be a single file or array of files
732 if (is_array($names)) {
733 for ($i = 0; $i < count($names); $i++) {
734 if (empty($names[$i])) {
735 continue;
736 }
737 $files[] = array(
738 'name' => $names[$i],
739 'type' => $types[$i],
740 'tmp_name' => $tmp_names[$i],
741 'error' => $errors[$i],
742 'size' => $sizes[$i],
743 );
744 }
745 } else {
746 if (!empty($names)) {
747 $files[] = array(
748 'name' => $names,
749 'type' => $types,
750 'tmp_name' => $tmp_names,
751 'error' => $errors,
752 'size' => $sizes,
753 );
754 }
755 }
756
757 return $files;
758 }
759
760 /**
761 * Ensure the protected upload directory exists with .htaccess and index.php.
762 */
763 private static function EnsureUploadDir()
764 {
765 $upload_dir = wp_upload_dir();
766 $base_path = $upload_dir['basedir'] . '/' . self::GetUploadSubdir();
767
768 // Create base directory if needed
769 if (!is_dir($base_path)) {
770 wp_mkdir_p($base_path);
771 }
772
773 // Use WP_Filesystem for file writes (required by plugin review guidelines)
774 global $wp_filesystem;
775 if (empty($wp_filesystem)) {
776 require_once(ABSPATH . 'wp-admin/includes/file.php');
777 WP_Filesystem();
778 }
779
780 // WP_Filesystem() can fail to initialize (e.g. a non-direct method
781 // needing credentials that are not stored); this runs during visitor
782 // form submissions, so skip the scaffolding instead of fataling on a
783 // null object. FS_CHMOD_FILE is also only defined after successful
784 // initialization. The deny rules are defense-in-depth on top of the
785 // randomized directory name and filenames.
786 if (!empty($wp_filesystem)) {
787 // Create .htaccess to deny direct access (Apache)
788 $htaccess = $base_path . '/.htaccess';
789 if (!file_exists($htaccess)) {
790 $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);
791 }
792
793 // Create web.config to deny direct access (IIS). accessPolicy="None"
794 // strips Read/Script/Execute so any request to this dir returns 403,
795 // mirroring the Apache "Require all denied" posture above.
796 $webconfig = $base_path . '/web.config';
797 if (!file_exists($webconfig)) {
798 $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);
799 }
800
801 // Create index.php to prevent directory listing
802 $index = $base_path . '/index.php';
803 if (!file_exists($index)) {
804 $wp_filesystem->put_contents($index, "<?php\n// Silence is golden.\n", FS_CHMOD_FILE);
805 }
806 }
807
808 // Also protect the current year/month subdirectory
809 $current_path = $upload_dir['path'];
810 if (strpos($current_path, $base_path) === 0 && !is_dir($current_path)) {
811 wp_mkdir_p($current_path);
812 }
813 }
814 }
815