| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentBoards\App\Hooks\Handlers; |
| 4 |
|
| 5 |
use DateTimeImmutable; |
| 6 |
use Exception; |
| 7 |
use FluentBoards\App\Services\Libs\FileSystem; |
| 8 |
use FluentBoards\App\Models\Attachment; |
| 9 |
use function Sodium\add; |
| 10 |
|
| 11 |
class FileHandler |
| 12 |
{ |
| 13 |
// private function validateFile($file) |
| 14 |
// { |
| 15 |
// if (!$file) { |
| 16 |
// throw new Exception('File is empty.'); |
| 17 |
// } |
| 18 |
// if (!$this->isFileTypeSupported($file)) { |
| 19 |
// throw new Exception('File type not supported'); |
| 20 |
// } |
| 21 |
// if ($file['size'] > $this->getFileUploadLimit()) { |
| 22 |
// throw new Exception('File size is too large'); |
| 23 |
// } |
| 24 |
// } |
| 25 |
|
| 26 |
private function getFileUploadLimit() { |
| 27 |
// Logic for calculating file upload limit as in your original code |
| 28 |
return min( |
| 29 |
wp_convert_hr_to_bytes(ini_get('upload_max_filesize')), |
| 30 |
wp_convert_hr_to_bytes(ini_get('post_max_size')), |
| 31 |
wp_max_upload_size() |
| 32 |
); |
| 33 |
} |
| 34 |
|
| 35 |
/** |
| 36 |
* Delete an attachment's stored file once no attachment row references it. |
| 37 |
* |
| 38 |
* Local files are removed here, but only when the stored path belongs to |
| 39 |
* Fluent Boards. Files on a cloud driver are handed to the storage add-on |
| 40 |
* through the `fluent_boards/delete_remote_attachment_file` action, because |
| 41 |
* the core plugin holds no bucket credentials. |
| 42 |
* |
| 43 |
* @param mixed $attachment Attachment row or snapshot (driver, file_path, attachment_type). |
| 44 |
* @param int|null $boardId |
| 45 |
* @param array|null $sharedReferences Preloaded local references from getSharedLocalReferences(). |
| 46 |
* @return bool |
| 47 |
*/ |
| 48 |
public function deleteAttachmentFile($attachment, $boardId = null, $sharedReferences = null) |
| 49 |
{ |
| 50 |
if ( |
| 51 |
!$attachment || |
| 52 |
$attachment->attachment_type === 'url' || |
| 53 |
empty($attachment->file_path) |
| 54 |
) { |
| 55 |
return false; |
| 56 |
} |
| 57 |
|
| 58 |
if (!empty($attachment->driver) && $attachment->driver !== 'local') { |
| 59 |
// Cloned tasks share the remote object, so keep it while another row points at it. |
| 60 |
if (Attachment::where('file_path', $attachment->file_path)->exists()) { |
| 61 |
return false; |
| 62 |
} |
| 63 |
|
| 64 |
do_action('fluent_boards/delete_remote_attachment_file', $attachment); |
| 65 |
|
| 66 |
return true; |
| 67 |
} |
| 68 |
|
| 69 |
$storedFilename = rawurldecode((string) $attachment->file_path); |
| 70 |
$isBareFilename = $storedFilename !== '' |
| 71 |
&& strpos($storedFilename, '/') === false |
| 72 |
&& strpos($storedFilename, '\\') === false; |
| 73 |
$filePath = FileSystem::resolveLocalAttachmentPath($attachment->file_path, $boardId); |
| 74 |
|
| 75 |
if (!$filePath) { |
| 76 |
return false; |
| 77 |
} |
| 78 |
|
| 79 |
// Legacy filenames need the board-qualified URL to distinguish same-named files across boards. |
| 80 |
if ($isBareFilename) { |
| 81 |
if ( |
| 82 |
empty($attachment->full_url) || |
| 83 |
$this->hasSharedLocalReference('full_url', $attachment->full_url, $sharedReferences) |
| 84 |
) { |
| 85 |
return false; |
| 86 |
} |
| 87 |
} elseif ($this->hasSharedLocalReference('file_path', $attachment->file_path, $sharedReferences)) { |
| 88 |
return false; |
| 89 |
} |
| 90 |
|
| 91 |
return (bool) wp_delete_file($filePath); |
| 92 |
} |
| 93 |
|
| 94 |
/** |
| 95 |
* Batch the shared-reference lookups deleteAttachmentFile() makes for local files. |
| 96 |
* |
| 97 |
* @param iterable $attachments Deleted attachment rows or snapshots. |
| 98 |
* @return array ['file_path' => [value => true], 'full_url' => [value => true]] |
| 99 |
*/ |
| 100 |
public function getSharedLocalReferences($attachments) |
| 101 |
{ |
| 102 |
$values = ['file_path' => [], 'full_url' => []]; |
| 103 |
foreach ($attachments as $attachment) { |
| 104 |
if (!empty($attachment->file_path)) { |
| 105 |
$values['file_path'][] = (string) $attachment->file_path; |
| 106 |
} |
| 107 |
if (!empty($attachment->full_url)) { |
| 108 |
$values['full_url'][] = (string) $attachment->full_url; |
| 109 |
} |
| 110 |
} |
| 111 |
|
| 112 |
$references = ['file_path' => [], 'full_url' => []]; |
| 113 |
foreach ($values as $column => $columnValues) { |
| 114 |
foreach (array_chunk(array_values(array_unique($columnValues)), 200) as $chunk) { |
| 115 |
foreach (Attachment::whereIn($column, $chunk)->pluck($column)->toArray() as $value) { |
| 116 |
$references[$column][(string) $value] = true; |
| 117 |
} |
| 118 |
} |
| 119 |
} |
| 120 |
|
| 121 |
return $references; |
| 122 |
} |
| 123 |
|
| 124 |
private function hasSharedLocalReference($column, $value, $sharedReferences) |
| 125 |
{ |
| 126 |
if (is_array($sharedReferences)) { |
| 127 |
return isset($sharedReferences[$column][(string) $value]); |
| 128 |
} |
| 129 |
|
| 130 |
return Attachment::where($column, $value)->exists(); |
| 131 |
} |
| 132 |
|
| 133 |
|
| 134 |
/** |
| 135 |
* Action Scheduler callback: delete the remote files of attachment rows that |
| 136 |
* were removed in bulk (a deleted board), outside the request that deleted them. |
| 137 |
* |
| 138 |
* @param array $files List of ['file_path' => string, 'driver' => string]. |
| 139 |
* @return void |
| 140 |
*/ |
| 141 |
public function deleteRemoteAttachmentFiles($files) |
| 142 |
{ |
| 143 |
foreach ((array) $files as $file) { |
| 144 |
if (!is_array($file) || empty($file['file_path']) || empty($file['driver'])) { |
| 145 |
continue; |
| 146 |
} |
| 147 |
|
| 148 |
$this->deleteAttachmentFile((object) [ |
| 149 |
'attachment_type' => 'file', |
| 150 |
'driver' => sanitize_key($file['driver']), |
| 151 |
'file_path' => (string) $file['file_path'], |
| 152 |
]); |
| 153 |
} |
| 154 |
} |
| 155 |
|
| 156 |
/** |
| 157 |
* Summary of isFileTypeSupported checking file type it will allow only file which is readable by browser |
| 158 |
* @param mixed $file |
| 159 |
* TODO: Refactorable: This can be in a Helper class. and we may pass it to frontend via wp_localize_script appvars |
| 160 |
* so that we can check similarly for better experience. |
| 161 |
* @return bool |
| 162 |
*/ |
| 163 |
public function isFileTypeSupported($file) |
| 164 |
{ |
| 165 |
// Define supported file types that are generally allowed by user |
| 166 |
$allowedMimeTypes = get_allowed_mime_types(); |
| 167 |
|
| 168 |
// Check if the file type is supported |
| 169 |
return in_array(strtolower($file['type']), $allowedMimeTypes); |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* @throws Exception |
| 174 |
*/ |
| 175 |
public function handleMediaFileUpload($data) |
| 176 |
{ |
| 177 |
// Check if file was uploaded |
| 178 |
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verification handled by REST API/controller layer |
| 179 |
if (!isset($_FILES['file']['tmp_name']) || !isset($_FILES['file']['name'])) { |
| 180 |
throw new Exception(esc_html__('No file was uploaded. Please try again.', 'fluent-boards')); |
| 181 |
} |
| 182 |
|
| 183 |
// Sanitize filename from request for validation |
| 184 |
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verification handled by REST API/controller layer |
| 185 |
$filename = sanitize_file_name(wp_unslash($_FILES['file']['name'])); |
| 186 |
|
| 187 |
// Validate and sanitize tmp_name before use |
| 188 |
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verification handled by REST API/controller layer |
| 189 |
$tmp_name = sanitize_text_field(wp_unslash($_FILES['file']['tmp_name'])); |
| 190 |
|
| 191 |
if (empty($tmp_name) || !file_exists($tmp_name) || !is_uploaded_file($tmp_name)) { |
| 192 |
throw new Exception(esc_html__('Invalid upload. Please try again.', 'fluent-boards')); |
| 193 |
} |
| 194 |
|
| 195 |
// Check if the uploaded file is an image |
| 196 |
$wp_filetype = wp_check_filetype_and_ext($tmp_name, $filename); |
| 197 |
|
| 198 |
if ( ! wp_match_mime_types( 'image', $wp_filetype['type'] ) ) { |
| 199 |
throw new Exception(esc_html__('The uploaded file is not a valid image. Please try again.', 'fluent-boards')); |
| 200 |
} |
| 201 |
require_once(ABSPATH . 'wp-admin/includes/image.php'); |
| 202 |
require_once( ABSPATH . 'wp-admin/includes/file.php' ); |
| 203 |
require_once( ABSPATH . 'wp-admin/includes/media.php' ); |
| 204 |
$attachment_id = media_handle_upload( 'file', 0, [] ); |
| 205 |
|
| 206 |
$attachment = wp_prepare_attachment_for_js( $attachment_id); |
| 207 |
if(!$attachment) { |
| 208 |
throw new Exception(esc_html__('The uploaded file is not a valid image. Please try again.', 'fluent-boards')); |
| 209 |
} else { |
| 210 |
return $attachment; |
| 211 |
} |
| 212 |
} |
| 213 |
|
| 214 |
} |
| 215 |
|