\admin_url( 'admin-ajax.php' ), 'nonce' => \wp_create_nonce( 'afsrreloaded_import' ), 'processing' => __( 'Processing...', 'add-from-server-reloaded' ), 'complete' => __( 'Import Complete!', 'add-from-server-reloaded' ), 'error' => __( 'An error occurred. Please try again.', '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() { $page_slug = \add_menu_page( __( 'Add From Server Reloaded', 'add-from-server-reloaded' ), __( 'Add From Server', 'add-from-server-reloaded' ), 'upload_files', 'add-from-server-reloaded', array( $this, 'menu_page' ), 'dashicons-upload', 30 ); \add_action( 'load-' . $page_slug, function() { \wp_enqueue_style( 'add-from-server-reloaded' ); \wp_enqueue_script( 'add-from-server-reloaded' ); // Handle settings save. $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 Reloaded', '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 ) { if ( current_user_can( 'upload_files' ) ) { array_unshift( $links, '' . __( 'Import Files', 'add-from-server-reloaded' ) . '' ); } return $links; } /** * Render the menu page. * * @since 4.0.0 */ public function menu_page() { // Set page title. global $title; $title = \__( 'Add From Server Reloaded', 'add-from-server-reloaded' ); // Handle any imports. $this->handle_imports(); echo '
' . \esc_html( $new_root ) . ''
),
'success'
);
}
/**
* Handle path selection cookie.
*
* @since 4.0.0
*/
public function path_selection_cookie() {
if ( isset( $_REQUEST['path'] ) && current_user_can( 'upload_files' ) ) { // 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;
}
$full_path = realpath( trailingslashit( $root ) . ltrim( $path, '/' ) );
// Security: Ensure the path is within root.
if ( ! $full_path || ! str_starts_with( $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();
$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();
foreach ( $folders as $folder ) {
$folder_path = trailingslashit( $root ) . ltrim( $folder, '/' );
$folder_files = array_merge( $folder_files, $this->get_files_from_folder( $folder_path, $root ) );
}
// 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;
$error_files = array();
$imported_files = array();
foreach ( (array) $files as $file ) {
$filename = trailingslashit( $root ) . ltrim( $file, '/' );
// Security: Verify the real path to prevent directory traversal.
$realpath = realpath( $filename );
if ( ! $realpath || ! str_starts_with( $realpath, $root ) ) {
$errors++;
$error_files[] = array(
'filename' => basename( $file ),
'message' => __( 'Security error: file is outside the allowed directory', 'add-from-server-reloaded' ),
);
continue;
}
$id = $this->handle_import_file( $realpath );
if ( \is_wp_error( $id ) ) {
$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 ) {
$message_class = $errors > 0 ? 'notice-warning' : 'notice-success';
echo '';
}
}
/**
* Recursively get all files from a folder.
*
* @since 4.0.5
*
* @param string $folder_path Absolute folder path.
* @param string $root Root directory path.
* @return array Array of relative file paths.
*/
protected function get_files_from_folder( $folder_path, $root ) {
$files = array();
// Security: Verify the real path.
$realpath = \realpath( $folder_path );
if ( ! $realpath || ! \str_starts_with( $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 = \str_replace( $root, '', $item );
$relative_item = \ltrim( $relative_item, '/' );
if ( \is_dir( $item ) ) {
// Recursively get files from subdirectory.
$files = \array_merge( $files, $this->get_files_from_folder( $item, $root ) );
} elseif ( \is_file( $item ) ) {
$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.
if ( ! $realpath || ! str_starts_with( $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' ) ) );
}
$duplicate = $this->check_if_duplicate( $file );
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( $file ),
$duplicate
),
) );
} else {
wp_send_json_success( array(
'is_duplicate' => 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;
$uploads = \wp_upload_dir();
$file = \wp_normalize_path( $file );
// Check if file is in uploads directory.
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;
}
}
// Also check by filename in case file was imported before.
$filename = \basename( $file );
$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.
* @return int|WP_Error Attachment ID on success, WP_Error on failure.
*/
public function handle_import_file( $file ) {
set_time_limit( 60 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Set reasonable time limit per file.
$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 = array( 'php', 'phtml', 'php3', 'php4', 'php5', 'phps', 'pht', 'phar', 'exe', 'sh', 'bat', 'cmd' );
$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' ) );
}
// Initially base it on the current time.
$time = time();
// A writable uploads dir will pass this test.
$uploads = wp_upload_dir( $time );
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' ) ) {
return new WP_Error( 'wrong_file_type', __( 'Sorry, this file type is not permitted for security reasons.', 'add-from-server-reloaded' ) );
}
// Check if file is already in media library (duplicate detection).
$duplicate = $this->check_if_duplicate( $file );
if ( $duplicate ) {
$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 )
)
);
}
// Is the file already in the uploads folder?
if ( preg_match( '|^' . preg_quote( wp_normalize_path( $uploads['basedir'] ), '|' ) . '(.*)$|i', $file, $mat ) ) {
$filename = basename( $file );
$new_file = $file;
$url = $uploads['baseurl'] . $mat[1];
$time = filemtime( $file ) ?: time();
// If file is in dated folder, try to match the date.
if ( preg_match( '|^/?(?P'; echo esc_html__( 'Unable to determine root directory. Please check your configuration.', 'add-from-server-reloaded' ); echo '
';
echo sprintf(
/* 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 '
' . esc_html( $root ) . '' ); ?>
' . esc_html( $root ) . '' ); ?>
%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
%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() )
)
)
);
}
}
}