boot_import_queue(); } /** * Bootstrap chunked / background import subsystem. * * @since 5.3.0 */ protected function boot_import_queue() { if ( ! class_exists( __NAMESPACE__ . '\\Installer' ) ) { return; } $this->import_processor = new Import_Processor( $this ); new Import_Ajax( $this->import_processor ); \add_action( 'init', static function () { Installer::maybe_upgrade(); }, 20 ); // Free-only teasers / locked Pro menu stubs. Pro boots real feature modules // from add-from-server-reloaded-pro when licensed (plugins_loaded:20+). Pro_Teaser::boot(); } /** * Get the import processor instance. * * @since 5.3.0 * * @return Import_Processor|null */ public function get_import_processor() { return $this->import_processor; } /** * Allow additional MIME types for upload. * * @since 4.0.0 * * @param array $mimes Allowed MIME types. * @return array */ public function allow_additional_mimes( $mimes ) { $mimes['md'] = 'text/markdown'; $mimes['json'] = 'application/json'; // Opt-in only: these types are blocked by default for security. if ( $this->allows_dangerous_file_types() ) { $mimes['svg'] = 'image/svg+xml'; $mimes['php'] = 'application/x-httpd-php'; $mimes['phtml'] = 'application/x-httpd-php'; $mimes['phps'] = 'application/x-httpd-php'; $mimes['pht'] = 'application/x-httpd-php'; $mimes['phar'] = 'application/octet-stream'; $mimes['exe'] = 'application/x-msdownload'; $mimes['sh'] = 'application/x-sh'; $mimes['bat'] = 'application/x-msdos-program'; $mimes['cmd'] = 'application/x-msdos-program'; } return $mimes; } /** * Whether the site allows importing PHP/SVG and other normally blocked types. * * @since 5.4.4 * * @return bool */ public function allows_dangerous_file_types() { return (bool) get_option( 'afsrreloaded_allow_dangerous_types', false ); } /** * Initialize admin hooks... * * @since 4.0.0 */ public function admin_init() { // Register JS & CSS with cache busting. $core_js = AFSRRELOADED_PLUGIN_DIR_PATH . 'add-from-server.js'; $core_css = AFSRRELOADED_PLUGIN_DIR_PATH . 'add-from-server.css'; \wp_register_script( 'add-from-server-reloaded', \plugins_url( '/add-from-server.js', __FILE__ ), array( 'jquery' ), file_exists( $core_js ) ? (string) filemtime( $core_js ) : AFSRRELOADED_VERSION, true ); \wp_register_style( 'add-from-server-reloaded', \plugins_url( '/add-from-server.css', __FILE__ ), array(), file_exists( $core_css ) ? (string) filemtime( $core_css ) : AFSRRELOADED_VERSION ); $wizard_css = AFSRRELOADED_PLUGIN_DIR_PATH . 'assets/css/admin-styles.css'; $wizard_js = AFSRRELOADED_PLUGIN_DIR_PATH . 'assets/js/admin-scripts.js'; \wp_register_style( 'afsr-admin-ui', \plugins_url( 'assets/css/admin-styles.css', __FILE__ ), array( 'common', 'dashicons', 'add-from-server-reloaded' ), file_exists( $wizard_css ) ? (string) filemtime( $wizard_css ) : AFSRRELOADED_VERSION ); \wp_register_script( 'afsr-admin-ui', \plugins_url( 'assets/js/admin-scripts.js', __FILE__ ), array( 'jquery', 'add-from-server-reloaded' ), file_exists( $wizard_js ) ? (string) filemtime( $wizard_js ) : AFSRRELOADED_VERSION, true ); // Localize script for AJAX. \wp_localize_script( 'add-from-server-reloaded', 'afsrreloadedData', array( 'ajaxurl' => \admin_url( 'admin-ajax.php' ), 'nonce' => \wp_create_nonce( 'afsrreloaded_import' ), 'historyUrl' => \admin_url( 'admin.php?page=add-from-server-reloaded-history' ), 'proUrl' => 'https://elearningevolve.com/products/add-from-server-pro/', 'chunkSize' => (int) apply_filters( 'afsrreloaded_import_chunk_size', Import_Processor::DEFAULT_CHUNK_SIZE ), 'features' => Features::js_flags(), 'processing' => __( 'Processing...', 'add-from-server-reloaded' ), 'scanning' => __( 'Scanning folders...', 'add-from-server-reloaded' ), 'importing' => __( 'Importing files...', 'add-from-server-reloaded' ), 'complete' => __( 'Import Complete!', 'add-from-server-reloaded' ), 'cancelled' => __( 'Import cancelled.', 'add-from-server-reloaded' ), 'paused' => __( 'Import paused.', 'add-from-server-reloaded' ), 'error' => __( 'An error occurred. Please try again.', 'add-from-server-reloaded' ), 'confirmLarge' => __( 'This may take a while. Continue?', 'add-from-server-reloaded' ), 'selectSomething' => __( 'Please select at least one file or folder to import.', 'add-from-server-reloaded' ), 'backgroundHint' => __( 'You can leave this page; the import will continue in the background.', 'add-from-server-reloaded' ), 'selectToImport' => __( 'Select files to import', 'add-from-server-reloaded' ), 'selectedSuffix' => __( 'selected', 'add-from-server-reloaded' ), 'i18n' => array( 'imported' => __( 'Imported', 'add-from-server-reloaded' ), 'duplicates' => __( 'Duplicates', 'add-from-server-reloaded' ), 'errors' => __( 'Errors', 'add-from-server-reloaded' ), 'skipped' => __( 'Skipped', 'add-from-server-reloaded' ), 'progress' => __( 'Progress', 'add-from-server-reloaded' ), 'pause' => __( 'Pause', 'add-from-server-reloaded' ), 'resume' => __( 'Resume', 'add-from-server-reloaded' ), 'cancel' => __( 'Cancel', 'add-from-server-reloaded' ), 'retry' => __( 'Retry failed', 'add-from-server-reloaded' ), 'viewHistory' => __( 'View import history', 'add-from-server-reloaded' ), ), ) ); \add_filter( 'plugin_action_links_' . \plugin_basename( AFSRRELOADED_PLUGIN_FILE ), array( $this, 'add_upload_link' ) ); // Handle the path selection early. $this->path_selection_cookie(); } /** * Register admin menu. * * @since 4.0.0 */ public function admin_menu() { $cap = Capabilities::rbac_enabled() ? Capabilities::CAP_BROWSE : 'upload_files'; $menu_label = Features::is_pro() ? __( 'AFS Pro', 'add-from-server-reloaded' ) : __( 'AFS Lite', 'add-from-server-reloaded' ); $page_slug = \add_menu_page( __( 'Add From Server Lite', 'add-from-server-reloaded' ), $menu_label, $cap, 'add-from-server-reloaded', array( $this, 'menu_page' ), 'dashicons-upload', 30 ); $settings_hook = \add_submenu_page( 'add-from-server-reloaded', __( 'Settings', 'add-from-server-reloaded' ), __( 'Settings', 'add-from-server-reloaded' ), Capabilities::rbac_enabled() ? Capabilities::CAP_SETTINGS : 'manage_options', 'add-from-server-reloaded-settings', array( $this, 'render_settings_page' ) ); \add_action( 'load-' . $page_slug, function () { Pro_Teaser::enqueue_browse_layout_critical_css(); \wp_enqueue_style( 'dashicons' ); \wp_enqueue_style( 'add-from-server-reloaded' ); \wp_enqueue_script( 'add-from-server-reloaded' ); \wp_enqueue_style( 'afsr-admin-ui' ); \wp_enqueue_script( 'afsr-admin-ui' ); // Handle settings save. $this->handle_settings_save(); } ); if ( $settings_hook ) { \add_action( 'load-' . $settings_hook, function () { Pro_Teaser::enqueue_locked_ui_assets(); $this->handle_settings_save(); } ); } // Set page title to avoid deprecation warnings. \add_filter( 'admin_title', array( $this, 'set_admin_page_title' ), 10, 2 ); } /** * Set admin page title. * * @since 5.0.0 * * @param string $admin_title The page title. * @param string $title The original title. * @return string */ public function set_admin_page_title( $admin_title, $title ) { $screen = \get_current_screen(); if ( $screen && 'add-from-server-reloaded' === $screen->id ) { return \__( 'Add From Server Lite', 'add-from-server-reloaded' ) . $admin_title; } return $admin_title; } /** * Add plugin action links. * * @since 4.0.0 * * @param array $links Plugin action links. * @return array */ public function add_upload_link( $links ) { $extra = array(); if ( Capabilities::can_browse() ) { $extra[] = '' . esc_html__( 'Import Files', 'add-from-server-reloaded' ) . ''; } $can_settings = Capabilities::rbac_enabled() ? current_user_can( Capabilities::CAP_SETTINGS ) : current_user_can( 'manage_options' ); if ( $can_settings ) { $extra[] = '' . esc_html__( 'Settings', 'add-from-server-reloaded' ) . ''; } if ( ! empty( $extra ) ) { $links = array_merge( $extra, $links ); } if ( ! Features::is_pro() ) { $links['afsr_get_pro'] = sprintf( '%2$s', esc_url( Pro_Teaser::UPGRADE_URL ), esc_html__( 'Get AFS Pro', 'add-from-server-reloaded' ) ); } return $links; } /** * Render the menu page. * * @since 4.0.0 */ public function menu_page() { // Set page title. global $title; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- WordPress admin menu pages expect $title. $title = \__( 'Add From Server Lite', 'add-from-server-reloaded' ); // Legacy non-JS fallback import (chunked AJAX is preferred when JS is available). if ( isset( $_POST['import'] ) && ( ! empty( $_POST['files'] ) || ! empty( $_POST['folders'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified inside handle_imports(). $this->handle_imports(); } echo '
'; echo '

' . esc_html__( 'Add From Server Lite', 'add-from-server-reloaded' ) . '

'; $this->outdated_options_notice(); $this->main_content(); echo '
'; } /** * Dedicated Settings screen (root directory, risk options, license). * * @since 5.4.3 */ public function render_settings_page() { if ( ! Capabilities::can_manage_settings() ) { \wp_die( esc_html__( 'You do not have permission to access this page.', 'add-from-server-reloaded' ) ); } $root = $this->get_root(); $allow_dangerous = $this->allows_dangerous_file_types(); Pro_Teaser::enqueue_locked_ui_assets(); ?>

' . \esc_html( $new_root ) . '' ), 'success' ); } /** * Handle path selection cookie. * * @since 4.0.0 */ public function path_selection_cookie() { if ( isset( $_REQUEST['path'] ) && Capabilities::can_browse() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended // Sanitize the path. $path = sanitize_text_field( wp_unslash( $_REQUEST['path'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended // Verify path is within allowed root. $root = $this->get_root(); if ( ! $root ) { return; } // Root / reset. if ( '/' === $path || '' === $path ) { $_COOKIE[ COOKIE ] = '/'; $admin_url_parts = wp_parse_url( admin_url() ); setcookie( COOKIE, '/', time() + 30 * DAY_IN_SECONDS, isset( $admin_url_parts['path'] ) ? $admin_url_parts['path'] : '/', isset( $admin_url_parts['host'] ) ? $admin_url_parts['host'] : '', 'https' === ( isset( $admin_url_parts['scheme'] ) ? $admin_url_parts['scheme'] : 'http' ), true ); return; } $full_path = realpath( trailingslashit( $root ) . ltrim( $path, '/' ) ); // Security: Ensure the path is within root (boundary, not prefix-only). if ( ! $full_path || ! Path_Guard::path_has_root_boundary( $full_path, $root ) ) { return; } $_COOKIE[ COOKIE ] = $path; $admin_url_parts = wp_parse_url( admin_url() ); setcookie( COOKIE, $path, time() + 30 * DAY_IN_SECONDS, isset( $admin_url_parts['path'] ) ? $admin_url_parts['path'] : '/', isset( $admin_url_parts['host'] ) ? $admin_url_parts['host'] : '', 'https' === ( isset( $admin_url_parts['scheme'] ) ? $admin_url_parts['scheme'] : 'http' ), true ); } } /** * Handle file imports. * * @since 4.0.0 */ public function handle_imports() { if ( empty( $_POST['files'] ) && empty( $_POST['folders'] ) ) { return; } check_admin_referer( 'afsrreloaded_import' ); $files = isset( $_POST['files'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['files'] ) ) : array(); $folders = isset( $_POST['folders'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['folders'] ) ) : array(); $selected_files = $files; $root = $this->get_root(); if ( ! $root ) { wp_die( esc_html__( 'Unable to determine root directory. Please check your configuration.', 'add-from-server-reloaded' ) ); } // Get all files from selected folders. $folder_files = array(); $blocked_files = array(); foreach ( $folders as $folder ) { $folder_path = trailingslashit( $root ) . ltrim( $folder, '/' ); $folder_files = array_merge( $folder_files, $this->get_files_from_folder( $folder_path, $root, $blocked_files ) ); } // Merge folder files with individually selected files. $files = array_merge( $files, $folder_files ); // Enable output buffering for progress updates. if ( ! defined( 'DOING_AJAX' ) ) { flush(); if ( function_exists( 'wp_ob_end_flush_all' ) ) { wp_ob_end_flush_all(); } } $imported = 0; $errors = 0; $duplicates = 0; $skipped_selected = array(); $error_files = array(); $imported_files = array(); $duplicate_files = array(); foreach ( (array) $files as $file ) { $filename = trailingslashit( $root ) . ltrim( $file, '/' ); // Security: Verify the real path to prevent directory traversal (root + trailing slash). $realpath = realpath( $filename ); if ( ! $realpath || ! Path_Guard::path_has_root_boundary( $realpath, $root ) ) { ++$errors; $error_files[] = array( 'filename' => basename( $file ), 'message' => __( 'Security error: file is outside the allowed directory', 'add-from-server-reloaded' ), ); continue; } // If user selected specific files, skip restricted ones with a clear message. if ( ! empty( $selected_files ) && in_array( $file, $selected_files, true ) && $this->is_restricted_file( $realpath ) ) { $skipped_selected[] = array( 'filename' => basename( $file ), 'message' => __( 'This file was not imported due to security restrictions.', 'add-from-server-reloaded' ), ); continue; } $id = $this->handle_import_file( $realpath ); if ( \is_wp_error( $id ) ) { if ( 'file_exists' === $id->get_error_code() ) { ++$duplicates; $duplicate_files[] = array( 'filename' => basename( $file ), 'message' => $id->get_error_message(), ); } else { ++$errors; $error_files[] = array( 'filename' => basename( $file ), 'message' => $id->get_error_message(), ); } } else { ++$imported; $imported_files[] = array( 'filename' => basename( $file ), 'id' => $id, ); } if ( ! defined( 'DOING_AJAX' ) ) { flush(); } } // Single summary message. if ( $imported > 0 || $errors > 0 || $duplicates > 0 || ! empty( $blocked_files ) || ! empty( $skipped_selected ) ) { $message_class = ( $errors > 0 ) ? 'notice-warning' : 'notice-success'; echo '

'; if ( $imported > 0 ) { echo ''; printf( /* translators: %d: number of files */ \esc_html( \_n( '%d file imported successfully.', '%d files imported successfully.', $imported, 'add-from-server-reloaded' ) ), absint( $imported ) ); echo ''; if ( ! empty( $folders ) ) { // Show uploaded folder names when folder import is used. echo '
'; foreach ( $folders as $folder ) { $folder_name = basename( $folder ); if ( ! $folder_name ) { $folder_name = $folder; } echo '' . esc_html( $folder_name ) . ' ' . esc_html__( 'folder uploaded.', 'add-from-server-reloaded' ) . '
'; } echo '
'; } if ( ! empty( $imported_files ) ) { // Show imported file names (works for folder and direct selection). echo '
'; foreach ( $imported_files as $file ) { $edit_link = admin_url( 'post.php?post=' . $file['id'] . '&action=edit' ); echo '' . esc_html( $file['filename'] ) . '
'; } echo '
'; } } if ( $duplicates > 0 ) { if ( $imported > 0 ) { echo '

'; } echo ''; printf( /* translators: %d: number of duplicates */ esc_html( _n( '%d file already exists in Media Library.', '%d files already exist in Media Library.', $duplicates, 'add-from-server-reloaded' ) ), absint( $duplicates ) ); echo ''; if ( ! empty( $duplicate_files ) ) { echo '
'; foreach ( $duplicate_files as $dup ) { echo '' . esc_html( $dup['filename'] ) . ': ' . wp_kses_post( $dup['message'] ) . '
'; } echo '
'; } } if ( $errors > 0 ) { if ( $imported > 0 ) { echo '

'; } echo ''; printf( /* translators: %d: number of errors */ \esc_html( \_n( '%d file failed.', '%d files failed.', $errors, 'add-from-server-reloaded' ) ), absint( $errors ) ); echo ''; // Show error details. if ( ! empty( $error_files ) && empty( $folders ) && ! empty( $selected_files ) ) { echo '
'; foreach ( $error_files as $error ) { echo '' . \esc_html( $error['filename'] ) . ': ' . \wp_kses_post( $error['message'] ) . '
'; } echo '
'; } } if ( ! empty( $blocked_files ) ) { if ( $imported > 0 || $errors > 0 || $duplicates > 0 ) { echo '

'; } echo ''; printf( /* translators: %d: number of blocked files */ \esc_html( \_n( '%d file was skipped for security reasons.', '%d files were skipped for security reasons.', count( $blocked_files ), 'add-from-server-reloaded' ) ), absint( count( $blocked_files ) ) ); echo ''; $restricted_list = implode( ', ', array_map( 'strtoupper', $this->get_restricted_extensions() ) ); echo '
'; printf( /* translators: %s: comma-separated list of restricted extensions */ esc_html__( 'Some files in the selected folders were not imported because their file types are not allowed: %s.', 'add-from-server-reloaded' ), esc_html( $restricted_list ) ); echo ''; } if ( ! empty( $skipped_selected ) ) { if ( $imported > 0 || $errors > 0 || $duplicates > 0 ) { echo '

'; } echo '' . esc_html__( 'Some files were skipped for security reasons.', 'add-from-server-reloaded' ) . ''; echo '
'; foreach ( $skipped_selected as $skip ) { echo '' . esc_html( $skip['filename'] ) . ': ' . esc_html( $skip['message'] ) . '
'; } echo '
'; } echo '

'; } } /** * Get restricted file extensions. * * @since 5.0.1 * * @return array */ protected function get_restricted_extensions() { if ( $this->allows_dangerous_file_types() ) { return array(); } return array( 'php', 'phtml', 'phps', 'pht', 'phar', 'exe', 'sh', 'bat', 'cmd' ); } /** * Check if a file is restricted from import. * * @since 5.0.1 * * @param string $path File path. * @return bool True if restricted. */ public function is_restricted_file( $path ) { $dangerous_extensions = $this->get_restricted_extensions(); $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); if ( in_array( $ext, $dangerous_extensions, true ) ) { return true; } // Admin opted in: skip WordPress MIME denylist for unknown/extra types (SVG, etc.). if ( $this->allows_dangerous_file_types() ) { return false; } $wp_filetype = \wp_check_filetype( $path, null ); $type = $wp_filetype['type']; $ext_check = $wp_filetype['ext']; if ( ( ! $type || ! $ext_check ) && ! current_user_can( 'unfiltered_upload' ) ) { return true; } return false; } /** * Recursively get all files from a folder. * * @since 4.0.5 * * @param string $folder_path Absolute folder path. * @param string $root Root directory path. * @param array $blocked_files Restricted relative paths collected during recursion. * @return array Array of relative file paths. */ protected function get_files_from_folder( $folder_path, $root, &$blocked_files = array() ) { $files = array(); // Security: Verify the real path (directory boundary, not prefix-only). $realpath = \realpath( $folder_path ); if ( ! $realpath || ! Path_Guard::path_has_root_boundary( $realpath, $root ) ) { return $files; } $items = \glob( $realpath . '/*' ); if ( ! $items ) { return $files; } foreach ( $items as $item ) { // Skip hidden files and directories. if ( '.' === \basename( $item )[0] ) { continue; } $relative_item = Path_Guard::absolute_to_relative( $item, $root ); if ( false === $relative_item ) { continue; } if ( \is_dir( $item ) ) { // Recursively get files from subdirectory. $files = \array_merge( $files, $this->get_files_from_folder( $item, $root, $blocked_files ) ); } elseif ( \is_file( $item ) ) { if ( $this->is_restricted_file( $item ) ) { $blocked_files[] = $relative_item; continue; } $files[] = $relative_item; } } return $files; } /** * AJAX handler for batch imports. * * @since 4.0.0 */ public function ajax_batch_import() { check_ajax_referer( 'afsrreloaded_import', 'nonce' ); if ( ! current_user_can( 'upload_files' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to upload files.', 'add-from-server-reloaded' ) ) ); } $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : ''; if ( empty( $file ) ) { wp_send_json_error( array( 'message' => __( 'No file specified.', 'add-from-server-reloaded' ) ) ); } $root = $this->get_root(); if ( ! $root ) { wp_send_json_error( array( 'message' => __( 'Unable to determine root directory.', 'add-from-server-reloaded' ) ) ); } $filename = trailingslashit( $root ) . ltrim( $file, '/' ); $realpath = realpath( $filename ); // Security: Verify the real path (directory boundary, not prefix-only). if ( ! $realpath || ! Path_Guard::path_has_root_boundary( $realpath, $root ) ) { wp_send_json_error( array( 'message' => sprintf( /* translators: %s: file name */ __( 'Security error: %s is outside the allowed directory.', 'add-from-server-reloaded' ), basename( $file ) ), ) ); } $id = $this->handle_import_file( $realpath ); if ( is_wp_error( $id ) ) { wp_send_json_error( array( 'message' => $id->get_error_message(), 'file' => basename( $file ), ) ); } else { wp_send_json_success( array( 'message' => sprintf( /* translators: %s: file name */ __( '%s imported successfully.', 'add-from-server-reloaded' ), basename( $file ) ), 'file' => basename( $file ), 'attachment_id' => $id, ) ); } } /** * AJAX handler for checking duplicates. * * @since 4.0.0 */ public function ajax_check_duplicate() { check_ajax_referer( 'afsrreloaded_import', 'nonce' ); if ( ! current_user_can( 'upload_files' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission.', 'add-from-server-reloaded' ) ) ); } $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : ''; if ( empty( $file ) ) { wp_send_json_error( array( 'message' => __( 'No file specified.', 'add-from-server-reloaded' ) ) ); } $resolved = \realpath( $file ); $root = $this->get_root(); if ( false === $resolved || ! \is_file( $resolved ) || ! $root || ! Path_Guard::is_under_root( $root, $resolved ) ) { wp_send_json_error( array( 'message' => __( 'Invalid file path.', 'add-from-server-reloaded' ) ) ); } $resolved = \wp_normalize_path( $resolved ); $duplicate = $this->check_if_duplicate( $resolved ); if ( $duplicate ) { wp_send_json_success( array( 'is_duplicate' => true, 'message' => sprintf( /* translators: 1: file name, 2: attachment ID */ __( '%1$s already exists in the media library (ID: %2$d).', 'add-from-server-reloaded' ), basename( $resolved ), $duplicate ), ) ); } else { wp_send_json_success( array( 'is_duplicate' => false, ) ); } } /** * Get file hash to detect duplicates reliably. * * Skips hashing when the file is larger than the configured byte cap so a * single request cannot burn the full PHP time budget on md5_file() (DoS * hardening). Oversized files still import; duplicate checks then fall back * to path / filename matching only. This does not make large imports faster. * * @since 5.0.2 * @since 6.0.0 Skip hash above afsrreloaded_file_hash_max_bytes (default 64 MiB). * * @param string $file File path. * @return string|false File MD5 hash or false on error / over size cap. */ protected function get_file_hash( $file ) { if ( ! \is_string( $file ) || '' === $file ) { return false; } // Require a normal readable file (rejects dirs / special devices like /dev/urandom). if ( ! \is_file( $file ) || ! \is_readable( $file ) ) { return false; } $size = \filesize( $file ); if ( false === $size || $size < 0 ) { return false; } /** * Max bytes hashed for duplicate detection (0 = no cap). * * @since 6.0.0 * * @param int $max_bytes Default 64 MiB. * @param string $file Absolute file path. */ $max_bytes = (int) \apply_filters( 'afsrreloaded_file_hash_max_bytes', 64 * 1024 * 1024, $file ); if ( $max_bytes > 0 && (int) $size > $max_bytes ) { return false; } $hash = md5_file( $file ); return $hash ? $hash : false; } /** * Check if file is already in media library. * * @since 4.0.0 * * @param string $file File path. * @return int|false Attachment ID if duplicate found, false otherwise. */ protected function check_if_duplicate( $file ) { global $wpdb; $file = \wp_normalize_path( $file ); $filename = \basename( $file ); // First, check by file hash (most reliable). $file_hash = $this->get_file_hash( $file ); if ( $file_hash ) { $attachment_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_afsrreloaded_file_hash' AND meta_value = %s LIMIT 1", $file_hash ) ); if ( $attachment_id ) { return (int) $attachment_id; } } // Also check by filename in uploads directory. $uploads = \wp_upload_dir( null, false ); if ( preg_match( '|^' . preg_quote( \wp_normalize_path( $uploads['basedir'] ), '|' ) . '(.*)$|i', $file, $mat ) ) { $attached_file = ltrim( $mat[1], '/' ); // Query for existing attachment by exact path. $attachment_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value = %s LIMIT 1", $attached_file ) ); if ( $attachment_id ) { return (int) $attachment_id; } } // Check by filename in media library. $attachment_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p WHERE p.post_type = 'attachment' AND p.guid LIKE %s LIMIT 1", '%/' . $wpdb->esc_like( $filename ) ) ); return $attachment_id ? (int) $attachment_id : false; } /** * Handle individual file import. * * @since 4.0.0 * * @param string $file Full file path. * @param array $args { * Optional. Import behavior flags. * * @type bool $generate_metadata Whether to generate image sizes immediately. Default true. * @type string $source_relative Relative path under plugin root (for folder preserve). * @type bool $preserve_structure Whether to keep source folder tree under uploads. * @type string $duplicate_action skip|replace|rename when Pro advanced_duplicates is on. * } * @return int|WP_Error Attachment ID on success, WP_Error on failure. */ public function handle_import_file( $file, $args = array() ) { set_time_limit( 60 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Set reasonable time limit per file. $args = wp_parse_args( $args, array( 'generate_metadata' => true, 'source_relative' => '', 'preserve_structure' => false, 'duplicate_action' => 'skip', ) ); $file = wp_normalize_path( $file ); // Security: Verify file exists and is readable. if ( ! file_exists( $file ) || ! is_readable( $file ) ) { return new WP_Error( 'file_not_readable', __( 'The file does not exist or is not readable.', 'add-from-server-reloaded' ) ); } // Security: Prevent importing of PHP files or other dangerous types. $dangerous_extensions = $this->get_restricted_extensions(); $ext = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ); if ( in_array( $ext, $dangerous_extensions, true ) ) { return new WP_Error( 'dangerous_file_type', __( 'This file type cannot be imported for security reasons.', 'add-from-server-reloaded' ) ); } // Base the time on the file's modified time when possible. $time = filemtime( $file ); if ( ! $time || ! is_int( $time ) ) { $time = time(); } // Guard against invalid or extreme file dates. $current_time = (int) current_time( 'timestamp' ); $current_year = (int) gmdate( 'Y', $current_time ); $time_year = (int) gmdate( 'Y', $time ); if ( $time_year < 1970 || $time_year > ( $current_year + 1 ) ) { $time = $current_time; } // If the file path contains a valid YYYY/MM segment, use that date. if ( preg_match( '~/(?P\d{4})/(?P0[1-9]|1[0-2])(?:/|$)~', wp_normalize_path( $file ), $datemat ) ) { $year = (int) $datemat['year']; $month = (int) $datemat['month']; if ( $year >= 1970 && $year <= ( $current_year + 1 ) ) { $time = mktime( 0, 0, 0, $month, 1, $year ); } } // Get uploads info without creating new year/month folders. $uploads = wp_upload_dir( $time, false ); if ( ! empty( $uploads['error'] ) ) { return new WP_Error( 'upload_error', $uploads['error'] ); } $wp_filetype = \wp_check_filetype( $file, null ); $type = $wp_filetype['type']; $ext_check = $wp_filetype['ext']; if ( ( ! $type || ! $ext_check ) && ! current_user_can( 'unfiltered_upload' ) && ! $this->allows_dangerous_file_types() ) { return new WP_Error( 'wrong_file_type', __( 'Sorry, this file type is not permitted for security reasons.', 'add-from-server-reloaded' ) ); } // When opted in, ensure attachment creation still has a usable MIME/type. if ( ( ! $type || ! $ext_check ) && $this->allows_dangerous_file_types() ) { $ext_check = $ext ? $ext : strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ); $type = $type ? $type : 'application/octet-stream'; } $dup_action = 'skip'; if ( Features::enabled( 'advanced_duplicates' ) ) { $dup_action = sanitize_key( $args['duplicate_action'] ); if ( ! in_array( $dup_action, array( 'skip', 'replace', 'rename' ), true ) ) { /** * Default duplicate action when the request omits a valid one (Pro). * * @since 5.5.0 * * @param string $action Default: skip. */ $fallback = sanitize_key( (string) apply_filters( 'afsrreloaded_default_duplicate_action', 'skip' ) ); $dup_action = in_array( $fallback, array( 'skip', 'replace', 'rename' ), true ) ? $fallback : 'skip'; } } // Check if file is already in media library (duplicate detection). $duplicate = ( 'rename' === $dup_action ) ? false : $this->check_if_duplicate( $file ); if ( $duplicate ) { if ( 'replace' === $dup_action ) { \wp_delete_attachment( (int) $duplicate, true ); } else { $edit_link = \admin_url( 'post.php?post=' . $duplicate . '&action=edit' ); return new WP_Error( 'file_exists', sprintf( /* translators: %s: link to edit attachment */ __( 'File already exists. View in Media Library', 'add-from-server-reloaded' ), \esc_url( $edit_link ) ) ); } } $upload_context = array( 'source_relative' => (string) $args['source_relative'], 'preserve_structure' => ! empty( $args['preserve_structure'] ) && Features::enabled( 'folder_preserve' ), ); // Is the file already in the uploads folder? if ( preg_match( '|^' . preg_quote( wp_normalize_path( $uploads['basedir'] ), '|' ) . '(.*)$|i', $file, $mat ) ) { $filename = basename( $file ); $file_mtime = filemtime( $file ); if ( $file_mtime ) { $time = $file_mtime; } // Ensure the destination uploads folder exists for copying (use plugin prefix). $uploads = $this->get_import_uploads_dir( $time, true, $upload_context ); if ( ! empty( $uploads['error'] ) ) { return new WP_Error( 'upload_error', $uploads['error'] ); } $target_dir = wp_normalize_path( $uploads['path'] ); $current_dir = wp_normalize_path( dirname( $file ) ); // If the file is already in the target directory, keep it. if ( 0 === strcmp( $current_dir, $target_dir ) ) { $new_file = $file; $url = $uploads['url'] . '/' . $filename; } else { $filename = \wp_unique_filename( $uploads['path'], $filename ); $new_file = $uploads['path'] . '/' . $filename; // Copy into the destination folder; keep the original source file. if ( false === @copy( $file, $new_file ) ) { return new WP_Error( 'upload_error', sprintf( /* translators: %s: upload directory path */ __( 'The selected file could not be copied to %s.', 'add-from-server-reloaded' ), $uploads['path'] ) ); } // Set correct file permissions. $stat = stat( dirname( $new_file ) ); $perms = $stat['mode'] & 0000666; chmod( $new_file, $perms ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod $url = $uploads['url'] . '/' . $filename; } } else { // Ensure the destination uploads folder exists for copying (use plugin prefix). $uploads = $this->get_import_uploads_dir( $time, true, $upload_context ); if ( ! empty( $uploads['error'] ) ) { return new WP_Error( 'upload_error', $uploads['error'] ); } // File is outside uploads directory - copy it. $filename = \wp_unique_filename( $uploads['path'], basename( $file ) ); $new_file = $uploads['path'] . '/' . $filename; if ( false === @copy( $file, $new_file ) ) { return new WP_Error( 'upload_error', sprintf( /* translators: %s: upload directory path */ __( 'The selected file could not be copied to %s.', 'add-from-server-reloaded' ), $uploads['path'] ) ); } // Set correct file permissions. $stat = stat( dirname( $new_file ) ); $perms = $stat['mode'] & 0000666; chmod( $new_file, $perms ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod // Compute the URL. $url = $uploads['url'] . '/' . $filename; } // Apply the core upload filter so other plugins can adjust sideloads (same as wp_handle_sideload). $return = apply_filters( 'wp_handle_upload', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WP filter, invoked intentionally. array( 'file' => $new_file, 'url' => $url, 'type' => $type, ), 'sideload' ); $new_file = $return['file']; $url = $return['url']; $type = $return['type']; // Generate title from filename. $title = preg_replace( '!\.[^.]+$!', '', basename( $file ) ); $content = ''; $excerpt = ''; // Extract metadata for audio files. if ( preg_match( '#^audio#', $type ) ) { $meta = \wp_read_audio_metadata( $new_file ); if ( ! empty( $meta['title'] ) ) { $title = $meta['title']; } if ( ! empty( $title ) ) { if ( ! empty( $meta['album'] ) && ! empty( $meta['artist'] ) ) { /* translators: 1: audio track title, 2: album title, 3: artist name */ $content .= sprintf( __( '"%1$s" from %2$s by %3$s.', 'add-from-server-reloaded' ), $title, $meta['album'], $meta['artist'] ); } elseif ( ! empty( $meta['album'] ) ) { /* translators: 1: audio track title, 2: album title */ $content .= sprintf( __( '"%1$s" from %2$s.', 'add-from-server-reloaded' ), $title, $meta['album'] ); } elseif ( ! empty( $meta['artist'] ) ) { /* translators: 1: audio track title, 2: artist name */ $content .= sprintf( __( '"%1$s" by %2$s.', 'add-from-server-reloaded' ), $title, $meta['artist'] ); } else { /* translators: %s: audio track title */ $content .= sprintf( __( '"%s".', 'add-from-server-reloaded' ), $title ); } } elseif ( ! empty( $meta['album'] ) ) { if ( ! empty( $meta['artist'] ) ) { /* translators: 1: audio album title, 2: artist name */ $content .= sprintf( __( '%1$s by %2$s.', 'add-from-server-reloaded' ), $meta['album'], $meta['artist'] ); } else { $content .= $meta['album'] . '.'; } } elseif ( ! empty( $meta['artist'] ) ) { $content .= $meta['artist'] . '.'; } if ( ! empty( $meta['year'] ) ) { /* translators: %d: release year */ $content .= ' ' . sprintf( __( 'Released: %d.', 'add-from-server-reloaded' ), $meta['year'] ); } if ( ! empty( $meta['track_number'] ) ) { $track_number = explode( '/', $meta['track_number'] ); if ( isset( $track_number[1] ) ) { /* translators: 1: track number, 2: total tracks */ $content .= ' ' . sprintf( __( 'Track %1$s of %2$s.', 'add-from-server-reloaded' ), number_format_i18n( $track_number[0] ), number_format_i18n( $track_number[1] ) ); } else { /* translators: %s: track number */ $content .= ' ' . sprintf( __( 'Track %s.', 'add-from-server-reloaded' ), number_format_i18n( $track_number[0] ) ); } } if ( ! empty( $meta['genre'] ) ) { /* translators: %s: genre */ $content .= ' ' . sprintf( __( 'Genre: %s.', 'add-from-server-reloaded' ), $meta['genre'] ); } // Use image exif/iptc data for title and caption defaults if possible. } elseif ( 0 === strpos( $type, 'image/' ) ) { $image_meta = @\wp_read_image_metadata( $new_file ); if ( $image_meta && ! empty( $image_meta['title'] ) && ! is_numeric( sanitize_title( $image_meta['title'] ) ) ) { $title = $image_meta['title']; } if ( $image_meta && ! empty( $image_meta['caption'] ) ) { $excerpt = $image_meta['caption']; } } // Construct the attachment array. $attachment_time_local = current_time( 'mysql' ); $attachment_time_gmt = get_gmt_from_date( $attachment_time_local ); $attachment = array( 'post_mime_type' => $type, 'guid' => $url, 'post_parent' => 0, 'post_title' => $title, 'post_name' => $title, 'post_content' => $content, 'post_excerpt' => $excerpt, 'post_date' => $attachment_time_local, 'post_date_gmt' => $attachment_time_gmt, ); /** * Filters the attachment data before import. * * @since 4.0.0 * * @param array $attachment Attachment data. * @param string $file File path. */ $attachment = apply_filters( 'afsrreloaded_import_attachment_data', $attachment, $file ); // Backwards compatibility filter. $attachment = apply_filters( 'afsrreloaded_import_details', $attachment, $file, 0, 'current' ); // Save the data. $id = \wp_insert_attachment( $attachment, $new_file, 0 ); if ( ! \is_wp_error( $id ) ) { if ( ! empty( $args['generate_metadata'] ) ) { // Generate attachment metadata (thumbnails / image sizes). $data = \wp_generate_attachment_metadata( $id, $new_file ); \wp_update_attachment_metadata( $id, $data ); } else { // Defer expensive metadata generation; Pro background cron consumes this. \update_post_meta( $id, '_afsrreloaded_needs_metadata', 1 ); do_action( 'afsrreloaded_schedule_background_tick', null ); } // Store file hash for reliable duplicate detection. $file_hash = $this->get_file_hash( $new_file ); if ( $file_hash ) { \update_post_meta( $id, '_afsrreloaded_file_hash', $file_hash ); } /** * Fires after a file has been imported. * * @since 4.0.0 * * @param int $id Attachment ID. * @param string $file File path. */ do_action( 'afsrreloaded_file_imported', $id, $file ); } return $id; } /** * Get the default directory. * * @since 4.0.0 * * @return string Default directory path. */ protected function get_default_dir() { $root = $this->get_root(); if ( ! $root ) { return ''; } // Always start at the configured root directory. return $root; } /** * Create the main content for the page. * * @since 4.0.0 */ public function main_content() { $url = admin_url( 'admin.php?page=add-from-server-reloaded' ); $root = $this->get_root(); if ( ! $root ) { echo '

'; echo esc_html__( 'Unable to determine root directory. Please check your configuration.', 'add-from-server-reloaded' ); echo '

'; printf( /* translators: %s: constant name */ esc_html__( 'You can define the %s constant in your wp-config.php file to set a custom root directory.', 'add-from-server-reloaded' ), 'ADD_FROM_SERVER_RELOADED' ); echo '

'; return; } $cwd = $this->get_default_dir(); // Prefer the current request path (folder click) over a stale cookie. // Stale cookies from a different root caused doubled paths for RBAC users. $relative_path = null; if ( isset( $_REQUEST['path'] ) && Capabilities::can_browse() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $relative_path = sanitize_text_field( wp_unslash( $_REQUEST['path'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } elseif ( ! empty( $_COOKIE[ COOKIE ] ) ) { $relative_path = sanitize_text_field( wp_unslash( $_COOKIE[ COOKIE ] ) ); } if ( null !== $relative_path && '/' !== $relative_path && '' !== $relative_path ) { $temp_cwd = realpath( trailingslashit( $root ) . ltrim( $relative_path, '/' ) ); // Validate the path stays under root. if ( $temp_cwd && Path_Guard::path_has_root_boundary( $temp_cwd, $root ) ) { $cwd = $temp_cwd; } } // Validate current directory. if ( ! str_starts_with( $cwd, $root ) ) { $cwd = $root; } $cwd_relative = substr( $cwd, strlen( $root ) ); // Build breadcrumb navigation. $dirparts = array(); $dirparts[] = '' . esc_html( trailingslashit( $root ) ) . ' '; $dir_path = ''; foreach ( array_filter( explode( '/', $cwd_relative ) ) as $dir ) { $dir_path = $dir_path . '/' . $dir; $dir_label = $dir ? $dir : basename( $root ); $dirparts[] = '' . esc_html( $dir_label ) . '/ '; } $dirparts = implode( '', $dirparts ); // Sort function for case-insensitive alphabetical sorting. $sort_by_text = function ( $a, $b ) { return strtolower( $a['text'] ) <=> strtolower( $b['text'] ); }; // Get a list of files to show. $globbed = glob( rtrim( $cwd, '/' ) . '/*' ); $nodes = is_array( $globbed ) ? $globbed : array(); $directories = array_flip( array_filter( $nodes, function ( $node ) { return is_dir( $node ); } ) ); $get_root_relative_path = function ( $path ) use ( $root ) { $root_offset = strlen( $root ); if ( '/' !== $root ) { ++$root_offset; } return substr( $path, $root_offset ); }; // One level at a time — do not collapse empty intermediate folders into // paths like "wordpress/wp-content/" or "2026/09/". array_walk( $directories, function ( &$data, $path ) use ( $get_root_relative_path ) { if ( ! is_readable( $path ) ) { $data = false; return; } $data = array( 'text' => basename( $path ) . '/', 'path' => $get_root_relative_path( $path ), ); } ); $directories = array_filter( $directories ); // Sort the directories case insensitively. uasort( $directories, $sort_by_text ); // Prefix the parent directory. if ( Path_Guard::path_has_root_boundary( dirname( $cwd ), $root ) && 0 !== strcmp( $cwd, dirname( $cwd ) ) ) { $parent_relative_path = $get_root_relative_path( dirname( $cwd ) ); $directories = array_merge( array( dirname( $cwd ) => array( 'text' => __( 'Parent Folder', 'add-from-server-reloaded' ), 'path' => $parent_relative_path ? $parent_relative_path : '/', ), ), $directories ); } $files = array_flip( array_filter( $nodes, function ( $node ) { return is_file( $node ); } ) ); array_walk( $files, function ( &$data, $path ) use ( $root, $get_root_relative_path ) { // Honor Settings → allow dangerous types (not just WP MIME / unfiltered_upload). $importable = ! $this->is_restricted_file( $path ); $readable = is_readable( $path ); // Get file modification time. $file_date = ''; if ( $readable && file_exists( $path ) ) { $file_date = date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), filemtime( $path ) ); } $filetype = \wp_check_filetype( $path ); $data = array( 'text' => basename( $path ), 'file' => $get_root_relative_path( $path ), 'importable' => $importable, 'readable' => $readable, 'size' => $readable ? size_format( filesize( $path ) ) : 'N/A', 'size_bytes' => $readable ? (int) filesize( $path ) : 0, 'mtime' => $readable ? (int) filemtime( $path ) : 0, 'ext' => strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ), 'mime' => (string) ( ! empty( $filetype['type'] ) ? $filetype['type'] : '' ), 'date' => $file_date, 'error' => ( ! $importable ? 'doesnt-meet-guidelines' : ( ! $readable ? 'unreadable' : false ) ), ); } ); // Sort case insensitively. uasort( $files, $sort_by_text ); ?>

  1. 1
  2. 2
  3. 3
', $is_parent ? ' ' : '', esc_url( add_query_arg( 'path', rawurlencode( $dir['path'] ), $url ) ), esc_html( $folder_label ), $is_parent ? ' ' : esc_html( '-' ), esc_html( $folder_date ) ); if ( ! $is_parent ) { ++$folder_id; } } $file_id = 0; foreach ( $files as $file ) { $error_str = ''; if ( 'doesnt-meet-guidelines' === $file['error'] ) { $error_str = __( 'Sorry, this file type is not permitted for security reasons.', 'add-from-server-reloaded' ); } elseif ( 'unreadable' === $file['error'] ) { $error_str = __( 'Sorry, but this file is unreadable by your Webserver. Perhaps check your File Permissions?', 'add-from-server-reloaded' ); } $file_error = ! empty( $file['error'] ) ? $file['error'] : ''; $ext_badge = ! empty( $file['ext'] ) ? '' . esc_html( strtoupper( $file['ext'] ) ) . '' : ''; printf( '', esc_attr( $file_error ), esc_attr( $error_str ), absint( $file_id++ ), esc_attr( $file['file'] ), disabled( false, $file['readable'] && $file['importable'], false ), esc_html( $file['text'] ), esc_html( $file['size'] ), esc_html( ! empty( $file['mtime'] ) ? date_i18n( get_option( 'date_format' ), (int) $file['mtime'] ) : '' ), esc_attr( strtolower( $file['text'] ) ), esc_attr( $file['ext'] ?? '' ), esc_attr( $file['mime'] ?? '' ), absint( $file['size_bytes'] ?? 0 ), absint( $file['mtime'] ?? 0 ), $ext_badge // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above. ); } if ( array_filter( array_column( $files, 'error' ) ) ) { printf( '', esc_html__( 'Show hidden files', 'add-from-server-reloaded' ) ); } ?>
%1$s %3$s %4$s %5$s
%7$s %8$s
 %s

%s

', wp_kses_post( /* translators: %username% and %role% are literal example text that users may have entered in settings, not string placeholders. The tag links to options.php page. */ __( 'You previously used the "Root Directory" option with a placeholder, such as %username% or %role%. Unfortunately this feature is no longer supported. As a result, Add From Server has been disabled for users who have restricted upload privileges. To make this warning go away, empty the "frmsvr_root" option on options.php.', 'add-from-server-reloaded' ) ) ); } if ( $old_root && ! str_starts_with( $old_root, $this->get_root() ) ) { printf( '

%s

', wp_kses_post( sprintf( /* translators: 1: old root path, 2: new root path */ __( 'Warning: Root Directory changed. You previously used %1$s as your "Root Directory", this has been changed to %2$s. To restore your previous settings, add the following line to your wp-config.php file: define( "ADD_FROM_SERVER_RELOADED", "%1$s" ); To make this warning go away, empty the "frmsvr_root" option on options.php.', 'add-from-server-reloaded' ), esc_html( $old_root ), esc_html( $this->get_root() ) ) ) ); } } }