subDir = $subDir;
return $this;
}
/**
* Read file content from custom upload dir of this application
* @return string [path]
*/
public function _get($file)
{
$arr = explode('/', $file);
$fileName = end($arr);
if ($this->subDir) {
$fileName = $this->subDir . DIRECTORY_SEPARATOR . $fileName;
}
$filePath = $this->getDir() . DIRECTORY_SEPARATOR . $fileName;
// Use WordPress Filesystem API
global $wp_filesystem;
if (!function_exists('WP_Filesystem')) {
require_once(ABSPATH . 'wp-admin/includes/file.php');
}
WP_Filesystem();
return $wp_filesystem->get_contents($filePath);
}
/**
* Get custom upload dir name of this application
* @return string [directory path]
*/
public function _getDir()
{
$uploadDir = wp_upload_dir();
$fbsUploadDir = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
if ($this->subDir) {
return $uploadDir['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir. DIRECTORY_SEPARATOR. $this->subDir;
}
return $uploadDir['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
}
/**
* Prepare local storage with Apache rules or verified host-managed protection.
* Installing rules alone does not prove the HTTP server enforces them.
* Pass false to defer any required HTTP verification to cron.
*/
public function _installUploadProtectionRules($allowProbe = true)
{
// Protect the root even when called on a board-specific filesystem instance.
$directory = (new static())->_getDir();
if (!wp_mkdir_p($directory)) {
return false;
}
$path = $directory . '/.htaccess';
$rules = [
'',
'Require all denied',
'',
'',
'Order deny,allow',
'Deny from all',
'',
];
// Hosts may lock security configuration while leaving upload storage writable.
if (is_file($path) && is_readable($path)) {
$contents = str_replace("\r\n", "\n", file_get_contents($path));
if (preg_match_all('/^# BEGIN Fluent Boards\n(.*?)^# END Fluent Boards$/ms', $contents, $blocks) === 1) {
$existingRules = array_values(array_filter(array_map('trim', explode("\n", $blocks[1][0])), function ($line) {
return $line !== '' && strpos($line, '#') !== 0;
}));
if ($existingRules === $rules) {
return true;
}
}
}
if (!function_exists('insert_with_markers')) {
require_once ABSPATH . 'wp-admin/includes/misc.php';
}
if (insert_with_markers($path, 'Fluent Boards', $rules)) {
return true;
}
// Unmarked, locked host rules and server-level restrictions need no rewrite.
return $this->_checkUploadProtection($allowProbe) === true;
}
/**
* Probe direct access without login cookies using a harmless temporary file.
* Return true for denied access, false for exposure, or null when unverified.
* Pass false to read only the cache and schedule a missing check in cron.
*/
public function _checkUploadProtection($allowProbe = true)
{
$directory = (new static())->_getDir();
$uploads = wp_upload_dir();
$folder = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
$url = trailingslashit($uploads['baseurl']) . trim($folder, '/');
$rulesPath = $directory . '/.htaccess';
$rulesHash = is_file($rulesPath) && is_readable($rulesPath) ? hash_file('sha256', $rulesPath) : '';
$cacheKey = Constant::UPLOAD_PROTECTION_CHECK_PREFIX . md5($directory . '|' . $url);
$cached = get_transient($cacheKey);
if (is_array($cached) && isset($cached['rules_hash']) && $cached['rules_hash'] === $rulesHash && array_key_exists('blocked', $cached)) {
return $cached['blocked'];
}
if (!$allowProbe) {
$hook = 'fluent_boards/check_upload_protection';
if (!wp_next_scheduled($hook)) {
wp_schedule_single_event(time(), $hook);
}
return null;
}
$blocked = null;
$filename = 'fbs-access-check-' . wp_generate_uuid4() . '.txt';
$path = trailingslashit($directory) . $filename;
$contents = wp_generate_uuid4();
try {
if (is_dir($directory) && is_writable($directory)
&& file_put_contents($path, $contents, LOCK_EX) === strlen($contents)) {
// The upload URL comes from trusted site configuration, never request input.
$response = wp_remote_get($url . '/' . $filename, [
'timeout' => 5,
'redirection' => 0,
'limit_response_size' => 1024,
'headers' => ['Cache-Control' => 'no-cache'],
]);
if (!is_wp_error($response)) {
$status = wp_remote_retrieve_response_code($response);
if (in_array($status, [403, 404], true)) {
$blocked = true;
} elseif ($status === 200 && wp_remote_retrieve_body($response) === $contents) {
$blocked = false;
}
}
}
} finally {
if (is_file($path)) {
wp_delete_file($path);
}
}
set_transient($cacheKey, ['rules_hash' => $rulesHash, 'blocked' => $blocked], 5 * MINUTE_IN_SECONDS);
return $blocked;
}
/**
* Get absolute path of file using custom upload dir name of this application
* @return string [file path]
*/
public function _getAbsolutePathOfFile($file)
{
return $this->_getDir() . DIRECTORY_SEPARATOR . $file;
}
/**
* Resolve a stored attachment path only when it belongs to Fluent Boards uploads.
*
* @param string $storedPath
* @param int|null $boardId
* @return string|null
*/
public function _resolveLocalAttachmentPath($storedPath, $boardId = null)
{
if (!$storedPath) {
return null;
}
$storedFilename = rawurldecode((string) $storedPath);
$isBareFilename = $storedFilename !== ''
&& strpos($storedFilename, '/') === false
&& strpos($storedFilename, '\\') === false;
if ($isBareFilename && $boardId === null) {
return null;
}
$filePath = $isBareFilename ? null : realpath($storedPath);
$allowedDirectory = $this->_getDir();
$pluginRoot = realpath($allowedDirectory);
if (!$pluginRoot) {
return null;
}
$pluginRoot = rtrim($pluginRoot, DIRECTORY_SEPARATOR);
$allowedRoot = $pluginRoot;
if ($boardId !== null) {
if (!is_int($boardId) && !is_string($boardId)) {
return null;
}
$boardId = filter_var($boardId, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
if ($boardId === false) {
return null;
}
$allowedDirectory .= DIRECTORY_SEPARATOR . 'board_' . $boardId;
if (is_link($allowedDirectory)) {
return null;
}
$allowedRoot = realpath($allowedDirectory);
$expectedBoardRoot = $pluginRoot . DIRECTORY_SEPARATOR . 'board_' . $boardId;
if (!$allowedRoot || $allowedRoot !== $expectedBoardRoot) {
return null;
}
}
if ($isBareFilename) {
$filePath = realpath($allowedDirectory . DIRECTORY_SEPARATOR . $storedFilename);
}
if (!$filePath || !$allowedRoot || !is_file($filePath)) {
return null;
}
$allowedRoot = rtrim($allowedRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
return strpos($filePath, $allowedRoot) === 0 ? $filePath : null;
}
/**
* Upload request files into the application's custom upload directory.
* @return array
*/
public function _uploadFromRequest()
{
return $this->_put(FluentBoards('request')->files());
}
/**
* Prepare upload-directory protection before accepting local files.
* @param array $files
* @return array
*/
public function _put($files)
{
if (!$this->_installUploadProtectionRules()) {
throw new Exception(__('Could not prepare upload-directory protection. Please ask your hosting provider to block direct file access and check directory permissions.', 'fluent-boards'));
}
if (!function_exists('wp_handle_upload')) {
require_once(ABSPATH . 'wp-admin/includes/file.php');
}
$this->overrideUploadDir();
$uploadOverrides = [
'test_form' => false,
// Accept the same allow-list FluentBoards validates against, so wp_handle_upload
// doesn't reject broader types (e.g. .json, .md) by extension.
'mimes' => \FluentBoards\App\Services\UploadService::getAllowedMimeMap(),
];
$uploadedFiles = []; // Initialize the array
if(is_object($files)) {
$files = [$files];
}
foreach ((array)$files as $file) {
$filesArray = $file->toArray();
// tmp_name is the path to the uploaded file which is required for wp_handle_upload, new framework update
// changed the way to get the tmp_name Reference to line # 448 in File.php
$filesArray['tmp_name'] = $file->getRealPath();
$extraData = Arr::only($filesArray, ['name', 'size']);
$uploadsData = \wp_handle_upload($filesArray, $uploadOverrides);
// Add the full path to the file
$uploadsData['full_path'] = $this->_getAbsolutePathOfFile($uploadsData['file']);
$uploadedFiles[] = array_merge($extraData, $uploadsData);
}
return $uploadedFiles;
}
/**
* Delete a file from custom upload directory of this application
* @param array $files
* @return void
*/
public function _delete($files)
{
$files = (array)$files;
foreach ($files as $file) {
$arr = explode('/', $file);
$fileName = end($arr);
$filePath = $this->getDir() . '/' . $fileName;
if (file_exists($filePath)) {
wp_delete_file($filePath);
}
}
}
/**
* Register filters for custom upload dir
*/
public function _overrideUploadDir()
{
add_filter('wp_handle_upload_prefilter', function ($file) {
add_filter('upload_dir', [$this, '_setCustomUploadDir']);
add_filter('wp_handle_upload', function ($fileinfo) {
remove_filter('upload_dir', [$this, '_setCustomUploadDir']);
$fileinfo['file'] = basename($fileinfo['file']);
return $fileinfo;
});
return $this->_renameFileName($file);
});
}
/**
* Set plugin's custom upload dir
* @param array $param
* @return array $param
*/
public function _setCustomUploadDir($param)
{
$fbsUploadDir = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
if ($this->subDir) {
$fbsUploadDir .= DIRECTORY_SEPARATOR . $this->subDir;
if (!is_dir($param['basedir'] . $fbsUploadDir)) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Creating custom upload subdirectory during upload process, WP_Filesystem not initialized
@mkdir($param['basedir'] . $fbsUploadDir, 0755);
}
}
$param['url'] = $param['baseurl'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
$param['path'] = $param['basedir'] . DIRECTORY_SEPARATOR . $fbsUploadDir;
return $param;
}
/**
* Rename the uploaded file name before saving
* @param array $file
* @return array $file
*/
public function _renameFileName($file)
{
$prefix = wp_generate_uuid4() . '-';
$prefix = apply_filters('fluent_boards/uploaded_file_name_prefix', $prefix);
$file['name'] = $prefix . $file['name'];
return $file;
}
public function _deleteDir($dir)
{
$dir = $this->getDir() . DIRECTORY_SEPARATOR . $dir;
if (is_dir($dir)) {
$this->deleteContents($dir);
}
}
/**
* Delete a directory and its content
* Lead Note: this method should be called via scheduler or queue
* @param string $dir
* @return bool
*/
private function deleteContents($dir)
{
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
if(is_dir("$dir/$file")) {
// Recursively delete subdirectory
$this->deleteContents("$dir/$file");
} else {
// Delete file
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Recursive directory cleanup, direct file operations required
unlink("$dir/$file");
}
}
// Delete the directory itself. If the directory is not empty, it will not be deleted.
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Recursive directory removal, WP_Filesystem not suitable for this operation
return rmdir($dir);
}
public static function __callStatic($method, $params)
{
$instance = new static;
return call_user_func_array([$instance, $method], $params);
}
public function __call($method, $params)
{
$hiddenMethod = "_" . $method;
$method = method_exists($this, $hiddenMethod) ? $hiddenMethod : $method;
return call_user_func_array([$this, $method], $params);
}
}